Skip to main content

chio_store_sqlite/budget_store/
store.rs

1use super::*;
2
3/// Budget-store schema revision. Bump on every schema-affecting change.
4pub(crate) const BUDGET_STORE_SUPPORTED_SCHEMA_VERSION: i32 = 10;
5const BUDGET_INVOCATION_CAPTURE_SCHEMA_VERSION: i32 = 2;
6/// Stable key under which this store records its schema revision in the shared
7/// keyed metadata table, distinct from any co-located store's key.
8const BUDGET_STORE_SCHEMA_KEY: &str = "budget";
9/// Tables shipped before schema stamping existed, used to adopt a pre-stamping
10/// budget database rather than reject it as foreign.
11const BUDGET_STORE_LEGACY_ANCHOR_TABLES: &[&str] = &["capability_grant_budgets"];
12
13impl SqliteBudgetStore {
14    pub fn open(path: impl AsRef<Path>) -> Result<Self, BudgetStoreError> {
15        let path = path.as_ref();
16        // Resolve any `file:` URI to its on-disk parent before creating it, so a
17        // URI-configured store creates the real backing directory rather than a
18        // bogus scheme-prefixed one.
19        if let Some(parent) = crate::sqlite_parent_dir_to_create(path) {
20            fs::create_dir_all(&parent)?;
21        }
22
23        let mut connection = Connection::open(path)?;
24        Self::initialize_connection(&mut connection, false)?;
25        Ok(Self {
26            connection: Arc::new(Mutex::new(connection)),
27            serving_owner: None,
28        })
29    }
30
31    pub(crate) fn initialize_connection_offline(
32        connection: &mut Connection,
33    ) -> Result<(), BudgetStoreError> {
34        Self::initialize_connection(connection, true)
35    }
36
37    fn initialize_connection(
38        connection: &mut Connection,
39        allow_provisioned: bool,
40    ) -> Result<(), BudgetStoreError> {
41        if !allow_provisioned {
42            if let Some(epoch) = crate::serving_owner::provisioned_owner_epoch(connection)? {
43                return Err(BudgetStoreError::Fenced {
44                    expected_epoch: 0,
45                    actual_epoch: Some(epoch),
46                });
47            }
48        }
49        let on_disk_schema_version = crate::check_schema_version(
50            connection,
51            BUDGET_STORE_SCHEMA_KEY,
52            BUDGET_STORE_SUPPORTED_SCHEMA_VERSION,
53            BUDGET_STORE_LEGACY_ANCHOR_TABLES,
54        )
55        .map_err(|error| BudgetStoreError::Invariant(error.to_string()))?;
56        connection.execute_batch(
57            r#"
58            PRAGMA journal_mode = WAL;
59            PRAGMA synchronous = FULL;
60            PRAGMA busy_timeout = 5000;
61            PRAGMA foreign_keys = ON;
62
63            CREATE TABLE IF NOT EXISTS capability_grant_budgets (
64                capability_id TEXT NOT NULL,
65                grant_index INTEGER NOT NULL,
66                invocation_count INTEGER NOT NULL,
67                updated_at INTEGER NOT NULL,
68                seq INTEGER NOT NULL DEFAULT 0,
69                total_cost_exposed INTEGER NOT NULL DEFAULT 0,
70                total_cost_realized_spend INTEGER NOT NULL DEFAULT 0,
71                PRIMARY KEY (capability_id, grant_index)
72            );
73
74            CREATE INDEX IF NOT EXISTS idx_capability_grant_budgets_updated_at
75                ON capability_grant_budgets(updated_at);
76
77            CREATE TABLE IF NOT EXISTS budget_replication_meta (
78                singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
79                next_seq INTEGER NOT NULL
80            );
81
82            CREATE TABLE IF NOT EXISTS budget_authorization_holds (
83                hold_id TEXT PRIMARY KEY,
84                capability_id TEXT NOT NULL,
85                grant_index INTEGER NOT NULL,
86                authorized_exposure_units INTEGER NOT NULL,
87                remaining_exposure_units INTEGER NOT NULL,
88                invocation_count_debited INTEGER NOT NULL,
89                invocation_captured INTEGER NOT NULL DEFAULT 0,
90                disposition TEXT NOT NULL,
91                authority_id TEXT,
92                lease_id TEXT,
93                lease_epoch INTEGER,
94                created_at INTEGER NOT NULL,
95                updated_at INTEGER NOT NULL
96            );
97
98            CREATE INDEX IF NOT EXISTS idx_budget_authorization_holds_capability
99                ON budget_authorization_holds(capability_id, grant_index);
100
101            CREATE TABLE IF NOT EXISTS budget_mutation_events (
102                event_id TEXT PRIMARY KEY,
103                hold_id TEXT,
104                capability_id TEXT NOT NULL,
105                grant_index INTEGER NOT NULL,
106                kind TEXT NOT NULL,
107                allowed INTEGER,
108                recorded_at INTEGER NOT NULL,
109                event_seq INTEGER,
110                usage_seq INTEGER,
111                exposure_units INTEGER NOT NULL DEFAULT 0,
112                realized_spend_units INTEGER NOT NULL DEFAULT 0,
113                max_invocations INTEGER,
114                max_exposure_per_invocation INTEGER,
115                max_total_exposure_units INTEGER,
116                invocation_count_after INTEGER NOT NULL,
117                total_cost_exposed_after INTEGER NOT NULL,
118                total_cost_realized_spend_after INTEGER NOT NULL,
119                authority_id TEXT,
120                lease_id TEXT,
121                lease_epoch INTEGER
122            );
123
124            CREATE INDEX IF NOT EXISTS idx_budget_mutation_events_capability
125                ON budget_mutation_events(capability_id, grant_index, recorded_at);
126
127            CREATE UNIQUE INDEX IF NOT EXISTS idx_budget_mutation_events_event_seq
128                ON budget_mutation_events(event_seq);
129
130            CREATE TABLE IF NOT EXISTS budget_import_floors (
131                authority_id TEXT PRIMARY KEY,
132                floor_seq    INTEGER NOT NULL DEFAULT 0,
133                CHECK (floor_seq >= 0)
134            );
135
136            CREATE TABLE IF NOT EXISTS budget_ack_head_watermark (
137                singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
138                head_seq  INTEGER NOT NULL DEFAULT 0,
139                CHECK (head_seq >= 0)
140            );
141
142            -- Incrementally-maintained per-origin ack head, so the status path does
143            -- not GROUP BY the full mutation history every call. Each entry is the
144            -- highest event_seq that origin has WITHIN the verified global contiguous
145            -- prefix (<= the watermark); a DELETE clears it (see the trigger) so a
146            -- hole forces a rebuild rather than leaving a stale-high per-origin head.
147            CREATE TABLE IF NOT EXISTS budget_origin_ack_heads (
148                authority_id TEXT PRIMARY KEY,
149                head_seq     INTEGER NOT NULL,
150                CHECK (head_seq >= 0)
151            );
152
153            -- Metadata tombstones for event_seqs that were CONSUMED but have no
154            -- surviving mutation event: the leader-local rollback-retry path
155            -- (existing_event_allowed) and the delta-import replace path delete a
156            -- rolled-back authorize and re-append it under a fresh higher seq,
157            -- permanently abandoning the original seq. Recording the abandoned seq
158            -- lets the global contiguous ack head treat it as FILLED so it does not
159            -- stall cluster-wide at the hole. This never over-counts: an abandoned
160            -- seq is never a live write (its write was rolled back / superseded), so
161            -- no witness targets it; a genuinely MISSING event (never received, never
162            -- deleted here) is not recorded and still caps the head.
163            CREATE TABLE IF NOT EXISTS budget_abandoned_event_seqs (
164                seq INTEGER PRIMARY KEY,
165                CHECK (seq > 0)
166            );
167            "#,
168        )?;
169        ensure_budget_ack_head_reset_trigger(connection)?;
170        connection.execute(
171            r#"
172            INSERT INTO budget_replication_meta (singleton, next_seq)
173            VALUES (1, 0)
174            ON CONFLICT(singleton) DO NOTHING
175            "#,
176            [],
177        )?;
178        connection.execute(
179            r#"
180            INSERT INTO budget_ack_head_watermark (singleton, head_seq)
181            VALUES (1, 0)
182            ON CONFLICT(singleton) DO NOTHING
183            "#,
184            [],
185        )?;
186        ensure_budget_seq_column(connection)?;
187        ensure_split_budget_cost_columns(connection)?;
188        ensure_budget_hold_authority_columns(connection)?;
189        ensure_budget_hold_reserved_until_column(connection)?;
190        ensure_budget_hold_reserved_currency_column(connection)?;
191        ensure_budget_hold_reserved_payment_reference_column(connection)?;
192        ensure_budget_hold_reserved_envelope_columns(connection)?;
193        ensure_budget_mutation_event_authority_columns(connection)?;
194        ensure_budget_mutation_event_seq_column(connection)?;
195        initialize_budget_replication_seq(connection)?;
196        let migration = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
197        let capture_column_added = ensure_budget_hold_invocation_captured_column(&migration)?;
198        if on_disk_schema_version >= BUDGET_STORE_SUPPORTED_SCHEMA_VERSION && capture_column_added {
199            migration.rollback()?;
200            return Err(BudgetStoreError::Invariant(
201                "budget schema version declares invocation capture support but the column is missing"
202                    .to_string(),
203            ));
204        }
205        if on_disk_schema_version < BUDGET_INVOCATION_CAPTURE_SCHEMA_VERSION {
206            migration.execute(
207                r#"
208                UPDATE budget_authorization_holds
209                SET invocation_captured = 1
210                WHERE disposition = 'open'
211                "#,
212                [],
213            )?;
214        }
215        ensure_composite_budget_schema(&migration, on_disk_schema_version)?;
216        crate::stamp_schema_version(
217            &migration,
218            BUDGET_STORE_SCHEMA_KEY,
219            BUDGET_STORE_SUPPORTED_SCHEMA_VERSION,
220        )
221        .map_err(|error| BudgetStoreError::Invariant(error.to_string()))?;
222        migration.commit()?;
223        verify_budget_foreign_keys(connection)?;
224        verify_budget_projection_invariants(connection)?;
225        Self::rebuild_snapshot_proof_caches(connection)?;
226
227        Ok(())
228    }
229
230    pub(crate) fn open_alongside(
231        connection: Arc<Mutex<Connection>>,
232        serving_owner: Arc<crate::serving_owner::SqliteServingOwner>,
233    ) -> Self {
234        Self {
235            connection,
236            serving_owner: Some(serving_owner),
237        }
238    }
239
240    pub(super) fn connection(&self) -> Result<MutexGuard<'_, Connection>, BudgetStoreError> {
241        self.connection.lock().map_err(|_| {
242            BudgetStoreError::Invariant("sqlite budget store lock poisoned".to_string())
243        })
244    }
245
246    pub(super) fn begin_write<'a>(
247        &self,
248        connection: &'a mut Connection,
249    ) -> Result<rusqlite::Transaction<'a>, BudgetStoreError> {
250        let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
251        crate::serving_owner::verify_budget_fence(&transaction, self.serving_owner.as_deref())?;
252        if let Some(owner) = self.serving_owner.as_ref() {
253            owner
254                .verify_authority_anchor(&transaction)
255                .map_err(map_serving_owner_error)?;
256        }
257        Ok(transaction)
258    }
259
260    pub(super) fn begin_read<'a>(
261        &self,
262        connection: &'a mut Connection,
263    ) -> Result<rusqlite::Transaction<'a>, BudgetStoreError> {
264        let transaction = connection.transaction_with_behavior(TransactionBehavior::Deferred)?;
265        crate::serving_owner::verify_budget_fence(&transaction, self.serving_owner.as_deref())?;
266        if let Some(owner) = self.serving_owner.as_ref() {
267            owner
268                .verify_authority_anchor(&transaction)
269                .map_err(map_serving_owner_error)?;
270        }
271        Ok(transaction)
272    }
273
274    pub(super) fn append_joint_commit(
275        &self,
276        transaction: &rusqlite::Transaction<'_>,
277        kind: BudgetMutationKind,
278        event_id: &str,
279        event_seq: u64,
280    ) -> Result<(), BudgetStoreError> {
281        let owner = self.serving_owner.as_ref().ok_or_else(|| {
282            BudgetStoreError::Invariant(
283                "structured sqlite mutation requires a joint serving owner".to_string(),
284            )
285        })?;
286        owner
287            .append_global_commit(transaction, kind.as_str(), "budget", event_id, event_seq)
288            .map_err(map_serving_owner_error)
289    }
290
291    pub(super) fn sync_joint_anchor(
292        &self,
293        connection: &Connection,
294    ) -> Result<(), BudgetStoreError> {
295        let owner = self.serving_owner.as_ref().ok_or_else(|| {
296            BudgetStoreError::Invariant(
297                "structured sqlite mutation requires a joint serving owner".to_string(),
298            )
299        })?;
300        owner
301            .sync_authority_anchor(connection)
302            .map_err(map_serving_owner_error)
303    }
304
305    pub(super) fn commit_joint_transaction(
306        &self,
307        transaction: rusqlite::Transaction<'_>,
308    ) -> Result<(), BudgetStoreError> {
309        transaction.commit().map_err(|error| {
310            let detail = format!("sqlite budget commit outcome is unknown: {error}");
311            match self.serving_owner.as_ref() {
312                Some(owner) => map_serving_owner_error(owner.outcome_unknown(detail)),
313                None => BudgetStoreError::OutcomeUnknown(detail),
314            }
315        })
316    }
317
318    /// Per-origin contiguous ack head, anchored on the peer's GLOBAL contiguous
319    /// import head.
320    ///
321    /// `event_seq` is a single store-wide dense sequence, so each origin's events
322    /// are a sparse subsequence of one global stream; a per-origin gaps-and-
323    /// islands run (partitioned by authority) mis-models this: an origin whose
324    /// block starts mid-sequence after a leadership change looks like a gap and is
325    /// wrongly dropped, stalling its writes.
326    ///
327    /// Instead this first computes the GLOBAL contiguous head H (the largest seq
328    /// such that every global event_seq in `1..=H` is present with no hole) as a
329    /// single gaps-and-islands run over the whole stream (NOT partitioned),
330    /// anchored at genesis (island 0). A hole caps H below it. Legacy
331    /// NULL-authority events still occupy their global slot, so they count for
332    /// contiguity (a present slot is not a gap) but are never reported as an ack.
333    /// It then reports, per origin, `MAX(event_seq)` among that origin's events
334    /// with `event_seq <= H`.
335    ///
336    /// Sound because global-contiguity enforcement on the puller means holding H
337    /// implies holding EVERY event (all origins) at
338    /// seq `<= H`, so `head[origin] >= write.event_seq` iff the peer durably holds
339    /// that write and all its predecessors. Fail-closed: a global hole caps H, so
340    /// no origin is ever reported past a missing global predecessor, and a missing
341    /// prefix (nothing at seq 1) yields H = 0 and no acks.
342    ///
343    /// NOTE: genesis anchoring assumes budget mutation events are never
344    /// bulk-compacted below seq 1 (they are not today); if such compaction is
345    /// added, anchor at a durable global floor instead.
346    ///
347    /// NOTE: a rollback-retry that abandons a seq (existing_event_allowed)
348    /// leaves a permanent interior hole that caps this GLOBAL head, stalling
349    /// quorum budget-writes above the hole for EVERY origin cluster-wide (not
350    /// per-origin) until operator intervention - it does not self-heal, since a
351    /// snapshot from the holed leader carries the hole. Fail-closed (a hole
352    /// withholds quorum and never over-counts).
353    /// PERF: this runs on every cluster status request (once per sync round, an
354    /// interval clamped as low as 50ms), so it must not rescan the whole ledger.
355    /// The GLOBAL contiguous head is maintained incrementally against a durable
356    /// watermark W (`budget_ack_head_watermark`): each call only advances W over
357    /// the rows ABOVE it (a window scan bounded to `event_seq > W`), so steady
358    /// state cost is O(new rows), not O(history). Soundness holds because W only
359    /// advances while the run stays gap-free, and any DELETE of a mutation event
360    /// resets W to 0 (`reset_budget_ack_head_watermark`, backstopped structurally
361    /// by the `budget_mutation_events_reset_ack_head_watermark` AFTER DELETE
362    /// trigger so a future or out-of-band delete site cannot skip the reset),
363    /// forcing the next call to re-verify from genesis so a hole punched below W
364    /// can never leave a stale-high head that over-counts.
365    pub fn budget_ack_heads(&self) -> Result<Vec<(String, u64)>, BudgetStoreError> {
366        let mut connection = self.connection()?;
367        let transaction = self.begin_write(&mut connection)?;
368        transaction.execute(
369            r#"
370            UPDATE budget_ack_head_watermark
371            SET head_seq = MAX(
372                head_seq,
373                (SELECT covered_head FROM budget_snapshot_coverage WHERE singleton = 1)
374            )
375            WHERE singleton = 1
376            "#,
377            [],
378        )?;
379        let watermark: i64 = transaction.query_row(
380            "SELECT head_seq FROM budget_ack_head_watermark WHERE singleton = 1",
381            [],
382            |row| row.get(0),
383        )?;
384        let watermark = watermark.max(0) as u64;
385        let watermark_sqlite = budget_u64_to_sqlite(watermark, "head_seq")?;
386        // Advance the head over slots ABOVE the watermark only. A slot is FILLED if
387        // a real mutation event occupies it OR it is a recorded abandoned/tombstoned
388        // seq (a rolled-back-then-re-appended write's original seq). The contiguous
389        // run from W+1 has island == W (its first slot W+1 has ROW_NUMBER 1), so the
390        // new head is MAX(seq) in that island, or W when W+1 is neither present nor
391        // abandoned. Treating abandoned seqs as filled lets the head advance past a
392        // rollback-retry hole; a genuinely MISSING event (never recorded as
393        // abandoned) is NOT filled and still caps the head.
394        // NULL-authority events still occupy a slot for contiguity but are never
395        // reported as an ack below.
396        // Fast path (avoid rescanning the ledger after a gap):
397        // the contiguous run can only advance when the very next slot (W+1) is
398        // FILLED (a mutation event or a recorded abandoned seq). When a real hole
399        // sits at W+1 (data loss, or a legacy snapshot that lacks abandoned slots),
400        // the head is pinned at W forever, yet the window query below still computes
401        // ROW_NUMBER() over the whole suffix `> W` on every status-path call. Probe
402        // the single W+1 slot first (an indexed point lookup): if it is absent, the
403        // head is W, so skip the O(suffix) window scan entirely. This is purely an
404        // optimization: the window query ALSO yields exactly W when W+1 is absent
405        // (the smallest filled seq > W then has island >= W+1, so no row matches the
406        // island == W run and COALESCE(MAX(seq), W) collapses to W), so the
407        // genesis-anchored gaps-and-islands head is unchanged.
408        let next_slot = watermark_sqlite.checked_add(1).ok_or_else(|| {
409            BudgetStoreError::Overflow("budget acknowledgement head overflowed i64".to_string())
410        })?;
411        let next_slot_filled: bool = transaction.query_row(
412            r#"
413            SELECT EXISTS(
414                SELECT 1 FROM budget_mutation_events WHERE event_seq = ?1
415                UNION ALL
416                SELECT 1 FROM budget_abandoned_event_seqs WHERE seq = ?1
417            )
418            "#,
419            rusqlite::params![next_slot],
420            |row| row.get::<_, i64>(0).map(|value| value != 0),
421        )?;
422        let head: i64 = if next_slot_filled {
423            transaction.query_row(
424                r#"
425                WITH filled AS (
426                    SELECT event_seq AS seq
427                    FROM budget_mutation_events
428                    WHERE event_seq IS NOT NULL AND event_seq > ?1
429                    UNION
430                    SELECT seq
431                    FROM budget_abandoned_event_seqs
432                    WHERE seq > ?1
433                ),
434                run AS (
435                    SELECT
436                        seq,
437                        seq - ROW_NUMBER() OVER (ORDER BY seq) AS island
438                    FROM filled
439                )
440                SELECT COALESCE(MAX(seq), ?1) AS head_seq
441                FROM run
442                WHERE island = ?1
443                "#,
444                rusqlite::params![watermark_sqlite],
445                |row| row.get(0),
446            )?
447        } else {
448            watermark_sqlite
449        };
450        let head = head.max(0) as u64;
451        if head > watermark {
452            let head_sqlite = budget_u64_to_sqlite(head, "head_seq")?;
453            transaction.execute(
454                "UPDATE budget_ack_head_watermark SET head_seq = ?1 WHERE singleton = 1",
455                rusqlite::params![head_sqlite],
456            )?;
457            // Fold ONLY the newly-covered rows (watermark, head] into the durable
458            // per-origin heads, so the status path never GROUPs the full history:
459            // steady-state cost is O(new rows). MAX keeps each origin monotone
460            // within a hole-free window; a DELETE clears this table (trigger +
461            // reset helper), so a per-origin head can never sit above a hole.
462            transaction.execute(
463                r#"
464                INSERT INTO budget_origin_ack_heads (authority_id, head_seq)
465                SELECT authority_id, MAX(event_seq)
466                FROM budget_mutation_events
467                WHERE authority_id IS NOT NULL
468                  AND event_seq IS NOT NULL
469                  AND event_seq > ?1
470                  AND event_seq <= ?2
471                GROUP BY authority_id
472                ON CONFLICT(authority_id)
473                    DO UPDATE SET head_seq = MAX(head_seq, excluded.head_seq)
474                "#,
475                rusqlite::params![watermark_sqlite, head_sqlite],
476            )?;
477        }
478        let mut statement =
479            transaction.prepare("SELECT authority_id, head_seq FROM budget_origin_ack_heads")?;
480        let rows = statement
481            .query_map([], |row| {
482                let origin: String = row.get(0)?;
483                let ack_head = budget_u64_from_row(row, 1, "ack_head")?;
484                Ok((origin, ack_head))
485            })?
486            .collect::<Result<Vec<_>, _>>()
487            .map_err(BudgetStoreError::from)?;
488        drop(statement);
489        transaction.commit()?;
490        Ok(rows)
491    }
492
493    /// Reset the durable contiguous ack-head watermark to 0, forcing the next
494    /// `budget_ack_heads` call to re-verify the global run from genesis. Called
495    /// whenever a mutation event is DELETED (a hole may have been punched at or
496    /// below the watermark): re-verification caps the head below the hole so a
497    /// stale-high watermark can never over-count a witness.
498    ///
499    /// This is redundant with the `budget_mutation_events_reset_ack_head_watermark`
500    /// AFTER DELETE trigger created in `open` (defense-in-depth): the trigger fires
501    /// structurally on every delete against the table, so this manual call is a
502    /// belt-and-suspenders in-transaction reset at each known call site, not the
503    /// sole enforcement point.
504    #[cfg(test)]
505    pub(crate) fn reset_budget_ack_head_watermark(
506        transaction: &rusqlite::Transaction<'_>,
507    ) -> Result<(), BudgetStoreError> {
508        transaction.execute(
509            "UPDATE budget_ack_head_watermark SET head_seq = 0 WHERE singleton = 1",
510            [],
511        )?;
512        // Clear the incrementally-maintained per-origin heads too: a hole below a
513        // per-origin head would otherwise leave it stale-high until a full rebuild.
514        transaction.execute("DELETE FROM budget_origin_ack_heads", [])?;
515        Ok(())
516    }
517
518    /// The abandoned/tombstoned event_seqs (rolled-back-then-re-appended writes'
519    /// original seqs) that `budget_ack_heads` treats as filled slots. Replicated
520    /// in the cluster snapshot so a FRESH follower - which never held the original
521    /// event and so never fired the delete-trigger that records it locally - still
522    /// learns the slot is abandoned and does not stall its contiguous head at the
523    /// hole.
524    pub fn list_abandoned_event_seqs(&self) -> Result<Vec<u64>, BudgetStoreError> {
525        let mut connection = self.connection()?;
526        let transaction = self.begin_read(&mut connection)?;
527        let mut statement =
528            transaction.prepare("SELECT seq FROM budget_abandoned_event_seqs ORDER BY seq ASC")?;
529        let rows = statement.query_map([], |row| {
530            let seq: i64 = row.get(0)?;
531            Ok(seq.max(0) as u64)
532        })?;
533        let rows = rows.collect::<Result<Vec<_>, _>>()?;
534        drop(statement);
535        transaction.rollback()?;
536        Ok(rows)
537    }
538
539    /// The abandoned event_seqs RANGE-ENCODED as inclusive `(start, end)` runs of
540    /// consecutive seqs (ascending, non-overlapping).
541    ///
542    /// The cluster snapshot carries the abandoned set this way instead of one entry
543    /// per seq. A rollback storm abandons a long CONTIGUOUS run of seqs; enumerated,
544    /// that is millions of integers that push the snapshot body past
545    /// MAX_PEER_RESPONSE_BYTES, so the force-snapshot recovery path fails to decode
546    /// cluster_snapshot() and the peer stalls in force_snapshot forever (unlike the
547    /// delta path, the snapshot backstop has no further fallback). Range-encoded,
548    /// each contiguous run collapses to one
549    /// small pair, and the run count is bounded by the number of live mutation events
550    /// (a run is separated from the next by a filled non-abandoned slot), which the
551    /// snapshot already carries and which dominate the byte budget - so if the events
552    /// fit under the cap the ranges do too. The encoding is LOSSLESS: a follower
553    /// expands the runs to the identical seq set, so the filled-or-abandoned
554    /// head-advance semantics are preserved bit-for-bit. Computed in SQL
555    /// (gaps-and-islands) so the full seq set is never materialized here either.
556    pub fn list_abandoned_event_seq_ranges(&self) -> Result<Vec<(u64, u64)>, BudgetStoreError> {
557        let mut connection = self.connection()?;
558        let transaction = self.begin_read(&mut connection)?;
559        let mut statement = transaction.prepare(
560            r#"
561            SELECT MIN(seq) AS start_seq, MAX(seq) AS end_seq
562            FROM (
563                SELECT seq, seq - ROW_NUMBER() OVER (ORDER BY seq) AS island
564                FROM budget_abandoned_event_seqs
565            )
566            GROUP BY island
567            ORDER BY start_seq ASC
568            "#,
569        )?;
570        let rows = statement.query_map([], |row| {
571            let start: i64 = row.get(0)?;
572            let end: i64 = row.get(1)?;
573            Ok((start.max(0) as u64, end.max(0) as u64))
574        })?;
575        let rows = rows.collect::<Result<Vec<_>, _>>()?;
576        drop(statement);
577        transaction.rollback()?;
578        Ok(rows)
579    }
580
581    /// Abandoned event_seqs strictly above `after_seq` (ascending). The budget
582    /// delta endpoint returns these alongside the pulled events so the puller can
583    /// treat the abandoned slots as filled and not reject the leader's legitimately
584    /// gappy stream.
585    pub fn list_abandoned_event_seqs_after(
586        &self,
587        after_seq: u64,
588    ) -> Result<Vec<u64>, BudgetStoreError> {
589        let after_seq = budget_u64_to_sqlite(after_seq, "after_seq")?;
590        let mut connection = self.connection()?;
591        let transaction = self.begin_read(&mut connection)?;
592        let mut statement = transaction.prepare(
593            "SELECT seq FROM budget_abandoned_event_seqs WHERE seq > ?1 ORDER BY seq ASC",
594        )?;
595        let rows = statement.query_map(rusqlite::params![after_seq], |row| {
596            let seq: i64 = row.get(0)?;
597            Ok(seq.max(0) as u64)
598        })?;
599        let rows = rows.collect::<Result<Vec<_>, _>>()?;
600        drop(statement);
601        transaction.rollback()?;
602        Ok(rows)
603    }
604
605    /// Abandoned event_seqs in `(after_seq, up_to_seq]` (ascending), at most
606    /// `limit` entries.
607    ///
608    /// The budget delta endpoint uses this to BOUND the abandoned list it serves.
609    /// A rollback storm can pack an
610    /// arbitrarily large abandoned window between the follower cursor and the next
611    /// live event; serializing all of it (the old unbounded
612    /// `list_abandoned_event_seqs_after` + in-memory `<= page_max` filter) could
613    /// exceed the peer-response byte cap, so the client rejects the body while
614    /// decoding and never gets to classify the oversized window as
615    /// snapshot-recovery, pinning the cursor forever. Both the upper bound and the
616    /// row cap are pushed into SQL so a huge window is never materialized here.
617    pub fn list_abandoned_event_seqs_in_range(
618        &self,
619        after_seq: u64,
620        up_to_seq: u64,
621        limit: usize,
622    ) -> Result<Vec<u64>, BudgetStoreError> {
623        let after_seq = budget_u64_to_sqlite(after_seq, "after_seq")?;
624        let up_to_seq = budget_u64_to_sqlite(up_to_seq, "up_to_seq")?;
625        let mut connection = self.connection()?;
626        let transaction = self.begin_read(&mut connection)?;
627        let mut statement = transaction.prepare(
628            "SELECT seq FROM budget_abandoned_event_seqs \
629             WHERE seq > ?1 AND seq <= ?2 ORDER BY seq ASC LIMIT ?3",
630        )?;
631        let rows = statement.query_map(
632            rusqlite::params![after_seq, up_to_seq, limit as i64],
633            |row| {
634                let seq: i64 = row.get(0)?;
635                Ok(seq.max(0) as u64)
636            },
637        )?;
638        let rows = rows.collect::<Result<Vec<_>, _>>()?;
639        drop(statement);
640        transaction.rollback()?;
641        Ok(rows)
642    }
643
644    pub(super) fn upsert_usage_in_transaction(
645        transaction: &rusqlite::Transaction<'_>,
646        record: &BudgetUsageRecord,
647    ) -> Result<(), BudgetStoreError> {
648        let existing = transaction
649            .query_row(
650                r#"
651                SELECT capability_id, grant_index, invocation_count, updated_at, seq,
652                       total_cost_exposed, total_cost_realized_spend
653                FROM capability_grant_budgets
654                WHERE capability_id = ?1 AND grant_index = ?2
655                "#,
656                params![&record.capability_id, i64::from(record.grant_index)],
657                record_from_row,
658            )
659            .optional()?;
660        if let Some(existing) = existing
661            .as_ref()
662            .filter(|existing| existing.seq == record.seq)
663        {
664            if existing != record {
665                return Err(BudgetStoreError::Invariant(format!(
666                    "budget usage `{}` grant {} reused sequence {} with different counters",
667                    record.capability_id, record.grant_index, record.seq
668                )));
669            }
670            return Ok(());
671        }
672        let seq = budget_u64_to_sqlite(record.seq, "seq")?;
673        let total_cost_exposed =
674            budget_u64_to_sqlite(record.total_cost_exposed, "total_cost_exposed")?;
675        let total_cost_realized_spend = budget_u64_to_sqlite(
676            record.total_cost_realized_spend,
677            "total_cost_realized_spend",
678        )?;
679        raise_budget_replication_seq_floor(transaction, record.seq)?;
680        transaction.execute(
681            r#"
682            INSERT INTO capability_grant_budgets (
683                capability_id,
684                grant_index,
685                invocation_count,
686                updated_at,
687                seq,
688                total_cost_exposed,
689                total_cost_realized_spend
690            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
691            ON CONFLICT(capability_id, grant_index) DO UPDATE SET
692                invocation_count = CASE
693                    WHEN excluded.seq > capability_grant_budgets.seq
694                        THEN excluded.invocation_count
695                    ELSE capability_grant_budgets.invocation_count
696                END,
697                updated_at = CASE
698                    WHEN excluded.seq > capability_grant_budgets.seq
699                        THEN excluded.updated_at
700                    ELSE capability_grant_budgets.updated_at
701                END,
702                total_cost_exposed = CASE
703                    WHEN excluded.seq > capability_grant_budgets.seq
704                        THEN excluded.total_cost_exposed
705                    ELSE capability_grant_budgets.total_cost_exposed
706                END,
707                total_cost_realized_spend = CASE
708                    WHEN excluded.seq > capability_grant_budgets.seq
709                        THEN excluded.total_cost_realized_spend
710                    ELSE capability_grant_budgets.total_cost_realized_spend
711                END,
712                seq = MAX(capability_grant_budgets.seq, excluded.seq)
713            "#,
714            params![
715                &record.capability_id,
716                i64::from(record.grant_index),
717                i64::from(record.invocation_count),
718                record.updated_at,
719                seq,
720                total_cost_exposed,
721                total_cost_realized_spend,
722            ],
723        )?;
724        Ok(())
725    }
726
727    #[cfg(test)]
728    pub(crate) fn delete_mutation_event(&self, event_id: &str) -> Result<(), BudgetStoreError> {
729        self.require_standalone_mutation("budget mutation event deletion")?;
730        let mut connection = self.connection()?;
731        let transaction = self.begin_write(&mut connection)?;
732        transaction.execute(
733            "DELETE FROM budget_mutation_events WHERE event_id = ?1",
734            params![event_id],
735        )?;
736        Self::reset_budget_ack_head_watermark(&transaction)?;
737        transaction.commit()?;
738        Ok(())
739    }
740
741    pub fn hold_authority(
742        &self,
743        hold_id: &str,
744    ) -> Result<Option<BudgetEventAuthority>, BudgetStoreError> {
745        let mut connection = self.connection()?;
746        let transaction = self.begin_read(&mut connection)?;
747        let authority = Self::load_hold(&transaction, hold_id)?.and_then(|hold| hold.authority);
748        transaction.rollback()?;
749        Ok(authority)
750    }
751
752    pub fn import_mutation_record(
753        &self,
754        record: &BudgetMutationRecord,
755    ) -> Result<(), BudgetStoreError> {
756        self.require_standalone_mutation("budget mutation record import")?;
757        let mut connection = self.connection()?;
758        let transaction = self.begin_write(&mut connection)?;
759        Self::import_mutation_record_in_transaction(&transaction, record)?;
760        Self::reconcile_imported_usages(&transaction, &[], std::slice::from_ref(record))?;
761        transaction.commit()?;
762        Ok(())
763    }
764
765    pub(super) fn import_mutation_record_in_transaction(
766        transaction: &rusqlite::Transaction<'_>,
767        record: &BudgetMutationRecord,
768    ) -> Result<(), BudgetStoreError> {
769        Self::validate_import_record_sqlite_range(record)?;
770        let mut normalized = record.clone();
771        if legacy_event_lifecycle_is_unset(&normalized) {
772            let lifecycle = imported_event_lifecycle(transaction, &normalized)?;
773            normalized.authorization_outcome = lifecycle.0;
774            normalized.invocation_state_before = lifecycle.1;
775            normalized.invocation_state_after = lifecycle.2;
776            normalized.monetary_state_before = lifecycle.3;
777            normalized.monetary_state_after = lifecycle.4;
778        }
779        Self::validate_supported_import_record(&normalized)?;
780        let record = &normalized;
781        if let Some(hold_id) = record.hold_id.as_deref() {
782            Self::reject_structured_hold_from_legacy_writer(
783                transaction,
784                Some(hold_id),
785                "budget mutation import",
786            )?;
787        }
788
789        let duplicate_event =
790            if let Some(existing) = Self::load_mutation_event(transaction, &record.event_id)? {
791                if Self::same_imported_mutation(&existing, record) {
792                    true
793                } else {
794                    return Err(BudgetStoreError::Invariant(format!(
795                        "budget event_id `{}` was reused for a different mutation",
796                        record.event_id
797                    )));
798                }
799            } else {
800                Self::validate_import_hold_frontier(transaction, record)?;
801                Self::validate_import_event_usage_chain(transaction, record)?;
802                Self::insert_imported_mutation_event(transaction, record)?;
803                false
804            };
805        if duplicate_event {
806            return Ok(());
807        }
808
809        raise_budget_replication_seq_floor(transaction, record.event_seq)?;
810        if let Some(usage_seq) = record.usage_seq {
811            raise_budget_replication_seq_floor(transaction, usage_seq)?;
812        }
813        Self::apply_imported_hold_state(transaction, record)?;
814        Ok(())
815    }
816
817    fn validate_supported_import_record(
818        record: &BudgetMutationRecord,
819    ) -> Result<(), BudgetStoreError> {
820        let unsupported_kind = matches!(
821            record.kind,
822            BudgetMutationKind::ReserveInvocation
823                | BudgetMutationKind::AuthorizeCumulativeApproval
824                | BudgetMutationKind::ReverseInvocation
825                | BudgetMutationKind::CaptureSpend
826        );
827        let composite_projection = record.admission_binding.is_some()
828            || !record.invocation_quota_usages.is_empty()
829            || !record.invocation_quota_mutations.is_empty()
830            || record.cumulative_approval.is_some()
831            || record.cumulative_approval_mutation.is_some()
832            || record.cumulative_approval_set_digest.is_some();
833        if unsupported_kind || composite_projection {
834            return Err(BudgetStoreError::Invariant(format!(
835                "budget mutation `{}` uses state unsupported by the sqlite budget store",
836                record.kind.as_str()
837            )));
838        }
839        validate_legacy_event_lifecycle(record)?;
840        Self::validate_import_event_shape(record)?;
841        Ok(())
842    }
843
844    fn validate_import_record_sqlite_range(
845        record: &BudgetMutationRecord,
846    ) -> Result<(), BudgetStoreError> {
847        if record.event_seq == 0 || record.usage_seq == Some(0) {
848            return Err(BudgetStoreError::Invariant(
849                "imported budget sequences must be positive".to_string(),
850            ));
851        }
852        if record
853            .usage_seq
854            .is_some_and(|usage_seq| usage_seq > record.event_seq)
855        {
856            return Err(BudgetStoreError::Invariant(
857                "imported budget usage sequence exceeds its event sequence".to_string(),
858            ));
859        }
860        budget_u64_to_sqlite(record.event_seq, "event_seq")?;
861        optional_budget_u64_to_sqlite(record.usage_seq, "usage_seq")?;
862        budget_u64_to_sqlite(record.exposure_units, "exposure_units")?;
863        budget_u64_to_sqlite(record.realized_spend_units, "realized_spend_units")?;
864        optional_budget_u64_to_sqlite(
865            record.max_cost_per_invocation,
866            "max_exposure_per_invocation",
867        )?;
868        optional_budget_u64_to_sqlite(record.max_total_cost_units, "max_total_exposure_units")?;
869        budget_u64_to_sqlite(record.total_cost_exposed_after, "total_cost_exposed_after")?;
870        budget_u64_to_sqlite(
871            record.total_cost_realized_spend_after,
872            "total_cost_realized_spend_after",
873        )?;
874        if let Some(authority) = record.authority.as_ref() {
875            budget_u64_to_sqlite(authority.lease_epoch, "lease_epoch")?;
876        }
877        Ok(())
878    }
879
880    /// Insert one imported mutation event row verbatim. Shared by the new-event
881    /// import path and the follower rollback-retry REPLACE path (which deletes the
882    /// superseded row and tombstones its seq before re-inserting the leader's
883    /// re-appended event under its fresh higher event_seq). A plain INSERT is
884    /// deliberate and fail-closed: the unique event_seq index rejects a corrupt
885    /// stream that reused a seq for a different event rather than silently masking
886    /// it.
887    fn insert_imported_mutation_event(
888        transaction: &rusqlite::Transaction<'_>,
889        record: &BudgetMutationRecord,
890    ) -> Result<(), BudgetStoreError> {
891        transaction.execute(
892            r#"
893                INSERT INTO budget_mutation_events (
894                    event_id,
895                    hold_id,
896                    capability_id,
897                    grant_index,
898                    kind,
899                    allowed,
900                    recorded_at,
901                    event_seq,
902                    usage_seq,
903                    exposure_units,
904                    realized_spend_units,
905                    max_invocations,
906                    max_exposure_per_invocation,
907                    max_total_exposure_units,
908                    invocation_count_after,
909                    total_cost_exposed_after,
910                    total_cost_realized_spend_after,
911                    authority_id,
912                    lease_id,
913                    lease_epoch,
914                    authorization_outcome,
915                    invocation_state_before,
916                    invocation_state_after,
917                    monetary_state_before,
918                    monetary_state_after
919                ) VALUES (
920                    ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10,
921                    ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20,
922                    ?21, ?22, ?23, ?24, ?25
923                )
924                "#,
925            params![
926                record.event_id,
927                record.hold_id,
928                record.capability_id,
929                i64::from(record.grant_index),
930                record.kind.as_str(),
931                record
932                    .allowed
933                    .map(|value| if value { 1_i64 } else { 0_i64 }),
934                record.recorded_at,
935                budget_u64_to_sqlite(record.event_seq, "event_seq")?,
936                optional_budget_u64_to_sqlite(record.usage_seq, "usage_seq")?,
937                budget_u64_to_sqlite(record.exposure_units, "exposure_units")?,
938                budget_u64_to_sqlite(record.realized_spend_units, "realized_spend_units")?,
939                record.max_invocations.map(i64::from),
940                optional_budget_u64_to_sqlite(
941                    record.max_cost_per_invocation,
942                    "max_exposure_per_invocation",
943                )?,
944                optional_budget_u64_to_sqlite(
945                    record.max_total_cost_units,
946                    "max_total_exposure_units",
947                )?,
948                i64::from(record.invocation_count_after),
949                budget_u64_to_sqlite(record.total_cost_exposed_after, "total_cost_exposed_after",)?,
950                budget_u64_to_sqlite(
951                    record.total_cost_realized_spend_after,
952                    "total_cost_realized_spend_after",
953                )?,
954                record
955                    .authority
956                    .as_ref()
957                    .map(|value| value.authority_id.as_str()),
958                record
959                    .authority
960                    .as_ref()
961                    .map(|value| value.lease_id.as_str()),
962                record
963                    .authority
964                    .as_ref()
965                    .map(|value| budget_u64_to_sqlite(value.lease_epoch, "lease_epoch"))
966                    .transpose()?,
967                record
968                    .authorization_outcome
969                    .map(budget_authorization_outcome_text),
970                budget_invocation_state_text(record.invocation_state_before),
971                budget_invocation_state_text(record.invocation_state_after),
972                budget_monetary_state_text(record.monetary_state_before),
973                budget_monetary_state_text(record.monetary_state_after),
974            ],
975        )?;
976        Ok(())
977    }
978
979    fn same_imported_mutation(
980        existing: &BudgetMutationRecord,
981        imported: &BudgetMutationRecord,
982    ) -> bool {
983        existing.event_id == imported.event_id
984            && existing.event_seq == imported.event_seq
985            && existing.usage_seq == imported.usage_seq
986            && existing.recorded_at == imported.recorded_at
987            && existing.hold_id == imported.hold_id
988            && existing.capability_id == imported.capability_id
989            && existing.grant_index == imported.grant_index
990            && existing.kind == imported.kind
991            && existing.allowed == imported.allowed
992            && existing.authorization_outcome == imported.authorization_outcome
993            && existing.invocation_state_before == imported.invocation_state_before
994            && existing.invocation_state_after == imported.invocation_state_after
995            && existing.monetary_state_before == imported.monetary_state_before
996            && existing.monetary_state_after == imported.monetary_state_after
997            && existing.exposure_units == imported.exposure_units
998            && existing.realized_spend_units == imported.realized_spend_units
999            && existing.max_invocations == imported.max_invocations
1000            && existing.max_cost_per_invocation == imported.max_cost_per_invocation
1001            && existing.max_total_cost_units == imported.max_total_cost_units
1002            && existing.invocation_count_after == imported.invocation_count_after
1003            && existing.total_cost_exposed_after == imported.total_cost_exposed_after
1004            && existing.total_cost_realized_spend_after == imported.total_cost_realized_spend_after
1005            && existing.authority == imported.authority
1006    }
1007
1008    pub fn list_usages_after(
1009        &self,
1010        limit: usize,
1011        after_seq: Option<u64>,
1012    ) -> Result<Vec<BudgetUsageRecord>, BudgetStoreError> {
1013        let after_seq = optional_budget_u64_to_sqlite(after_seq, "after_seq")?;
1014        let mut connection = self.connection()?;
1015        let transaction = self.begin_read(&mut connection)?;
1016        let mut statement = transaction.prepare(
1017            r#"
1018            SELECT
1019                capability_id,
1020                grant_index,
1021                invocation_count,
1022                updated_at,
1023                seq,
1024                total_cost_exposed,
1025                total_cost_realized_spend
1026            FROM capability_grant_budgets
1027            WHERE (?1 IS NULL OR seq > ?1)
1028            ORDER BY seq ASC
1029            LIMIT ?2
1030            "#,
1031        )?;
1032        let rows = statement.query_map(params![after_seq, limit as i64], record_from_row)?;
1033        let rows = rows.collect::<Result<Vec<_>, _>>()?;
1034        drop(statement);
1035        transaction.rollback()?;
1036        Ok(rows)
1037    }
1038
1039    pub fn list_all_usages(&self) -> Result<Vec<BudgetUsageRecord>, BudgetStoreError> {
1040        let mut connection = self.connection()?;
1041        let transaction = self.begin_read(&mut connection)?;
1042        let mut statement = transaction.prepare(
1043            r#"
1044            SELECT
1045                capability_id,
1046                grant_index,
1047                invocation_count,
1048                updated_at,
1049                seq,
1050                total_cost_exposed,
1051                total_cost_realized_spend
1052            FROM capability_grant_budgets
1053            ORDER BY updated_at DESC, capability_id ASC, grant_index ASC
1054            "#,
1055        )?;
1056        let rows = statement.query_map([], record_from_row)?;
1057        let rows = rows.collect::<Result<Vec<_>, _>>()?;
1058        drop(statement);
1059        transaction.rollback()?;
1060        Ok(rows)
1061    }
1062
1063    pub fn list_mutation_events_after_seq(
1064        &self,
1065        limit: usize,
1066        after_event_seq: u64,
1067    ) -> Result<Vec<BudgetMutationRecord>, BudgetStoreError> {
1068        let after_event_seq = budget_u64_to_sqlite(after_event_seq, "after_event_seq")?;
1069        let mut connection = self.connection()?;
1070        let transaction = self.begin_read(&mut connection)?;
1071        let mut statement = transaction.prepare(
1072            r#"
1073            SELECT event_id
1074            FROM budget_mutation_events
1075            WHERE event_seq > ?1
1076            ORDER BY event_seq ASC
1077            LIMIT ?2
1078            "#,
1079        )?;
1080        let event_ids = statement
1081            .query_map(params![after_event_seq, limit as i64], |row| {
1082                row.get::<_, String>(0)
1083            })?
1084            .collect::<Result<Vec<_>, _>>()?;
1085        drop(statement);
1086        let events = event_ids
1087            .iter()
1088            .map(|event_id| {
1089                Self::load_projected_mutation_event(&transaction, event_id)?.ok_or_else(|| {
1090                    BudgetStoreError::Invariant(format!(
1091                        "budget mutation event `{event_id}` disappeared while listing"
1092                    ))
1093                })
1094            })
1095            .collect::<Result<Vec<_>, _>>()?;
1096        transaction.rollback()?;
1097        Ok(events)
1098    }
1099
1100    fn generated_event_id(
1101        transaction: &rusqlite::Transaction<'_>,
1102    ) -> Result<String, BudgetStoreError> {
1103        let count =
1104            transaction.query_row("SELECT COUNT(*) FROM budget_mutation_events", [], |row| {
1105                row.get::<_, i64>(0)
1106            })?;
1107        Ok(format!(
1108            "sqlite-budget-event-{}-{}",
1109            unix_now(),
1110            count.max(0) + 1
1111        ))
1112    }
1113
1114    pub(super) fn load_hold(
1115        transaction: &rusqlite::Transaction<'_>,
1116        hold_id: &str,
1117    ) -> Result<Option<SqliteBudgetHold>, BudgetStoreError> {
1118        transaction
1119            .query_row(
1120                r#"
1121                SELECT
1122                    hold_id,
1123                    capability_id,
1124                    grant_index,
1125                    authorized_exposure_units,
1126                    remaining_exposure_units,
1127                    invocation_count_debited,
1128                    invocation_captured,
1129                    disposition,
1130                    authority_id,
1131                    lease_id,
1132                    lease_epoch
1133                FROM budget_authorization_holds
1134                WHERE hold_id = ?1
1135                "#,
1136                params![hold_id],
1137                |row| {
1138                    let disposition = row.get::<_, String>(7)?;
1139                    let authority =
1140                        sqlite_budget_event_authority(row.get(8)?, row.get(9)?, row.get(10)?)?;
1141                    Ok(SqliteBudgetHold {
1142                        hold_id: row.get(0)?,
1143                        capability_id: row.get(1)?,
1144                        grant_index: budget_usize_from_row(row, 2, "grant_index")?,
1145                        authorized_exposure_units: budget_u64_from_row(
1146                            row,
1147                            3,
1148                            "authorized_exposure_units",
1149                        )?,
1150                        remaining_exposure_units: budget_u64_from_row(
1151                            row,
1152                            4,
1153                            "remaining_exposure_units",
1154                        )?,
1155                        invocation_count_debited: row.get::<_, i64>(5)? > 0,
1156                        invocation_captured: row.get::<_, i64>(6)? > 0,
1157                        disposition: HoldDisposition::parse(&disposition).ok_or_else(|| {
1158                            rusqlite::Error::FromSqlConversionFailure(
1159                                7,
1160                                rusqlite::types::Type::Text,
1161                                Box::new(std::io::Error::new(
1162                                    std::io::ErrorKind::InvalidData,
1163                                    format!("unknown hold disposition `{disposition}`"),
1164                                )),
1165                            )
1166                        })?,
1167                        authority,
1168                    })
1169                },
1170            )
1171            .optional()
1172            .map_err(Into::into)
1173    }
1174
1175    pub(super) fn has_captured_hold(
1176        transaction: &rusqlite::Transaction<'_>,
1177        capability_id: &str,
1178        grant_index: usize,
1179    ) -> Result<bool, BudgetStoreError> {
1180        transaction
1181            .query_row(
1182                r#"
1183                SELECT EXISTS(
1184                    SELECT 1
1185                    FROM budget_authorization_holds
1186                    WHERE capability_id = ?1
1187                      AND grant_index = ?2
1188                      AND invocation_captured = 1
1189                )
1190                "#,
1191                params![capability_id, grant_index as i64],
1192                |row| row.get(0),
1193            )
1194            .map_err(Into::into)
1195    }
1196
1197    pub(super) fn has_live_hold(
1198        transaction: &rusqlite::Transaction<'_>,
1199        capability_id: &str,
1200        grant_index: usize,
1201    ) -> Result<bool, BudgetStoreError> {
1202        transaction
1203            .query_row(
1204                r#"
1205                SELECT EXISTS(
1206                    SELECT 1
1207                    FROM budget_authorization_holds
1208                    WHERE capability_id = ?1
1209                      AND grant_index = ?2
1210                      AND invocation_count_debited = 1
1211                      AND disposition != 'reversed'
1212                )
1213                "#,
1214                params![capability_id, grant_index as i64],
1215                |row| row.get(0),
1216            )
1217            .map_err(Into::into)
1218    }
1219
1220    pub(super) fn load_mutation_event(
1221        connection: &Connection,
1222        event_id: &str,
1223    ) -> Result<Option<BudgetMutationRecord>, BudgetStoreError> {
1224        connection
1225            .query_row(
1226                r#"
1227                SELECT
1228                    event_id,
1229                    hold_id,
1230                    capability_id,
1231                    grant_index,
1232                    kind,
1233                    allowed,
1234                    recorded_at,
1235                    event_seq,
1236                    usage_seq,
1237                    exposure_units,
1238                    realized_spend_units,
1239                    max_invocations,
1240                    max_exposure_per_invocation,
1241                    max_total_exposure_units,
1242                    invocation_count_after,
1243                    total_cost_exposed_after,
1244                    total_cost_realized_spend_after,
1245                    authority_id,
1246                    lease_id,
1247                    lease_epoch,
1248                    authorization_outcome,
1249                    invocation_state_before,
1250                    invocation_state_after,
1251                    monetary_state_before,
1252                    monetary_state_after
1253                FROM budget_mutation_events
1254                WHERE event_id = ?1
1255                "#,
1256                params![event_id],
1257                mutation_record_from_row,
1258            )
1259            .optional()
1260            .map_err(Into::into)
1261    }
1262
1263    pub(super) fn create_hold(
1264        transaction: &rusqlite::Transaction<'_>,
1265        hold_id: &str,
1266        capability_id: &str,
1267        grant_index: usize,
1268        authorized_exposure_units: u64,
1269        authority: Option<&BudgetEventAuthority>,
1270    ) -> Result<(), BudgetStoreError> {
1271        let now = unix_now();
1272        transaction.execute(
1273            r#"
1274            INSERT INTO budget_authorization_holds (
1275                hold_id,
1276                capability_id,
1277                grant_index,
1278                authorized_exposure_units,
1279                remaining_exposure_units,
1280                invocation_count_debited,
1281                invocation_captured,
1282                disposition,
1283                authority_id,
1284                lease_id,
1285                lease_epoch,
1286                created_at,
1287                updated_at
1288            ) VALUES (?1, ?2, ?3, ?4, ?5, 1, 0, ?6, ?7, ?8, ?9, ?10, ?10)
1289            "#,
1290            params![
1291                hold_id,
1292                capability_id,
1293                grant_index as i64,
1294                budget_u64_to_sqlite(authorized_exposure_units, "authorized_exposure_units",)?,
1295                budget_u64_to_sqlite(authorized_exposure_units, "remaining_exposure_units",)?,
1296                HoldDisposition::Open.as_str(),
1297                authority.map(|value| value.authority_id.as_str()),
1298                authority.map(|value| value.lease_id.as_str()),
1299                authority
1300                    .map(|value| budget_u64_to_sqlite(value.lease_epoch, "lease_epoch"))
1301                    .transpose()?,
1302                now,
1303            ],
1304        )?;
1305        Ok(())
1306    }
1307
1308    pub(super) fn update_hold(
1309        transaction: &rusqlite::Transaction<'_>,
1310        hold_id: &str,
1311        remaining_exposure_units: u64,
1312        disposition: HoldDisposition,
1313        authority: Option<&BudgetEventAuthority>,
1314    ) -> Result<(), BudgetStoreError> {
1315        transaction.execute(
1316            r#"
1317            UPDATE budget_authorization_holds
1318            SET remaining_exposure_units = ?2,
1319                disposition = ?3,
1320                authority_id = ?4,
1321                lease_id = ?5,
1322                lease_epoch = ?6,
1323                updated_at = ?7
1324            WHERE hold_id = ?1
1325            "#,
1326            params![
1327                hold_id,
1328                budget_u64_to_sqlite(remaining_exposure_units, "remaining_exposure_units",)?,
1329                disposition.as_str(),
1330                authority.map(|value| value.authority_id.as_str()),
1331                authority.map(|value| value.lease_id.as_str()),
1332                authority
1333                    .map(|value| budget_u64_to_sqlite(value.lease_epoch, "lease_epoch"))
1334                    .transpose()?,
1335                unix_now(),
1336            ],
1337        )?;
1338        Ok(())
1339    }
1340
1341    #[allow(clippy::too_many_arguments)]
1342    pub(super) fn upsert_hold(
1343        transaction: &rusqlite::Transaction<'_>,
1344        hold_id: &str,
1345        capability_id: &str,
1346        grant_index: usize,
1347        authorized_exposure_units: u64,
1348        remaining_exposure_units: u64,
1349        invocation_captured: bool,
1350        disposition: HoldDisposition,
1351        authority: Option<&BudgetEventAuthority>,
1352    ) -> Result<(), BudgetStoreError> {
1353        let now = unix_now();
1354        transaction.execute(
1355            r#"
1356            INSERT INTO budget_authorization_holds (
1357                hold_id,
1358                capability_id,
1359                grant_index,
1360                authorized_exposure_units,
1361                remaining_exposure_units,
1362                invocation_count_debited,
1363                invocation_captured,
1364                disposition,
1365                authority_id,
1366                lease_id,
1367                lease_epoch,
1368                created_at,
1369                updated_at
1370            ) VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6, ?7, ?8, ?9, ?10, ?11, ?11)
1371            ON CONFLICT(hold_id) DO UPDATE SET
1372                capability_id = excluded.capability_id,
1373                grant_index = excluded.grant_index,
1374                authorized_exposure_units = excluded.authorized_exposure_units,
1375                remaining_exposure_units = excluded.remaining_exposure_units,
1376                invocation_count_debited = excluded.invocation_count_debited,
1377                invocation_captured = excluded.invocation_captured,
1378                disposition = excluded.disposition,
1379                authority_id = excluded.authority_id,
1380                lease_id = excluded.lease_id,
1381                lease_epoch = excluded.lease_epoch,
1382                updated_at = excluded.updated_at
1383            "#,
1384            params![
1385                hold_id,
1386                capability_id,
1387                grant_index as i64,
1388                budget_u64_to_sqlite(authorized_exposure_units, "authorized_exposure_units",)?,
1389                budget_u64_to_sqlite(remaining_exposure_units, "remaining_exposure_units",)?,
1390                if invocation_captured { 1_i64 } else { 0_i64 },
1391                disposition.as_str(),
1392                authority.map(|value| value.authority_id.as_str()),
1393                authority.map(|value| value.lease_id.as_str()),
1394                authority
1395                    .map(|value| budget_u64_to_sqlite(value.lease_epoch, "lease_epoch"))
1396                    .transpose()?,
1397                now,
1398            ],
1399        )?;
1400        Ok(())
1401    }
1402
1403    pub(super) fn delete_hold_if_exists(
1404        transaction: &rusqlite::Transaction<'_>,
1405        hold_id: &str,
1406    ) -> Result<(), BudgetStoreError> {
1407        transaction.execute(
1408            "DELETE FROM budget_authorization_holds WHERE hold_id = ?1",
1409            params![hold_id],
1410        )?;
1411        Ok(())
1412    }
1413
1414    pub(super) fn ensure_open_hold(
1415        transaction: &rusqlite::Transaction<'_>,
1416        hold_id: &str,
1417        capability_id: &str,
1418        grant_index: usize,
1419    ) -> Result<SqliteBudgetHold, BudgetStoreError> {
1420        let hold = Self::load_hold(transaction, hold_id)?.ok_or_else(|| {
1421            BudgetStoreError::Invariant(format!("missing budget hold `{hold_id}`"))
1422        })?;
1423        if hold.capability_id != capability_id || hold.grant_index != grant_index {
1424            return Err(BudgetStoreError::Invariant(format!(
1425                "budget hold `{hold_id}` does not match capability/grant"
1426            )));
1427        }
1428        if hold.disposition != HoldDisposition::Open {
1429            return Err(BudgetStoreError::Invariant(format!(
1430                "budget hold `{hold_id}` is no longer open"
1431            )));
1432        }
1433        Ok(hold)
1434    }
1435
1436    pub(super) fn validate_hold_authority(
1437        hold_id: &str,
1438        current: Option<&BudgetEventAuthority>,
1439        requested: Option<&BudgetEventAuthority>,
1440    ) -> Result<Option<BudgetEventAuthority>, BudgetStoreError> {
1441        match (current, requested) {
1442            (None, None) => Ok(None),
1443            (None, Some(_)) => Err(BudgetStoreError::Invariant(format!(
1444                "budget hold `{hold_id}` was created without authority lease metadata"
1445            ))),
1446            (Some(_), None) => Err(BudgetStoreError::Invariant(format!(
1447                "budget hold `{hold_id}` requires authority lease metadata"
1448            ))),
1449            (Some(current), Some(requested)) => {
1450                if current.authority_id != requested.authority_id {
1451                    return Err(BudgetStoreError::Invariant(format!(
1452                        "budget hold `{hold_id}` authority_id does not match the open lease"
1453                    )));
1454                }
1455                if requested.lease_id != current.lease_id {
1456                    return Err(BudgetStoreError::Invariant(format!(
1457                        "budget hold `{hold_id}` lease_id does not match the open lease epoch"
1458                    )));
1459                }
1460                if requested.lease_epoch < current.lease_epoch {
1461                    return Err(BudgetStoreError::Invariant(format!(
1462                        "budget hold `{hold_id}` authority lease epoch regressed"
1463                    )));
1464                }
1465                if requested.lease_epoch > current.lease_epoch {
1466                    return Err(BudgetStoreError::Invariant(format!(
1467                        "budget hold `{hold_id}` authority lease epoch advanced beyond the open lease"
1468                    )));
1469                }
1470                Ok(Some(requested.clone()))
1471            }
1472        }
1473    }
1474
1475    pub(super) fn validate_replay_authority(
1476        event_id: &str,
1477        persisted: Option<&BudgetEventAuthority>,
1478        requested: Option<&BudgetEventAuthority>,
1479    ) -> Result<(), BudgetStoreError> {
1480        if persisted == requested {
1481            return Ok(());
1482        }
1483        Err(BudgetStoreError::Invariant(format!(
1484            "budget event_id `{event_id}` authority metadata does not match the original mutation"
1485        )))
1486    }
1487
1488    fn existing_increment_allowed(
1489        transaction: &rusqlite::Transaction<'_>,
1490        event_id: Option<&str>,
1491        capability_id: &str,
1492        grant_index: usize,
1493        max_invocations: Option<u32>,
1494    ) -> Result<Option<bool>, BudgetStoreError> {
1495        let Some(event_id) = event_id else {
1496            return Ok(None);
1497        };
1498        let existing = transaction
1499            .query_row(
1500                r#"
1501                SELECT capability_id, grant_index, kind, allowed, max_invocations
1502                FROM budget_mutation_events
1503                WHERE event_id = ?1
1504                "#,
1505                params![event_id],
1506                |row| {
1507                    Ok((
1508                        row.get::<_, String>(0)?,
1509                        budget_usize_from_row(row, 1, "grant_index")?,
1510                        row.get::<_, String>(2)?,
1511                        row.get::<_, Option<i64>>(3)?,
1512                        optional_budget_u32_from_row(row, 4, "max_invocations")?,
1513                    ))
1514                },
1515            )
1516            .optional()?;
1517        let Some((
1518            existing_capability_id,
1519            existing_grant_index,
1520            existing_kind,
1521            existing_allowed,
1522            existing_max_invocations,
1523        )) = existing
1524        else {
1525            return Ok(None);
1526        };
1527        let mutation_matches = existing_capability_id == capability_id
1528            && existing_grant_index == grant_index
1529            && existing_kind == BudgetMutationKind::IncrementInvocation.as_str()
1530            && existing_max_invocations == max_invocations;
1531        if !mutation_matches {
1532            return Err(BudgetStoreError::Invariant(format!(
1533                "budget event_id `{event_id}` was reused for a different mutation"
1534            )));
1535        }
1536        Ok(Some(existing_allowed.unwrap_or(0) > 0))
1537    }
1538
1539    #[allow(clippy::too_many_arguments)]
1540    pub(super) fn existing_event_allowed(
1541        transaction: &rusqlite::Transaction<'_>,
1542        event_id: Option<&str>,
1543        kind: BudgetMutationKind,
1544        capability_id: &str,
1545        grant_index: usize,
1546        hold_id: Option<&str>,
1547        authority: Option<&BudgetEventAuthority>,
1548        exposure_units: u64,
1549        realized_spend_units: u64,
1550        max_invocations: Option<u32>,
1551        max_cost_per_invocation: Option<u64>,
1552        max_total_cost_units: Option<u64>,
1553    ) -> Result<Option<Option<bool>>, BudgetStoreError> {
1554        let Some(event_id) = event_id else {
1555            return Ok(None);
1556        };
1557        let existing = transaction
1558            .query_row(
1559                r#"
1560                SELECT
1561                    hold_id,
1562                    capability_id,
1563                    grant_index,
1564                    kind,
1565                    allowed,
1566                    exposure_units,
1567                    realized_spend_units,
1568                    max_invocations,
1569                    max_exposure_per_invocation,
1570                    max_total_exposure_units
1571                FROM budget_mutation_events
1572                WHERE event_id = ?1
1573                "#,
1574                params![event_id],
1575                |row| {
1576                    Ok((
1577                        row.get::<_, Option<String>>(0)?,
1578                        row.get::<_, String>(1)?,
1579                        budget_usize_from_row(row, 2, "grant_index")?,
1580                        row.get::<_, String>(3)?,
1581                        row.get::<_, Option<i64>>(4)?,
1582                        budget_u64_from_row(row, 5, "exposure_units")?,
1583                        budget_u64_from_row(row, 6, "realized_spend_units")?,
1584                        optional_budget_u32_from_row(row, 7, "max_invocations")?,
1585                        optional_budget_u64_from_row(row, 8, "max_exposure_per_invocation")?,
1586                        optional_budget_u64_from_row(row, 9, "max_total_exposure_units")?,
1587                    ))
1588                },
1589            )
1590            .optional()?;
1591        let Some((
1592            existing_hold_id,
1593            existing_capability_id,
1594            existing_grant_index,
1595            existing_kind,
1596            existing_allowed,
1597            existing_exposure_units,
1598            existing_realized_spend_units,
1599            existing_max_invocations,
1600            existing_max_exposure_per_invocation,
1601            existing_max_total_exposure_units,
1602        )) = existing
1603        else {
1604            return Ok(None);
1605        };
1606        let max_invocations_matches = existing_max_invocations == max_invocations;
1607        let max_per_matches = existing_max_exposure_per_invocation == max_cost_per_invocation;
1608        let max_total_matches = existing_max_total_exposure_units == max_total_cost_units;
1609        let mutation_matches = existing_capability_id == capability_id
1610            && existing_grant_index == grant_index
1611            && existing_kind == kind.as_str()
1612            && existing_hold_id.as_deref() == hold_id
1613            && existing_exposure_units == exposure_units
1614            && existing_realized_spend_units == realized_spend_units
1615            && max_invocations_matches
1616            && max_per_matches
1617            && max_total_matches;
1618        let existing_allowed = existing_allowed.map(|value| value > 0);
1619        let existing_record =
1620            Self::load_mutation_event(transaction, event_id)?.ok_or_else(|| {
1621                BudgetStoreError::Invariant(
1622                    "budget mutation event disappeared during idempotency check".to_string(),
1623                )
1624            })?;
1625        if !mutation_matches {
1626            return Err(BudgetStoreError::Invariant(format!(
1627                "budget event_id `{event_id}` was reused for a different mutation"
1628            )));
1629        }
1630        Self::validate_replay_authority(event_id, existing_record.authority.as_ref(), authority)?;
1631        Ok(Some(existing_allowed))
1632    }
1633
1634    #[allow(clippy::too_many_arguments)]
1635    pub(super) fn append_mutation_event(
1636        transaction: &rusqlite::Transaction<'_>,
1637        event_id: Option<&str>,
1638        hold_id: Option<&str>,
1639        authority: Option<&BudgetEventAuthority>,
1640        capability_id: &str,
1641        grant_index: usize,
1642        kind: BudgetMutationKind,
1643        allowed: Option<bool>,
1644        event_seq: u64,
1645        usage_seq: Option<u64>,
1646        exposure_units: u64,
1647        realized_spend_units: u64,
1648        max_invocations: Option<u32>,
1649        max_cost_per_invocation: Option<u64>,
1650        max_total_cost_units: Option<u64>,
1651        invocation_count_after: u32,
1652        total_cost_exposed_after: u64,
1653        total_cost_realized_spend_after: u64,
1654    ) -> Result<BudgetMutationRecord, BudgetStoreError> {
1655        validate_budget_grant_index(grant_index)?;
1656        let event_id = match event_id {
1657            Some(event_id) => event_id.to_string(),
1658            None => Self::generated_event_id(transaction)?,
1659        };
1660        let recorded_at = unix_now();
1661        let (
1662            authorization_outcome,
1663            invocation_state_before,
1664            invocation_state_after,
1665            monetary_state_before,
1666            monetary_state_after,
1667        ) = appended_event_lifecycle(transaction, hold_id, kind, allowed, exposure_units)?;
1668        transaction.execute(
1669            r#"
1670            INSERT INTO budget_mutation_events (
1671                event_id,
1672                hold_id,
1673                capability_id,
1674                grant_index,
1675                kind,
1676                allowed,
1677                recorded_at,
1678                event_seq,
1679                usage_seq,
1680                exposure_units,
1681                realized_spend_units,
1682                max_invocations,
1683                max_exposure_per_invocation,
1684                max_total_exposure_units,
1685                invocation_count_after,
1686                total_cost_exposed_after,
1687                total_cost_realized_spend_after,
1688                authority_id,
1689                lease_id,
1690                lease_epoch,
1691                authorization_outcome,
1692                invocation_state_before,
1693                invocation_state_after,
1694                monetary_state_before,
1695                monetary_state_after
1696            ) VALUES (
1697                ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10,
1698                ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20,
1699                ?21, ?22, ?23, ?24, ?25
1700            )
1701            "#,
1702            params![
1703                event_id,
1704                hold_id,
1705                capability_id,
1706                grant_index as i64,
1707                kind.as_str(),
1708                allowed.map(|value| if value { 1_i64 } else { 0_i64 }),
1709                recorded_at,
1710                budget_u64_to_sqlite(event_seq, "event_seq")?,
1711                optional_budget_u64_to_sqlite(usage_seq, "usage_seq")?,
1712                budget_u64_to_sqlite(exposure_units, "exposure_units")?,
1713                budget_u64_to_sqlite(realized_spend_units, "realized_spend_units")?,
1714                max_invocations.map(i64::from),
1715                optional_budget_u64_to_sqlite(
1716                    max_cost_per_invocation,
1717                    "max_exposure_per_invocation",
1718                )?,
1719                optional_budget_u64_to_sqlite(max_total_cost_units, "max_total_exposure_units",)?,
1720                i64::from(invocation_count_after),
1721                budget_u64_to_sqlite(total_cost_exposed_after, "total_cost_exposed_after",)?,
1722                budget_u64_to_sqlite(
1723                    total_cost_realized_spend_after,
1724                    "total_cost_realized_spend_after",
1725                )?,
1726                authority.map(|value| value.authority_id.as_str()),
1727                authority.map(|value| value.lease_id.as_str()),
1728                authority
1729                    .map(|value| budget_u64_to_sqlite(value.lease_epoch, "lease_epoch"))
1730                    .transpose()?,
1731                authorization_outcome.map(budget_authorization_outcome_text),
1732                budget_invocation_state_text(invocation_state_before),
1733                budget_invocation_state_text(invocation_state_after),
1734                budget_monetary_state_text(monetary_state_before),
1735                budget_monetary_state_text(monetary_state_after),
1736            ],
1737        )?;
1738        if usage_seq == Some(event_seq) {
1739            let changed = transaction.execute(
1740                r#"
1741                UPDATE capability_grant_budgets SET updated_at = ?3
1742                WHERE capability_id = ?1 AND grant_index = ?2 AND seq = ?4
1743                "#,
1744                params![
1745                    capability_id,
1746                    grant_index as i64,
1747                    recorded_at,
1748                    budget_u64_to_sqlite(event_seq, "event_seq")?,
1749                ],
1750            )?;
1751            if changed != 1 {
1752                return Err(BudgetStoreError::Invariant(format!(
1753                    "budget event `{event_id}` has no matching usage projection"
1754                )));
1755            }
1756        }
1757        Ok(BudgetMutationRecord {
1758            event_id,
1759            hold_id: hold_id.map(ToOwned::to_owned),
1760            admission_binding: None,
1761            capability_id: capability_id.to_string(),
1762            grant_index: grant_index as u32,
1763            kind,
1764            allowed,
1765            authorization_outcome,
1766            invocation_state_before,
1767            invocation_state_after,
1768            monetary_state_before,
1769            monetary_state_after,
1770            recorded_at,
1771            event_seq,
1772            usage_seq,
1773            exposure_units,
1774            realized_spend_units,
1775            max_invocations,
1776            max_cost_per_invocation,
1777            max_total_cost_units,
1778            invocation_count_after,
1779            invocation_quota_usages: Vec::new(),
1780            invocation_quota_mutations: Vec::new(),
1781            cumulative_approval: None,
1782            cumulative_approval_mutation: None,
1783            cumulative_approval_set_digest: None,
1784            total_cost_exposed_after,
1785            total_cost_realized_spend_after,
1786            authority: authority.cloned(),
1787        })
1788    }
1789
1790    pub fn try_increment_with_event_id(
1791        &self,
1792        capability_id: &str,
1793        grant_index: usize,
1794        max_invocations: Option<u32>,
1795        event_id: Option<&str>,
1796    ) -> Result<bool, BudgetStoreError> {
1797        self.require_standalone_mutation("unbound invocation increment")?;
1798        validate_budget_grant_index(grant_index)?;
1799        let mut connection = self.connection()?;
1800        let transaction = self.begin_write(&mut connection)?;
1801        Self::reject_legacy_admission_after_composite_history(
1802            &transaction,
1803            capability_id,
1804            grant_index,
1805        )?;
1806
1807        if let Some(allowed) = SqliteBudgetStore::existing_increment_allowed(
1808            &transaction,
1809            event_id,
1810            capability_id,
1811            grant_index,
1812            max_invocations,
1813        )? {
1814            transaction.rollback()?;
1815            return Ok(allowed);
1816        }
1817
1818        let current: Option<(u32, u64, u64)> = transaction
1819            .query_row(
1820                r#"
1821                SELECT invocation_count, total_cost_exposed, total_cost_realized_spend
1822                FROM capability_grant_budgets
1823                WHERE capability_id = ?1 AND grant_index = ?2
1824                "#,
1825                params![capability_id, grant_index as i64],
1826                |row| {
1827                    Ok((
1828                        budget_u32_from_row(row, 0, "invocation_count")?,
1829                        budget_u64_from_row(row, 1, "total_cost_exposed")?,
1830                        budget_u64_from_row(row, 2, "total_cost_realized_spend")?,
1831                    ))
1832                },
1833            )
1834            .optional()?;
1835        let (current, total_cost_exposed, total_cost_realized_spend) = current.unwrap_or((0, 0, 0));
1836        let updated_at = unix_now();
1837
1838        if let Some(max) = max_invocations {
1839            if current >= max {
1840                let event_seq = allocate_budget_replication_seq(&transaction)?;
1841                SqliteBudgetStore::append_mutation_event(
1842                    &transaction,
1843                    event_id,
1844                    None,
1845                    None,
1846                    capability_id,
1847                    grant_index,
1848                    BudgetMutationKind::IncrementInvocation,
1849                    Some(false),
1850                    event_seq,
1851                    None,
1852                    0,
1853                    0,
1854                    max_invocations,
1855                    None,
1856                    None,
1857                    current,
1858                    total_cost_exposed,
1859                    total_cost_realized_spend,
1860                )?;
1861                transaction.commit()?;
1862                return Ok(false);
1863            }
1864        }
1865
1866        let invocation_count_after = current.checked_add(1).ok_or_else(|| {
1867            BudgetStoreError::Overflow("invocation count overflowed u32".to_string())
1868        })?;
1869        let seq = allocate_budget_replication_seq(&transaction)?;
1870        transaction.execute(
1871            r#"
1872            INSERT INTO capability_grant_budgets (
1873                capability_id,
1874                grant_index,
1875                invocation_count,
1876                updated_at,
1877                seq,
1878                total_cost_exposed,
1879                total_cost_realized_spend
1880            ) VALUES (?1, ?2, ?3, ?4, ?5, 0, 0)
1881            ON CONFLICT(capability_id, grant_index) DO UPDATE SET
1882                invocation_count = excluded.invocation_count,
1883                updated_at = excluded.updated_at,
1884                seq = excluded.seq
1885            "#,
1886            params![
1887                capability_id,
1888                grant_index as i64,
1889                i64::from(invocation_count_after),
1890                updated_at,
1891                budget_u64_to_sqlite(seq, "seq")?,
1892            ],
1893        )?;
1894        SqliteBudgetStore::append_mutation_event(
1895            &transaction,
1896            event_id,
1897            None,
1898            None,
1899            capability_id,
1900            grant_index,
1901            BudgetMutationKind::IncrementInvocation,
1902            Some(true),
1903            seq,
1904            Some(seq),
1905            0,
1906            0,
1907            max_invocations,
1908            None,
1909            None,
1910            invocation_count_after,
1911            total_cost_exposed,
1912            total_cost_realized_spend,
1913        )?;
1914        transaction.commit()?;
1915        Ok(true)
1916    }
1917}
1918
1919pub(super) fn map_serving_owner_error(
1920    error: crate::serving_owner::SqliteServingOwnerError,
1921) -> BudgetStoreError {
1922    match error {
1923        crate::serving_owner::SqliteServingOwnerError::OutcomeUnknown(detail) => {
1924            BudgetStoreError::OutcomeUnknown(detail)
1925        }
1926        error => BudgetStoreError::Invariant(error.to_string()),
1927    }
1928}