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 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 #[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 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 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 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 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 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 #[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}