Skip to main content

chio_store_sqlite/
budget_store.rs

1use std::fs;
2use std::path::Path;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use chio_kernel::budget_store::{BudgetEventAuthority, BudgetMutationKind, BudgetMutationRecord};
6use chio_kernel::{BudgetStore, BudgetStoreError, BudgetUsageRecord};
7use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior};
8
9pub struct SqliteBudgetStore {
10    connection: Connection,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14enum HoldDisposition {
15    Open,
16    Released,
17    Reversed,
18    Reconciled,
19}
20
21impl HoldDisposition {
22    fn as_str(&self) -> &'static str {
23        match self {
24            Self::Open => "open",
25            Self::Released => "released",
26            Self::Reversed => "reversed",
27            Self::Reconciled => "reconciled",
28        }
29    }
30
31    fn parse(value: &str) -> Option<Self> {
32        match value {
33            "open" => Some(Self::Open),
34            "released" => Some(Self::Released),
35            "reversed" => Some(Self::Reversed),
36            "reconciled" => Some(Self::Reconciled),
37            _ => None,
38        }
39    }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43struct SqliteBudgetHold {
44    hold_id: String,
45    capability_id: String,
46    grant_index: usize,
47    authorized_exposure_units: u64,
48    remaining_exposure_units: u64,
49    invocation_count_debited: bool,
50    disposition: HoldDisposition,
51    authority: Option<BudgetEventAuthority>,
52}
53
54impl SqliteBudgetStore {
55    pub fn open(path: impl AsRef<Path>) -> Result<Self, BudgetStoreError> {
56        let path = path.as_ref();
57        if let Some(parent) = path.parent() {
58            fs::create_dir_all(parent)?;
59        }
60
61        let mut connection = Connection::open(path)?;
62        connection.execute_batch(
63            r#"
64            PRAGMA journal_mode = WAL;
65            PRAGMA synchronous = FULL;
66            PRAGMA busy_timeout = 5000;
67
68            CREATE TABLE IF NOT EXISTS capability_grant_budgets (
69                capability_id TEXT NOT NULL,
70                grant_index INTEGER NOT NULL,
71                invocation_count INTEGER NOT NULL,
72                updated_at INTEGER NOT NULL,
73                seq INTEGER NOT NULL DEFAULT 0,
74                total_cost_exposed INTEGER NOT NULL DEFAULT 0,
75                total_cost_realized_spend INTEGER NOT NULL DEFAULT 0,
76                PRIMARY KEY (capability_id, grant_index)
77            );
78
79            CREATE INDEX IF NOT EXISTS idx_capability_grant_budgets_updated_at
80                ON capability_grant_budgets(updated_at);
81
82            CREATE TABLE IF NOT EXISTS budget_replication_meta (
83                singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
84                next_seq INTEGER NOT NULL
85            );
86
87            CREATE TABLE IF NOT EXISTS budget_authorization_holds (
88                hold_id TEXT PRIMARY KEY,
89                capability_id TEXT NOT NULL,
90                grant_index INTEGER NOT NULL,
91                authorized_exposure_units INTEGER NOT NULL,
92                remaining_exposure_units INTEGER NOT NULL,
93                invocation_count_debited INTEGER NOT NULL,
94                disposition TEXT NOT NULL,
95                authority_id TEXT,
96                lease_id TEXT,
97                lease_epoch INTEGER,
98                created_at INTEGER NOT NULL,
99                updated_at INTEGER NOT NULL
100            );
101
102            CREATE INDEX IF NOT EXISTS idx_budget_authorization_holds_capability
103                ON budget_authorization_holds(capability_id, grant_index);
104
105            CREATE TABLE IF NOT EXISTS budget_mutation_events (
106                event_id TEXT PRIMARY KEY,
107                hold_id TEXT,
108                capability_id TEXT NOT NULL,
109                grant_index INTEGER NOT NULL,
110                kind TEXT NOT NULL,
111                allowed INTEGER,
112                recorded_at INTEGER NOT NULL,
113                event_seq INTEGER,
114                usage_seq INTEGER,
115                exposure_units INTEGER NOT NULL DEFAULT 0,
116                realized_spend_units INTEGER NOT NULL DEFAULT 0,
117                max_invocations INTEGER,
118                max_exposure_per_invocation INTEGER,
119                max_total_exposure_units INTEGER,
120                invocation_count_after INTEGER NOT NULL,
121                total_cost_exposed_after INTEGER NOT NULL,
122                total_cost_realized_spend_after INTEGER NOT NULL,
123                authority_id TEXT,
124                lease_id TEXT,
125                lease_epoch INTEGER
126            );
127
128            CREATE INDEX IF NOT EXISTS idx_budget_mutation_events_capability
129                ON budget_mutation_events(capability_id, grant_index, recorded_at);
130
131            CREATE UNIQUE INDEX IF NOT EXISTS idx_budget_mutation_events_event_seq
132                ON budget_mutation_events(event_seq);
133            "#,
134        )?;
135        connection.execute(
136            r#"
137            INSERT INTO budget_replication_meta (singleton, next_seq)
138            VALUES (1, 0)
139            ON CONFLICT(singleton) DO NOTHING
140            "#,
141            [],
142        )?;
143        ensure_budget_seq_column(&connection)?;
144        ensure_split_budget_cost_columns(&connection)?;
145        ensure_budget_hold_authority_columns(&connection)?;
146        ensure_budget_mutation_event_authority_columns(&connection)?;
147        ensure_budget_mutation_event_seq_column(&connection)?;
148        initialize_budget_replication_seq(&mut connection)?;
149
150        Ok(Self { connection })
151    }
152
153    pub fn upsert_usage(&mut self, record: &BudgetUsageRecord) -> Result<(), BudgetStoreError> {
154        let transaction = self
155            .connection
156            .transaction_with_behavior(TransactionBehavior::Immediate)?;
157        Self::upsert_usage_in_transaction(&transaction, record)?;
158        transaction.commit()?;
159        Ok(())
160    }
161
162    pub fn import_snapshot_records(
163        &mut self,
164        usages: &[BudgetUsageRecord],
165        events: &[BudgetMutationRecord],
166    ) -> Result<(), BudgetStoreError> {
167        let transaction = self
168            .connection
169            .transaction_with_behavior(TransactionBehavior::Immediate)?;
170        for usage in usages {
171            Self::upsert_usage_in_transaction(&transaction, usage)?;
172        }
173        for event in events {
174            Self::import_mutation_record_in_transaction(&transaction, event)?;
175        }
176        transaction.commit()?;
177        Ok(())
178    }
179
180    fn upsert_usage_in_transaction(
181        transaction: &rusqlite::Transaction<'_>,
182        record: &BudgetUsageRecord,
183    ) -> Result<(), BudgetStoreError> {
184        raise_budget_replication_seq_floor(transaction, record.seq)?;
185        transaction.execute(
186            r#"
187            INSERT INTO capability_grant_budgets (
188                capability_id,
189                grant_index,
190                invocation_count,
191                updated_at,
192                seq,
193                total_cost_exposed,
194                total_cost_realized_spend
195            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
196            ON CONFLICT(capability_id, grant_index) DO UPDATE SET
197                invocation_count = CASE
198                    WHEN excluded.seq >= capability_grant_budgets.seq
199                        THEN excluded.invocation_count
200                    ELSE capability_grant_budgets.invocation_count
201                END,
202                updated_at = CASE
203                    WHEN excluded.seq >= capability_grant_budgets.seq
204                        THEN excluded.updated_at
205                    ELSE capability_grant_budgets.updated_at
206                END,
207                total_cost_exposed = CASE
208                    WHEN excluded.seq >= capability_grant_budgets.seq
209                        THEN excluded.total_cost_exposed
210                    ELSE capability_grant_budgets.total_cost_exposed
211                END,
212                total_cost_realized_spend = CASE
213                    WHEN excluded.seq >= capability_grant_budgets.seq
214                        THEN excluded.total_cost_realized_spend
215                    ELSE capability_grant_budgets.total_cost_realized_spend
216                END,
217                seq = MAX(capability_grant_budgets.seq, excluded.seq)
218            "#,
219            params![
220                &record.capability_id,
221                record.grant_index as i64,
222                record.invocation_count as i64,
223                record.updated_at,
224                record.seq as i64,
225                record.total_cost_exposed as i64,
226                record.total_cost_realized_spend as i64,
227            ],
228        )?;
229        Ok(())
230    }
231
232    pub fn delete_mutation_event(&mut self, event_id: &str) -> Result<(), BudgetStoreError> {
233        let transaction = self
234            .connection
235            .transaction_with_behavior(TransactionBehavior::Immediate)?;
236        transaction.execute(
237            "DELETE FROM budget_mutation_events WHERE event_id = ?1",
238            params![event_id],
239        )?;
240        transaction.commit()?;
241        Ok(())
242    }
243
244    pub fn delete_hold(&mut self, hold_id: &str) -> Result<(), BudgetStoreError> {
245        let transaction = self
246            .connection
247            .transaction_with_behavior(TransactionBehavior::Immediate)?;
248        transaction.execute(
249            "DELETE FROM budget_authorization_holds WHERE hold_id = ?1",
250            params![hold_id],
251        )?;
252        transaction.commit()?;
253        Ok(())
254    }
255
256    pub fn hold_authority(
257        &mut self,
258        hold_id: &str,
259    ) -> Result<Option<BudgetEventAuthority>, BudgetStoreError> {
260        let transaction = self
261            .connection
262            .transaction_with_behavior(TransactionBehavior::Deferred)?;
263        let authority = Self::load_hold(&transaction, hold_id)?.and_then(|hold| hold.authority);
264        transaction.rollback()?;
265        Ok(authority)
266    }
267
268    pub fn import_mutation_record(
269        &mut self,
270        record: &BudgetMutationRecord,
271    ) -> Result<(), BudgetStoreError> {
272        let transaction = self
273            .connection
274            .transaction_with_behavior(TransactionBehavior::Immediate)?;
275        Self::import_mutation_record_in_transaction(&transaction, record)?;
276        transaction.commit()?;
277        Ok(())
278    }
279
280    fn import_mutation_record_in_transaction(
281        transaction: &rusqlite::Transaction<'_>,
282        record: &BudgetMutationRecord,
283    ) -> Result<(), BudgetStoreError> {
284        raise_budget_replication_seq_floor(transaction, record.event_seq)?;
285        if let Some(usage_seq) = record.usage_seq {
286            raise_budget_replication_seq_floor(transaction, usage_seq)?;
287        }
288
289        let duplicate_event = if let Some(existing) =
290            Self::load_mutation_event(transaction, &record.event_id)?
291        {
292            if existing != *record {
293                if Self::rolled_back_authorize_can_be_replaced(transaction, &existing, record)? {
294                    transaction.execute(
295                        "DELETE FROM budget_mutation_events WHERE event_id = ?1",
296                        params![record.event_id],
297                    )?;
298                    if let Some(hold_id) = record.hold_id.as_deref() {
299                        transaction.execute(
300                            "DELETE FROM budget_authorization_holds WHERE hold_id = ?1",
301                            params![hold_id],
302                        )?;
303                    }
304                    false
305                } else {
306                    return Err(BudgetStoreError::Invariant(format!(
307                        "budget event_id `{}` was reused for a different mutation",
308                        record.event_id
309                    )));
310                }
311            } else {
312                true
313            }
314        } else {
315            transaction.execute(
316                r#"
317                INSERT INTO budget_mutation_events (
318                    event_id,
319                    hold_id,
320                    capability_id,
321                    grant_index,
322                    kind,
323                    allowed,
324                    recorded_at,
325                    event_seq,
326                    usage_seq,
327                    exposure_units,
328                    realized_spend_units,
329                    max_invocations,
330                    max_exposure_per_invocation,
331                    max_total_exposure_units,
332                    invocation_count_after,
333                    total_cost_exposed_after,
334                    total_cost_realized_spend_after,
335                    authority_id,
336                    lease_id,
337                    lease_epoch
338                ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)
339                "#,
340                params![
341                    record.event_id,
342                    record.hold_id,
343                    record.capability_id,
344                    i64::from(record.grant_index),
345                    record.kind.as_str(),
346                    record.allowed.map(|value| if value { 1_i64 } else { 0_i64 }),
347                    record.recorded_at,
348                    record.event_seq as i64,
349                    record.usage_seq.map(|value| value as i64),
350                    record.exposure_units as i64,
351                    record.realized_spend_units as i64,
352                    record.max_invocations.map(i64::from),
353                    record.max_cost_per_invocation.map(|value| value as i64),
354                    record.max_total_cost_units.map(|value| value as i64),
355                    i64::from(record.invocation_count_after),
356                    record.total_cost_exposed_after as i64,
357                    record.total_cost_realized_spend_after as i64,
358                    record.authority.as_ref().map(|value| value.authority_id.as_str()),
359                    record.authority.as_ref().map(|value| value.lease_id.as_str()),
360                    record.authority.as_ref().map(|value| value.lease_epoch as i64),
361                ],
362            )?;
363            false
364        };
365
366        if duplicate_event {
367            return Ok(());
368        }
369
370        Self::apply_imported_hold_state(transaction, record)?;
371        Ok(())
372    }
373
374    pub fn list_usages_after(
375        &self,
376        limit: usize,
377        after_seq: Option<u64>,
378    ) -> Result<Vec<BudgetUsageRecord>, BudgetStoreError> {
379        let mut statement = self.connection.prepare(
380            r#"
381            SELECT
382                capability_id,
383                grant_index,
384                invocation_count,
385                updated_at,
386                seq,
387                total_cost_exposed,
388                total_cost_realized_spend
389            FROM capability_grant_budgets
390            WHERE (?1 IS NULL OR seq > ?1)
391            ORDER BY seq ASC
392            LIMIT ?2
393            "#,
394        )?;
395        let rows = statement.query_map(
396            params![after_seq.map(|value| value as i64), limit as i64],
397            record_from_row,
398        )?;
399        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
400    }
401
402    pub fn list_all_usages(&self) -> Result<Vec<BudgetUsageRecord>, BudgetStoreError> {
403        let mut statement = self.connection.prepare(
404            r#"
405            SELECT
406                capability_id,
407                grant_index,
408                invocation_count,
409                updated_at,
410                seq,
411                total_cost_exposed,
412                total_cost_realized_spend
413            FROM capability_grant_budgets
414            ORDER BY updated_at DESC, capability_id ASC, grant_index ASC
415            "#,
416        )?;
417        let rows = statement.query_map([], record_from_row)?;
418        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
419    }
420
421    pub fn list_mutation_events_after_seq(
422        &self,
423        limit: usize,
424        after_event_seq: u64,
425    ) -> Result<Vec<BudgetMutationRecord>, BudgetStoreError> {
426        let mut statement = self.connection.prepare(
427            r#"
428            SELECT
429                event_id,
430                hold_id,
431                capability_id,
432                grant_index,
433                kind,
434                allowed,
435                recorded_at,
436                event_seq,
437                usage_seq,
438                exposure_units,
439                realized_spend_units,
440                max_invocations,
441                max_exposure_per_invocation,
442                max_total_exposure_units,
443                invocation_count_after,
444                total_cost_exposed_after,
445                total_cost_realized_spend_after,
446                authority_id,
447                lease_id,
448                lease_epoch
449            FROM budget_mutation_events
450            WHERE event_seq > ?1
451            ORDER BY event_seq ASC
452            LIMIT ?2
453            "#,
454        )?;
455        let rows = statement.query_map(params![after_event_seq as i64, limit as i64], |row| {
456            mutation_record_from_row(row)
457        })?;
458        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
459    }
460
461    fn generated_event_id(
462        transaction: &rusqlite::Transaction<'_>,
463    ) -> Result<String, BudgetStoreError> {
464        let count =
465            transaction.query_row("SELECT COUNT(*) FROM budget_mutation_events", [], |row| {
466                row.get::<_, i64>(0)
467            })?;
468        Ok(format!(
469            "sqlite-budget-event-{}-{}",
470            unix_now(),
471            count.max(0) + 1
472        ))
473    }
474
475    fn load_hold(
476        transaction: &rusqlite::Transaction<'_>,
477        hold_id: &str,
478    ) -> Result<Option<SqliteBudgetHold>, BudgetStoreError> {
479        transaction
480            .query_row(
481                r#"
482                SELECT
483                    hold_id,
484                    capability_id,
485                    grant_index,
486                    authorized_exposure_units,
487                    remaining_exposure_units,
488                    invocation_count_debited,
489                    disposition,
490                    authority_id,
491                    lease_id,
492                    lease_epoch
493                FROM budget_authorization_holds
494                WHERE hold_id = ?1
495                "#,
496                params![hold_id],
497                |row| {
498                    let disposition = row.get::<_, String>(6)?;
499                    let authority =
500                        sqlite_budget_event_authority(row.get(7)?, row.get(8)?, row.get(9)?)?;
501                    Ok(SqliteBudgetHold {
502                        hold_id: row.get(0)?,
503                        capability_id: row.get(1)?,
504                        grant_index: row.get::<_, i64>(2)?.max(0) as usize,
505                        authorized_exposure_units: row.get::<_, i64>(3)?.max(0) as u64,
506                        remaining_exposure_units: row.get::<_, i64>(4)?.max(0) as u64,
507                        invocation_count_debited: row.get::<_, i64>(5)? > 0,
508                        disposition: HoldDisposition::parse(&disposition).ok_or_else(|| {
509                            rusqlite::Error::FromSqlConversionFailure(
510                                6,
511                                rusqlite::types::Type::Text,
512                                Box::new(std::io::Error::new(
513                                    std::io::ErrorKind::InvalidData,
514                                    format!("unknown hold disposition `{disposition}`"),
515                                )),
516                            )
517                        })?,
518                        authority,
519                    })
520                },
521            )
522            .optional()
523            .map_err(Into::into)
524    }
525
526    fn load_mutation_event(
527        transaction: &rusqlite::Transaction<'_>,
528        event_id: &str,
529    ) -> Result<Option<BudgetMutationRecord>, BudgetStoreError> {
530        transaction
531            .query_row(
532                r#"
533                SELECT
534                    event_id,
535                    hold_id,
536                    capability_id,
537                    grant_index,
538                    kind,
539                    allowed,
540                    recorded_at,
541                    event_seq,
542                    usage_seq,
543                    exposure_units,
544                    realized_spend_units,
545                    max_invocations,
546                    max_exposure_per_invocation,
547                    max_total_exposure_units,
548                    invocation_count_after,
549                    total_cost_exposed_after,
550                    total_cost_realized_spend_after,
551                    authority_id,
552                    lease_id,
553                    lease_epoch
554                FROM budget_mutation_events
555                WHERE event_id = ?1
556                "#,
557                params![event_id],
558                mutation_record_from_row,
559            )
560            .optional()
561            .map_err(Into::into)
562    }
563
564    fn create_hold(
565        transaction: &rusqlite::Transaction<'_>,
566        hold_id: &str,
567        capability_id: &str,
568        grant_index: usize,
569        authorized_exposure_units: u64,
570        authority: Option<&BudgetEventAuthority>,
571    ) -> Result<(), BudgetStoreError> {
572        let now = unix_now();
573        transaction.execute(
574            r#"
575            INSERT INTO budget_authorization_holds (
576                hold_id,
577                capability_id,
578                grant_index,
579                authorized_exposure_units,
580                remaining_exposure_units,
581                invocation_count_debited,
582                disposition,
583                authority_id,
584                lease_id,
585                lease_epoch,
586                created_at,
587                updated_at
588            ) VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6, ?7, ?8, ?9, ?10, ?10)
589            "#,
590            params![
591                hold_id,
592                capability_id,
593                grant_index as i64,
594                authorized_exposure_units as i64,
595                authorized_exposure_units as i64,
596                HoldDisposition::Open.as_str(),
597                authority.map(|value| value.authority_id.as_str()),
598                authority.map(|value| value.lease_id.as_str()),
599                authority.map(|value| value.lease_epoch as i64),
600                now,
601            ],
602        )?;
603        Ok(())
604    }
605
606    fn update_hold(
607        transaction: &rusqlite::Transaction<'_>,
608        hold_id: &str,
609        remaining_exposure_units: u64,
610        disposition: HoldDisposition,
611        authority: Option<&BudgetEventAuthority>,
612    ) -> Result<(), BudgetStoreError> {
613        transaction.execute(
614            r#"
615            UPDATE budget_authorization_holds
616            SET remaining_exposure_units = ?2,
617                disposition = ?3,
618                authority_id = ?4,
619                lease_id = ?5,
620                lease_epoch = ?6,
621                updated_at = ?7
622            WHERE hold_id = ?1
623            "#,
624            params![
625                hold_id,
626                remaining_exposure_units as i64,
627                disposition.as_str(),
628                authority.map(|value| value.authority_id.as_str()),
629                authority.map(|value| value.lease_id.as_str()),
630                authority.map(|value| value.lease_epoch as i64),
631                unix_now(),
632            ],
633        )?;
634        Ok(())
635    }
636
637    #[allow(clippy::too_many_arguments)]
638    fn upsert_hold(
639        transaction: &rusqlite::Transaction<'_>,
640        hold_id: &str,
641        capability_id: &str,
642        grant_index: usize,
643        authorized_exposure_units: u64,
644        remaining_exposure_units: u64,
645        disposition: HoldDisposition,
646        authority: Option<&BudgetEventAuthority>,
647    ) -> Result<(), BudgetStoreError> {
648        let now = unix_now();
649        transaction.execute(
650            r#"
651            INSERT INTO budget_authorization_holds (
652                hold_id,
653                capability_id,
654                grant_index,
655                authorized_exposure_units,
656                remaining_exposure_units,
657                invocation_count_debited,
658                disposition,
659                authority_id,
660                lease_id,
661                lease_epoch,
662                created_at,
663                updated_at
664            ) VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6, ?7, ?8, ?9, ?10, ?10)
665            ON CONFLICT(hold_id) DO UPDATE SET
666                capability_id = excluded.capability_id,
667                grant_index = excluded.grant_index,
668                authorized_exposure_units = excluded.authorized_exposure_units,
669                remaining_exposure_units = excluded.remaining_exposure_units,
670                invocation_count_debited = excluded.invocation_count_debited,
671                disposition = excluded.disposition,
672                authority_id = excluded.authority_id,
673                lease_id = excluded.lease_id,
674                lease_epoch = excluded.lease_epoch,
675                updated_at = excluded.updated_at
676            "#,
677            params![
678                hold_id,
679                capability_id,
680                grant_index as i64,
681                authorized_exposure_units as i64,
682                remaining_exposure_units as i64,
683                disposition.as_str(),
684                authority.map(|value| value.authority_id.as_str()),
685                authority.map(|value| value.lease_id.as_str()),
686                authority.map(|value| value.lease_epoch as i64),
687                now,
688            ],
689        )?;
690        Ok(())
691    }
692
693    fn delete_hold_if_exists(
694        transaction: &rusqlite::Transaction<'_>,
695        hold_id: &str,
696    ) -> Result<(), BudgetStoreError> {
697        transaction.execute(
698            "DELETE FROM budget_authorization_holds WHERE hold_id = ?1",
699            params![hold_id],
700        )?;
701        Ok(())
702    }
703
704    fn apply_imported_hold_state(
705        transaction: &rusqlite::Transaction<'_>,
706        record: &BudgetMutationRecord,
707    ) -> Result<(), BudgetStoreError> {
708        let Some(hold_id) = record.hold_id.as_deref() else {
709            return Ok(());
710        };
711
712        match record.kind {
713            BudgetMutationKind::IncrementInvocation => Ok(()),
714            BudgetMutationKind::AuthorizeExposure => {
715                if record.allowed == Some(true) {
716                    Self::upsert_hold(
717                        transaction,
718                        hold_id,
719                        &record.capability_id,
720                        record.grant_index as usize,
721                        record.exposure_units,
722                        record.exposure_units,
723                        HoldDisposition::Open,
724                        record.authority.as_ref(),
725                    )
726                } else {
727                    Self::delete_hold_if_exists(transaction, hold_id)
728                }
729            }
730            BudgetMutationKind::ReleaseExposure => {
731                let hold = Self::load_hold(transaction, hold_id)?.ok_or_else(|| {
732                    BudgetStoreError::Invariant(format!(
733                        "missing budget hold `{hold_id}` while importing release event"
734                    ))
735                })?;
736                if hold.capability_id != record.capability_id
737                    || hold.grant_index != record.grant_index as usize
738                {
739                    return Err(BudgetStoreError::Invariant(format!(
740                        "budget hold `{hold_id}` does not match capability/grant"
741                    )));
742                }
743                let remaining = hold
744                    .remaining_exposure_units
745                    .checked_sub(record.exposure_units)
746                    .ok_or_else(|| {
747                        BudgetStoreError::Invariant(format!(
748                            "budget hold `{hold_id}` cannot release more than remaining exposure"
749                        ))
750                    })?;
751                let disposition = if remaining == 0 {
752                    HoldDisposition::Released
753                } else {
754                    HoldDisposition::Open
755                };
756                Self::upsert_hold(
757                    transaction,
758                    hold_id,
759                    &record.capability_id,
760                    record.grant_index as usize,
761                    hold.authorized_exposure_units,
762                    remaining,
763                    disposition,
764                    record.authority.as_ref().or(hold.authority.as_ref()),
765                )
766            }
767            BudgetMutationKind::ReverseExposure => {
768                let authorized_exposure_units = Self::load_hold(transaction, hold_id)?
769                    .map(|hold| hold.authorized_exposure_units)
770                    .unwrap_or(record.exposure_units);
771                Self::upsert_hold(
772                    transaction,
773                    hold_id,
774                    &record.capability_id,
775                    record.grant_index as usize,
776                    authorized_exposure_units,
777                    0,
778                    HoldDisposition::Reversed,
779                    record.authority.as_ref(),
780                )
781            }
782            BudgetMutationKind::ReconcileSpend => {
783                let authorized_exposure_units = Self::load_hold(transaction, hold_id)?
784                    .map(|hold| hold.authorized_exposure_units)
785                    .unwrap_or(record.exposure_units);
786                Self::upsert_hold(
787                    transaction,
788                    hold_id,
789                    &record.capability_id,
790                    record.grant_index as usize,
791                    authorized_exposure_units,
792                    0,
793                    HoldDisposition::Reconciled,
794                    record.authority.as_ref(),
795                )
796            }
797        }
798    }
799
800    fn ensure_open_hold(
801        transaction: &rusqlite::Transaction<'_>,
802        hold_id: &str,
803        capability_id: &str,
804        grant_index: usize,
805    ) -> Result<SqliteBudgetHold, BudgetStoreError> {
806        let hold = Self::load_hold(transaction, hold_id)?.ok_or_else(|| {
807            BudgetStoreError::Invariant(format!("missing budget hold `{hold_id}`"))
808        })?;
809        if hold.capability_id != capability_id || hold.grant_index != grant_index {
810            return Err(BudgetStoreError::Invariant(format!(
811                "budget hold `{hold_id}` does not match capability/grant"
812            )));
813        }
814        if hold.disposition != HoldDisposition::Open {
815            return Err(BudgetStoreError::Invariant(format!(
816                "budget hold `{hold_id}` is no longer open"
817            )));
818        }
819        Ok(hold)
820    }
821
822    fn validate_hold_authority(
823        hold_id: &str,
824        current: Option<&BudgetEventAuthority>,
825        requested: Option<&BudgetEventAuthority>,
826    ) -> Result<Option<BudgetEventAuthority>, BudgetStoreError> {
827        match (current, requested) {
828            (None, None) => Ok(None),
829            (None, Some(_)) => Err(BudgetStoreError::Invariant(format!(
830                "budget hold `{hold_id}` was created without authority lease metadata"
831            ))),
832            (Some(_), None) => Err(BudgetStoreError::Invariant(format!(
833                "budget hold `{hold_id}` requires authority lease metadata"
834            ))),
835            (Some(current), Some(requested)) => {
836                if current.authority_id != requested.authority_id {
837                    return Err(BudgetStoreError::Invariant(format!(
838                        "budget hold `{hold_id}` authority_id does not match the open lease"
839                    )));
840                }
841                if requested.lease_id != current.lease_id {
842                    return Err(BudgetStoreError::Invariant(format!(
843                        "budget hold `{hold_id}` lease_id does not match the open lease epoch"
844                    )));
845                }
846                if requested.lease_epoch < current.lease_epoch {
847                    return Err(BudgetStoreError::Invariant(format!(
848                        "budget hold `{hold_id}` authority lease epoch regressed"
849                    )));
850                }
851                if requested.lease_epoch > current.lease_epoch {
852                    return Err(BudgetStoreError::Invariant(format!(
853                        "budget hold `{hold_id}` authority lease epoch advanced beyond the open lease"
854                    )));
855                }
856                Ok(Some(requested.clone()))
857            }
858        }
859    }
860
861    fn existing_increment_allowed(
862        transaction: &rusqlite::Transaction<'_>,
863        event_id: Option<&str>,
864        capability_id: &str,
865        grant_index: usize,
866        max_invocations: Option<u32>,
867    ) -> Result<Option<bool>, BudgetStoreError> {
868        let Some(event_id) = event_id else {
869            return Ok(None);
870        };
871        let existing = transaction
872            .query_row(
873                r#"
874                SELECT capability_id, grant_index, kind, allowed, max_invocations
875                FROM budget_mutation_events
876                WHERE event_id = ?1
877                "#,
878                params![event_id],
879                |row| {
880                    Ok((
881                        row.get::<_, String>(0)?,
882                        row.get::<_, i64>(1)?.max(0) as usize,
883                        row.get::<_, String>(2)?,
884                        row.get::<_, Option<i64>>(3)?,
885                        row.get::<_, Option<i64>>(4)?,
886                    ))
887                },
888            )
889            .optional()?;
890        let Some((
891            existing_capability_id,
892            existing_grant_index,
893            existing_kind,
894            existing_allowed,
895            existing_max_invocations,
896        )) = existing
897        else {
898            return Ok(None);
899        };
900        let mutation_matches = existing_capability_id == capability_id
901            && existing_grant_index == grant_index
902            && existing_kind == BudgetMutationKind::IncrementInvocation.as_str()
903            && existing_max_invocations.map(|value| value.max(0) as u32) == max_invocations;
904        if !mutation_matches {
905            return Err(BudgetStoreError::Invariant(format!(
906                "budget event_id `{event_id}` was reused for a different mutation"
907            )));
908        }
909        Ok(Some(existing_allowed.unwrap_or(0) > 0))
910    }
911
912    fn sqlite_like_prefix_pattern(prefix: &str) -> String {
913        let mut pattern = String::with_capacity(prefix.len() + 1);
914        for ch in prefix.chars() {
915            match ch {
916                '\\' | '%' | '_' => {
917                    pattern.push('\\');
918                    pattern.push(ch);
919                }
920                _ => pattern.push(ch),
921            }
922        }
923        pattern.push('%');
924        pattern
925    }
926
927    fn rollback_event_exists(
928        transaction: &rusqlite::Transaction<'_>,
929        event_id: &str,
930    ) -> Result<bool, BudgetStoreError> {
931        let legacy_rollback_event_id = format!("{event_id}:rollback");
932        let rollback_prefix = format!("{event_id}:rollback:");
933        let rollback_prefix_pattern = Self::sqlite_like_prefix_pattern(&rollback_prefix);
934        Ok(transaction
935            .query_row(
936                r#"
937                SELECT 1
938                FROM budget_mutation_events
939                WHERE event_id = ?1
940                   OR event_id LIKE ?2 ESCAPE '\'
941                LIMIT 1
942                "#,
943                params![legacy_rollback_event_id, rollback_prefix_pattern],
944                |_| Ok(()),
945            )
946            .optional()?
947            .is_some())
948    }
949
950    fn rolled_back_authorize_can_be_replaced(
951        transaction: &rusqlite::Transaction<'_>,
952        existing: &BudgetMutationRecord,
953        replacement: &BudgetMutationRecord,
954    ) -> Result<bool, BudgetStoreError> {
955        if existing.kind != BudgetMutationKind::AuthorizeExposure
956            || replacement.kind != BudgetMutationKind::AuthorizeExposure
957            || existing.allowed != Some(true)
958            || replacement.allowed != Some(true)
959        {
960            return Ok(false);
961        }
962        let same_mutation_scope = existing.hold_id == replacement.hold_id
963            && existing.capability_id == replacement.capability_id
964            && existing.grant_index == replacement.grant_index
965            && existing.exposure_units == replacement.exposure_units
966            && existing.realized_spend_units == replacement.realized_spend_units
967            && existing.max_invocations == replacement.max_invocations
968            && existing.max_cost_per_invocation == replacement.max_cost_per_invocation
969            && existing.max_total_cost_units == replacement.max_total_cost_units;
970        if !same_mutation_scope {
971            return Ok(false);
972        }
973        Self::rollback_event_exists(transaction, &existing.event_id)
974    }
975
976    #[allow(clippy::too_many_arguments)]
977    fn existing_event_allowed(
978        transaction: &rusqlite::Transaction<'_>,
979        event_id: Option<&str>,
980        kind: BudgetMutationKind,
981        capability_id: &str,
982        grant_index: usize,
983        hold_id: Option<&str>,
984        authority: Option<&BudgetEventAuthority>,
985        exposure_units: u64,
986        realized_spend_units: u64,
987        max_invocations: Option<u32>,
988        max_cost_per_invocation: Option<u64>,
989        max_total_cost_units: Option<u64>,
990    ) -> Result<Option<Option<bool>>, BudgetStoreError> {
991        let Some(event_id) = event_id else {
992            return Ok(None);
993        };
994        let existing = transaction
995            .query_row(
996                r#"
997                SELECT
998                    hold_id,
999                    capability_id,
1000                    grant_index,
1001                    kind,
1002                    allowed,
1003                    exposure_units,
1004                    realized_spend_units,
1005                    max_invocations,
1006                    max_exposure_per_invocation,
1007                    max_total_exposure_units,
1008                    invocation_count_after,
1009                    total_cost_exposed_after,
1010                    total_cost_realized_spend_after,
1011                    authority_id,
1012                    lease_id,
1013                    lease_epoch
1014                FROM budget_mutation_events
1015                WHERE event_id = ?1
1016                "#,
1017                params![event_id],
1018                |row| {
1019                    let authority =
1020                        sqlite_budget_event_authority(row.get(13)?, row.get(14)?, row.get(15)?)?;
1021                    Ok((
1022                        row.get::<_, Option<String>>(0)?,
1023                        row.get::<_, String>(1)?,
1024                        row.get::<_, i64>(2)?.max(0) as usize,
1025                        row.get::<_, String>(3)?,
1026                        row.get::<_, Option<i64>>(4)?,
1027                        row.get::<_, i64>(5)?.max(0) as u64,
1028                        row.get::<_, i64>(6)?.max(0) as u64,
1029                        row.get::<_, Option<i64>>(7)?,
1030                        row.get::<_, Option<i64>>(8)?,
1031                        row.get::<_, Option<i64>>(9)?,
1032                        row.get::<_, i64>(10)?.max(0) as u32,
1033                        row.get::<_, i64>(11)?.max(0) as u64,
1034                        row.get::<_, i64>(12)?.max(0) as u64,
1035                        authority,
1036                    ))
1037                },
1038            )
1039            .optional()?;
1040        let Some((
1041            existing_hold_id,
1042            existing_capability_id,
1043            existing_grant_index,
1044            existing_kind,
1045            existing_allowed,
1046            existing_exposure_units,
1047            existing_realized_spend_units,
1048            existing_max_invocations,
1049            existing_max_exposure_per_invocation,
1050            existing_max_total_exposure_units,
1051            existing_invocation_count_after,
1052            existing_total_cost_exposed_after,
1053            existing_total_cost_realized_spend_after,
1054            existing_authority,
1055        )) = existing
1056        else {
1057            return Ok(None);
1058        };
1059        let max_invocations_matches =
1060            existing_max_invocations.map(|value| value.max(0) as u32) == max_invocations;
1061        let max_per_matches = existing_max_exposure_per_invocation.map(|value| value.max(0) as u64)
1062            == max_cost_per_invocation;
1063        let max_total_matches = existing_max_total_exposure_units.map(|value| value.max(0) as u64)
1064            == max_total_cost_units;
1065        let mutation_matches = existing_capability_id == capability_id
1066            && existing_grant_index == grant_index
1067            && existing_kind == kind.as_str()
1068            && existing_hold_id.as_deref() == hold_id
1069            && existing_exposure_units == exposure_units
1070            && existing_realized_spend_units == realized_spend_units
1071            && max_invocations_matches
1072            && max_per_matches
1073            && max_total_matches;
1074        let existing_allowed = existing_allowed.map(|value| value > 0);
1075        let rollback_exists = kind == BudgetMutationKind::AuthorizeExposure
1076            && existing_allowed == Some(true)
1077            && Self::rollback_event_exists(transaction, event_id)?;
1078        if !mutation_matches {
1079            return Err(BudgetStoreError::Invariant(format!(
1080                "budget event_id `{event_id}` was reused for a different mutation"
1081            )));
1082        }
1083        if rollback_exists {
1084            let current = transaction
1085                .query_row(
1086                    r#"
1087                    SELECT invocation_count, total_cost_exposed, total_cost_realized_spend
1088                    FROM capability_grant_budgets
1089                    WHERE capability_id = ?1 AND grant_index = ?2
1090                    "#,
1091                    params![capability_id, grant_index as i64],
1092                    |row| {
1093                        Ok((
1094                            row.get::<_, i64>(0)?.max(0) as u32,
1095                            row.get::<_, i64>(1)?.max(0) as u64,
1096                            row.get::<_, i64>(2)?.max(0) as u64,
1097                        ))
1098                    },
1099                )
1100                .optional()?;
1101            let usage_matches = current.is_some_and(
1102                |(invocation_count, total_cost_exposed, total_cost_realized_spend)| {
1103                    invocation_count == existing_invocation_count_after
1104                        && total_cost_exposed == existing_total_cost_exposed_after
1105                        && total_cost_realized_spend == existing_total_cost_realized_spend_after
1106                },
1107            );
1108            let hold_matches = match hold_id {
1109                Some(hold_id) => Self::load_hold(transaction, hold_id)?.is_some_and(|hold| {
1110                    hold.capability_id == capability_id
1111                        && hold.grant_index == grant_index
1112                        && hold.authorized_exposure_units == exposure_units
1113                        && hold.remaining_exposure_units == exposure_units
1114                        && hold.invocation_count_debited
1115                        && hold.disposition == HoldDisposition::Open
1116                }),
1117                None => true,
1118            };
1119            if usage_matches && hold_matches {
1120                return Ok(Some(existing_allowed));
1121            }
1122            transaction.execute(
1123                "DELETE FROM budget_mutation_events WHERE event_id = ?1",
1124                params![event_id],
1125            )?;
1126            if let Some(hold_id) = hold_id {
1127                transaction.execute(
1128                    "DELETE FROM budget_authorization_holds WHERE hold_id = ?1",
1129                    params![hold_id],
1130                )?;
1131            }
1132            return Ok(None);
1133        }
1134        if existing_authority.as_ref() != authority {
1135            return Err(BudgetStoreError::Invariant(format!(
1136                "budget event_id `{event_id}` was reused for a different mutation"
1137            )));
1138        }
1139        Ok(Some(existing_allowed))
1140    }
1141
1142    #[allow(clippy::too_many_arguments)]
1143    fn append_mutation_event(
1144        transaction: &rusqlite::Transaction<'_>,
1145        event_id: Option<&str>,
1146        hold_id: Option<&str>,
1147        authority: Option<&BudgetEventAuthority>,
1148        capability_id: &str,
1149        grant_index: usize,
1150        kind: BudgetMutationKind,
1151        allowed: Option<bool>,
1152        event_seq: u64,
1153        usage_seq: Option<u64>,
1154        exposure_units: u64,
1155        realized_spend_units: u64,
1156        max_invocations: Option<u32>,
1157        max_cost_per_invocation: Option<u64>,
1158        max_total_cost_units: Option<u64>,
1159        invocation_count_after: u32,
1160        total_cost_exposed_after: u64,
1161        total_cost_realized_spend_after: u64,
1162    ) -> Result<(), BudgetStoreError> {
1163        let event_id = match event_id {
1164            Some(event_id) => event_id.to_string(),
1165            None => Self::generated_event_id(transaction)?,
1166        };
1167        transaction.execute(
1168            r#"
1169            INSERT INTO budget_mutation_events (
1170                event_id,
1171                hold_id,
1172                capability_id,
1173                grant_index,
1174                kind,
1175                allowed,
1176                recorded_at,
1177                event_seq,
1178                usage_seq,
1179                exposure_units,
1180                realized_spend_units,
1181                max_invocations,
1182                max_exposure_per_invocation,
1183                max_total_exposure_units,
1184                invocation_count_after,
1185                total_cost_exposed_after,
1186                total_cost_realized_spend_after,
1187                authority_id,
1188                lease_id,
1189                lease_epoch
1190            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)
1191            "#,
1192            params![
1193                event_id,
1194                hold_id,
1195                capability_id,
1196                grant_index as i64,
1197                kind.as_str(),
1198                allowed.map(|value| if value { 1_i64 } else { 0_i64 }),
1199                unix_now(),
1200                event_seq as i64,
1201                usage_seq.map(|value| value as i64),
1202                exposure_units as i64,
1203                realized_spend_units as i64,
1204                max_invocations.map(i64::from),
1205                max_cost_per_invocation.map(|value| value as i64),
1206                max_total_cost_units.map(|value| value as i64),
1207                invocation_count_after as i64,
1208                total_cost_exposed_after as i64,
1209                total_cost_realized_spend_after as i64,
1210                authority.map(|value| value.authority_id.as_str()),
1211                authority.map(|value| value.lease_id.as_str()),
1212                authority.map(|value| value.lease_epoch as i64),
1213            ],
1214        )?;
1215        Ok(())
1216    }
1217
1218    pub fn try_increment_with_event_id(
1219        &mut self,
1220        capability_id: &str,
1221        grant_index: usize,
1222        max_invocations: Option<u32>,
1223        event_id: Option<&str>,
1224    ) -> Result<bool, BudgetStoreError> {
1225        let transaction = self
1226            .connection
1227            .transaction_with_behavior(TransactionBehavior::Immediate)?;
1228
1229        if let Some(allowed) = SqliteBudgetStore::existing_increment_allowed(
1230            &transaction,
1231            event_id,
1232            capability_id,
1233            grant_index,
1234            max_invocations,
1235        )? {
1236            transaction.rollback()?;
1237            return Ok(allowed);
1238        }
1239
1240        let current: Option<(u32, u64, u64)> = transaction
1241            .query_row(
1242                r#"
1243                SELECT invocation_count, total_cost_exposed, total_cost_realized_spend
1244                FROM capability_grant_budgets
1245                WHERE capability_id = ?1 AND grant_index = ?2
1246                "#,
1247                params![capability_id, grant_index as i64],
1248                |row| {
1249                    Ok((
1250                        row.get::<_, i64>(0)?.max(0) as u32,
1251                        row.get::<_, i64>(1)?.max(0) as u64,
1252                        row.get::<_, i64>(2)?.max(0) as u64,
1253                    ))
1254                },
1255            )
1256            .optional()?;
1257        let (current, total_cost_exposed, total_cost_realized_spend) = current.unwrap_or((0, 0, 0));
1258        let updated_at = unix_now();
1259
1260        if let Some(max) = max_invocations {
1261            if current >= max {
1262                let event_seq = allocate_budget_replication_seq(&transaction)?;
1263                SqliteBudgetStore::append_mutation_event(
1264                    &transaction,
1265                    event_id,
1266                    None,
1267                    None,
1268                    capability_id,
1269                    grant_index,
1270                    BudgetMutationKind::IncrementInvocation,
1271                    Some(false),
1272                    event_seq,
1273                    None,
1274                    0,
1275                    0,
1276                    max_invocations,
1277                    None,
1278                    None,
1279                    current,
1280                    total_cost_exposed,
1281                    total_cost_realized_spend,
1282                )?;
1283                transaction.commit()?;
1284                return Ok(false);
1285            }
1286        }
1287
1288        let seq = allocate_budget_replication_seq(&transaction)?;
1289        transaction.execute(
1290            r#"
1291            INSERT INTO capability_grant_budgets (
1292                capability_id,
1293                grant_index,
1294                invocation_count,
1295                updated_at,
1296                seq,
1297                total_cost_exposed,
1298                total_cost_realized_spend
1299            ) VALUES (?1, ?2, ?3, ?4, ?5, 0, 0)
1300            ON CONFLICT(capability_id, grant_index) DO UPDATE SET
1301                invocation_count = excluded.invocation_count,
1302                updated_at = excluded.updated_at,
1303                seq = excluded.seq
1304            "#,
1305            params![
1306                capability_id,
1307                grant_index as i64,
1308                current.saturating_add(1) as i64,
1309                updated_at,
1310                seq as i64,
1311            ],
1312        )?;
1313        SqliteBudgetStore::append_mutation_event(
1314            &transaction,
1315            event_id,
1316            None,
1317            None,
1318            capability_id,
1319            grant_index,
1320            BudgetMutationKind::IncrementInvocation,
1321            Some(true),
1322            seq,
1323            Some(seq),
1324            0,
1325            0,
1326            max_invocations,
1327            None,
1328            None,
1329            current.saturating_add(1),
1330            total_cost_exposed,
1331            total_cost_realized_spend,
1332        )?;
1333        transaction.commit()?;
1334        Ok(true)
1335    }
1336}
1337
1338impl BudgetStore for SqliteBudgetStore {
1339    fn try_increment(
1340        &mut self,
1341        capability_id: &str,
1342        grant_index: usize,
1343        max_invocations: Option<u32>,
1344    ) -> Result<bool, BudgetStoreError> {
1345        self.try_increment_with_event_id(capability_id, grant_index, max_invocations, None)
1346    }
1347
1348    fn try_charge_cost(
1349        &mut self,
1350        capability_id: &str,
1351        grant_index: usize,
1352        max_invocations: Option<u32>,
1353        cost_units: u64,
1354        max_cost_per_invocation: Option<u64>,
1355        max_total_cost_units: Option<u64>,
1356    ) -> Result<bool, BudgetStoreError> {
1357        self.try_charge_cost_with_ids(
1358            capability_id,
1359            grant_index,
1360            max_invocations,
1361            cost_units,
1362            max_cost_per_invocation,
1363            max_total_cost_units,
1364            None,
1365            None,
1366        )
1367    }
1368
1369    fn try_charge_cost_with_ids(
1370        &mut self,
1371        capability_id: &str,
1372        grant_index: usize,
1373        max_invocations: Option<u32>,
1374        cost_units: u64,
1375        max_cost_per_invocation: Option<u64>,
1376        max_total_cost_units: Option<u64>,
1377        hold_id: Option<&str>,
1378        event_id: Option<&str>,
1379    ) -> Result<bool, BudgetStoreError> {
1380        self.try_charge_cost_with_ids_and_authority(
1381            capability_id,
1382            grant_index,
1383            max_invocations,
1384            cost_units,
1385            max_cost_per_invocation,
1386            max_total_cost_units,
1387            hold_id,
1388            event_id,
1389            None,
1390        )
1391    }
1392
1393    fn try_charge_cost_with_ids_and_authority(
1394        &mut self,
1395        capability_id: &str,
1396        grant_index: usize,
1397        max_invocations: Option<u32>,
1398        cost_units: u64,
1399        max_cost_per_invocation: Option<u64>,
1400        max_total_cost_units: Option<u64>,
1401        hold_id: Option<&str>,
1402        event_id: Option<&str>,
1403        authority: Option<&BudgetEventAuthority>,
1404    ) -> Result<bool, BudgetStoreError> {
1405        let transaction = self
1406            .connection
1407            .transaction_with_behavior(TransactionBehavior::Immediate)?;
1408
1409        if let Some(existing_allowed) = SqliteBudgetStore::existing_event_allowed(
1410            &transaction,
1411            event_id,
1412            BudgetMutationKind::AuthorizeExposure,
1413            capability_id,
1414            grant_index,
1415            hold_id,
1416            authority,
1417            cost_units,
1418            0,
1419            max_invocations,
1420            max_cost_per_invocation,
1421            max_total_cost_units,
1422        )? {
1423            transaction.rollback()?;
1424            return Ok(existing_allowed.unwrap_or(false));
1425        }
1426
1427        let row: Option<(i64, u64, u64)> = transaction
1428            .query_row(
1429                r#"
1430                SELECT invocation_count, total_cost_exposed, total_cost_realized_spend
1431                FROM capability_grant_budgets
1432                WHERE capability_id = ?1 AND grant_index = ?2
1433                "#,
1434                params![capability_id, grant_index as i64],
1435                |row| {
1436                    Ok((
1437                        row.get(0)?,
1438                        row.get::<_, i64>(1)?.max(0) as u64,
1439                        row.get::<_, i64>(2)?.max(0) as u64,
1440                    ))
1441                },
1442            )
1443            .optional()?;
1444        let (current_count, current_exposed, current_realized) = row.unwrap_or((0, 0, 0));
1445        let current_count = current_count.max(0) as u32;
1446
1447        if let Some(hold_id) = hold_id {
1448            let retry_follows_rollback = match event_id {
1449                Some(event_id) => Self::rollback_event_exists(&transaction, event_id)?,
1450                None => false,
1451            };
1452            if let Some(hold) = SqliteBudgetStore::load_hold(&transaction, hold_id)? {
1453                if hold.capability_id == capability_id
1454                    && hold.grant_index == grant_index
1455                    && hold.authorized_exposure_units == cost_units
1456                    && hold.remaining_exposure_units == cost_units
1457                    && hold.invocation_count_debited
1458                    && hold.disposition == HoldDisposition::Open
1459                    && current_exposed >= cost_units
1460                {
1461                    let current = transaction
1462                        .query_row(
1463                            r#"
1464                            SELECT seq, invocation_count, total_cost_exposed, total_cost_realized_spend
1465                            FROM capability_grant_budgets
1466                            WHERE capability_id = ?1 AND grant_index = ?2
1467                            "#,
1468                            params![capability_id, grant_index as i64],
1469                            |row| {
1470                                Ok((
1471                                    row.get::<_, i64>(0)?.max(0) as u64,
1472                                    row.get::<_, i64>(1)?.max(0) as u32,
1473                                    row.get::<_, i64>(2)?.max(0) as u64,
1474                                    row.get::<_, i64>(3)?.max(0) as u64,
1475                                ))
1476                            },
1477                        )
1478                        .optional()?;
1479                    if let Some((
1480                        usage_seq,
1481                        invocation_count_after,
1482                        total_cost_exposed_after,
1483                        total_cost_realized_spend_after,
1484                    )) = current
1485                    {
1486                        let event_seq = allocate_budget_replication_seq(&transaction)?;
1487                        if retry_follows_rollback {
1488                            SqliteBudgetStore::upsert_hold(
1489                                &transaction,
1490                                hold_id,
1491                                capability_id,
1492                                grant_index,
1493                                cost_units,
1494                                cost_units,
1495                                HoldDisposition::Open,
1496                                authority,
1497                            )?;
1498                        }
1499                        SqliteBudgetStore::append_mutation_event(
1500                            &transaction,
1501                            event_id,
1502                            Some(hold_id),
1503                            authority,
1504                            capability_id,
1505                            grant_index,
1506                            BudgetMutationKind::AuthorizeExposure,
1507                            Some(true),
1508                            event_seq,
1509                            Some(usage_seq),
1510                            cost_units,
1511                            0,
1512                            max_invocations,
1513                            max_cost_per_invocation,
1514                            max_total_cost_units,
1515                            invocation_count_after,
1516                            total_cost_exposed_after,
1517                            total_cost_realized_spend_after,
1518                        )?;
1519                        transaction.commit()?;
1520                        return Ok(true);
1521                    }
1522                }
1523            }
1524            if retry_follows_rollback {
1525                Self::delete_hold_if_exists(&transaction, hold_id)?;
1526            }
1527        }
1528
1529        let mut allowed = true;
1530
1531        if let Some(max) = max_invocations {
1532            if current_count >= max {
1533                allowed = false;
1534            }
1535        }
1536        if let Some(max_per) = max_cost_per_invocation {
1537            if cost_units > max_per {
1538                allowed = false;
1539            }
1540        }
1541        if let Some(max_total) = max_total_cost_units {
1542            let current_total = checked_committed_cost_units(current_exposed, current_realized)?;
1543            let new_total = current_total.checked_add(cost_units).ok_or_else(|| {
1544                BudgetStoreError::Overflow(
1545                    "authorized exposure + cost_units overflowed u64".to_string(),
1546                )
1547            })?;
1548            if new_total > max_total {
1549                allowed = false;
1550            }
1551        }
1552
1553        let (
1554            invocation_count_after,
1555            total_cost_exposed_after,
1556            total_cost_realized_spend_after,
1557            event_seq,
1558            usage_seq,
1559        );
1560        if allowed {
1561            if let Some(hold_id) = hold_id {
1562                let retry_follows_rollback = match event_id {
1563                    Some(event_id) => Self::rollback_event_exists(&transaction, event_id)?,
1564                    None => false,
1565                };
1566                if retry_follows_rollback {
1567                    if let Some(hold) = SqliteBudgetStore::load_hold(&transaction, hold_id)? {
1568                        if hold.capability_id == capability_id
1569                            && hold.grant_index == grant_index
1570                            && hold.authorized_exposure_units == cost_units
1571                            && hold.remaining_exposure_units == cost_units
1572                            && hold.invocation_count_debited
1573                            && hold.disposition == HoldDisposition::Open
1574                            && current_exposed >= cost_units
1575                        {
1576                            let current = transaction
1577                                .query_row(
1578                                    r#"
1579                                    SELECT seq, invocation_count, total_cost_exposed, total_cost_realized_spend
1580                                    FROM capability_grant_budgets
1581                                    WHERE capability_id = ?1 AND grant_index = ?2
1582                                    "#,
1583                                    params![capability_id, grant_index as i64],
1584                                    |row| {
1585                                        Ok((
1586                                            row.get::<_, i64>(0)?.max(0) as u64,
1587                                            row.get::<_, i64>(1)?.max(0) as u32,
1588                                            row.get::<_, i64>(2)?.max(0) as u64,
1589                                            row.get::<_, i64>(3)?.max(0) as u64,
1590                                        ))
1591                                    },
1592                                )
1593                                .optional()?;
1594                            if let Some((
1595                                usage_seq,
1596                                invocation_count_after,
1597                                total_cost_exposed_after,
1598                                total_cost_realized_spend_after,
1599                            )) = current
1600                            {
1601                                let event_seq = allocate_budget_replication_seq(&transaction)?;
1602                                SqliteBudgetStore::upsert_hold(
1603                                    &transaction,
1604                                    hold_id,
1605                                    capability_id,
1606                                    grant_index,
1607                                    cost_units,
1608                                    cost_units,
1609                                    HoldDisposition::Open,
1610                                    authority,
1611                                )?;
1612                                SqliteBudgetStore::append_mutation_event(
1613                                    &transaction,
1614                                    event_id,
1615                                    Some(hold_id),
1616                                    authority,
1617                                    capability_id,
1618                                    grant_index,
1619                                    BudgetMutationKind::AuthorizeExposure,
1620                                    Some(true),
1621                                    event_seq,
1622                                    Some(usage_seq),
1623                                    cost_units,
1624                                    0,
1625                                    max_invocations,
1626                                    max_cost_per_invocation,
1627                                    max_total_cost_units,
1628                                    invocation_count_after,
1629                                    total_cost_exposed_after,
1630                                    total_cost_realized_spend_after,
1631                                )?;
1632                                transaction.commit()?;
1633                                return Ok(true);
1634                            }
1635                        }
1636                    }
1637                    Self::delete_hold_if_exists(&transaction, hold_id)?;
1638                } else if let Some(hold) = SqliteBudgetStore::load_hold(&transaction, hold_id)? {
1639                    if hold.capability_id == capability_id
1640                        && hold.grant_index == grant_index
1641                        && hold.authorized_exposure_units == cost_units
1642                        && hold.remaining_exposure_units == cost_units
1643                        && hold.invocation_count_debited
1644                        && hold.disposition == HoldDisposition::Open
1645                    {
1646                        let current = transaction
1647                            .query_row(
1648                                r#"
1649                                SELECT seq, invocation_count, total_cost_exposed, total_cost_realized_spend
1650                                FROM capability_grant_budgets
1651                                WHERE capability_id = ?1 AND grant_index = ?2
1652                                "#,
1653                                params![capability_id, grant_index as i64],
1654                                |row| {
1655                                    Ok((
1656                                        row.get::<_, i64>(0)?.max(0) as u64,
1657                                        row.get::<_, i64>(1)?.max(0) as u32,
1658                                        row.get::<_, i64>(2)?.max(0) as u64,
1659                                        row.get::<_, i64>(3)?.max(0) as u64,
1660                                    ))
1661                                },
1662                            )
1663                            .optional()?;
1664                        if let Some((
1665                            seq,
1666                            invocation_count_after,
1667                            total_cost_exposed_after,
1668                            total_cost_realized_spend_after,
1669                        )) = current
1670                        {
1671                            if total_cost_exposed_after < cost_units {
1672                                transaction.rollback()?;
1673                                return Err(BudgetStoreError::Invariant(format!(
1674                                    "budget hold `{hold_id}` is not reflected in usage totals"
1675                                )));
1676                            }
1677                            SqliteBudgetStore::append_mutation_event(
1678                                &transaction,
1679                                event_id,
1680                                Some(hold_id),
1681                                authority,
1682                                capability_id,
1683                                grant_index,
1684                                BudgetMutationKind::AuthorizeExposure,
1685                                Some(true),
1686                                seq,
1687                                Some(seq),
1688                                cost_units,
1689                                0,
1690                                max_invocations,
1691                                max_cost_per_invocation,
1692                                max_total_cost_units,
1693                                invocation_count_after,
1694                                total_cost_exposed_after,
1695                                total_cost_realized_spend_after,
1696                            )?;
1697                            transaction.commit()?;
1698                            return Ok(true);
1699                        }
1700                    }
1701                    transaction.rollback()?;
1702                    return Err(BudgetStoreError::Invariant(format!(
1703                        "budget hold `{hold_id}` already exists"
1704                    )));
1705                }
1706            }
1707            let new_total_cost_exposed =
1708                current_exposed.checked_add(cost_units).ok_or_else(|| {
1709                    BudgetStoreError::Overflow(
1710                        "total_cost_exposed + cost_units overflowed u64".to_string(),
1711                    )
1712                })?;
1713            let updated_at = unix_now();
1714            let seq = allocate_budget_replication_seq(&transaction)?;
1715            transaction.execute(
1716                r#"
1717                INSERT INTO capability_grant_budgets (
1718                    capability_id,
1719                    grant_index,
1720                    invocation_count,
1721                    updated_at,
1722                    seq,
1723                    total_cost_exposed,
1724                    total_cost_realized_spend
1725                ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
1726                ON CONFLICT(capability_id, grant_index) DO UPDATE SET
1727                    invocation_count = excluded.invocation_count,
1728                    updated_at = excluded.updated_at,
1729                    seq = excluded.seq,
1730                    total_cost_exposed = excluded.total_cost_exposed,
1731                    total_cost_realized_spend = excluded.total_cost_realized_spend
1732                "#,
1733                params![
1734                    capability_id,
1735                    grant_index as i64,
1736                    (current_count.saturating_add(1)) as i64,
1737                    updated_at,
1738                    seq as i64,
1739                    new_total_cost_exposed as i64,
1740                    current_realized as i64,
1741                ],
1742            )?;
1743            if let Some(hold_id) = hold_id {
1744                SqliteBudgetStore::create_hold(
1745                    &transaction,
1746                    hold_id,
1747                    capability_id,
1748                    grant_index,
1749                    cost_units,
1750                    authority,
1751                )?;
1752            }
1753            invocation_count_after = current_count.saturating_add(1);
1754            total_cost_exposed_after = new_total_cost_exposed;
1755            total_cost_realized_spend_after = current_realized;
1756            event_seq = seq;
1757            usage_seq = Some(seq);
1758        } else {
1759            event_seq = allocate_budget_replication_seq(&transaction)?;
1760            invocation_count_after = current_count;
1761            total_cost_exposed_after = current_exposed;
1762            total_cost_realized_spend_after = current_realized;
1763            usage_seq = None;
1764        }
1765        SqliteBudgetStore::append_mutation_event(
1766            &transaction,
1767            event_id,
1768            hold_id,
1769            authority,
1770            capability_id,
1771            grant_index,
1772            BudgetMutationKind::AuthorizeExposure,
1773            Some(allowed),
1774            event_seq,
1775            usage_seq,
1776            cost_units,
1777            0,
1778            max_invocations,
1779            max_cost_per_invocation,
1780            max_total_cost_units,
1781            invocation_count_after,
1782            total_cost_exposed_after,
1783            total_cost_realized_spend_after,
1784        )?;
1785        transaction.commit()?;
1786        Ok(allowed)
1787    }
1788
1789    fn reverse_charge_cost(
1790        &mut self,
1791        capability_id: &str,
1792        grant_index: usize,
1793        cost_units: u64,
1794    ) -> Result<(), BudgetStoreError> {
1795        self.reverse_charge_cost_with_ids(capability_id, grant_index, cost_units, None, None)
1796    }
1797
1798    fn reverse_charge_cost_with_ids(
1799        &mut self,
1800        capability_id: &str,
1801        grant_index: usize,
1802        cost_units: u64,
1803        hold_id: Option<&str>,
1804        event_id: Option<&str>,
1805    ) -> Result<(), BudgetStoreError> {
1806        self.reverse_charge_cost_with_ids_and_authority(
1807            capability_id,
1808            grant_index,
1809            cost_units,
1810            hold_id,
1811            event_id,
1812            None,
1813        )
1814    }
1815
1816    fn reverse_charge_cost_with_ids_and_authority(
1817        &mut self,
1818        capability_id: &str,
1819        grant_index: usize,
1820        cost_units: u64,
1821        hold_id: Option<&str>,
1822        event_id: Option<&str>,
1823        authority: Option<&BudgetEventAuthority>,
1824    ) -> Result<(), BudgetStoreError> {
1825        let transaction = self
1826            .connection
1827            .transaction_with_behavior(TransactionBehavior::Immediate)?;
1828
1829        if SqliteBudgetStore::existing_event_allowed(
1830            &transaction,
1831            event_id,
1832            BudgetMutationKind::ReverseExposure,
1833            capability_id,
1834            grant_index,
1835            hold_id,
1836            authority,
1837            cost_units,
1838            0,
1839            None,
1840            None,
1841            None,
1842        )?
1843        .is_some()
1844        {
1845            transaction.rollback()?;
1846            return Ok(());
1847        }
1848        if let Some(hold_id) = hold_id {
1849            let hold = SqliteBudgetStore::ensure_open_hold(
1850                &transaction,
1851                hold_id,
1852                capability_id,
1853                grant_index,
1854            )?;
1855            if hold.remaining_exposure_units != cost_units || !hold.invocation_count_debited {
1856                transaction.rollback()?;
1857                return Err(BudgetStoreError::Invariant(format!(
1858                    "budget hold `{hold_id}` does not match reverse amount"
1859                )));
1860            }
1861            SqliteBudgetStore::validate_hold_authority(
1862                hold_id,
1863                hold.authority.as_ref(),
1864                authority,
1865            )?;
1866        }
1867
1868        let current = transaction
1869            .query_row(
1870                r#"
1871                SELECT invocation_count, total_cost_exposed, total_cost_realized_spend
1872                FROM capability_grant_budgets
1873                WHERE capability_id = ?1 AND grant_index = ?2
1874                "#,
1875                params![capability_id, grant_index as i64],
1876                |row| {
1877                    Ok((
1878                        row.get::<_, i64>(0)?,
1879                        row.get::<_, i64>(1)?.max(0) as u64,
1880                        row.get::<_, i64>(2)?.max(0) as u64,
1881                    ))
1882                },
1883            )
1884            .optional()?;
1885
1886        let Some((invocation_count, total_cost_exposed, total_cost_realized_spend)) = current
1887        else {
1888            transaction.rollback()?;
1889            return Err(BudgetStoreError::Invariant(
1890                "missing charged budget row".to_string(),
1891            ));
1892        };
1893
1894        if invocation_count <= 0 {
1895            transaction.rollback()?;
1896            return Err(BudgetStoreError::Invariant(
1897                "cannot reverse charge with zero invocation_count".to_string(),
1898            ));
1899        }
1900        if total_cost_exposed < cost_units {
1901            transaction.rollback()?;
1902            return Err(BudgetStoreError::Invariant(
1903                "cannot reverse charge larger than total_cost_exposed".to_string(),
1904            ));
1905        }
1906
1907        let new_total_cost_exposed = total_cost_exposed - cost_units;
1908        let seq = allocate_budget_replication_seq(&transaction)?;
1909        transaction.execute(
1910            r#"
1911            UPDATE capability_grant_budgets
1912            SET invocation_count = ?3,
1913                updated_at = ?4,
1914                seq = ?5,
1915                total_cost_exposed = ?6
1916            WHERE capability_id = ?1 AND grant_index = ?2
1917            "#,
1918            params![
1919                capability_id,
1920                grant_index as i64,
1921                invocation_count - 1,
1922                unix_now(),
1923                seq as i64,
1924                new_total_cost_exposed as i64,
1925            ],
1926        )?;
1927        if let Some(hold_id) = hold_id {
1928            let next_authority = SqliteBudgetStore::validate_hold_authority(
1929                hold_id,
1930                SqliteBudgetStore::ensure_open_hold(
1931                    &transaction,
1932                    hold_id,
1933                    capability_id,
1934                    grant_index,
1935                )?
1936                .authority
1937                .as_ref(),
1938                authority,
1939            )?;
1940            SqliteBudgetStore::update_hold(
1941                &transaction,
1942                hold_id,
1943                0,
1944                HoldDisposition::Reversed,
1945                next_authority.as_ref(),
1946            )?;
1947        }
1948        SqliteBudgetStore::append_mutation_event(
1949            &transaction,
1950            event_id,
1951            hold_id,
1952            authority,
1953            capability_id,
1954            grant_index,
1955            BudgetMutationKind::ReverseExposure,
1956            None,
1957            seq,
1958            Some(seq),
1959            cost_units,
1960            0,
1961            None,
1962            None,
1963            None,
1964            (invocation_count - 1).max(0) as u32,
1965            new_total_cost_exposed,
1966            total_cost_realized_spend,
1967        )?;
1968        transaction.commit()?;
1969        Ok(())
1970    }
1971
1972    fn reduce_charge_cost(
1973        &mut self,
1974        capability_id: &str,
1975        grant_index: usize,
1976        cost_units: u64,
1977    ) -> Result<(), BudgetStoreError> {
1978        self.reduce_charge_cost_with_ids(capability_id, grant_index, cost_units, None, None)
1979    }
1980
1981    fn reduce_charge_cost_with_ids(
1982        &mut self,
1983        capability_id: &str,
1984        grant_index: usize,
1985        cost_units: u64,
1986        hold_id: Option<&str>,
1987        event_id: Option<&str>,
1988    ) -> Result<(), BudgetStoreError> {
1989        self.reduce_charge_cost_with_ids_and_authority(
1990            capability_id,
1991            grant_index,
1992            cost_units,
1993            hold_id,
1994            event_id,
1995            None,
1996        )
1997    }
1998
1999    fn reduce_charge_cost_with_ids_and_authority(
2000        &mut self,
2001        capability_id: &str,
2002        grant_index: usize,
2003        cost_units: u64,
2004        hold_id: Option<&str>,
2005        event_id: Option<&str>,
2006        authority: Option<&BudgetEventAuthority>,
2007    ) -> Result<(), BudgetStoreError> {
2008        let transaction = self
2009            .connection
2010            .transaction_with_behavior(TransactionBehavior::Immediate)?;
2011
2012        if SqliteBudgetStore::existing_event_allowed(
2013            &transaction,
2014            event_id,
2015            BudgetMutationKind::ReleaseExposure,
2016            capability_id,
2017            grant_index,
2018            hold_id,
2019            authority,
2020            cost_units,
2021            0,
2022            None,
2023            None,
2024            None,
2025        )?
2026        .is_some()
2027        {
2028            transaction.rollback()?;
2029            return Ok(());
2030        }
2031        if let Some(hold_id) = hold_id {
2032            let hold = SqliteBudgetStore::ensure_open_hold(
2033                &transaction,
2034                hold_id,
2035                capability_id,
2036                grant_index,
2037            )?;
2038            if hold.remaining_exposure_units < cost_units {
2039                transaction.rollback()?;
2040                return Err(BudgetStoreError::Invariant(format!(
2041                    "budget hold `{hold_id}` cannot release more than remaining exposure"
2042                )));
2043            }
2044            SqliteBudgetStore::validate_hold_authority(
2045                hold_id,
2046                hold.authority.as_ref(),
2047                authority,
2048            )?;
2049        }
2050
2051        let current = transaction
2052            .query_row(
2053                r#"
2054                SELECT invocation_count, total_cost_exposed, total_cost_realized_spend
2055                FROM capability_grant_budgets
2056                WHERE capability_id = ?1 AND grant_index = ?2
2057                "#,
2058                params![capability_id, grant_index as i64],
2059                |row| {
2060                    Ok((
2061                        row.get::<_, i64>(0)?,
2062                        row.get::<_, i64>(1)?.max(0) as u64,
2063                        row.get::<_, i64>(2)?.max(0) as u64,
2064                    ))
2065                },
2066            )
2067            .optional()?;
2068
2069        let Some((invocation_count, total_cost_exposed, total_cost_realized_spend)) = current
2070        else {
2071            transaction.rollback()?;
2072            return Err(BudgetStoreError::Invariant(
2073                "missing charged budget row".to_string(),
2074            ));
2075        };
2076
2077        if total_cost_exposed < cost_units {
2078            transaction.rollback()?;
2079            return Err(BudgetStoreError::Invariant(
2080                "cannot reduce charge larger than total_cost_exposed".to_string(),
2081            ));
2082        }
2083
2084        let new_total_cost_exposed = total_cost_exposed - cost_units;
2085        let seq = allocate_budget_replication_seq(&transaction)?;
2086        transaction.execute(
2087            r#"
2088            UPDATE capability_grant_budgets
2089            SET updated_at = ?3,
2090                seq = ?4,
2091                total_cost_exposed = ?5
2092            WHERE capability_id = ?1 AND grant_index = ?2
2093            "#,
2094            params![
2095                capability_id,
2096                grant_index as i64,
2097                unix_now(),
2098                seq as i64,
2099                new_total_cost_exposed as i64,
2100            ],
2101        )?;
2102        if let Some(hold_id) = hold_id {
2103            let hold = SqliteBudgetStore::ensure_open_hold(
2104                &transaction,
2105                hold_id,
2106                capability_id,
2107                grant_index,
2108            )?;
2109            let next_authority = SqliteBudgetStore::validate_hold_authority(
2110                hold_id,
2111                hold.authority.as_ref(),
2112                authority,
2113            )?;
2114            let remaining = hold.remaining_exposure_units - cost_units;
2115            let disposition = if remaining == 0 {
2116                HoldDisposition::Released
2117            } else {
2118                HoldDisposition::Open
2119            };
2120            SqliteBudgetStore::update_hold(
2121                &transaction,
2122                hold_id,
2123                remaining,
2124                disposition,
2125                next_authority.as_ref(),
2126            )?;
2127        }
2128        SqliteBudgetStore::append_mutation_event(
2129            &transaction,
2130            event_id,
2131            hold_id,
2132            authority,
2133            capability_id,
2134            grant_index,
2135            BudgetMutationKind::ReleaseExposure,
2136            None,
2137            seq,
2138            Some(seq),
2139            cost_units,
2140            0,
2141            None,
2142            None,
2143            None,
2144            invocation_count.max(0) as u32,
2145            new_total_cost_exposed,
2146            total_cost_realized_spend,
2147        )?;
2148        transaction.commit()?;
2149        Ok(())
2150    }
2151
2152    fn settle_charge_cost(
2153        &mut self,
2154        capability_id: &str,
2155        grant_index: usize,
2156        exposed_cost_units: u64,
2157        realized_cost_units: u64,
2158    ) -> Result<(), BudgetStoreError> {
2159        self.settle_charge_cost_with_ids(
2160            capability_id,
2161            grant_index,
2162            exposed_cost_units,
2163            realized_cost_units,
2164            None,
2165            None,
2166        )
2167    }
2168
2169    fn settle_charge_cost_with_ids(
2170        &mut self,
2171        capability_id: &str,
2172        grant_index: usize,
2173        exposed_cost_units: u64,
2174        realized_cost_units: u64,
2175        hold_id: Option<&str>,
2176        event_id: Option<&str>,
2177    ) -> Result<(), BudgetStoreError> {
2178        self.settle_charge_cost_with_ids_and_authority(
2179            capability_id,
2180            grant_index,
2181            exposed_cost_units,
2182            realized_cost_units,
2183            hold_id,
2184            event_id,
2185            None,
2186        )
2187    }
2188
2189    fn settle_charge_cost_with_ids_and_authority(
2190        &mut self,
2191        capability_id: &str,
2192        grant_index: usize,
2193        exposed_cost_units: u64,
2194        realized_cost_units: u64,
2195        hold_id: Option<&str>,
2196        event_id: Option<&str>,
2197        authority: Option<&BudgetEventAuthority>,
2198    ) -> Result<(), BudgetStoreError> {
2199        if realized_cost_units > exposed_cost_units {
2200            return Err(BudgetStoreError::Invariant(
2201                "cannot realize spend larger than exposed cost".to_string(),
2202            ));
2203        }
2204
2205        let transaction = self
2206            .connection
2207            .transaction_with_behavior(TransactionBehavior::Immediate)?;
2208
2209        if SqliteBudgetStore::existing_event_allowed(
2210            &transaction,
2211            event_id,
2212            BudgetMutationKind::ReconcileSpend,
2213            capability_id,
2214            grant_index,
2215            hold_id,
2216            authority,
2217            exposed_cost_units,
2218            realized_cost_units,
2219            None,
2220            None,
2221            None,
2222        )?
2223        .is_some()
2224        {
2225            transaction.rollback()?;
2226            return Ok(());
2227        }
2228        if let Some(hold_id) = hold_id {
2229            let hold = SqliteBudgetStore::ensure_open_hold(
2230                &transaction,
2231                hold_id,
2232                capability_id,
2233                grant_index,
2234            )?;
2235            if hold.remaining_exposure_units != exposed_cost_units {
2236                transaction.rollback()?;
2237                return Err(BudgetStoreError::Invariant(format!(
2238                    "budget hold `{hold_id}` does not match reconciled exposure"
2239                )));
2240            }
2241            SqliteBudgetStore::validate_hold_authority(
2242                hold_id,
2243                hold.authority.as_ref(),
2244                authority,
2245            )?;
2246        }
2247
2248        let current = transaction
2249            .query_row(
2250                r#"
2251                SELECT invocation_count, total_cost_exposed, total_cost_realized_spend
2252                FROM capability_grant_budgets
2253                WHERE capability_id = ?1 AND grant_index = ?2
2254                "#,
2255                params![capability_id, grant_index as i64],
2256                |row| {
2257                    Ok((
2258                        row.get::<_, i64>(0)?,
2259                        row.get::<_, i64>(1)?.max(0) as u64,
2260                        row.get::<_, i64>(2)?.max(0) as u64,
2261                    ))
2262                },
2263            )
2264            .optional()?;
2265
2266        let Some((invocation_count, total_cost_exposed, total_cost_realized_spend)) = current
2267        else {
2268            transaction.rollback()?;
2269            return Err(BudgetStoreError::Invariant(
2270                "missing charged budget row".to_string(),
2271            ));
2272        };
2273
2274        if invocation_count <= 0 {
2275            transaction.rollback()?;
2276            return Err(BudgetStoreError::Invariant(
2277                "cannot settle charge with zero invocation_count".to_string(),
2278            ));
2279        }
2280        if total_cost_exposed < exposed_cost_units {
2281            transaction.rollback()?;
2282            return Err(BudgetStoreError::Invariant(
2283                "cannot settle more exposure than total_cost_exposed".to_string(),
2284            ));
2285        }
2286
2287        let new_total_cost_exposed = total_cost_exposed - exposed_cost_units;
2288        let new_total_cost_realized_spend = total_cost_realized_spend
2289            .checked_add(realized_cost_units)
2290            .ok_or_else(|| {
2291                BudgetStoreError::Overflow(
2292                    "total_cost_realized_spend + realized_cost_units overflowed u64".to_string(),
2293                )
2294            })?;
2295
2296        let seq = allocate_budget_replication_seq(&transaction)?;
2297        transaction.execute(
2298            r#"
2299            UPDATE capability_grant_budgets
2300            SET updated_at = ?3,
2301                seq = ?4,
2302                total_cost_exposed = ?5,
2303                total_cost_realized_spend = ?6
2304            WHERE capability_id = ?1 AND grant_index = ?2
2305            "#,
2306            params![
2307                capability_id,
2308                grant_index as i64,
2309                unix_now(),
2310                seq as i64,
2311                new_total_cost_exposed as i64,
2312                new_total_cost_realized_spend as i64,
2313            ],
2314        )?;
2315        if let Some(hold_id) = hold_id {
2316            let next_authority = SqliteBudgetStore::validate_hold_authority(
2317                hold_id,
2318                SqliteBudgetStore::ensure_open_hold(
2319                    &transaction,
2320                    hold_id,
2321                    capability_id,
2322                    grant_index,
2323                )?
2324                .authority
2325                .as_ref(),
2326                authority,
2327            )?;
2328            SqliteBudgetStore::update_hold(
2329                &transaction,
2330                hold_id,
2331                0,
2332                HoldDisposition::Reconciled,
2333                next_authority.as_ref(),
2334            )?;
2335        }
2336        SqliteBudgetStore::append_mutation_event(
2337            &transaction,
2338            event_id,
2339            hold_id,
2340            authority,
2341            capability_id,
2342            grant_index,
2343            BudgetMutationKind::ReconcileSpend,
2344            None,
2345            seq,
2346            Some(seq),
2347            exposed_cost_units,
2348            realized_cost_units,
2349            None,
2350            None,
2351            None,
2352            invocation_count.max(0) as u32,
2353            new_total_cost_exposed,
2354            new_total_cost_realized_spend,
2355        )?;
2356        transaction.commit()?;
2357        Ok(())
2358    }
2359
2360    fn list_usages(
2361        &self,
2362        limit: usize,
2363        capability_id: Option<&str>,
2364    ) -> Result<Vec<BudgetUsageRecord>, BudgetStoreError> {
2365        let mut statement = self.connection.prepare(
2366            r#"
2367            SELECT
2368                capability_id,
2369                grant_index,
2370                invocation_count,
2371                updated_at,
2372                seq,
2373                total_cost_exposed,
2374                total_cost_realized_spend
2375            FROM capability_grant_budgets
2376            WHERE (?1 IS NULL OR capability_id = ?1)
2377            ORDER BY updated_at DESC, capability_id ASC, grant_index ASC
2378            LIMIT ?2
2379            "#,
2380        )?;
2381        let rows = statement.query_map(params![capability_id, limit as i64], record_from_row)?;
2382        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
2383    }
2384
2385    fn get_usage(
2386        &self,
2387        capability_id: &str,
2388        grant_index: usize,
2389    ) -> Result<Option<BudgetUsageRecord>, BudgetStoreError> {
2390        self.connection
2391            .query_row(
2392                r#"
2393                SELECT
2394                    capability_id,
2395                    grant_index,
2396                    invocation_count,
2397                    updated_at,
2398                    seq,
2399                    total_cost_exposed,
2400                    total_cost_realized_spend
2401                FROM capability_grant_budgets
2402                WHERE capability_id = ?1 AND grant_index = ?2
2403                "#,
2404                params![capability_id, grant_index as i64],
2405                record_from_row,
2406            )
2407            .optional()
2408            .map_err(Into::into)
2409    }
2410
2411    fn list_mutation_events(
2412        &self,
2413        limit: usize,
2414        capability_id: Option<&str>,
2415        grant_index: Option<usize>,
2416    ) -> Result<Vec<BudgetMutationRecord>, BudgetStoreError> {
2417        let mut statement = self.connection.prepare(
2418            r#"
2419            SELECT
2420                event_id,
2421                hold_id,
2422                capability_id,
2423                grant_index,
2424                kind,
2425                allowed,
2426                recorded_at,
2427                event_seq,
2428                usage_seq,
2429                exposure_units,
2430                realized_spend_units,
2431                max_invocations,
2432                max_exposure_per_invocation,
2433                max_total_exposure_units,
2434                invocation_count_after,
2435                total_cost_exposed_after,
2436                total_cost_realized_spend_after,
2437                authority_id,
2438                lease_id,
2439                lease_epoch
2440            FROM budget_mutation_events
2441            WHERE (?1 IS NULL OR capability_id = ?1)
2442              AND (?2 IS NULL OR grant_index = ?2)
2443            ORDER BY event_seq ASC
2444            LIMIT ?3
2445            "#,
2446        )?;
2447        let rows = statement.query_map(
2448            params![
2449                capability_id,
2450                grant_index.map(|value| value as i64),
2451                limit as i64
2452            ],
2453            mutation_record_from_row,
2454        )?;
2455        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
2456    }
2457}
2458
2459fn checked_committed_cost_units(
2460    total_cost_exposed: u64,
2461    total_cost_realized_spend: u64,
2462) -> Result<u64, BudgetStoreError> {
2463    total_cost_exposed
2464        .checked_add(total_cost_realized_spend)
2465        .ok_or_else(|| {
2466            BudgetStoreError::Overflow(
2467                "total_cost_exposed + total_cost_realized_spend overflowed u64".to_string(),
2468            )
2469        })
2470}
2471
2472fn record_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<BudgetUsageRecord> {
2473    let total_cost_exposed = row.get::<_, i64>(5)?.max(0) as u64;
2474    let total_cost_realized_spend = row.get::<_, i64>(6)?.max(0) as u64;
2475    Ok(BudgetUsageRecord {
2476        capability_id: row.get(0)?,
2477        grant_index: row.get::<_, i64>(1)?.max(0) as u32,
2478        invocation_count: row.get::<_, i64>(2)?.max(0) as u32,
2479        updated_at: row.get(3)?,
2480        seq: row.get::<_, i64>(4)?.max(0) as u64,
2481        total_cost_exposed,
2482        total_cost_realized_spend,
2483    })
2484}
2485
2486fn mutation_record_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<BudgetMutationRecord> {
2487    let kind = row.get::<_, String>(4)?;
2488    let kind = BudgetMutationKind::parse(&kind).ok_or_else(|| {
2489        rusqlite::Error::FromSqlConversionFailure(
2490            4,
2491            rusqlite::types::Type::Text,
2492            Box::new(std::io::Error::new(
2493                std::io::ErrorKind::InvalidData,
2494                format!("unknown budget mutation kind `{kind}`"),
2495            )),
2496        )
2497    })?;
2498    let authority = sqlite_budget_event_authority(row.get(17)?, row.get(18)?, row.get(19)?)?;
2499    Ok(BudgetMutationRecord {
2500        event_id: row.get(0)?,
2501        hold_id: row.get(1)?,
2502        capability_id: row.get(2)?,
2503        grant_index: row.get::<_, i64>(3)?.max(0) as u32,
2504        kind,
2505        allowed: row.get::<_, Option<i64>>(5)?.map(|value| value > 0),
2506        recorded_at: row.get(6)?,
2507        event_seq: row.get::<_, i64>(7)?.max(0) as u64,
2508        usage_seq: row
2509            .get::<_, Option<i64>>(8)?
2510            .map(|value| value.max(0) as u64),
2511        exposure_units: row.get::<_, i64>(9)?.max(0) as u64,
2512        realized_spend_units: row.get::<_, i64>(10)?.max(0) as u64,
2513        max_invocations: row
2514            .get::<_, Option<i64>>(11)?
2515            .map(|value| value.max(0) as u32),
2516        max_cost_per_invocation: row
2517            .get::<_, Option<i64>>(12)?
2518            .map(|value| value.max(0) as u64),
2519        max_total_cost_units: row
2520            .get::<_, Option<i64>>(13)?
2521            .map(|value| value.max(0) as u64),
2522        invocation_count_after: row.get::<_, i64>(14)?.max(0) as u32,
2523        total_cost_exposed_after: row.get::<_, i64>(15)?.max(0) as u64,
2524        total_cost_realized_spend_after: row.get::<_, i64>(16)?.max(0) as u64,
2525        authority,
2526    })
2527}
2528
2529fn sqlite_budget_event_authority(
2530    authority_id: Option<String>,
2531    lease_id: Option<String>,
2532    lease_epoch: Option<i64>,
2533) -> rusqlite::Result<Option<BudgetEventAuthority>> {
2534    match (authority_id, lease_id, lease_epoch) {
2535        (None, None, None) => Ok(None),
2536        (Some(authority_id), Some(lease_id), Some(lease_epoch)) if lease_epoch >= 0 => {
2537            Ok(Some(BudgetEventAuthority {
2538                authority_id,
2539                lease_id,
2540                lease_epoch: lease_epoch as u64,
2541            }))
2542        }
2543        _ => Err(rusqlite::Error::FromSqlConversionFailure(
2544            0,
2545            rusqlite::types::Type::Text,
2546            Box::new(std::io::Error::new(
2547                std::io::ErrorKind::InvalidData,
2548                "invalid budget authority lease columns",
2549            )),
2550        )),
2551    }
2552}
2553
2554fn ensure_budget_seq_column(connection: &Connection) -> Result<(), BudgetStoreError> {
2555    let mut statement = connection.prepare("PRAGMA table_info(capability_grant_budgets)")?;
2556    let columns = statement.query_map([], |row| row.get::<_, String>(1))?;
2557    let has_seq = columns
2558        .collect::<Result<Vec<_>, _>>()?
2559        .iter()
2560        .any(|column| column == "seq");
2561    if !has_seq {
2562        connection.execute(
2563            "ALTER TABLE capability_grant_budgets ADD COLUMN seq INTEGER NOT NULL DEFAULT 0",
2564            [],
2565        )?;
2566    }
2567    connection.execute(
2568        "CREATE INDEX IF NOT EXISTS idx_capability_grant_budgets_seq ON capability_grant_budgets(seq)",
2569        [],
2570    )?;
2571    Ok(())
2572}
2573
2574fn ensure_split_budget_cost_columns(connection: &Connection) -> Result<(), BudgetStoreError> {
2575    let mut statement = connection.prepare("PRAGMA table_info(capability_grant_budgets)")?;
2576    let columns = statement.query_map([], |row| row.get::<_, String>(1))?;
2577    let columns = columns.collect::<Result<Vec<_>, _>>()?;
2578    if !columns.iter().any(|c| c == "total_cost_exposed")
2579        || !columns.iter().any(|c| c == "total_cost_realized_spend")
2580    {
2581        return Err(BudgetStoreError::Invariant(
2582            "unsupported budget schema: missing split cost columns `total_cost_exposed` and `total_cost_realized_spend`".to_string(),
2583        ));
2584    }
2585    Ok(())
2586}
2587
2588fn ensure_budget_hold_authority_columns(connection: &Connection) -> Result<(), BudgetStoreError> {
2589    let mut statement = connection.prepare("PRAGMA table_info(budget_authorization_holds)")?;
2590    let columns = statement.query_map([], |row| row.get::<_, String>(1))?;
2591    let columns = columns.collect::<Result<Vec<_>, _>>()?;
2592    if !columns.iter().any(|column| column == "authority_id") {
2593        connection.execute(
2594            "ALTER TABLE budget_authorization_holds ADD COLUMN authority_id TEXT",
2595            [],
2596        )?;
2597    }
2598    if !columns.iter().any(|column| column == "lease_id") {
2599        connection.execute(
2600            "ALTER TABLE budget_authorization_holds ADD COLUMN lease_id TEXT",
2601            [],
2602        )?;
2603    }
2604    if !columns.iter().any(|column| column == "lease_epoch") {
2605        connection.execute(
2606            "ALTER TABLE budget_authorization_holds ADD COLUMN lease_epoch INTEGER",
2607            [],
2608        )?;
2609    }
2610    Ok(())
2611}
2612
2613fn ensure_budget_mutation_event_authority_columns(
2614    connection: &Connection,
2615) -> Result<(), BudgetStoreError> {
2616    let mut statement = connection.prepare("PRAGMA table_info(budget_mutation_events)")?;
2617    let columns = statement.query_map([], |row| row.get::<_, String>(1))?;
2618    let columns = columns.collect::<Result<Vec<_>, _>>()?;
2619    if !columns.iter().any(|column| column == "authority_id") {
2620        connection.execute(
2621            "ALTER TABLE budget_mutation_events ADD COLUMN authority_id TEXT",
2622            [],
2623        )?;
2624    }
2625    if !columns.iter().any(|column| column == "lease_id") {
2626        connection.execute(
2627            "ALTER TABLE budget_mutation_events ADD COLUMN lease_id TEXT",
2628            [],
2629        )?;
2630    }
2631    if !columns.iter().any(|column| column == "lease_epoch") {
2632        connection.execute(
2633            "ALTER TABLE budget_mutation_events ADD COLUMN lease_epoch INTEGER",
2634            [],
2635        )?;
2636    }
2637    Ok(())
2638}
2639
2640fn ensure_budget_mutation_event_seq_column(
2641    connection: &Connection,
2642) -> Result<(), BudgetStoreError> {
2643    let mut statement = connection.prepare("PRAGMA table_info(budget_mutation_events)")?;
2644    let columns = statement.query_map([], |row| row.get::<_, String>(1))?;
2645    let columns = columns.collect::<Result<Vec<_>, _>>()?;
2646    if !columns.iter().any(|column| column == "event_seq") {
2647        connection.execute(
2648            "ALTER TABLE budget_mutation_events ADD COLUMN event_seq INTEGER",
2649            [],
2650        )?;
2651    }
2652    connection.execute(
2653        "CREATE UNIQUE INDEX IF NOT EXISTS idx_budget_mutation_events_event_seq ON budget_mutation_events(event_seq)",
2654        [],
2655    )?;
2656    Ok(())
2657}
2658
2659/// Initialize the replication sequence counter from existing rows on first open.
2660///
2661/// Uses an IMMEDIATE transaction, which acquires a write lock before any reads
2662/// or writes occur. In SQLite WAL mode, IMMEDIATE transactions are serialized:
2663/// concurrent reads can proceed, but no two processes can both hold IMMEDIATE
2664/// (or EXCLUSIVE) transactions simultaneously. This means two processes calling
2665/// `initialize_budget_replication_seq` concurrently will be serialized by
2666/// SQLite's locking protocol -- the second caller blocks until the first commits,
2667/// then runs with the updated seq floor already in place. No additional
2668/// application-level locking is required.
2669fn initialize_budget_replication_seq(connection: &mut Connection) -> Result<(), BudgetStoreError> {
2670    let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
2671    let mut next_seq = current_budget_replication_seq(&transaction)?
2672        .max(max_budget_usage_seq(&transaction)?)
2673        .max(max_budget_mutation_event_seq(&transaction)?);
2674    let mut statement = transaction.prepare(
2675        r#"
2676        SELECT rowid
2677        FROM capability_grant_budgets
2678        WHERE seq <= 0
2679        ORDER BY updated_at ASC, capability_id ASC, grant_index ASC
2680        "#,
2681    )?;
2682    let pending = statement
2683        .query_map([], |row| row.get::<_, i64>(0))?
2684        .collect::<Result<Vec<_>, _>>()?;
2685    drop(statement);
2686    for rowid in pending {
2687        next_seq = next_seq.saturating_add(1);
2688        transaction.execute(
2689            "UPDATE capability_grant_budgets SET seq = ?1 WHERE rowid = ?2",
2690            params![next_seq as i64, rowid],
2691        )?;
2692    }
2693
2694    let existing_event_seq_count = transaction.query_row(
2695        "SELECT COUNT(*) FROM budget_mutation_events WHERE event_seq IS NOT NULL AND event_seq > 0",
2696        [],
2697        |row| row.get::<_, i64>(0),
2698    )?;
2699    if existing_event_seq_count <= 0 {
2700        let mut statement = transaction.prepare(
2701            r#"
2702            SELECT rowid
2703            FROM budget_mutation_events
2704            ORDER BY rowid ASC
2705            "#,
2706        )?;
2707        let pending = statement
2708            .query_map([], |row| row.get::<_, i64>(0))?
2709            .collect::<Result<Vec<_>, _>>()?;
2710        drop(statement);
2711        let mut event_seq = 0u64;
2712        for rowid in pending {
2713            event_seq = event_seq.saturating_add(1);
2714            transaction.execute(
2715                "UPDATE budget_mutation_events SET event_seq = ?1 WHERE rowid = ?2",
2716                params![event_seq as i64, rowid],
2717            )?;
2718        }
2719        next_seq = next_seq.max(event_seq);
2720    } else {
2721        let mut statement = transaction.prepare(
2722            r#"
2723            SELECT rowid
2724            FROM budget_mutation_events
2725            WHERE event_seq IS NULL OR event_seq <= 0
2726            ORDER BY rowid ASC
2727            "#,
2728        )?;
2729        let pending = statement
2730            .query_map([], |row| row.get::<_, i64>(0))?
2731            .collect::<Result<Vec<_>, _>>()?;
2732        drop(statement);
2733        for rowid in pending {
2734            next_seq = next_seq.saturating_add(1);
2735            transaction.execute(
2736                "UPDATE budget_mutation_events SET event_seq = ?1 WHERE rowid = ?2",
2737                params![next_seq as i64, rowid],
2738            )?;
2739        }
2740    }
2741    set_budget_replication_seq(&transaction, next_seq)?;
2742    transaction.commit()?;
2743    Ok(())
2744}
2745
2746fn allocate_budget_replication_seq(
2747    transaction: &rusqlite::Transaction<'_>,
2748) -> Result<u64, BudgetStoreError> {
2749    let current = current_budget_replication_seq(transaction)?
2750        .max(max_budget_usage_seq(transaction)?)
2751        .max(max_budget_mutation_event_seq(transaction)?);
2752    let next_seq = current.saturating_add(1);
2753    set_budget_replication_seq(transaction, next_seq)?;
2754    Ok(next_seq)
2755}
2756
2757fn raise_budget_replication_seq_floor(
2758    transaction: &rusqlite::Transaction<'_>,
2759    seq: u64,
2760) -> Result<(), BudgetStoreError> {
2761    let current = current_budget_replication_seq(transaction)?;
2762    if seq > current {
2763        set_budget_replication_seq(transaction, seq)?;
2764    }
2765    Ok(())
2766}
2767
2768fn current_budget_replication_seq(
2769    transaction: &rusqlite::Transaction<'_>,
2770) -> Result<u64, BudgetStoreError> {
2771    let next_seq = transaction.query_row(
2772        "SELECT next_seq FROM budget_replication_meta WHERE singleton = 1",
2773        [],
2774        |row| row.get::<_, i64>(0),
2775    )?;
2776    Ok(next_seq.max(0) as u64)
2777}
2778
2779fn max_budget_usage_seq(transaction: &rusqlite::Transaction<'_>) -> Result<u64, BudgetStoreError> {
2780    let max_seq = transaction.query_row(
2781        "SELECT COALESCE(MAX(seq), 0) FROM capability_grant_budgets",
2782        [],
2783        |row| row.get::<_, i64>(0),
2784    )?;
2785    Ok(max_seq.max(0) as u64)
2786}
2787
2788fn max_budget_mutation_event_seq(
2789    transaction: &rusqlite::Transaction<'_>,
2790) -> Result<u64, BudgetStoreError> {
2791    let max_seq = transaction.query_row(
2792        "SELECT COALESCE(MAX(event_seq), 0) FROM budget_mutation_events",
2793        [],
2794        |row| row.get::<_, i64>(0),
2795    )?;
2796    Ok(max_seq.max(0) as u64)
2797}
2798
2799fn set_budget_replication_seq(
2800    transaction: &rusqlite::Transaction<'_>,
2801    seq: u64,
2802) -> Result<(), BudgetStoreError> {
2803    transaction.execute(
2804        "UPDATE budget_replication_meta SET next_seq = ?1 WHERE singleton = 1",
2805        params![seq as i64],
2806    )?;
2807    Ok(())
2808}
2809
2810fn unix_now() -> i64 {
2811    SystemTime::now()
2812        .duration_since(UNIX_EPOCH)
2813        .map(|duration| duration.as_secs() as i64)
2814        .unwrap_or(0)
2815}
2816
2817#[cfg(test)]
2818#[allow(clippy::expect_used, clippy::unwrap_used)]
2819mod tests {
2820    use super::*;
2821    use chio_kernel::InMemoryBudgetStore;
2822
2823    fn unique_db_path(prefix: &str) -> std::path::PathBuf {
2824        let nonce = SystemTime::now()
2825            .duration_since(UNIX_EPOCH)
2826            .expect("time before epoch")
2827            .as_nanos();
2828        std::env::temp_dir().join(format!("{prefix}-{nonce}.sqlite3"))
2829    }
2830
2831    fn usage_record(
2832        capability_id: &str,
2833        grant_index: u32,
2834        invocation_count: u32,
2835        updated_at: i64,
2836        seq: u64,
2837        total_cost_exposed: u64,
2838        total_cost_realized_spend: u64,
2839    ) -> BudgetUsageRecord {
2840        BudgetUsageRecord {
2841            capability_id: capability_id.to_string(),
2842            grant_index,
2843            invocation_count,
2844            updated_at,
2845            seq,
2846            total_cost_exposed,
2847            total_cost_realized_spend,
2848        }
2849    }
2850
2851    fn assert_usage_totals(record: &BudgetUsageRecord, exposed: u64, realized: u64) {
2852        assert_eq!(record.total_cost_exposed, exposed);
2853        assert_eq!(record.total_cost_realized_spend, realized);
2854        assert_eq!(record.committed_cost_units().unwrap(), exposed + realized);
2855    }
2856
2857    fn authority(authority_id: &str, lease_id: &str, lease_epoch: u64) -> BudgetEventAuthority {
2858        BudgetEventAuthority {
2859            authority_id: authority_id.to_string(),
2860            lease_id: lease_id.to_string(),
2861            lease_epoch,
2862        }
2863    }
2864
2865    #[test]
2866    fn sqlite_budget_store_persists_across_reopen() {
2867        let path = unique_db_path("chio-budgets");
2868        {
2869            let mut store = SqliteBudgetStore::open(&path).unwrap();
2870            assert!(store.try_increment("cap-1", 0, Some(2)).unwrap());
2871            assert!(store.try_increment("cap-1", 0, Some(2)).unwrap());
2872            assert!(!store.try_increment("cap-1", 0, Some(2)).unwrap());
2873        }
2874
2875        let reopened = SqliteBudgetStore::open(&path).unwrap();
2876        let records = reopened.list_usages(10, Some("cap-1")).unwrap();
2877        assert_eq!(records.len(), 1);
2878        assert_eq!(records[0].invocation_count, 2);
2879
2880        let _ = fs::remove_file(path);
2881    }
2882
2883    #[test]
2884    fn sqlite_budget_store_rejects_pre_split_budget_schema() {
2885        let path = unique_db_path("chio-budget-pre-split-schema");
2886        {
2887            let connection = Connection::open(&path).unwrap();
2888            connection
2889                .execute_batch(
2890                    r#"
2891                    CREATE TABLE capability_grant_budgets (
2892                        capability_id TEXT NOT NULL,
2893                        grant_index INTEGER NOT NULL,
2894                        invocation_count INTEGER NOT NULL,
2895                        updated_at INTEGER NOT NULL,
2896                        total_cost_charged INTEGER NOT NULL DEFAULT 0,
2897                        PRIMARY KEY (capability_id, grant_index)
2898                    );
2899                    INSERT INTO capability_grant_budgets (
2900                        capability_id,
2901                        grant_index,
2902                        invocation_count,
2903                        updated_at,
2904                        total_cost_charged
2905                    ) VALUES ('cap-1', 0, 1, 10, 55);
2906                    "#,
2907                )
2908                .unwrap();
2909        }
2910
2911        let error = match SqliteBudgetStore::open(&path) {
2912            Ok(_) => panic!("pre-split budget schema should be rejected"),
2913            Err(error) => error,
2914        };
2915        assert!(error.to_string().contains(
2916            "missing split cost columns `total_cost_exposed` and `total_cost_realized_spend`"
2917        ));
2918
2919        let _ = fs::remove_file(path);
2920    }
2921
2922    #[test]
2923    fn sqlite_budget_store_upsert_usage_keeps_newer_seq_state() {
2924        let path = unique_db_path("chio-budget-upsert");
2925        let mut store = SqliteBudgetStore::open(&path).unwrap();
2926        store
2927            .upsert_usage(&usage_record("cap-1", 0, 3, 10, 3, 300, 0))
2928            .unwrap();
2929        store
2930            .upsert_usage(&usage_record("cap-1", 0, 2, 9, 2, 200, 0))
2931            .unwrap();
2932
2933        let records = store.list_usages(10, Some("cap-1")).unwrap();
2934        assert_eq!(records[0].invocation_count, 3);
2935        assert_usage_totals(&records[0], 300, 0);
2936        assert_eq!(records[0].seq, 3);
2937
2938        let _ = fs::remove_file(path);
2939    }
2940
2941    #[test]
2942    fn sqlite_budget_store_uses_seq_for_same_key_delta_queries() {
2943        let path = unique_db_path("chio-budget-seq-delta");
2944        let mut store = SqliteBudgetStore::open(&path).unwrap();
2945
2946        assert!(store.try_increment("cap-1", 0, Some(5)).unwrap());
2947        let first = store.list_usages(10, Some("cap-1")).unwrap();
2948        assert_eq!(first.len(), 1);
2949        let first_seq = first[0].seq;
2950
2951        assert!(store.try_increment("cap-1", 0, Some(5)).unwrap());
2952        assert!(store.try_increment("cap-1", 0, Some(5)).unwrap());
2953
2954        let delta = store.list_usages_after(10, Some(first_seq)).unwrap();
2955        assert_eq!(delta.len(), 1);
2956        assert_eq!(delta[0].invocation_count, 3);
2957        assert!(delta[0].seq > first_seq);
2958
2959        let _ = fs::remove_file(path);
2960    }
2961
2962    #[test]
2963    fn sqlite_budget_store_preserves_imported_seq_across_failover_writes() {
2964        let path = unique_db_path("chio-budget-seq-floor");
2965        let mut store = SqliteBudgetStore::open(&path).unwrap();
2966
2967        store
2968            .upsert_usage(&usage_record("cap-1", 0, 3, 10, 42, 0, 0))
2969            .unwrap();
2970        assert!(store.try_increment("cap-1", 0, Some(5)).unwrap());
2971
2972        let records = store.list_usages(10, Some("cap-1")).unwrap();
2973        assert_eq!(records.len(), 1);
2974        assert_eq!(records[0].invocation_count, 4);
2975        assert_eq!(records[0].seq, 43);
2976
2977        let _ = fs::remove_file(path);
2978    }
2979
2980    // --- try_charge_cost tests ---
2981
2982    #[test]
2983    fn budget_store_try_charge_cost_within_limits_returns_true_sqlite() {
2984        let path = unique_db_path("chio-charge-cost-ok");
2985        let mut store = SqliteBudgetStore::open(&path).unwrap();
2986        // 100 units, cap is 200 per invocation, total cap is 1000
2987        let ok = store
2988            .try_charge_cost("cap-1", 0, Some(10), 100, Some(200), Some(1000))
2989            .unwrap();
2990        assert!(ok);
2991
2992        let records = store.list_usages(10, Some("cap-1")).unwrap();
2993        assert_eq!(records[0].invocation_count, 1);
2994        assert_usage_totals(&records[0], 100, 0);
2995
2996        let _ = fs::remove_file(path);
2997    }
2998
2999    #[test]
3000    fn budget_store_try_charge_cost_exceeds_per_invocation_cap_sqlite() {
3001        let path = unique_db_path("chio-charge-cost-per-inv");
3002        let mut store = SqliteBudgetStore::open(&path).unwrap();
3003        // 500 units > max_cost_per_invocation of 200
3004        let ok = store
3005            .try_charge_cost("cap-1", 0, Some(10), 500, Some(200), Some(1000))
3006            .unwrap();
3007        assert!(!ok);
3008
3009        // Nothing should have been charged
3010        let records = store.list_usages(10, Some("cap-1")).unwrap();
3011        assert!(records.is_empty() || records[0].invocation_count == 0);
3012
3013        let _ = fs::remove_file(path);
3014    }
3015
3016    #[test]
3017    fn budget_store_try_charge_cost_exceeds_total_cap_sqlite() {
3018        let path = unique_db_path("chio-charge-cost-total");
3019        let mut store = SqliteBudgetStore::open(&path).unwrap();
3020        // First charge 900 of 1000 budget
3021        assert!(store
3022            .try_charge_cost("cap-1", 0, Some(10), 900, Some(1000), Some(1000))
3023            .unwrap());
3024        // Second charge of 200 would exceed the total cap of 1000
3025        let ok = store
3026            .try_charge_cost("cap-1", 0, Some(10), 200, Some(1000), Some(1000))
3027            .unwrap();
3028        assert!(!ok);
3029
3030        let records = store.list_usages(10, Some("cap-1")).unwrap();
3031        assert_usage_totals(&records[0], 900, 0);
3032
3033        let _ = fs::remove_file(path);
3034    }
3035
3036    #[test]
3037    fn budget_store_try_charge_cost_atomic_increment_sqlite() {
3038        let path = unique_db_path("chio-charge-cost-atomic");
3039        let mut store = SqliteBudgetStore::open(&path).unwrap();
3040        assert!(store
3041            .try_charge_cost("cap-1", 0, None, 100, Some(200), Some(1000))
3042            .unwrap());
3043        assert!(store
3044            .try_charge_cost("cap-1", 0, None, 150, Some(200), Some(1000))
3045            .unwrap());
3046
3047        let records = store.list_usages(10, Some("cap-1")).unwrap();
3048        assert_eq!(records[0].invocation_count, 2);
3049        assert_usage_totals(&records[0], 250, 0);
3050
3051        let _ = fs::remove_file(path);
3052    }
3053
3054    #[test]
3055    fn budget_store_try_charge_cost_within_limits_returns_true_inmemory() {
3056        let mut store = InMemoryBudgetStore::new();
3057        let ok = store
3058            .try_charge_cost("cap-1", 0, Some(10), 100, Some(200), Some(1000))
3059            .unwrap();
3060        assert!(ok);
3061
3062        let records = store.list_usages(10, Some("cap-1")).unwrap();
3063        assert_eq!(records[0].invocation_count, 1);
3064        assert_usage_totals(&records[0], 100, 0);
3065    }
3066
3067    #[test]
3068    fn budget_store_try_charge_cost_exceeds_per_invocation_cap_inmemory() {
3069        let mut store = InMemoryBudgetStore::new();
3070        let ok = store
3071            .try_charge_cost("cap-1", 0, Some(10), 500, Some(200), Some(1000))
3072            .unwrap();
3073        assert!(!ok);
3074    }
3075
3076    #[test]
3077    fn budget_store_try_charge_cost_exceeds_total_cap_inmemory() {
3078        let mut store = InMemoryBudgetStore::new();
3079        assert!(store
3080            .try_charge_cost("cap-1", 0, Some(10), 900, Some(1000), Some(1000))
3081            .unwrap());
3082        let ok = store
3083            .try_charge_cost("cap-1", 0, Some(10), 200, Some(1000), Some(1000))
3084            .unwrap();
3085        assert!(!ok);
3086    }
3087
3088    #[test]
3089    fn budget_usage_record_includes_split_cost_state() {
3090        let mut store = InMemoryBudgetStore::new();
3091        assert!(store
3092            .try_charge_cost("cap-1", 0, None, 42, None, None)
3093            .unwrap());
3094        let records = store.list_usages(10, Some("cap-1")).unwrap();
3095        assert_usage_totals(&records[0], 42, 0);
3096    }
3097
3098    #[test]
3099    fn budget_store_reverse_charge_cost_restores_prior_state_inmemory() {
3100        let mut store = InMemoryBudgetStore::new();
3101        assert!(store
3102            .try_charge_cost("cap-1", 0, Some(10), 100, Some(200), Some(1000))
3103            .unwrap());
3104
3105        store.reverse_charge_cost("cap-1", 0, 100).unwrap();
3106
3107        let record = store.get_usage("cap-1", 0).unwrap().unwrap();
3108        assert_eq!(record.invocation_count, 0);
3109        assert_usage_totals(&record, 0, 0);
3110    }
3111
3112    #[test]
3113    fn budget_store_reverse_charge_cost_restores_prior_state_sqlite() {
3114        let path = unique_db_path("chio-reverse-charge");
3115        let mut store = SqliteBudgetStore::open(&path).unwrap();
3116        assert!(store
3117            .try_charge_cost("cap-1", 0, Some(10), 100, Some(200), Some(1000))
3118            .unwrap());
3119
3120        store.reverse_charge_cost("cap-1", 0, 100).unwrap();
3121
3122        let record = store.get_usage("cap-1", 0).unwrap().unwrap();
3123        assert_eq!(record.invocation_count, 0);
3124        assert_usage_totals(&record, 0, 0);
3125
3126        let _ = fs::remove_file(path);
3127    }
3128
3129    #[test]
3130    fn budget_store_reduce_charge_cost_releases_exposure_only_inmemory() {
3131        let mut store = InMemoryBudgetStore::new();
3132        assert!(store
3133            .try_charge_cost("cap-1", 0, Some(10), 100, Some(200), Some(1000))
3134            .unwrap());
3135
3136        store.reduce_charge_cost("cap-1", 0, 25).unwrap();
3137
3138        let record = store.get_usage("cap-1", 0).unwrap().unwrap();
3139        assert_eq!(record.invocation_count, 1);
3140        assert_usage_totals(&record, 75, 0);
3141    }
3142
3143    #[test]
3144    fn budget_store_reduce_charge_cost_releases_exposure_only_sqlite() {
3145        let path = unique_db_path("chio-reduce-charge");
3146        let mut store = SqliteBudgetStore::open(&path).unwrap();
3147        assert!(store
3148            .try_charge_cost("cap-1", 0, Some(10), 100, Some(200), Some(1000))
3149            .unwrap());
3150
3151        store.reduce_charge_cost("cap-1", 0, 25).unwrap();
3152
3153        let record = store.get_usage("cap-1", 0).unwrap().unwrap();
3154        assert_eq!(record.invocation_count, 1);
3155        assert_usage_totals(&record, 75, 0);
3156
3157        let _ = fs::remove_file(path);
3158    }
3159
3160    #[test]
3161    fn budget_store_settle_charge_cost_moves_exposure_to_realized_inmemory() {
3162        let mut store = InMemoryBudgetStore::new();
3163        assert!(store
3164            .try_charge_cost("cap-1", 0, Some(10), 100, Some(200), Some(1000))
3165            .unwrap());
3166
3167        store.settle_charge_cost("cap-1", 0, 100, 75).unwrap();
3168
3169        let record = store.get_usage("cap-1", 0).unwrap().unwrap();
3170        assert_eq!(record.invocation_count, 1);
3171        assert_usage_totals(&record, 0, 75);
3172    }
3173
3174    #[test]
3175    fn budget_store_settle_charge_cost_moves_exposure_to_realized_sqlite() {
3176        let path = unique_db_path("chio-settle-charge");
3177        let mut store = SqliteBudgetStore::open(&path).unwrap();
3178        assert!(store
3179            .try_charge_cost("cap-1", 0, Some(10), 100, Some(200), Some(1000))
3180            .unwrap());
3181
3182        store.settle_charge_cost("cap-1", 0, 100, 75).unwrap();
3183
3184        let record = store.get_usage("cap-1", 0).unwrap().unwrap();
3185        assert_eq!(record.invocation_count, 1);
3186        assert_usage_totals(&record, 0, 75);
3187
3188        let _ = fs::remove_file(path);
3189    }
3190
3191    #[test]
3192    fn budget_store_try_charge_cost_with_ids_is_idempotent_inmemory() {
3193        let mut store = InMemoryBudgetStore::new();
3194        let hold_id = "hold-cap-1-0";
3195        let event_id = "hold-cap-1-0:authorize";
3196
3197        assert!(store
3198            .try_charge_cost_with_ids(
3199                "cap-1",
3200                0,
3201                Some(10),
3202                100,
3203                Some(200),
3204                Some(1000),
3205                Some(hold_id),
3206                Some(event_id),
3207            )
3208            .unwrap());
3209        assert!(store
3210            .try_charge_cost_with_ids(
3211                "cap-1",
3212                0,
3213                Some(10),
3214                100,
3215                Some(200),
3216                Some(1000),
3217                Some(hold_id),
3218                Some(event_id),
3219            )
3220            .unwrap());
3221
3222        let usage = store.get_usage("cap-1", 0).unwrap().unwrap();
3223        assert_eq!(usage.invocation_count, 1);
3224        assert_usage_totals(&usage, 100, 0);
3225
3226        let events = store
3227            .list_mutation_events(10, Some("cap-1"), Some(0))
3228            .unwrap();
3229        assert_eq!(events.len(), 1);
3230        assert_eq!(events[0].event_id, event_id);
3231        assert_eq!(events[0].hold_id.as_deref(), Some(hold_id));
3232        assert_eq!(events[0].kind, BudgetMutationKind::AuthorizeExposure);
3233        assert_eq!(events[0].allowed, Some(true));
3234    }
3235
3236    #[test]
3237    fn budget_store_try_charge_cost_with_ids_is_idempotent_sqlite() {
3238        let path = unique_db_path("chio-charge-cost-idempotent");
3239        let mut store = SqliteBudgetStore::open(&path).unwrap();
3240        let hold_id = "hold-cap-1-0";
3241        let event_id = "hold-cap-1-0:authorize";
3242
3243        assert!(store
3244            .try_charge_cost_with_ids(
3245                "cap-1",
3246                0,
3247                Some(10),
3248                100,
3249                Some(200),
3250                Some(1000),
3251                Some(hold_id),
3252                Some(event_id),
3253            )
3254            .unwrap());
3255        assert!(store
3256            .try_charge_cost_with_ids(
3257                "cap-1",
3258                0,
3259                Some(10),
3260                100,
3261                Some(200),
3262                Some(1000),
3263                Some(hold_id),
3264                Some(event_id),
3265            )
3266            .unwrap());
3267
3268        let usage = store.get_usage("cap-1", 0).unwrap().unwrap();
3269        assert_eq!(usage.invocation_count, 1);
3270        assert_usage_totals(&usage, 100, 0);
3271
3272        let events = store
3273            .list_mutation_events(10, Some("cap-1"), Some(0))
3274            .unwrap();
3275        assert_eq!(events.len(), 1);
3276        assert_eq!(events[0].event_id, event_id);
3277        assert_eq!(events[0].hold_id.as_deref(), Some(hold_id));
3278        assert_eq!(events[0].kind, BudgetMutationKind::AuthorizeExposure);
3279        assert_eq!(events[0].allowed, Some(true));
3280
3281        let _ = fs::remove_file(path);
3282    }
3283
3284    #[test]
3285    fn budget_store_settle_with_ids_is_idempotent_and_append_only_sqlite() {
3286        let path = unique_db_path("chio-settle-charge-idempotent");
3287        let mut store = SqliteBudgetStore::open(&path).unwrap();
3288        let hold_id = "hold-cap-1-0";
3289        let authorize_event_id = "hold-cap-1-0:authorize";
3290        let reconcile_event_id = "hold-cap-1-0:reconcile";
3291
3292        assert!(store
3293            .try_charge_cost_with_ids(
3294                "cap-1",
3295                0,
3296                Some(10),
3297                100,
3298                Some(200),
3299                Some(1000),
3300                Some(hold_id),
3301                Some(authorize_event_id),
3302            )
3303            .unwrap());
3304        store
3305            .settle_charge_cost_with_ids(
3306                "cap-1",
3307                0,
3308                100,
3309                75,
3310                Some(hold_id),
3311                Some(reconcile_event_id),
3312            )
3313            .unwrap();
3314        store
3315            .settle_charge_cost_with_ids(
3316                "cap-1",
3317                0,
3318                100,
3319                75,
3320                Some(hold_id),
3321                Some(reconcile_event_id),
3322            )
3323            .unwrap();
3324
3325        let usage = store.get_usage("cap-1", 0).unwrap().unwrap();
3326        assert_eq!(usage.invocation_count, 1);
3327        assert_usage_totals(&usage, 0, 75);
3328
3329        let events = store
3330            .list_mutation_events(10, Some("cap-1"), Some(0))
3331            .unwrap();
3332        assert_eq!(events.len(), 2);
3333        assert_eq!(events[0].event_id, authorize_event_id);
3334        assert_eq!(events[1].event_id, reconcile_event_id);
3335        assert_eq!(events[1].hold_id.as_deref(), Some(hold_id));
3336        assert_eq!(events[1].kind, BudgetMutationKind::ReconcileSpend);
3337        assert_eq!(events[1].exposure_units, 100);
3338        assert_eq!(events[1].realized_spend_units, 75);
3339        assert_eq!(events[1].total_cost_exposed_after, 0);
3340        assert_eq!(events[1].total_cost_realized_spend_after, 75);
3341
3342        let _ = fs::remove_file(path);
3343    }
3344
3345    #[test]
3346    fn budget_store_reduce_charge_cost_allows_zero_invocation_release_sqlite() {
3347        let path = unique_db_path("chio-reduce-charge-zero-invocations");
3348        let mut store = SqliteBudgetStore::open(&path).unwrap();
3349        store
3350            .upsert_usage(&usage_record("cap-zero", 0, 0, 10, 10, 40, 0))
3351            .unwrap();
3352
3353        store.reduce_charge_cost("cap-zero", 0, 25).unwrap();
3354
3355        let usage = store.get_usage("cap-zero", 0).unwrap().unwrap();
3356        assert_eq!(usage.invocation_count, 0);
3357        assert_usage_totals(&usage, 15, 0);
3358
3359        let events = store
3360            .list_mutation_events(10, Some("cap-zero"), Some(0))
3361            .unwrap();
3362        assert_eq!(events.len(), 1);
3363        assert_eq!(events[0].kind, BudgetMutationKind::ReleaseExposure);
3364        assert_eq!(events[0].invocation_count_after, 0);
3365        assert_eq!(events[0].total_cost_exposed_after, 15);
3366
3367        let _ = fs::remove_file(path);
3368    }
3369
3370    #[test]
3371    fn budget_store_list_mutation_events_preserves_append_order_sqlite() {
3372        let path = unique_db_path("chio-budget-event-order");
3373        let mut store = SqliteBudgetStore::open(&path).unwrap();
3374
3375        assert!(store
3376            .try_charge_cost_with_ids(
3377                "cap-order",
3378                0,
3379                Some(10),
3380                100,
3381                Some(200),
3382                Some(1000),
3383                Some("hold-cap-order-0"),
3384                Some("z-authorize"),
3385            )
3386            .unwrap());
3387        store
3388            .reduce_charge_cost_with_ids(
3389                "cap-order",
3390                0,
3391                25,
3392                Some("hold-cap-order-0"),
3393                Some("a-release"),
3394            )
3395            .unwrap();
3396
3397        let events = store
3398            .list_mutation_events(10, Some("cap-order"), Some(0))
3399            .unwrap();
3400        let event_ids = events
3401            .iter()
3402            .map(|record| record.event_id.as_str())
3403            .collect::<Vec<_>>();
3404        assert_eq!(event_ids, vec!["z-authorize", "a-release"]);
3405
3406        let _ = fs::remove_file(path);
3407    }
3408
3409    #[test]
3410    fn budget_store_hold_authority_requires_exact_lease_inmemory() {
3411        let mut store = InMemoryBudgetStore::new();
3412        let hold_id = "hold-cap-lease-0";
3413        let authorize_event_id = "hold-cap-lease-0:authorize";
3414        let release_event_id = "hold-cap-lease-0:release";
3415        let reconcile_event_id = "hold-cap-lease-0:reconcile";
3416        let initial = authority("budget-primary", "lease-7", 7);
3417        let advanced = authority("budget-primary", "lease-7", 8);
3418        let stale = authority("budget-primary", "lease-7", 6);
3419
3420        assert!(store
3421            .try_charge_cost_with_ids_and_authority(
3422                "cap-lease",
3423                0,
3424                Some(10),
3425                100,
3426                Some(200),
3427                Some(1000),
3428                Some(hold_id),
3429                Some(authorize_event_id),
3430                Some(&initial),
3431            )
3432            .unwrap());
3433
3434        let missing = store
3435            .reduce_charge_cost_with_ids_and_authority(
3436                "cap-lease",
3437                0,
3438                25,
3439                Some(hold_id),
3440                Some("hold-cap-lease-0:release-missing"),
3441                None,
3442            )
3443            .expect_err("missing lease metadata should fail closed");
3444        assert!(missing
3445            .to_string()
3446            .contains("requires authority lease metadata"));
3447
3448        let stale_error = store
3449            .reduce_charge_cost_with_ids_and_authority(
3450                "cap-lease",
3451                0,
3452                25,
3453                Some(hold_id),
3454                Some("hold-cap-lease-0:release-stale"),
3455                Some(&stale),
3456            )
3457            .expect_err("stale lease epoch should fail closed");
3458        assert!(stale_error.to_string().contains("lease epoch regressed"));
3459
3460        let advanced_error = store
3461            .reduce_charge_cost_with_ids_and_authority(
3462                "cap-lease",
3463                0,
3464                25,
3465                Some(hold_id),
3466                Some(release_event_id),
3467                Some(&advanced),
3468            )
3469            .expect_err("advanced lease epoch should fail closed");
3470        assert!(advanced_error
3471            .to_string()
3472            .contains("advanced beyond the open lease"));
3473
3474        store
3475            .reduce_charge_cost_with_ids_and_authority(
3476                "cap-lease",
3477                0,
3478                25,
3479                Some(hold_id),
3480                Some(release_event_id),
3481                Some(&initial),
3482            )
3483            .unwrap();
3484        store
3485            .settle_charge_cost_with_ids_and_authority(
3486                "cap-lease",
3487                0,
3488                75,
3489                75,
3490                Some(hold_id),
3491                Some(reconcile_event_id),
3492                Some(&initial),
3493            )
3494            .unwrap();
3495
3496        let usage = store.get_usage("cap-lease", 0).unwrap().unwrap();
3497        assert_eq!(usage.invocation_count, 1);
3498        assert_usage_totals(&usage, 0, 75);
3499
3500        let events = store
3501            .list_mutation_events(10, Some("cap-lease"), Some(0))
3502            .unwrap();
3503        assert_eq!(events.len(), 3);
3504        assert_eq!(events[0].authority.as_ref(), Some(&initial));
3505        assert_eq!(events[1].authority.as_ref(), Some(&initial));
3506        assert_eq!(events[2].authority.as_ref(), Some(&initial));
3507    }
3508
3509    #[test]
3510    fn budget_store_event_id_reuse_rejects_authority_rollover_sqlite() {
3511        let path = unique_db_path("chio-hold-authority-event-reuse");
3512        let mut store = SqliteBudgetStore::open(&path).unwrap();
3513        let hold_id = "hold-cap-lease-0";
3514        let event_id = "hold-cap-lease-0:authorize";
3515        let initial = authority("budget-primary", "lease-7", 7);
3516        let changed = authority("budget-primary", "lease-8", 8);
3517
3518        assert!(store
3519            .try_charge_cost_with_ids_and_authority(
3520                "cap-lease",
3521                0,
3522                Some(10),
3523                100,
3524                Some(200),
3525                Some(1000),
3526                Some(hold_id),
3527                Some(event_id),
3528                Some(&initial),
3529            )
3530            .unwrap());
3531
3532        let error = store
3533            .try_charge_cost_with_ids_and_authority(
3534                "cap-lease",
3535                0,
3536                Some(10),
3537                100,
3538                Some(200),
3539                Some(1000),
3540                Some(hold_id),
3541                Some(event_id),
3542                Some(&changed),
3543            )
3544            .expect_err("reused event id with different authority should fail closed");
3545        assert!(error
3546            .to_string()
3547            .contains("was reused for a different mutation"));
3548
3549        let usage = store.get_usage("cap-lease", 0).unwrap().unwrap();
3550        assert_eq!(usage.invocation_count, 1);
3551        assert_usage_totals(&usage, 100, 0);
3552
3553        let events = store
3554            .list_mutation_events(10, Some("cap-lease"), Some(0))
3555            .unwrap();
3556        assert_eq!(events.len(), 1);
3557        assert_eq!(events[0].authority.as_ref(), Some(&initial));
3558
3559        let _ = fs::remove_file(path);
3560    }
3561
3562    #[test]
3563    fn budget_store_deleted_provisional_event_allows_retry_after_compensation_sqlite() {
3564        let path = unique_db_path("chio-hold-authority-compensation");
3565        let mut store = SqliteBudgetStore::open(&path).unwrap();
3566        let hold_id = "hold-cap-lease-0";
3567        let event_id = "hold-cap-lease-0:authorize";
3568        let initial = authority("budget-primary", "lease-7", 7);
3569        let changed = authority("budget-primary", "lease-8", 8);
3570
3571        assert!(store
3572            .try_charge_cost_with_ids_and_authority(
3573                "cap-lease",
3574                0,
3575                Some(10),
3576                100,
3577                Some(200),
3578                Some(1000),
3579                Some(hold_id),
3580                Some(event_id),
3581                Some(&initial),
3582            )
3583            .unwrap());
3584        store
3585            .reverse_charge_cost_with_ids_and_authority(
3586                "cap-lease",
3587                0,
3588                100,
3589                Some(hold_id),
3590                Some("hold-cap-lease-0:authorize:rollback"),
3591                Some(&initial),
3592            )
3593            .unwrap();
3594        store.delete_hold(hold_id).unwrap();
3595        store.delete_mutation_event(event_id).unwrap();
3596
3597        assert!(store
3598            .try_charge_cost_with_ids_and_authority(
3599                "cap-lease",
3600                0,
3601                Some(10),
3602                100,
3603                Some(200),
3604                Some(1000),
3605                Some(hold_id),
3606                Some(event_id),
3607                Some(&changed),
3608            )
3609            .unwrap());
3610
3611        let usage = store.get_usage("cap-lease", 0).unwrap().unwrap();
3612        assert_eq!(usage.invocation_count, 1);
3613        assert_usage_totals(&usage, 100, 0);
3614
3615        let events = store
3616            .list_mutation_events(10, Some("cap-lease"), Some(0))
3617            .unwrap();
3618        let event_ids = events
3619            .iter()
3620            .map(|record| record.event_id.as_str())
3621            .collect::<Vec<_>>();
3622        assert_eq!(
3623            event_ids,
3624            vec![
3625                "hold-cap-lease-0:authorize:rollback",
3626                "hold-cap-lease-0:authorize"
3627            ]
3628        );
3629
3630        let _ = fs::remove_file(path);
3631    }
3632
3633    #[test]
3634    fn budget_store_rollback_artifact_allows_retry_with_new_authority_sqlite() {
3635        let path = unique_db_path("chio-hold-authority-rollback-retry");
3636        let mut store = SqliteBudgetStore::open(&path).unwrap();
3637        let hold_id = "hold-cap-lease-0";
3638        let event_id = "hold-cap-lease-0:authorize";
3639        let rollback_event_id = "hold-cap-lease-0:authorize:rollback:2";
3640        let initial = authority("budget-primary", "lease-7", 7);
3641        let changed = authority("budget-primary", "lease-8", 8);
3642
3643        assert!(store
3644            .try_charge_cost_with_ids_and_authority(
3645                "cap-lease",
3646                0,
3647                Some(10),
3648                100,
3649                Some(200),
3650                Some(1000),
3651                Some(hold_id),
3652                Some(event_id),
3653                Some(&initial),
3654            )
3655            .unwrap());
3656        store
3657            .reverse_charge_cost_with_ids_and_authority(
3658                "cap-lease",
3659                0,
3660                100,
3661                Some(hold_id),
3662                Some(rollback_event_id),
3663                Some(&initial),
3664            )
3665            .unwrap();
3666
3667        assert!(store
3668            .try_charge_cost_with_ids_and_authority(
3669                "cap-lease",
3670                0,
3671                Some(10),
3672                100,
3673                Some(200),
3674                Some(1000),
3675                Some(hold_id),
3676                Some(event_id),
3677                Some(&changed),
3678            )
3679            .unwrap());
3680
3681        let usage = store.get_usage("cap-lease", 0).unwrap().unwrap();
3682        assert_eq!(usage.invocation_count, 1);
3683        assert_usage_totals(&usage, 100, 0);
3684
3685        let events = store
3686            .list_mutation_events(10, Some("cap-lease"), Some(0))
3687            .unwrap();
3688        let authorize = events
3689            .iter()
3690            .find(|record| record.event_id == event_id)
3691            .expect("replacement authorize event");
3692        assert_eq!(authorize.authority.as_ref(), Some(&changed));
3693
3694        let _ = fs::remove_file(path);
3695    }
3696
3697    #[test]
3698    fn budget_store_retry_after_rollback_replaces_orphaned_open_hold_sqlite() {
3699        let path = unique_db_path("chio-hold-rollback-orphan-retry");
3700        let mut store = SqliteBudgetStore::open(&path).unwrap();
3701        let hold_id = "hold-cap-orphan-0";
3702        let event_id = "hold-cap-orphan-0:authorize";
3703        let rollback_event_id = "hold-cap-orphan-0:authorize:rollback:5";
3704        let initial = authority("budget-primary", "lease-7", 7);
3705        let changed = authority("budget-primary", "lease-8", 8);
3706
3707        for _ in 0..3 {
3708            assert!(store
3709                .try_increment_with_event_id("cap-orphan", 0, Some(10), None)
3710                .unwrap());
3711        }
3712        assert!(store
3713            .try_charge_cost_with_ids_and_authority(
3714                "cap-orphan",
3715                0,
3716                Some(10),
3717                75,
3718                Some(100),
3719                Some(400),
3720                Some(hold_id),
3721                Some(event_id),
3722                Some(&initial),
3723            )
3724            .unwrap());
3725        store
3726            .reverse_charge_cost_with_ids_and_authority(
3727                "cap-orphan",
3728                0,
3729                75,
3730                Some(hold_id),
3731                Some(rollback_event_id),
3732                Some(&initial),
3733            )
3734            .unwrap();
3735
3736        let transaction = store
3737            .connection
3738            .transaction_with_behavior(TransactionBehavior::Immediate)
3739            .unwrap();
3740        transaction
3741            .execute(
3742                "DELETE FROM budget_mutation_events WHERE event_id = ?1",
3743                params![event_id],
3744            )
3745            .unwrap();
3746        SqliteBudgetStore::upsert_hold(
3747            &transaction,
3748            hold_id,
3749            "cap-orphan",
3750            0,
3751            75,
3752            75,
3753            HoldDisposition::Open,
3754            Some(&initial),
3755        )
3756        .unwrap();
3757        transaction.commit().unwrap();
3758
3759        assert!(store
3760            .try_charge_cost_with_ids_and_authority(
3761                "cap-orphan",
3762                0,
3763                Some(10),
3764                75,
3765                Some(100),
3766                Some(400),
3767                Some(hold_id),
3768                Some(event_id),
3769                Some(&changed),
3770            )
3771            .unwrap());
3772
3773        let usage = store.get_usage("cap-orphan", 0).unwrap().unwrap();
3774        assert_eq!(usage.invocation_count, 4);
3775        assert_usage_totals(&usage, 75, 0);
3776
3777        let events = store
3778            .list_mutation_events(20, Some("cap-orphan"), Some(0))
3779            .unwrap();
3780        let rollback = events
3781            .iter()
3782            .find(|record| record.event_id == rollback_event_id)
3783            .expect("rollback event");
3784        let retry = events
3785            .iter()
3786            .find(|record| record.event_id == event_id)
3787            .expect("retry authorize event");
3788        assert!(retry.event_seq > rollback.event_seq);
3789        assert_eq!(retry.authority.as_ref(), Some(&changed));
3790
3791        let transaction = store
3792            .connection
3793            .transaction_with_behavior(TransactionBehavior::Immediate)
3794            .unwrap();
3795        let hold = SqliteBudgetStore::load_hold(&transaction, hold_id)
3796            .unwrap()
3797            .expect("retry open hold");
3798        assert_eq!(hold.remaining_exposure_units, 75);
3799        assert_eq!(hold.disposition, HoldDisposition::Open);
3800        drop(transaction);
3801
3802        let transaction = store
3803            .connection
3804            .transaction_with_behavior(TransactionBehavior::Immediate)
3805            .unwrap();
3806        transaction
3807            .execute(
3808                "DELETE FROM budget_mutation_events WHERE event_id = ?1",
3809                params![event_id],
3810            )
3811            .unwrap();
3812        transaction.commit().unwrap();
3813
3814        assert!(store
3815            .try_charge_cost_with_ids_and_authority(
3816                "cap-orphan",
3817                0,
3818                Some(10),
3819                75,
3820                Some(100),
3821                Some(400),
3822                Some(hold_id),
3823                Some(event_id),
3824                Some(&changed),
3825            )
3826            .unwrap());
3827        let events = store
3828            .list_mutation_events(20, Some("cap-orphan"), Some(0))
3829            .unwrap();
3830        let replayed_retry = events
3831            .iter()
3832            .find(|record| record.event_id == event_id)
3833            .expect("replayed retry authorize event");
3834        assert_eq!(replayed_retry.allowed, Some(true));
3835        assert_eq!(replayed_retry.usage_seq, Some(usage.seq));
3836
3837        let _ = fs::remove_file(path);
3838    }
3839
3840    #[test]
3841    fn import_mutation_record_keeps_duplicate_release_events_idempotent_sqlite() {
3842        let path = unique_db_path("chio-budget-import-release-idempotent");
3843        let mut store = SqliteBudgetStore::open(&path).unwrap();
3844        let hold_id = "hold-cap-import-0";
3845        let authorize_event_id = "hold-cap-import-0:authorize";
3846        let release_event_id = "hold-cap-import-0:release";
3847
3848        assert!(store
3849            .try_charge_cost_with_ids(
3850                "cap-import",
3851                0,
3852                Some(10),
3853                100,
3854                Some(200),
3855                Some(1000),
3856                Some(hold_id),
3857                Some(authorize_event_id),
3858            )
3859            .unwrap());
3860        store
3861            .reduce_charge_cost_with_ids(
3862                "cap-import",
3863                0,
3864                100,
3865                Some(hold_id),
3866                Some(release_event_id),
3867            )
3868            .unwrap();
3869
3870        let release_record = store
3871            .list_mutation_events(10, Some("cap-import"), Some(0))
3872            .unwrap()
3873            .into_iter()
3874            .find(|record| record.event_id == release_event_id)
3875            .expect("release event record");
3876
3877        store.import_mutation_record(&release_record).unwrap();
3878
3879        let usage = store.get_usage("cap-import", 0).unwrap().unwrap();
3880        assert_eq!(usage.invocation_count, 1);
3881        assert_usage_totals(&usage, 0, 0);
3882
3883        let transaction = store
3884            .connection
3885            .transaction_with_behavior(TransactionBehavior::Immediate)
3886            .unwrap();
3887        let hold = SqliteBudgetStore::load_hold(&transaction, hold_id)
3888            .unwrap()
3889            .expect("released hold state");
3890        assert_eq!(hold.remaining_exposure_units, 0);
3891        assert_eq!(hold.disposition, HoldDisposition::Released);
3892        drop(transaction);
3893
3894        let events = store
3895            .list_mutation_events(10, Some("cap-import"), Some(0))
3896            .unwrap();
3897        assert_eq!(events.len(), 2);
3898        assert_eq!(events[0].event_id, authorize_event_id);
3899        assert_eq!(events[1].event_id, release_event_id);
3900
3901        let _ = fs::remove_file(path);
3902    }
3903
3904    #[test]
3905    fn import_snapshot_records_replay_is_idempotent_when_peer_cursor_is_lost_sqlite() {
3906        let source_path = unique_db_path("chio-budget-import-replay-source");
3907        let target_path = unique_db_path("chio-budget-import-replay-target");
3908        let mut source = SqliteBudgetStore::open(&source_path).unwrap();
3909
3910        assert!(source
3911            .try_charge_cost_with_ids(
3912                "cap-import-replay",
3913                0,
3914                Some(5),
3915                25,
3916                Some(50),
3917                Some(250),
3918                Some("hold-import-replay-0"),
3919                Some("hold-import-replay-0:authorize"),
3920            )
3921            .unwrap());
3922        let usage = source
3923            .get_usage("cap-import-replay", 0)
3924            .unwrap()
3925            .expect("source usage");
3926        let events = source
3927            .list_mutation_events(10, Some("cap-import-replay"), Some(0))
3928            .unwrap();
3929
3930        let mut target = SqliteBudgetStore::open(&target_path).unwrap();
3931        target
3932            .import_snapshot_records(std::slice::from_ref(&usage), &events)
3933            .unwrap();
3934        target
3935            .import_snapshot_records(std::slice::from_ref(&usage), &events)
3936            .unwrap();
3937
3938        let replicated_usage = target
3939            .get_usage("cap-import-replay", 0)
3940            .unwrap()
3941            .expect("replicated usage");
3942        assert_eq!(replicated_usage.invocation_count, 1);
3943        assert_usage_totals(&replicated_usage, 25, 0);
3944        let replicated_events = target
3945            .list_mutation_events(10, Some("cap-import-replay"), Some(0))
3946            .unwrap();
3947        assert_eq!(replicated_events.len(), 1);
3948        assert_eq!(
3949            replicated_events[0].event_id,
3950            "hold-import-replay-0:authorize"
3951        );
3952
3953        let _ = fs::remove_file(source_path);
3954        let _ = fs::remove_file(target_path);
3955    }
3956
3957    #[test]
3958    fn import_snapshot_records_rolls_back_usage_rows_when_mutation_conflicts_sqlite() {
3959        let path = unique_db_path("chio-budget-import-atomic");
3960        let mut store = SqliteBudgetStore::open(&path).unwrap();
3961        let initial_authority = authority("budget-primary", "lease-1", 1);
3962        let conflicting_authority = authority("budget-primary", "lease-2", 2);
3963
3964        assert!(store
3965            .try_charge_cost_with_ids_and_authority(
3966                "cap-import",
3967                0,
3968                Some(10),
3969                25,
3970                Some(100),
3971                Some(500),
3972                Some("hold-cap-import-atomic-0"),
3973                Some("hold-cap-import-atomic-0:authorize"),
3974                Some(&initial_authority),
3975            )
3976            .unwrap());
3977
3978        let mut conflicting_event = store
3979            .list_mutation_events(10, Some("cap-import"), Some(0))
3980            .unwrap()
3981            .into_iter()
3982            .find(|record| record.event_id == "hold-cap-import-atomic-0:authorize")
3983            .expect("existing authorize event");
3984        conflicting_event.authority = Some(conflicting_authority);
3985
3986        let imported_usage = usage_record("cap-import-rollback", 0, 2, unix_now(), 88, 40, 5);
3987
3988        let error = store
3989            .import_snapshot_records(&[imported_usage], &[conflicting_event])
3990            .expect_err("conflicting event import should fail atomically");
3991        assert!(error
3992            .to_string()
3993            .contains("reused for a different mutation"));
3994        assert!(store.get_usage("cap-import-rollback", 0).unwrap().is_none());
3995
3996        let existing = store.get_usage("cap-import", 0).unwrap().unwrap();
3997        assert_eq!(existing.invocation_count, 1);
3998        assert_usage_totals(&existing, 25, 0);
3999
4000        let _ = fs::remove_file(path);
4001    }
4002
4003    #[test]
4004    fn budget_store_open_hold_recovers_missing_authorize_event_sqlite() {
4005        let path = unique_db_path("chio-hold-authority-recover-missing-event");
4006        let mut store = SqliteBudgetStore::open(&path).unwrap();
4007        let hold_id = "hold-cap-recover-0";
4008        let event_id = "hold-cap-recover-0:authorize";
4009        let authority = authority("budget-primary", "lease-7", 7);
4010
4011        assert!(store
4012            .try_charge_cost_with_ids_and_authority(
4013                "cap-recover",
4014                0,
4015                Some(10),
4016                100,
4017                Some(200),
4018                Some(1000),
4019                Some(hold_id),
4020                Some(event_id),
4021                Some(&authority),
4022            )
4023            .unwrap());
4024        store.delete_mutation_event(event_id).unwrap();
4025
4026        assert!(store
4027            .try_charge_cost_with_ids_and_authority(
4028                "cap-recover",
4029                0,
4030                Some(10),
4031                100,
4032                Some(200),
4033                Some(1000),
4034                Some(hold_id),
4035                Some(event_id),
4036                Some(&authority),
4037            )
4038            .unwrap());
4039
4040        let events = store
4041            .list_mutation_events(10, Some("cap-recover"), Some(0))
4042            .unwrap();
4043        assert_eq!(events.len(), 1);
4044        assert_eq!(events[0].event_id, event_id);
4045
4046        let _ = fs::remove_file(path);
4047    }
4048
4049    #[test]
4050    fn upsert_usage_preserves_newer_split_cost_state() {
4051        let path = unique_db_path("chio-budget-upsert-cost");
4052        let mut store = SqliteBudgetStore::open(&path).unwrap();
4053
4054        // Higher-seq record written first
4055        store
4056            .upsert_usage(&usage_record("cap-1", 0, 5, 10, 10, 500, 0))
4057            .unwrap();
4058
4059        // Lower-seq record written second (stale replica)
4060        store
4061            .upsert_usage(&usage_record("cap-1", 0, 3, 12, 5, 300, 0))
4062            .unwrap();
4063
4064        let records = store.list_usages(10, Some("cap-1")).unwrap();
4065        assert_usage_totals(&records[0], 500, 0);
4066        assert_eq!(records[0].seq, 10);
4067
4068        let _ = fs::remove_file(path);
4069    }
4070
4071    #[test]
4072    fn upsert_usage_does_not_resurrect_split_cost_state_from_stale_seq() {
4073        let path = unique_db_path("chio-budget-upsert-split");
4074        let mut store = SqliteBudgetStore::open(&path).unwrap();
4075
4076        store
4077            .upsert_usage(&usage_record("cap-1", 0, 1, 20, 20, 0, 75))
4078            .unwrap();
4079        store
4080            .upsert_usage(&usage_record("cap-1", 0, 1, 10, 10, 100, 0))
4081            .unwrap();
4082
4083        let records = store.list_usages(10, Some("cap-1")).unwrap();
4084        assert_usage_totals(&records[0], 0, 75);
4085        assert_eq!(records[0].seq, 20);
4086
4087        let _ = fs::remove_file(path);
4088    }
4089
4090    /// Documents the HA overrun bound for monetary budget enforcement.
4091    ///
4092    /// In a split-brain scenario across N nodes, each node may independently
4093    /// approve one invocation at max_cost_per_invocation before the LWW merge
4094    /// propagates. The worst-case overrun is bounded by:
4095    ///   overrun <= max_cost_per_invocation * node_count
4096    ///
4097    /// This test asserts the bound holds for a simulated 2-node split-brain.
4098    #[test]
4099    fn concurrent_charge_overrun_bound() {
4100        let path_a = unique_db_path("chio-overrun-node-a");
4101        let path_b = unique_db_path("chio-overrun-node-b");
4102
4103        let max_per_invocation: u64 = 100;
4104        let total_budget: u64 = 150; // Tight: allows 1 full invocation + small buffer
4105        let node_count: u64 = 2;
4106
4107        // Both nodes start fresh (simulating split-brain: neither sees the other's write)
4108        let mut node_a = SqliteBudgetStore::open(&path_a).unwrap();
4109        let mut node_b = SqliteBudgetStore::open(&path_b).unwrap();
4110
4111        // Both nodes independently approve an invocation of max_per_invocation
4112        let approved_a = node_a
4113            .try_charge_cost(
4114                "cap-split",
4115                0,
4116                None,
4117                max_per_invocation,
4118                Some(max_per_invocation),
4119                Some(total_budget),
4120            )
4121            .unwrap();
4122        let approved_b = node_b
4123            .try_charge_cost(
4124                "cap-split",
4125                0,
4126                None,
4127                max_per_invocation,
4128                Some(max_per_invocation),
4129                Some(total_budget),
4130            )
4131            .unwrap();
4132
4133        // Both nodes approved (split-brain; each sees a fresh slate)
4134        assert!(approved_a, "node A should approve");
4135        assert!(approved_b, "node B should approve");
4136
4137        // The actual combined spend exceeds the total budget
4138        let combined_spend = max_per_invocation * node_count;
4139        // The overrun is bounded by max_cost_per_invocation * node_count
4140        let max_overrun = max_per_invocation * node_count;
4141        assert!(
4142            combined_spend <= max_overrun,
4143            "HA overrun bound violated: combined_spend={combined_spend} > max_overrun={max_overrun}"
4144        );
4145
4146        // After LWW merge converges, outstanding exposure remains conservatively bounded.
4147        let record_a = node_a.list_usages(1, Some("cap-split")).unwrap();
4148        let record_b = node_b.list_usages(1, Some("cap-split")).unwrap();
4149        let total_after_merge = record_a[0].total_cost_exposed + record_b[0].total_cost_exposed;
4150        assert!(
4151            total_after_merge <= max_overrun,
4152            "post-merge total {total_after_merge} exceeds bound {max_overrun}"
4153        );
4154
4155        let _ = fs::remove_file(path_a);
4156        let _ = fs::remove_file(path_b);
4157    }
4158
4159    #[test]
4160    fn budget_store_zero_max_total_denies_any_charge_inmemory() {
4161        // A grant with max_total_cost = 0 must deny even a charge of 1 unit.
4162        let mut store = InMemoryBudgetStore::new();
4163        let ok = store
4164            .try_charge_cost("cap-zero-budget", 0, None, 1, None, Some(0))
4165            .unwrap();
4166        assert!(
4167            !ok,
4168            "any charge against a zero-unit total budget must be denied"
4169        );
4170        let records = store.list_usages(10, Some("cap-zero-budget")).unwrap();
4171        assert!(
4172            records.is_empty() || records[0].invocation_count == 0,
4173            "no invocations should be recorded against a zero-unit budget"
4174        );
4175    }
4176
4177    #[test]
4178    fn budget_store_zero_max_total_denies_any_charge_sqlite() {
4179        let path = unique_db_path("chio-zero-budget-sqlite");
4180        let mut store = SqliteBudgetStore::open(&path).unwrap();
4181        let ok = store
4182            .try_charge_cost("cap-zero-budget", 0, None, 1, None, Some(0))
4183            .unwrap();
4184        assert!(
4185            !ok,
4186            "any charge against a zero-unit total budget must be denied"
4187        );
4188        let records = store.list_usages(10, Some("cap-zero-budget")).unwrap();
4189        assert!(
4190            records.is_empty() || records[0].invocation_count == 0,
4191            "no invocations should be recorded against a zero-unit budget"
4192        );
4193        let _ = fs::remove_file(path);
4194    }
4195
4196    #[test]
4197    fn budget_store_zero_cost_invocation_succeeds_and_records_zero_inmemory() {
4198        // A zero-cost invocation against a monetary grant should succeed and
4199        // record cost_charged = 0.
4200        let mut store = InMemoryBudgetStore::new();
4201        let ok = store
4202            .try_charge_cost("cap-zero-cost", 0, None, 0, None, Some(1000))
4203            .unwrap();
4204        assert!(
4205            ok,
4206            "zero-cost invocation should succeed when budget is available"
4207        );
4208        let records = store.list_usages(10, Some("cap-zero-cost")).unwrap();
4209        assert_eq!(records.len(), 1);
4210        assert_eq!(records[0].invocation_count, 1);
4211        assert_usage_totals(&records[0], 0, 0);
4212    }
4213
4214    #[test]
4215    fn budget_store_zero_cost_invocation_succeeds_and_records_zero_sqlite() {
4216        let path = unique_db_path("chio-zero-cost-sqlite");
4217        let mut store = SqliteBudgetStore::open(&path).unwrap();
4218        let ok = store
4219            .try_charge_cost("cap-zero-cost", 0, None, 0, None, Some(1000))
4220            .unwrap();
4221        assert!(
4222            ok,
4223            "zero-cost invocation should succeed when budget is available"
4224        );
4225        let records = store.list_usages(10, Some("cap-zero-cost")).unwrap();
4226        assert_eq!(records.len(), 1);
4227        assert_eq!(records[0].invocation_count, 1);
4228        assert_usage_totals(&records[0], 0, 0);
4229        let _ = fs::remove_file(path);
4230    }
4231}