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 pub budget_commit_index: Option<u64>,
466 pub event_id: Option<String>,
467 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}