Skip to main content

chio_kernel/
budget_store.rs

1use chio_core::capability::scope::MonetaryAmount;
2
3use crate::supplemental_quota::CanonicalRevocationSet;
4
5#[derive(Debug, thiserror::Error)]
6pub enum BudgetStoreError {
7    #[error("sqlite error: {0}")]
8    Sqlite(#[from] rusqlite::Error),
9
10    #[error("failed to prepare budget store directory: {0}")]
11    Io(#[from] std::io::Error),
12
13    #[error("budget arithmetic overflow: {0}")]
14    Overflow(String),
15
16    #[error(
17        "budget mutation fenced: expected owner epoch {expected_epoch}, actual owner epoch {actual_epoch:?}"
18    )]
19    Fenced {
20        expected_epoch: u64,
21        actual_epoch: Option<u64>,
22    },
23
24    #[error("budget state invariant violated: {0}")]
25    Invariant(String),
26
27    #[error("budget mutation durable outcome is unknown: {0}")]
28    OutcomeUnknown(String),
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct BudgetUsageRecord {
33    pub capability_id: String,
34    pub grant_index: u32,
35    pub invocation_count: u32,
36    pub updated_at: i64,
37    pub seq: u64,
38    pub total_cost_exposed: u64,
39    pub total_cost_realized_spend: u64,
40}
41
42impl BudgetUsageRecord {
43    pub fn committed_cost_units(&self) -> Result<u64, BudgetStoreError> {
44        checked_committed_cost_units(self.total_cost_exposed, self.total_cost_realized_spend)
45    }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum BudgetMutationKind {
50    IncrementInvocation,
51    ReserveInvocation,
52    AuthorizeExposure,
53    CaptureInvocation,
54    AuthorizeCumulativeApproval,
55    ReverseInvocation,
56    CancelCapturedBeforeDispatch,
57    ReverseExposure,
58    ReleaseExposure,
59    ReconcileSpend,
60    CaptureSpend,
61}
62
63impl BudgetMutationKind {
64    pub fn as_str(self) -> &'static str {
65        match self {
66            Self::IncrementInvocation => "increment_invocation",
67            Self::ReserveInvocation => "reserve_invocation",
68            Self::AuthorizeExposure => "authorize_exposure",
69            Self::CaptureInvocation => "capture_invocation",
70            Self::AuthorizeCumulativeApproval => "authorize_cumulative_approval",
71            Self::ReverseInvocation => "reverse_invocation",
72            Self::CancelCapturedBeforeDispatch => "cancel_captured_before_dispatch",
73            Self::ReverseExposure => "reverse_exposure",
74            Self::ReleaseExposure => "release_exposure",
75            Self::ReconcileSpend => "reconcile_spend",
76            Self::CaptureSpend => "capture_spend",
77        }
78    }
79
80    pub fn parse(value: &str) -> Option<Self> {
81        match value {
82            "increment_invocation" => Some(Self::IncrementInvocation),
83            "reserve_invocation" => Some(Self::ReserveInvocation),
84            "authorize_exposure" => Some(Self::AuthorizeExposure),
85            "capture_invocation" => Some(Self::CaptureInvocation),
86            "authorize_cumulative_approval" => Some(Self::AuthorizeCumulativeApproval),
87            "reverse_invocation" => Some(Self::ReverseInvocation),
88            "cancel_captured_before_dispatch" => Some(Self::CancelCapturedBeforeDispatch),
89            "reverse_exposure" => Some(Self::ReverseExposure),
90            "release_exposure" => Some(Self::ReleaseExposure),
91            "reconcile_spend" => Some(Self::ReconcileSpend),
92            "capture_spend" => Some(Self::CaptureSpend),
93            _ => None,
94        }
95    }
96}
97
98include!("budget_store/model.rs");
99
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct BudgetAuthorizeHoldRequest {
102    pub capability_id: String,
103    pub grant_index: usize,
104    pub max_invocations: Option<u32>,
105    pub invocation_quotas: Vec<BudgetInvocationQuota>,
106    pub cumulative_approval: Option<BudgetCumulativeApprovalRequest>,
107    pub admission_binding: Option<BudgetAdmissionBinding>,
108    pub requested_exposure_units: u64,
109    pub max_cost_per_invocation: Option<u64>,
110    pub max_total_cost_units: Option<u64>,
111    pub hold_id: Option<String>,
112    pub event_id: Option<String>,
113    pub authority: Option<BudgetEventAuthority>,
114}
115
116impl BudgetAuthorizeHoldRequest {
117    pub fn validate(&self) -> Result<(), BudgetStoreError> {
118        self.validate_composite().map(|_| ())
119    }
120
121    fn validated_invocation_quotas(&self) -> Result<Vec<BudgetInvocationQuota>, BudgetStoreError> {
122        if self.invocation_quotas.len() > MAX_INVOCATION_QUOTAS_PER_ADMISSION {
123            return Err(BudgetStoreError::Invariant(format!(
124                "at most {MAX_INVOCATION_QUOTAS_PER_ADMISSION} invocation quotas are allowed"
125            )));
126        }
127        for quota in &self.invocation_quotas {
128            quota.key.validate()?;
129        }
130        if self
131            .invocation_quotas
132            .windows(2)
133            .any(|pair| pair[0].key >= pair[1].key)
134        {
135            return Err(BudgetStoreError::Invariant(
136                "invocation quotas must be strictly sorted by unique key".to_string(),
137            ));
138        }
139
140        if self.invocation_quotas.is_empty() {
141            return self
142                .max_invocations
143                .map(|max_invocations| {
144                    u32::try_from(self.grant_index)
145                        .map(|grant_index| {
146                            vec![BudgetInvocationQuota {
147                                key: BudgetQuotaKey::grant(self.capability_id.clone(), grant_index),
148                                max_invocations,
149                            }]
150                        })
151                        .map_err(|_| {
152                            BudgetStoreError::Invariant(
153                                "grant_index does not fit the budget quota key".to_string(),
154                            )
155                        })
156                })
157                .transpose()
158                .map(Option::unwrap_or_default);
159        }
160
161        let grant_index = u32::try_from(self.grant_index).map_err(|_| {
162            BudgetStoreError::Invariant("grant_index does not fit the budget quota key".to_string())
163        })?;
164        let expected_key = BudgetQuotaKey::grant(self.capability_id.clone(), grant_index);
165        let grant_quotas = self
166            .invocation_quotas
167            .iter()
168            .filter(|quota| quota.key.profile == BudgetQuotaProfile::GrantInvocation)
169            .collect::<Vec<_>>();
170        if grant_quotas.len() > 1
171            || grant_quotas
172                .first()
173                .is_some_and(|quota| quota.key != expected_key)
174        {
175            return Err(BudgetStoreError::Invariant(
176                "structured grant quota must match the authorization capability and grant"
177                    .to_string(),
178            ));
179        }
180
181        if let Some(max_invocations) = self.max_invocations {
182            if !self
183                .invocation_quotas
184                .iter()
185                .any(|quota| quota.key == expected_key && quota.max_invocations == max_invocations)
186            {
187                return Err(BudgetStoreError::Invariant(
188                    "legacy max_invocations must match the structured grant quota".to_string(),
189                ));
190            }
191        }
192        Ok(self.invocation_quotas.clone())
193    }
194
195    fn validate_composite(&self) -> Result<Vec<BudgetInvocationQuota>, BudgetStoreError> {
196        let quotas = self.validated_invocation_quotas()?;
197        validate_optional_budget_identity(
198            self.hold_id.as_deref(),
199            self.event_id.as_deref(),
200            "budget authorization",
201        )?;
202        let durable = !quotas.is_empty()
203            || self.cumulative_approval.is_some()
204            || self.admission_binding.is_some();
205        if durable
206            && (self.hold_id.as_deref().is_none_or(str::is_empty)
207                || self.event_id.as_deref().is_none_or(str::is_empty))
208        {
209            return Err(BudgetStoreError::Invariant(
210                "composite budget authorization requires non-empty hold_id and event_id"
211                    .to_string(),
212            ));
213        }
214        let composite = !self.invocation_quotas.is_empty() || self.cumulative_approval.is_some();
215        if composite && self.admission_binding.is_none() {
216            return Err(BudgetStoreError::Invariant(
217                "composite budget authorization requires an admission binding".to_string(),
218            ));
219        }
220        if let Some(binding) = &self.admission_binding {
221            binding.validate()?;
222            if binding
223                .revocation_set
224                .ids()
225                .binary_search(&self.capability_id)
226                .is_err()
227            {
228                return Err(BudgetStoreError::Invariant(
229                    "admission revocation set must contain the leaf capability".to_string(),
230                ));
231            }
232            let has_supplemental = quotas.iter().any(|quota| {
233                quota.key.profile == BudgetQuotaProfile::SupplementalBrokerCapabilityExecution
234            });
235            let supplemental_fields = [
236                binding.supplemental_verifier_id.as_deref(),
237                binding.supplemental_verifier_config_digest.as_deref(),
238                binding
239                    .supplemental_authorization_artifact_digest
240                    .as_deref(),
241            ];
242            let has_complete_supplemental_binding = supplemental_fields
243                .iter()
244                .all(|value| value.is_some_and(|value| !value.is_empty()))
245                && binding.supplemental_authorization_expires_at.is_some();
246            let has_any_supplemental_binding = supplemental_fields.iter().any(Option::is_some)
247                || binding.supplemental_authorization_expires_at.is_some();
248            if has_supplemental != has_complete_supplemental_binding
249                || has_any_supplemental_binding != has_complete_supplemental_binding
250            {
251                return Err(BudgetStoreError::Invariant(
252                    "supplemental quota and verifier, artifact, and expiry bindings must be presented together"
253                        .to_string(),
254                ));
255            }
256        }
257        if let Some(cumulative) = &self.cumulative_approval {
258            cumulative.account_key.validate()?;
259            if cumulative.operation_id.is_empty() {
260                return Err(BudgetStoreError::Invariant(
261                    "cumulative approval operation_id must not be empty".to_string(),
262                ));
263            }
264            if self
265                .admission_binding
266                .as_ref()
267                .is_none_or(|binding| binding.operation_id != cumulative.operation_id)
268            {
269                return Err(BudgetStoreError::Invariant(
270                    "cumulative approval operation does not match admission binding".to_string(),
271                ));
272            }
273            let currency = cumulative.account_key.currency.as_str();
274            if cumulative.authority_threshold.currency != currency
275                || cumulative.effective_threshold.currency != currency
276                || cumulative.requested_authorized.currency != currency
277            {
278                return Err(BudgetStoreError::Invariant(
279                    "cumulative approval amounts must use the account currency".to_string(),
280                ));
281            }
282            if cumulative.effective_threshold.units > cumulative.authority_threshold.units {
283                return Err(BudgetStoreError::Invariant(
284                    "effective cumulative threshold exceeds authority threshold".to_string(),
285                ));
286            }
287        }
288        Ok(quotas)
289    }
290}
291
292#[derive(Debug, Clone, PartialEq, Eq)]
293pub struct BudgetReleaseHoldRequest {
294    pub capability_id: String,
295    pub grant_index: usize,
296    pub released_exposure_units: u64,
297    pub hold_id: Option<String>,
298    pub event_id: Option<String>,
299    pub authority: Option<BudgetEventAuthority>,
300}
301
302#[derive(Debug, Clone, PartialEq, Eq)]
303pub struct BudgetReverseHoldRequest {
304    pub capability_id: String,
305    pub grant_index: usize,
306    pub reversed_exposure_units: u64,
307    pub hold_id: Option<String>,
308    pub event_id: Option<String>,
309    pub expected_cumulative_approval_state: Option<BudgetCumulativeApprovalState>,
310    pub authority: Option<BudgetEventAuthority>,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq)]
314pub struct BudgetCaptureInvocationRequest {
315    pub capability_id: String,
316    pub grant_index: usize,
317    pub hold_id: String,
318    pub event_id: String,
319    pub trusted_time: Option<u64>,
320    pub authority: Option<BudgetEventAuthority>,
321}
322
323#[derive(Debug, Clone, PartialEq, Eq)]
324pub struct BudgetAuthorizeCumulativeApprovalRequest {
325    pub capability_id: String,
326    pub grant_index: usize,
327    pub operation_id: String,
328    pub hold_id: String,
329    pub admission_binding: BudgetAdmissionBinding,
330    pub approval_set_digest: String,
331    pub event_id: String,
332    pub authority: Option<BudgetEventAuthority>,
333}
334
335impl BudgetAuthorizeCumulativeApprovalRequest {
336    pub fn validate(&self) -> Result<(), BudgetStoreError> {
337        if self.capability_id.is_empty()
338            || self.operation_id.is_empty()
339            || self.hold_id.is_empty()
340            || !is_sha256_digest(&self.approval_set_digest)
341            || self.event_id.is_empty()
342        {
343            return Err(BudgetStoreError::Invariant(
344                "cumulative approval attachment identity must not be empty".to_string(),
345            ));
346        }
347        u32::try_from(self.grant_index).map_err(|_| {
348            BudgetStoreError::Invariant(
349                "cumulative approval grant_index does not fit u32".to_string(),
350            )
351        })?;
352        self.admission_binding.validate()?;
353        if self.admission_binding.operation_id != self.operation_id {
354            return Err(BudgetStoreError::Invariant(
355                "cumulative approval operation does not match admission binding".to_string(),
356            ));
357        }
358        Ok(())
359    }
360}
361
362#[derive(Debug, Clone, PartialEq, Eq)]
363pub enum BudgetCumulativeApprovalAuthorizationDecision {
364    Authorized(BudgetHoldMutationDecision),
365    AlreadyAuthorized(BudgetHoldMutationDecision),
366}
367
368#[derive(Debug, Clone, PartialEq, Eq)]
369pub enum BudgetInvocationCaptureDecision {
370    Captured(BudgetHoldMutationDecision),
371    AlreadyCaptured(BudgetHoldMutationDecision),
372}
373
374#[derive(Debug, Clone, PartialEq, Eq)]
375pub struct BudgetCancelCapturedBeforeDispatchRequest {
376    pub capability_id: String,
377    pub grant_index: usize,
378    pub hold_id: String,
379    pub event_id: String,
380    pub authority: Option<BudgetEventAuthority>,
381}
382
383fn validate_optional_budget_identity(
384    hold_id: Option<&str>,
385    event_id: Option<&str>,
386    transition: &str,
387) -> Result<(), BudgetStoreError> {
388    match (hold_id, event_id) {
389        (None, None) => Ok(()),
390        (None, Some(event_id)) if !event_id.is_empty() => Ok(()),
391        (Some(hold_id), Some(event_id)) if !hold_id.is_empty() && !event_id.is_empty() => Ok(()),
392        _ => Err(BudgetStoreError::Invariant(format!(
393            "{transition} requires non-empty identifiers and any hold_id requires an event_id"
394        ))),
395    }
396}
397
398fn validate_required_budget_identity(
399    hold_id: &str,
400    event_id: &str,
401    transition: &str,
402) -> Result<(), BudgetStoreError> {
403    if hold_id.is_empty() || event_id.is_empty() {
404        return Err(BudgetStoreError::Invariant(format!(
405            "{transition} requires non-empty hold_id and event_id"
406        )));
407    }
408    Ok(())
409}
410
411impl BudgetCaptureInvocationRequest {
412    pub fn validate(&self) -> Result<(), BudgetStoreError> {
413        validate_required_budget_identity(&self.hold_id, &self.event_id, "invocation capture")
414    }
415}
416
417impl BudgetCancelCapturedBeforeDispatchRequest {
418    pub fn validate(&self) -> Result<(), BudgetStoreError> {
419        validate_required_budget_identity(
420            &self.hold_id,
421            &self.event_id,
422            "captured invocation cancellation",
423        )
424    }
425}
426
427impl BudgetReverseHoldRequest {
428    pub fn validate(&self) -> Result<(), BudgetStoreError> {
429        validate_optional_budget_identity(
430            self.hold_id.as_deref(),
431            self.event_id.as_deref(),
432            "budget reversal",
433        )
434    }
435}
436
437impl BudgetReleaseHoldRequest {
438    pub fn validate(&self) -> Result<(), BudgetStoreError> {
439        validate_optional_budget_identity(
440            self.hold_id.as_deref(),
441            self.event_id.as_deref(),
442            "budget release",
443        )
444    }
445}
446
447impl BudgetReconcileHoldRequest {
448    pub fn validate(&self) -> Result<(), BudgetStoreError> {
449        validate_optional_budget_identity(
450            self.hold_id.as_deref(),
451            self.event_id.as_deref(),
452            "budget reconciliation",
453        )
454    }
455}
456
457impl BudgetCaptureHoldRequest {
458    pub fn validate(&self) -> Result<(), BudgetStoreError> {
459        validate_optional_budget_identity(
460            self.hold_id.as_deref(),
461            self.event_id.as_deref(),
462            "monetary capture",
463        )
464    }
465}
466
467#[derive(Debug, Clone, PartialEq, Eq)]
468pub enum BudgetCapturedBeforeDispatchCancellationDecision {
469    Cancelled(BudgetHoldMutationDecision),
470    AlreadyCancelled(BudgetHoldMutationDecision),
471}
472
473#[derive(Debug, Clone, PartialEq, Eq)]
474pub struct BudgetReconcileHoldRequest {
475    pub capability_id: String,
476    pub grant_index: usize,
477    pub exposed_cost_units: u64,
478    pub realized_spend_units: u64,
479    pub hold_id: Option<String>,
480    pub event_id: Option<String>,
481    pub authority: Option<BudgetEventAuthority>,
482}
483
484#[derive(Debug, Clone, PartialEq, Eq)]
485pub struct BudgetCaptureHoldRequest {
486    pub capability_id: String,
487    pub grant_index: usize,
488    pub exposed_cost_units: u64,
489    pub realized_spend_units: u64,
490    pub hold_id: Option<String>,
491    pub event_id: Option<String>,
492    pub authority: Option<BudgetEventAuthority>,
493}
494
495#[derive(Debug, Clone, PartialEq, Eq)]
496pub struct AuthorizedBudgetHold {
497    pub hold_id: Option<String>,
498    pub admission_binding: Option<BudgetAdmissionBinding>,
499    pub authorized_exposure_units: u64,
500    pub committed_cost_units_after: u64,
501    pub invocation_count_after: u32,
502    pub invocation_quota_usages: Vec<BudgetInvocationQuotaUsage>,
503    pub cumulative_approval: Option<BudgetCumulativeApprovalUsage>,
504    pub invocation_state: BudgetInvocationState,
505    pub monetary_state: BudgetMonetaryState,
506    pub metadata: BudgetCommitMetadata,
507}
508
509#[derive(Debug, Clone, PartialEq, Eq)]
510pub struct ApprovalRequiredBudgetHold {
511    pub hold_id: String,
512    pub admission_binding: BudgetAdmissionBinding,
513    pub authorized_exposure_units: u64,
514    pub committed_cost_units_after: u64,
515    pub invocation_count_after: u32,
516    pub invocation_quota_usages: Vec<BudgetInvocationQuotaUsage>,
517    pub cumulative_approval: BudgetCumulativeApprovalUsage,
518    pub invocation_state: BudgetInvocationState,
519    pub monetary_state: BudgetMonetaryState,
520    pub metadata: BudgetCommitMetadata,
521}
522
523#[derive(Debug, Clone, PartialEq, Eq)]
524pub struct DeniedBudgetHold {
525    pub hold_id: Option<String>,
526    pub admission_binding: Option<BudgetAdmissionBinding>,
527    pub attempted_exposure_units: u64,
528    pub committed_cost_units_after: u64,
529    pub invocation_count_after: u32,
530    pub invocation_quota_usages: Vec<BudgetInvocationQuotaUsage>,
531    pub cumulative_approval: Option<BudgetCumulativeApprovalUsage>,
532    pub invocation_state: BudgetInvocationState,
533    pub monetary_state: BudgetMonetaryState,
534    pub metadata: BudgetCommitMetadata,
535}
536
537#[derive(Debug, Clone, PartialEq, Eq)]
538pub enum BudgetAuthorizeHoldDecision {
539    Authorized(AuthorizedBudgetHold),
540    ApprovalRequired(ApprovalRequiredBudgetHold),
541    Denied(DeniedBudgetHold),
542    AlreadyCaptured(BudgetHoldMutationDecision),
543}
544
545#[derive(Debug, Clone, PartialEq, Eq)]
546pub struct BudgetHoldMutationDecision {
547    pub hold_id: Option<String>,
548    pub admission_binding: Option<BudgetAdmissionBinding>,
549    pub exposure_units: u64,
550    pub realized_spend_units: u64,
551    pub committed_cost_units_after: u64,
552    pub invocation_count_after: u32,
553    pub invocation_quota_usages: Vec<BudgetInvocationQuotaUsage>,
554    pub cumulative_approval: Option<BudgetCumulativeApprovalUsage>,
555    pub invocation_state: BudgetInvocationState,
556    pub monetary_state: BudgetMonetaryState,
557    pub metadata: BudgetCommitMetadata,
558}
559
560pub type BudgetReleaseHoldDecision = BudgetHoldMutationDecision;
561pub type BudgetReverseHoldDecision = BudgetHoldMutationDecision;
562pub type BudgetReconcileHoldDecision = BudgetHoldMutationDecision;
563pub type BudgetCaptureHoldDecision = BudgetHoldMutationDecision;
564
565#[derive(Debug, Clone, Copy, PartialEq, Eq)]
566pub enum BudgetHoldDispositionView {
567    Open,
568    Released,
569    Reversed,
570    Reconciled,
571    Expired,
572}
573
574impl BudgetHoldDispositionView {
575    #[must_use]
576    pub fn is_open(self) -> bool {
577        matches!(self, Self::Open)
578    }
579
580    #[must_use]
581    pub fn as_str(self) -> &'static str {
582        match self {
583            Self::Open => "open",
584            Self::Released => "released",
585            Self::Reversed => "reversed",
586            Self::Reconciled => "reconciled",
587            Self::Expired => "expired",
588        }
589    }
590}
591
592#[derive(Debug, Clone, Default, PartialEq, Eq)]
593pub struct ReservedHoldEnvelope {
594    pub budget_total: Option<u64>,
595    pub delegation_depth: u32,
596    pub root_budget_holder: String,
597}
598
599#[derive(Debug, Clone, PartialEq, Eq)]
600pub struct BudgetHoldSnapshot {
601    pub hold_id: String,
602    pub capability_id: String,
603    pub grant_index: usize,
604    pub authorized_exposure_units: u64,
605    pub remaining_exposure_units: u64,
606    pub disposition: BudgetHoldDispositionView,
607    pub reserved_until: Option<i64>,
608    pub reserved_currency: Option<String>,
609    pub reserved_payment_reference: Option<String>,
610    pub reserved_budget_total: Option<u64>,
611    pub reserved_delegation_depth: Option<u32>,
612    pub reserved_root_budget_holder: Option<String>,
613    pub authority: Option<BudgetEventAuthority>,
614}
615
616pub trait BudgetStore: Send + Sync {
617    fn try_increment(
618        &self,
619        capability_id: &str,
620        grant_index: usize,
621        max_invocations: Option<u32>,
622    ) -> Result<bool, BudgetStoreError>;
623
624    /// Atomically check monetary budget limits and record provisional exposure if within bounds.
625    ///
626    /// Checks:
627    /// 1. `invocation_count < max_invocations` (if set)
628    /// 2. `cost_units <= max_cost_per_invocation` (if set)
629    /// 3. `(total_cost_exposed + total_cost_realized_spend + cost_units)
630    ///    <= max_total_cost_units` (if set)
631    ///
632    /// On pass: increments `invocation_count` by 1 and `total_cost_exposed` by
633    /// `cost_units`, allocates a new replication seq, returns `Ok(true)`.
634    /// On any limit exceeded: rolls back, returns `Ok(false)`.
635    ///
636    // SAFETY: HA overrun bound = max_cost_per_invocation x node_count
637    // In a split-brain scenario, each HA node may independently approve up to
638    // one invocation at the full per-invocation cap before the LWW merge
639    // propagates the updated total. The maximum possible overrun is therefore
640    // bounded by max_cost_per_invocation multiplied by the number of active
641    // nodes in the HA cluster.
642    fn try_charge_cost(
643        &self,
644        capability_id: &str,
645        grant_index: usize,
646        max_invocations: Option<u32>,
647        cost_units: u64,
648        max_cost_per_invocation: Option<u64>,
649        max_total_cost_units: Option<u64>,
650    ) -> Result<bool, BudgetStoreError>;
651
652    #[allow(clippy::too_many_arguments)]
653    fn try_charge_cost_with_ids(
654        &self,
655        capability_id: &str,
656        grant_index: usize,
657        max_invocations: Option<u32>,
658        cost_units: u64,
659        max_cost_per_invocation: Option<u64>,
660        max_total_cost_units: Option<u64>,
661        hold_id: Option<&str>,
662        event_id: Option<&str>,
663    ) -> Result<bool, BudgetStoreError> {
664        if hold_id.is_some() || event_id.is_some() {
665            return Err(BudgetStoreError::Invariant(
666                "budget store does not support idempotent hold/event charging".to_string(),
667            ));
668        }
669        self.try_charge_cost(
670            capability_id,
671            grant_index,
672            max_invocations,
673            cost_units,
674            max_cost_per_invocation,
675            max_total_cost_units,
676        )
677    }
678
679    /// Apply a charge under the caller's durable hold/event identity and
680    /// authority fence. Implementations must preserve all three values or
681    /// return an explicit unsupported error. This method is required so a
682    /// backend upgrade cannot compile successfully and fail only on live calls.
683    #[allow(clippy::too_many_arguments)]
684    fn try_charge_cost_with_ids_and_authority(
685        &self,
686        capability_id: &str,
687        grant_index: usize,
688        max_invocations: Option<u32>,
689        cost_units: u64,
690        max_cost_per_invocation: Option<u64>,
691        max_total_cost_units: Option<u64>,
692        hold_id: Option<&str>,
693        event_id: Option<&str>,
694        authority: Option<&BudgetEventAuthority>,
695    ) -> Result<bool, BudgetStoreError>;
696
697    /// Reverse a previously applied provisional exposure for a pre-execution denial path.
698    fn reverse_charge_cost(
699        &self,
700        capability_id: &str,
701        grant_index: usize,
702        cost_units: u64,
703    ) -> Result<(), BudgetStoreError>;
704
705    fn reverse_charge_cost_with_ids(
706        &self,
707        capability_id: &str,
708        grant_index: usize,
709        cost_units: u64,
710        hold_id: Option<&str>,
711        event_id: Option<&str>,
712    ) -> Result<(), BudgetStoreError> {
713        if hold_id.is_some() || event_id.is_some() {
714            return Err(BudgetStoreError::Invariant(
715                "budget store does not support idempotent hold/event reversal".to_string(),
716            ));
717        }
718        self.reverse_charge_cost(capability_id, grant_index, cost_units)
719    }
720
721    /// Reverse a charge under the exact durable identity and authority fence.
722    fn reverse_charge_cost_with_ids_and_authority(
723        &self,
724        capability_id: &str,
725        grant_index: usize,
726        cost_units: u64,
727        hold_id: Option<&str>,
728        event_id: Option<&str>,
729        authority: Option<&BudgetEventAuthority>,
730    ) -> Result<(), BudgetStoreError>;
731
732    /// Release a previously exposed monetary amount without changing invocation count.
733    ///
734    /// This is used when the kernel needs to release provisional exposure without
735    /// realizing any spend in the budget store itself.
736    fn reduce_charge_cost(
737        &self,
738        capability_id: &str,
739        grant_index: usize,
740        cost_units: u64,
741    ) -> Result<(), BudgetStoreError>;
742
743    fn reduce_charge_cost_with_ids(
744        &self,
745        capability_id: &str,
746        grant_index: usize,
747        cost_units: u64,
748        hold_id: Option<&str>,
749        event_id: Option<&str>,
750    ) -> Result<(), BudgetStoreError> {
751        if hold_id.is_some() || event_id.is_some() {
752            return Err(BudgetStoreError::Invariant(
753                "budget store does not support idempotent hold/event release".to_string(),
754            ));
755        }
756        self.reduce_charge_cost(capability_id, grant_index, cost_units)
757    }
758
759    /// Release exposure under the exact durable identity and authority fence.
760    fn reduce_charge_cost_with_ids_and_authority(
761        &self,
762        capability_id: &str,
763        grant_index: usize,
764        cost_units: u64,
765        hold_id: Option<&str>,
766        event_id: Option<&str>,
767        authority: Option<&BudgetEventAuthority>,
768    ) -> Result<(), BudgetStoreError>;
769
770    /// Atomically release provisional exposure and record realized spend.
771    ///
772    /// This removes `exposed_cost_units` from `total_cost_exposed` and adds
773    /// `realized_cost_units` to `total_cost_realized_spend` without changing
774    /// invocation count. `realized_cost_units` must not exceed
775    /// `exposed_cost_units`.
776    fn settle_charge_cost(
777        &self,
778        capability_id: &str,
779        grant_index: usize,
780        exposed_cost_units: u64,
781        realized_cost_units: u64,
782    ) -> Result<(), BudgetStoreError>;
783
784    fn settle_charge_cost_with_ids(
785        &self,
786        capability_id: &str,
787        grant_index: usize,
788        exposed_cost_units: u64,
789        realized_cost_units: u64,
790        hold_id: Option<&str>,
791        event_id: Option<&str>,
792    ) -> Result<(), BudgetStoreError> {
793        if hold_id.is_some() || event_id.is_some() {
794            return Err(BudgetStoreError::Invariant(
795                "budget store does not support idempotent hold/event settlement".to_string(),
796            ));
797        }
798        self.settle_charge_cost(
799            capability_id,
800            grant_index,
801            exposed_cost_units,
802            realized_cost_units,
803        )
804    }
805
806    /// Reconcile exposure and spend under the exact durable identity and
807    /// authority fence.
808    #[allow(clippy::too_many_arguments)]
809    fn settle_charge_cost_with_ids_and_authority(
810        &self,
811        capability_id: &str,
812        grant_index: usize,
813        exposed_cost_units: u64,
814        realized_cost_units: u64,
815        hold_id: Option<&str>,
816        event_id: Option<&str>,
817        authority: Option<&BudgetEventAuthority>,
818    ) -> Result<(), BudgetStoreError>;
819
820    fn list_usages(
821        &self,
822        limit: usize,
823        capability_id: Option<&str>,
824    ) -> Result<Vec<BudgetUsageRecord>, BudgetStoreError>;
825
826    fn get_usage(
827        &self,
828        capability_id: &str,
829        grant_index: usize,
830    ) -> Result<Option<BudgetUsageRecord>, BudgetStoreError>;
831
832    fn get_invocation_quota_usage(
833        &self,
834        _key: &BudgetQuotaKey,
835    ) -> Result<Option<BudgetInvocationQuotaUsage>, BudgetStoreError> {
836        Err(BudgetStoreError::Invariant(
837            "invocation quota usage is unavailable for this backend".to_string(),
838        ))
839    }
840
841    fn get_cumulative_approval_account_usage(
842        &self,
843        _key: &BudgetCumulativeApprovalAccountKey,
844    ) -> Result<Option<BudgetCumulativeApprovalAccountUsage>, BudgetStoreError> {
845        Err(BudgetStoreError::Invariant(
846            "cumulative approval account usage is unavailable for this backend".to_string(),
847        ))
848    }
849
850    fn get_cumulative_approval_operation_usage(
851        &self,
852        _operation_id: &str,
853    ) -> Result<Option<BudgetCumulativeApprovalUsage>, BudgetStoreError> {
854        Err(BudgetStoreError::Invariant(
855            "cumulative approval operation usage is unavailable for this backend".to_string(),
856        ))
857    }
858
859    fn list_mutation_events(
860        &self,
861        _limit: usize,
862        _capability_id: Option<&str>,
863        _grant_index: Option<usize>,
864    ) -> Result<Vec<BudgetMutationRecord>, BudgetStoreError> {
865        Err(BudgetStoreError::Invariant(
866            "budget mutation events unavailable for this backend".to_string(),
867        ))
868    }
869
870    fn budget_guarantee_level(&self) -> BudgetGuaranteeLevel {
871        BudgetGuaranteeLevel::SingleNodeAtomic
872    }
873
874    fn budget_authority_profile(&self) -> BudgetAuthorityProfile {
875        BudgetAuthorityProfile::AuthoritativeHoldEvent
876    }
877
878    fn budget_metering_profile(&self) -> BudgetMeteringProfile {
879        BudgetMeteringProfile::MaxCostPreauthorizeThenReconcileActual
880    }
881
882    fn capture_invocation_reservations(
883        &self,
884        _request: BudgetCaptureInvocationRequest,
885    ) -> Result<BudgetInvocationCaptureDecision, BudgetStoreError> {
886        Err(BudgetStoreError::Invariant(
887            "invocation capture is not supported by this budget store".to_string(),
888        ))
889    }
890
891    fn authorize_cumulative_approval(
892        &self,
893        _request: BudgetAuthorizeCumulativeApprovalRequest,
894    ) -> Result<BudgetCumulativeApprovalAuthorizationDecision, BudgetStoreError> {
895        Err(BudgetStoreError::Invariant(
896            "cumulative approval attachment is not supported by this budget store".to_string(),
897        ))
898    }
899
900    fn cancel_captured_before_dispatch(
901        &self,
902        _request: BudgetCancelCapturedBeforeDispatchRequest,
903    ) -> Result<BudgetCapturedBeforeDispatchCancellationDecision, BudgetStoreError> {
904        Err(BudgetStoreError::Invariant(
905            "captured-before-dispatch cancellation is not supported by this budget store"
906                .to_string(),
907        ))
908    }
909
910    fn authorize_budget_hold(
911        &self,
912        request: BudgetAuthorizeHoldRequest,
913    ) -> Result<BudgetAuthorizeHoldDecision, BudgetStoreError> {
914        request.validate()?;
915        if !request.invocation_quotas.is_empty()
916            || request.cumulative_approval.is_some()
917            || request.admission_binding.is_some()
918        {
919            return Err(BudgetStoreError::Invariant(
920                "composite budget authorization is not supported by this budget store".to_string(),
921            ));
922        }
923        let allowed = self.try_charge_cost_with_ids_and_authority(
924            &request.capability_id,
925            request.grant_index,
926            request.max_invocations,
927            request.requested_exposure_units,
928            request.max_cost_per_invocation,
929            request.max_total_cost_units,
930            request.hold_id.as_deref(),
931            request.event_id.as_deref(),
932            request.authority.as_ref(),
933        )?;
934        let usage = self.get_usage(&request.capability_id, request.grant_index)?;
935        let committed_cost_units_after = usage
936            .as_ref()
937            .map(BudgetUsageRecord::committed_cost_units)
938            .transpose()?
939            .unwrap_or(0);
940        let invocation_count_after = usage.as_ref().map_or(0, |usage| usage.invocation_count);
941        let metadata = budget_commit_metadata(
942            self,
943            request.authority,
944            allowed
945                .then(|| usage.as_ref().map(|usage| usage.seq))
946                .flatten(),
947            request.event_id,
948            None,
949        );
950
951        if allowed {
952            Ok(BudgetAuthorizeHoldDecision::Authorized(
953                AuthorizedBudgetHold {
954                    hold_id: request.hold_id,
955                    admission_binding: request.admission_binding,
956                    authorized_exposure_units: request.requested_exposure_units,
957                    committed_cost_units_after,
958                    invocation_count_after,
959                    invocation_quota_usages: Vec::new(),
960                    cumulative_approval: None,
961                    invocation_state: BudgetInvocationState::Authorized,
962                    monetary_state: if request.requested_exposure_units == 0 {
963                        BudgetMonetaryState::None
964                    } else {
965                        BudgetMonetaryState::Exposed
966                    },
967                    metadata,
968                },
969            ))
970        } else {
971            Ok(BudgetAuthorizeHoldDecision::Denied(DeniedBudgetHold {
972                hold_id: request.hold_id,
973                admission_binding: request.admission_binding,
974                attempted_exposure_units: request.requested_exposure_units,
975                committed_cost_units_after,
976                invocation_count_after,
977                invocation_quota_usages: Vec::new(),
978                cumulative_approval: None,
979                invocation_state: BudgetInvocationState::Denied,
980                monetary_state: BudgetMonetaryState::None,
981                metadata,
982            }))
983        }
984    }
985
986    fn reverse_budget_hold(
987        &self,
988        _request: BudgetReverseHoldRequest,
989    ) -> Result<BudgetReverseHoldDecision, BudgetStoreError> {
990        Err(BudgetStoreError::Invariant(
991            "budget store does not support truthful rich reversal projections".to_string(),
992        ))
993    }
994
995    fn release_budget_hold(
996        &self,
997        _request: BudgetReleaseHoldRequest,
998    ) -> Result<BudgetReleaseHoldDecision, BudgetStoreError> {
999        Err(BudgetStoreError::Invariant(
1000            "budget store does not support truthful rich release projections".to_string(),
1001        ))
1002    }
1003
1004    fn reconcile_budget_hold(
1005        &self,
1006        _request: BudgetReconcileHoldRequest,
1007    ) -> Result<BudgetReconcileHoldDecision, BudgetStoreError> {
1008        Err(BudgetStoreError::Invariant(
1009            "budget store does not support truthful rich reconciliation projections".to_string(),
1010        ))
1011    }
1012
1013    fn capture_budget_hold(
1014        &self,
1015        _request: BudgetCaptureHoldRequest,
1016    ) -> Result<BudgetCaptureHoldDecision, BudgetStoreError> {
1017        Err(BudgetStoreError::Invariant(
1018            "budget store does not support a distinct monetary capture transition".to_string(),
1019        ))
1020    }
1021
1022    fn reap_orphaned_holds(
1023        &self,
1024        realized_by_hold: &std::collections::HashMap<String, u64>,
1025    ) -> Result<(usize, usize), BudgetStoreError> {
1026        let _ = realized_by_hold;
1027        Ok((0, 0))
1028    }
1029
1030    fn count_open_holds(&self) -> Result<usize, BudgetStoreError> {
1031        Ok(0)
1032    }
1033
1034    fn list_open_delegated_reserved_hold_ids(
1035        &self,
1036    ) -> Result<Option<Vec<String>>, BudgetStoreError> {
1037        Ok(None)
1038    }
1039
1040    fn request_id_has_reserved_hold(
1041        &self,
1042        request_id: &str,
1043    ) -> Result<Option<bool>, BudgetStoreError> {
1044        let _ = request_id;
1045        Ok(None)
1046    }
1047
1048    fn get_budget_hold(
1049        &self,
1050        hold_id: &str,
1051    ) -> Result<Option<BudgetHoldSnapshot>, BudgetStoreError> {
1052        let _ = hold_id;
1053        Ok(None)
1054    }
1055
1056    fn mark_hold_reserved(
1057        &self,
1058        _hold_id: &str,
1059        _reserved_until_unix_secs: i64,
1060        _currency: &str,
1061        _payment_reference: Option<&str>,
1062        _envelope: &ReservedHoldEnvelope,
1063    ) -> Result<(), BudgetStoreError> {
1064        Err(BudgetStoreError::Invariant(
1065            "budget store does not support durable monetary hold reservation".to_string(),
1066        ))
1067    }
1068
1069    fn reserve_invocation_hold(
1070        &self,
1071        _hold_id: &str,
1072        _capability_id: &str,
1073        _grant_index: usize,
1074        _reserved_until_unix_secs: i64,
1075        _envelope: &ReservedHoldEnvelope,
1076    ) -> Result<(), BudgetStoreError> {
1077        Err(BudgetStoreError::Invariant(
1078            "budget store does not support durable invocation hold reservation".to_string(),
1079        ))
1080    }
1081
1082    fn reap_expired_reserved_holds(&self, now_unix_secs: i64) -> Result<usize, BudgetStoreError> {
1083        let _ = now_unix_secs;
1084        Ok(0)
1085    }
1086}
1087
1088fn checked_committed_cost_units(
1089    total_cost_exposed: u64,
1090    total_cost_realized_spend: u64,
1091) -> Result<u64, BudgetStoreError> {
1092    total_cost_exposed
1093        .checked_add(total_cost_realized_spend)
1094        .ok_or_else(|| {
1095            BudgetStoreError::Overflow(
1096                "total_cost_exposed + total_cost_realized_spend overflowed u64".to_string(),
1097            )
1098        })
1099}
1100
1101mod in_memory;
1102
1103pub use in_memory::InMemoryBudgetStore;
1104
1105#[cfg(test)]
1106mod tests {
1107    use super::*;
1108
1109    fn test_admission_binding(operation_id: &str, capability_id: &str) -> BudgetAdmissionBinding {
1110        BudgetAdmissionBinding {
1111            operation_id: operation_id.to_string(),
1112            revocation_set: CanonicalRevocationSet::canonicalize(vec![capability_id.to_string()])
1113                .unwrap(),
1114            authorization_artifact_digests: Vec::new(),
1115            last_observed_revocation: None,
1116            supplemental_verifier_id: None,
1117            supplemental_verifier_config_digest: None,
1118            supplemental_authorization_artifact_digest: None,
1119            supplemental_authorization_expires_at: None,
1120        }
1121    }
1122
1123    #[test]
1124    fn synthesized_quota_requires_durable_identity() {
1125        let mut request = BudgetAuthorizeHoldRequest {
1126            capability_id: "cap-structured-validation".to_string(),
1127            grant_index: 0,
1128            max_invocations: Some(1),
1129            invocation_quotas: Vec::new(),
1130            cumulative_approval: None,
1131            admission_binding: None,
1132            requested_exposure_units: 0,
1133            max_cost_per_invocation: None,
1134            max_total_cost_units: None,
1135            hold_id: Some("hold-structured-validation".to_string()),
1136            event_id: Some("event-structured-validation".to_string()),
1137            authority: None,
1138        };
1139        assert_eq!(request.validate_composite().unwrap().len(), 1);
1140        request.hold_id = None;
1141        assert!(matches!(
1142            request.validate_composite(),
1143            Err(BudgetStoreError::Invariant(_))
1144        ));
1145
1146        request.hold_id = Some("hold-structured-validation".to_string());
1147        request.event_id = None;
1148        assert!(matches!(
1149            request.validate_composite(),
1150            Err(BudgetStoreError::Invariant(_))
1151        ));
1152
1153        request.event_id = Some("event-structured-validation".to_string());
1154        assert_eq!(request.validate_composite().unwrap().len(), 1);
1155    }
1156
1157    #[test]
1158    fn admission_binding_requires_canonical_artifact_digests() {
1159        let mut binding = test_admission_binding("operation-artifacts", "cap-artifacts");
1160        binding.authorization_artifact_digests = vec!["b".repeat(64), "a".repeat(64)];
1161        assert!(matches!(
1162            binding.validate(),
1163            Err(BudgetStoreError::Invariant(_))
1164        ));
1165
1166        let mut binding = test_admission_binding("operation-artifacts", "cap-artifacts");
1167        binding.supplemental_authorization_artifact_digest = Some("a".repeat(64));
1168        assert!(matches!(
1169            binding.validate(),
1170            Err(BudgetStoreError::Invariant(_))
1171        ));
1172
1173        binding.authorization_artifact_digests = vec!["a".repeat(64)];
1174        binding.supplemental_verifier_id = Some("verifier:test".to_string());
1175        binding.supplemental_verifier_config_digest = Some("b".repeat(64));
1176        binding.supplemental_authorization_expires_at = Some(1_000);
1177        binding.last_observed_revocation = Some(RevocationCommitMetadata {
1178            authority: BudgetEventAuthority {
1179                authority_id: "authority-1".to_string(),
1180                lease_id: "lease-1".to_string(),
1181                lease_epoch: 1,
1182            },
1183            guarantee_level: BudgetGuaranteeLevel::SingleNodeAtomic,
1184            commit_index: 1,
1185        });
1186        assert!(binding.validate().is_ok());
1187    }
1188
1189    #[test]
1190    fn unlimited_legacy_authorization_remains_unstructured() {
1191        let request = BudgetAuthorizeHoldRequest {
1192            capability_id: "cap-unlimited-legacy".to_string(),
1193            grant_index: 0,
1194            max_invocations: None,
1195            invocation_quotas: Vec::new(),
1196            cumulative_approval: None,
1197            admission_binding: None,
1198            requested_exposure_units: 0,
1199            max_cost_per_invocation: None,
1200            max_total_cost_units: None,
1201            hold_id: None,
1202            event_id: None,
1203            authority: None,
1204        };
1205
1206        assert!(request.validate_composite().unwrap().is_empty());
1207    }
1208
1209    #[test]
1210    fn rich_budget_identities_must_be_paired_and_non_empty() {
1211        let mut authorization = BudgetAuthorizeHoldRequest {
1212            capability_id: "cap-identity-validation".to_string(),
1213            grant_index: 0,
1214            max_invocations: None,
1215            invocation_quotas: Vec::new(),
1216            cumulative_approval: None,
1217            admission_binding: None,
1218            requested_exposure_units: 10,
1219            max_cost_per_invocation: Some(10),
1220            max_total_cost_units: Some(10),
1221            hold_id: Some(String::new()),
1222            event_id: Some(String::new()),
1223            authority: None,
1224        };
1225        assert!(matches!(
1226            authorization.validate(),
1227            Err(BudgetStoreError::Invariant(_))
1228        ));
1229        authorization.hold_id = Some("hold:identity-validation".to_string());
1230        authorization.event_id = None;
1231        assert!(matches!(
1232            authorization.validate(),
1233            Err(BudgetStoreError::Invariant(_))
1234        ));
1235        authorization.hold_id = None;
1236        authorization.event_id = Some("event:identity-validation".to_string());
1237        assert!(authorization.validate().is_ok());
1238
1239        assert!(matches!(
1240            BudgetCaptureInvocationRequest {
1241                capability_id: authorization.capability_id.clone(),
1242                grant_index: 0,
1243                hold_id: "hold:identity-validation".to_string(),
1244                event_id: String::new(),
1245                trusted_time: None,
1246                authority: None,
1247            }
1248            .validate(),
1249            Err(BudgetStoreError::Invariant(_))
1250        ));
1251        assert!(matches!(
1252            BudgetCancelCapturedBeforeDispatchRequest {
1253                capability_id: authorization.capability_id,
1254                grant_index: 0,
1255                hold_id: String::new(),
1256                event_id: "event:identity-validation:cancel".to_string(),
1257                authority: None,
1258            }
1259            .validate(),
1260            Err(BudgetStoreError::Invariant(_))
1261        ));
1262    }
1263
1264    #[test]
1265    fn authorize_and_reconcile_hold_preserve_authority_metadata() {
1266        let store = InMemoryBudgetStore::new();
1267        let authority = BudgetEventAuthority {
1268            authority_id: "kernel:test-authority".to_string(),
1269            lease_id: "single-node".to_string(),
1270            lease_epoch: 0,
1271        };
1272
1273        let decision = store
1274            .authorize_budget_hold(BudgetAuthorizeHoldRequest {
1275                capability_id: "cap-budget-1".to_string(),
1276                grant_index: 0,
1277                max_invocations: Some(4),
1278                invocation_quotas: Vec::new(),
1279                cumulative_approval: None,
1280                admission_binding: Some(test_admission_binding(
1281                    "budget-authority-metadata",
1282                    "cap-budget-1",
1283                )),
1284                requested_exposure_units: 100,
1285                max_cost_per_invocation: Some(100),
1286                max_total_cost_units: Some(1_000),
1287                hold_id: Some("hold-budget-1".to_string()),
1288                event_id: Some("hold-budget-1:authorize".to_string()),
1289                authority: Some(authority.clone()),
1290            })
1291            .unwrap();
1292        let BudgetAuthorizeHoldDecision::Authorized(authorized) = decision else {
1293            panic!("budget hold should be authorized");
1294        };
1295        assert_eq!(authorized.committed_cost_units_after, 100);
1296        assert_eq!(
1297            authorized.metadata.event_id.as_deref(),
1298            Some("hold-budget-1:authorize")
1299        );
1300        assert_eq!(authorized.metadata.budget_commit_index, Some(1));
1301        assert_eq!(
1302            authorized.metadata.budget_term().as_deref(),
1303            Some("kernel:test-authority:0")
1304        );
1305
1306        let capture = store
1307            .capture_invocation_reservations(BudgetCaptureInvocationRequest {
1308                capability_id: "cap-budget-1".to_string(),
1309                grant_index: 0,
1310                hold_id: "hold-budget-1".to_string(),
1311                event_id: "hold-budget-1:capture-invocation".to_string(),
1312                trusted_time: None,
1313                authority: Some(authority.clone()),
1314            })
1315            .unwrap();
1316        assert!(matches!(
1317            capture,
1318            BudgetInvocationCaptureDecision::Captured(_)
1319        ));
1320
1321        let reconcile = store
1322            .reconcile_budget_hold(BudgetReconcileHoldRequest {
1323                capability_id: "cap-budget-1".to_string(),
1324                grant_index: 0,
1325                exposed_cost_units: 100,
1326                realized_spend_units: 75,
1327                hold_id: Some("hold-budget-1".to_string()),
1328                event_id: Some("hold-budget-1:reconcile".to_string()),
1329                authority: Some(authority.clone()),
1330            })
1331            .unwrap();
1332        assert_eq!(reconcile.committed_cost_units_after, 75);
1333        assert_eq!(reconcile.realized_spend_units, 75);
1334        assert_eq!(
1335            reconcile.metadata.event_id.as_deref(),
1336            Some("hold-budget-1:reconcile")
1337        );
1338        assert_eq!(reconcile.metadata.budget_commit_index, Some(3));
1339        assert_eq!(reconcile.metadata.authority.as_ref(), Some(&authority));
1340
1341        let usage = store.get_usage("cap-budget-1", 0).unwrap().unwrap();
1342        assert_eq!(usage.total_cost_exposed, 0);
1343        assert_eq!(usage.total_cost_realized_spend, 75);
1344        assert_eq!(usage.committed_cost_units().unwrap(), 75);
1345
1346        let events = store
1347            .list_mutation_events(10, Some("cap-budget-1"), Some(0))
1348            .unwrap();
1349        assert_eq!(events.len(), 3);
1350        assert_eq!(events[0].kind, BudgetMutationKind::ReserveInvocation);
1351        assert_eq!(events[0].authority.as_ref(), Some(&authority));
1352        assert_eq!(events[1].kind, BudgetMutationKind::CaptureInvocation);
1353        assert_eq!(events[1].authority.as_ref(), Some(&authority));
1354        assert_eq!(events[2].kind, BudgetMutationKind::ReconcileSpend);
1355        assert_eq!(events[2].authority.as_ref(), Some(&authority));
1356        assert_eq!(events[2].realized_spend_units, 75);
1357    }
1358
1359    #[test]
1360    fn denied_authorize_hold_reports_durable_commit_metadata() {
1361        let store = InMemoryBudgetStore::new();
1362        let authority = BudgetEventAuthority {
1363            authority_id: "kernel:test-authority".to_string(),
1364            lease_id: "single-node".to_string(),
1365            lease_epoch: 0,
1366        };
1367
1368        let decision = store
1369            .authorize_budget_hold(BudgetAuthorizeHoldRequest {
1370                capability_id: "cap-budget-deny".to_string(),
1371                grant_index: 0,
1372                max_invocations: Some(1),
1373                invocation_quotas: Vec::new(),
1374                cumulative_approval: None,
1375                admission_binding: Some(test_admission_binding(
1376                    "budget-denial-metadata",
1377                    "cap-budget-deny",
1378                )),
1379                requested_exposure_units: 150,
1380                max_cost_per_invocation: Some(100),
1381                max_total_cost_units: Some(1_000),
1382                hold_id: Some("hold-budget-deny".to_string()),
1383                event_id: Some("hold-budget-deny:authorize".to_string()),
1384                authority: Some(authority.clone()),
1385            })
1386            .unwrap();
1387        let BudgetAuthorizeHoldDecision::Denied(denied) = decision else {
1388            panic!("budget hold should be denied");
1389        };
1390        assert_eq!(denied.committed_cost_units_after, 0);
1391        assert_eq!(denied.invocation_count_after, 0);
1392        assert_eq!(
1393            denied.metadata.event_id.as_deref(),
1394            Some("hold-budget-deny:authorize")
1395        );
1396        assert_eq!(denied.metadata.budget_commit_index, Some(1));
1397        assert_eq!(
1398            denied.metadata.guarantee_level,
1399            BudgetGuaranteeLevel::SingleNodeAtomic
1400        );
1401        assert_eq!(denied.metadata.authority.as_ref(), Some(&authority));
1402
1403        let events = store
1404            .list_mutation_events(10, Some("cap-budget-deny"), Some(0))
1405            .unwrap();
1406        assert_eq!(events.len(), 1);
1407        assert_eq!(events[0].allowed, Some(false));
1408        assert_eq!(events[0].authority.as_ref(), Some(&authority));
1409        assert!(store.get_usage("cap-budget-deny", 0).unwrap().is_none());
1410    }
1411}