Skip to main content

chio_kernel/budget_store/
model.rs

1pub const MAX_INVOCATION_QUOTAS_PER_ADMISSION: usize = 8;
2pub const MAX_AUTHORIZATION_ARTIFACT_DIGESTS: usize = 8;
3
4pub(crate) fn validate_authorization_artifact_digests(
5    digests: &[String],
6    require_nonempty: bool,
7) -> Result<(), BudgetStoreError> {
8    if (require_nonempty && digests.is_empty())
9        || digests.len() > MAX_AUTHORIZATION_ARTIFACT_DIGESTS
10        || digests.iter().any(|digest| !is_sha256_digest(digest))
11        || digests.windows(2).any(|pair| pair[0] >= pair[1])
12    {
13        return Err(BudgetStoreError::Invariant(format!(
14            "authorization artifact digests must contain {} to {MAX_AUTHORIZATION_ARTIFACT_DIGESTS} sorted unique SHA-256 values",
15            usize::from(require_nonempty)
16        )));
17    }
18    Ok(())
19}
20
21fn is_sha256_digest(value: &str) -> bool {
22    value.len() == 64
23        && value
24            .bytes()
25            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
29pub enum BudgetQuotaProfile {
30    GrantInvocation,
31    AggregateCapabilityInvocation,
32    AggregateFamilyInvocation,
33    SupplementalBrokerCapabilityExecution,
34}
35
36impl BudgetQuotaProfile {
37    pub fn as_str(self) -> &'static str {
38        match self {
39            Self::GrantInvocation => "chio.grant-invocation.v1",
40            Self::AggregateCapabilityInvocation => "chio.aggregate-capability-invocation.v1",
41            Self::AggregateFamilyInvocation => "chio.aggregate-family-invocation.v1",
42            Self::SupplementalBrokerCapabilityExecution => "chio.broker-capability-execution.v1",
43        }
44    }
45
46    pub fn parse(value: &str) -> Option<Self> {
47        match value {
48            "chio.grant-invocation.v1" => Some(Self::GrantInvocation),
49            "chio.aggregate-capability-invocation.v1" => Some(Self::AggregateCapabilityInvocation),
50            "chio.aggregate-family-invocation.v1" => Some(Self::AggregateFamilyInvocation),
51            "chio.broker-capability-execution.v1" => {
52                Some(Self::SupplementalBrokerCapabilityExecution)
53            }
54            _ => None,
55        }
56    }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
60pub struct BudgetQuotaKey {
61    pub profile: BudgetQuotaProfile,
62    pub owner_id: String,
63    pub grant_index: Option<u32>,
64}
65
66impl BudgetQuotaKey {
67    pub fn grant(capability_id: impl Into<String>, grant_index: u32) -> Self {
68        Self {
69            profile: BudgetQuotaProfile::GrantInvocation,
70            owner_id: capability_id.into(),
71            grant_index: Some(grant_index),
72        }
73    }
74
75    pub fn validate(&self) -> Result<(), BudgetStoreError> {
76        if self.owner_id.is_empty() {
77            return Err(BudgetStoreError::Invariant(
78                "budget quota owner_id must not be empty".to_string(),
79            ));
80        }
81        let grant_profile = self.profile == BudgetQuotaProfile::GrantInvocation;
82        if grant_profile != self.grant_index.is_some() {
83            return Err(BudgetStoreError::Invariant(
84                "only grant invocation quota keys may carry grant_index".to_string(),
85            ));
86        }
87        Ok(())
88    }
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct BudgetInvocationQuota {
93    pub key: BudgetQuotaKey,
94    pub max_invocations: u32,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct BudgetAdmissionBinding {
99    pub operation_id: String,
100    pub revocation_set: CanonicalRevocationSet,
101    pub authorization_artifact_digests: Vec<String>,
102    pub last_observed_revocation: Option<RevocationCommitMetadata>,
103    pub supplemental_verifier_id: Option<String>,
104    pub supplemental_verifier_config_digest: Option<String>,
105    pub supplemental_authorization_artifact_digest: Option<String>,
106    pub supplemental_authorization_expires_at: Option<u64>,
107}
108
109impl BudgetAdmissionBinding {
110    pub fn validate(&self) -> Result<(), BudgetStoreError> {
111        if self.operation_id.is_empty() || self.revocation_set.ids().is_empty() {
112            return Err(BudgetStoreError::Invariant(
113                "admission binding operation and revocation set must not be empty".to_string(),
114            ));
115        }
116        validate_authorization_artifact_digests(&self.authorization_artifact_digests, false)?;
117        if let Some(observation) = &self.last_observed_revocation {
118            observation.validate()?;
119        }
120        let supplemental_fields_present = [
121            self.supplemental_verifier_id.is_some(),
122            self.supplemental_verifier_config_digest.is_some(),
123            self.supplemental_authorization_artifact_digest.is_some(),
124            self.supplemental_authorization_expires_at.is_some(),
125        ];
126        let has_supplemental = supplemental_fields_present.iter().all(|present| *present);
127        if supplemental_fields_present.iter().any(|present| *present) != has_supplemental {
128            return Err(BudgetStoreError::Invariant(
129                "supplemental verifier, config, artifact, and expiry bindings must be presented together"
130                    .to_string(),
131            ));
132        }
133        if has_supplemental
134            && (self
135                .supplemental_verifier_id
136                .as_ref()
137                .is_some_and(String::is_empty)
138                || self
139                    .supplemental_verifier_config_digest
140                    .as_ref()
141                    .is_none_or(|digest| !is_sha256_digest(digest))
142                || self
143                    .supplemental_authorization_artifact_digest
144                    .as_ref()
145                    .is_none_or(|digest| !is_sha256_digest(digest))
146                || self
147                    .supplemental_authorization_expires_at
148                    .is_none_or(|expires_at| expires_at == 0)
149                || self.last_observed_revocation.is_none())
150        {
151            return Err(BudgetStoreError::Invariant(
152                "supplemental admission binding is not authority-complete".to_string(),
153            ));
154        }
155        if self
156            .supplemental_authorization_artifact_digest
157            .as_ref()
158            .is_some_and(|digest| {
159                self.authorization_artifact_digests
160                    .binary_search(digest)
161                    .is_err()
162            })
163        {
164            return Err(BudgetStoreError::Invariant(
165                "supplemental authorization artifact digest is absent from the admission artifact set"
166                    .to_string(),
167            ));
168        }
169        Ok(())
170    }
171}
172
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct BudgetInvocationQuotaUsage {
175    pub quota: BudgetInvocationQuota,
176    pub reserved_invocations: u32,
177    pub captured_invocations: u32,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct BudgetInvocationQuotaMutation {
182    pub quota: BudgetInvocationQuota,
183    pub reserved_invocations_before: u32,
184    pub captured_invocations_before: u32,
185    pub reserved_invocations_after: u32,
186    pub captured_invocations_after: u32,
187}
188
189impl BudgetInvocationQuotaUsage {
190    pub fn validate(&self) -> Result<(), BudgetStoreError> {
191        self.quota.key.validate()?;
192        if self.quota.max_invocations == 0
193            || self.invocation_count_after()? > self.quota.max_invocations
194        {
195            return Err(BudgetStoreError::Invariant(
196                "invocation quota usage exceeds its immutable positive maximum".to_string(),
197            ));
198        }
199        Ok(())
200    }
201
202    pub fn invocation_count_after(&self) -> Result<u32, BudgetStoreError> {
203        self.reserved_invocations
204            .checked_add(self.captured_invocations)
205            .ok_or_else(|| {
206                BudgetStoreError::Overflow(
207                    "reserved_invocations + captured_invocations overflowed u32".to_string(),
208                )
209            })
210    }
211}
212
213#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
214pub struct BudgetCumulativeApprovalAccountKey {
215    pub authority_id: String,
216    pub owner_id: String,
217    pub approval_budget_id: String,
218    pub approval_budget_epoch: u64,
219    pub root_grant_hash: String,
220    pub delegation_root_id: Option<String>,
221    pub root_binding_digest: Option<String>,
222    pub currency: String,
223}
224
225impl BudgetCumulativeApprovalAccountKey {
226    pub fn validate(&self) -> Result<(), BudgetStoreError> {
227        if self.authority_id.is_empty()
228            || self.owner_id.is_empty()
229            || self.approval_budget_id.is_empty()
230            || self.root_grant_hash.is_empty()
231            || self.currency.is_empty()
232        {
233            return Err(BudgetStoreError::Invariant(
234                "cumulative approval account key fields must not be empty".to_string(),
235            ));
236        }
237        if self.delegation_root_id.is_some() != self.root_binding_digest.is_some()
238            || self
239                .delegation_root_id
240                .as_ref()
241                .is_some_and(String::is_empty)
242            || self
243                .root_binding_digest
244                .as_ref()
245                .is_some_and(String::is_empty)
246        {
247            return Err(BudgetStoreError::Invariant(
248                "cumulative family root identity and binding digest must be presented together"
249                    .to_string(),
250            ));
251        }
252        Ok(())
253    }
254}
255
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct BudgetCumulativeApprovalRequest {
258    pub operation_id: String,
259    pub account_key: BudgetCumulativeApprovalAccountKey,
260    pub authority_threshold: MonetaryAmount,
261    pub effective_threshold: MonetaryAmount,
262    pub requested_authorized: MonetaryAmount,
263}
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266pub enum BudgetCumulativeApprovalState {
267    PendingApproval,
268    Authorized,
269    Captured,
270    ReversedBeforeDispatch,
271}
272
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274pub enum BudgetInvocationState {
275    Absent,
276    Authorized,
277    Captured,
278    Reversed,
279    Denied,
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum BudgetMonetaryState {
284    None,
285    Exposed,
286    Released,
287    Reconciled,
288    Captured,
289    Reversed,
290}
291
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
293pub enum BudgetAuthorizationOutcome {
294    Authorized,
295    ApprovalRequired,
296    Denied,
297}
298
299#[derive(Debug, Clone, PartialEq, Eq)]
300pub struct BudgetCumulativeApprovalUsage {
301    pub operation_id: String,
302    pub account_key: BudgetCumulativeApprovalAccountKey,
303    pub authority_threshold: MonetaryAmount,
304    pub effective_threshold: MonetaryAmount,
305    pub requested_authorized: MonetaryAmount,
306    pub reserved_authorized_after: MonetaryAmount,
307    pub captured_authorized_after: MonetaryAmount,
308    pub state: BudgetCumulativeApprovalState,
309    pub version: u64,
310}
311
312#[derive(Debug, Clone, PartialEq, Eq)]
313pub struct BudgetCumulativeApprovalAccountUsage {
314    pub account_key: BudgetCumulativeApprovalAccountKey,
315    pub authority_threshold: MonetaryAmount,
316    pub reserved_authorized: MonetaryAmount,
317    pub captured_authorized: MonetaryAmount,
318    pub version: u64,
319}
320
321#[derive(Debug, Clone, PartialEq, Eq)]
322pub struct BudgetCumulativeApprovalMutation {
323    pub operation_id: String,
324    pub account_key: BudgetCumulativeApprovalAccountKey,
325    pub state_before: Option<BudgetCumulativeApprovalState>,
326    pub state_after: BudgetCumulativeApprovalState,
327    pub reserved_authorized_before: MonetaryAmount,
328    pub captured_authorized_before: MonetaryAmount,
329    pub reserved_authorized_after: MonetaryAmount,
330    pub captured_authorized_after: MonetaryAmount,
331    pub version_before: u64,
332    pub version_after: u64,
333}
334
335#[derive(Debug, Clone, PartialEq, Eq)]
336pub struct BudgetEventAuthority {
337    pub authority_id: String,
338    pub lease_id: String,
339    pub lease_epoch: u64,
340}
341
342impl BudgetEventAuthority {
343    pub fn validate(&self) -> Result<(), BudgetStoreError> {
344        if self.authority_id.is_empty() || self.lease_id.is_empty() || self.lease_epoch == 0 {
345            return Err(BudgetStoreError::Invariant(
346                "budget event authority requires a non-empty fenced lease".to_string(),
347            ));
348        }
349        Ok(())
350    }
351}
352
353#[derive(Debug, Clone, PartialEq, Eq)]
354pub struct BudgetMutationRecord {
355    pub event_id: String,
356    pub hold_id: Option<String>,
357    pub admission_binding: Option<BudgetAdmissionBinding>,
358    pub capability_id: String,
359    pub grant_index: u32,
360    pub kind: BudgetMutationKind,
361    pub allowed: Option<bool>,
362    pub authorization_outcome: Option<BudgetAuthorizationOutcome>,
363    pub invocation_state_before: BudgetInvocationState,
364    pub invocation_state_after: BudgetInvocationState,
365    pub monetary_state_before: BudgetMonetaryState,
366    pub monetary_state_after: BudgetMonetaryState,
367    pub recorded_at: i64,
368    pub event_seq: u64,
369    pub usage_seq: Option<u64>,
370    pub exposure_units: u64,
371    pub realized_spend_units: u64,
372    pub max_invocations: Option<u32>,
373    pub max_cost_per_invocation: Option<u64>,
374    pub max_total_cost_units: Option<u64>,
375    pub invocation_count_after: u32,
376    pub invocation_quota_usages: Vec<BudgetInvocationQuotaUsage>,
377    pub invocation_quota_mutations: Vec<BudgetInvocationQuotaMutation>,
378    pub cumulative_approval: Option<BudgetCumulativeApprovalUsage>,
379    pub cumulative_approval_mutation: Option<BudgetCumulativeApprovalMutation>,
380    pub cumulative_approval_set_digest: Option<String>,
381    pub total_cost_exposed_after: u64,
382    pub total_cost_realized_spend_after: u64,
383    pub authority: Option<BudgetEventAuthority>,
384}
385
386#[derive(Debug, Clone, Copy, PartialEq, Eq)]
387pub enum BudgetGuaranteeLevel {
388    SingleNodeAtomic,
389    HaLinearizable,
390    PartitionEscrowed,
391    AdvisoryPosthoc,
392}
393
394#[derive(Debug, Clone, PartialEq, Eq)]
395pub struct RevocationCommitMetadata {
396    pub authority: BudgetEventAuthority,
397    pub guarantee_level: BudgetGuaranteeLevel,
398    pub commit_index: u64,
399}
400
401impl RevocationCommitMetadata {
402    pub fn validate(&self) -> Result<(), BudgetStoreError> {
403        self.authority.validate()?;
404        if self.commit_index == 0
405            || !matches!(
406                self.guarantee_level,
407                BudgetGuaranteeLevel::SingleNodeAtomic | BudgetGuaranteeLevel::HaLinearizable
408            )
409        {
410            return Err(BudgetStoreError::Invariant(
411                "revocation commit metadata requires an atomic fenced authority".to_string(),
412            ));
413        }
414        Ok(())
415    }
416}
417
418impl BudgetGuaranteeLevel {
419    pub fn as_str(self) -> &'static str {
420        match self {
421            Self::SingleNodeAtomic => "single_node_atomic",
422            Self::HaLinearizable => "ha_linearizable",
423            Self::PartitionEscrowed => "partition_escrowed",
424            Self::AdvisoryPosthoc => "advisory_posthoc",
425        }
426    }
427}
428
429#[derive(Debug, Clone, Copy, PartialEq, Eq)]
430pub enum BudgetAuthorityProfile {
431    AuthoritativeHoldEvent,
432}
433
434impl BudgetAuthorityProfile {
435    pub fn as_str(self) -> &'static str {
436        match self {
437            Self::AuthoritativeHoldEvent => "authoritative_hold_event",
438        }
439    }
440}
441
442#[derive(Debug, Clone, Copy, PartialEq, Eq)]
443pub enum BudgetMeteringProfile {
444    MaxCostPreauthorizeThenReconcileActual,
445}
446
447impl BudgetMeteringProfile {
448    pub fn as_str(self) -> &'static str {
449        match self {
450            Self::MaxCostPreauthorizeThenReconcileActual => {
451                "max_cost_preauthorize_then_reconcile_actual"
452            }
453        }
454    }
455}
456
457#[derive(Debug, Clone, PartialEq, Eq)]
458pub struct BudgetCommitMetadata {
459    pub authority: Option<BudgetEventAuthority>,
460    pub guarantee_level: BudgetGuaranteeLevel,
461    pub budget_profile: BudgetAuthorityProfile,
462    pub metering_profile: BudgetMeteringProfile,
463    /// Commit index for the decision, including a durable denial. Legacy backends
464    /// that cannot expose a denial event index return `None`.
465    pub budget_commit_index: Option<u64>,
466    pub event_id: Option<String>,
467    /// Authority-recorded creation time for the durable mutation. Composite
468    /// admission uses this stable timestamp when deriving retryable artifacts.
469    pub recorded_at_unix_seconds: Option<u64>,
470}
471
472impl BudgetCommitMetadata {
473    pub fn budget_term(&self) -> Option<String> {
474        self.authority
475            .as_ref()
476            .map(|authority| format!("{}:{}", authority.authority_id, authority.lease_epoch))
477    }
478}
479
480fn budget_commit_metadata<T: BudgetStore + ?Sized>(
481    store: &T,
482    authority: Option<BudgetEventAuthority>,
483    budget_commit_index: Option<u64>,
484    event_id: Option<String>,
485    recorded_at: Option<i64>,
486) -> BudgetCommitMetadata {
487    BudgetCommitMetadata {
488        authority,
489        guarantee_level: store.budget_guarantee_level(),
490        budget_profile: store.budget_authority_profile(),
491        metering_profile: store.budget_metering_profile(),
492        budget_commit_index,
493        event_id,
494        recorded_at_unix_seconds: recorded_at.and_then(|value| u64::try_from(value).ok()),
495    }
496}