1use super::*;
2use rusqlite::{params, Connection, Transaction};
3
4mod cumulative_model;
5mod event_projection;
6mod model;
7mod transitions;
8
9pub(crate) use transitions::AdmissionCaptureBinding;
10mod validation;
11
12use cumulative_model::*;
13use model::*;
14use transitions::*;
15use validation::*;
16
17#[derive(Clone, Copy)]
18pub(crate) struct AdmissionAuthorizationBinding<'a> {
19 pub(crate) operation: &'a chio_kernel::admission_operation::AdmissionOperationV1,
20 pub(crate) recovery_lease: &'a chio_kernel::admission_operation::AdmissionRecoveryLease,
21 pub(crate) payment_journal: Option<&'a PaymentJournalRecord>,
22 pub(crate) credit_exposure:
23 Option<&'a chio_credit::obligation::CreditExposureReservationRequest>,
24 pub(crate) trusted_now_unix_ms: u64,
25}
26
27impl SqliteBudgetStore {
28 pub(crate) fn composite_quota_usage(
29 &self,
30 key: &BudgetQuotaKey,
31 ) -> Result<Option<BudgetInvocationQuotaUsage>, BudgetStoreError> {
32 let mut connection = self.connection()?;
33 let transaction = self.begin_read(&mut connection)?;
34 let usage = load_quota_state(&transaction, key)?.map(|state| state.usage());
35 transaction.rollback()?;
36 Ok(usage)
37 }
38
39 pub(crate) fn composite_cumulative_operation_usage(
40 &self,
41 operation_id: &str,
42 ) -> Result<Option<BudgetCumulativeApprovalUsage>, BudgetStoreError> {
43 self.cumulative_approval_operation_projection(operation_id)
44 .map(|projection| projection.map(|(usage, _)| usage))
45 }
46
47 pub fn cumulative_approval_operation_projection(
49 &self,
50 operation_id: &str,
51 ) -> Result<Option<(BudgetCumulativeApprovalUsage, BudgetMutationRecord)>, BudgetStoreError>
52 {
53 let mut connection = self.connection()?;
54 let transaction = self.begin_read(&mut connection)?;
55 let row = transaction
56 .query_row(
57 r#"
58 SELECT operation.operation_id, (
59 SELECT event.event_id
60 FROM budget_event_cumulative_approval AS cumulative
61 JOIN budget_mutation_events AS event
62 ON event.event_id = cumulative.event_id
63 WHERE cumulative.operation_id = operation.operation_id
64 ORDER BY event.event_seq DESC
65 LIMIT 1
66 )
67 FROM budget_cumulative_approval_operations AS operation
68 WHERE operation.operation_id = ?1
69 "#,
70 params![operation_id],
71 |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)),
72 )
73 .optional()?;
74 let Some((stored_operation_id, event_id)) = row else {
75 transaction.rollback()?;
76 return Ok(None);
77 };
78 let event_id = event_id.ok_or_else(|| {
79 BudgetStoreError::Invariant(format!(
80 "cumulative approval operation `{stored_operation_id}` has no durable event"
81 ))
82 })?;
83 let event =
84 Self::load_projected_mutation_event(&transaction, &event_id)?.ok_or_else(|| {
85 BudgetStoreError::Invariant(format!(
86 "cumulative approval operation `{stored_operation_id}` lost its event"
87 ))
88 })?;
89 let usage = event
90 .cumulative_approval
91 .clone()
92 .filter(|usage| usage.operation_id == stored_operation_id)
93 .ok_or_else(|| {
94 BudgetStoreError::Invariant(format!(
95 "cumulative approval operation `{stored_operation_id}` lost its projection"
96 ))
97 })?;
98 transaction.rollback()?;
99 Ok(Some((usage, event)))
100 }
101
102 pub(super) fn authorize_composite_hold(
103 &self,
104 request: BudgetAuthorizeHoldRequest,
105 ) -> Result<BudgetAuthorizeHoldDecision, BudgetStoreError> {
106 self.authorize_composite_hold_inner(request, None)
107 .map(|(decision, _)| decision)
108 }
109
110 pub(crate) fn authorize_composite_hold_and_commit_admission(
111 &self,
112 request: BudgetAuthorizeHoldRequest,
113 binding: AdmissionAuthorizationBinding<'_>,
114 ) -> Result<
115 (
116 BudgetAuthorizeHoldDecision,
117 chio_kernel::admission_operation::AdmissionOperationV1,
118 ),
119 BudgetStoreError,
120 > {
121 let (decision, operation) = self.authorize_composite_hold_inner(request, Some(binding))?;
122 let operation = operation.ok_or_else(|| {
123 BudgetStoreError::Invariant(
124 "combined budget authorization omitted its admission operation".to_owned(),
125 )
126 })?;
127 Ok((decision, operation))
128 }
129
130 fn authorize_composite_hold_inner(
131 &self,
132 request: BudgetAuthorizeHoldRequest,
133 binding: Option<AdmissionAuthorizationBinding<'_>>,
134 ) -> Result<
135 (
136 BudgetAuthorizeHoldDecision,
137 Option<chio_kernel::admission_operation::AdmissionOperationV1>,
138 ),
139 BudgetStoreError,
140 > {
141 request.validate()?;
142 let quotas = normalized_quotas(&request)?;
143 validate_composite_sqlite_range(&request, "as)?;
144 let hold_id = request.hold_id.as_deref().ok_or_else(|| {
145 BudgetStoreError::Invariant("composite budget hold_id is missing".to_string())
146 })?;
147 let event_id = request.event_id.as_deref().ok_or_else(|| {
148 BudgetStoreError::Invariant("composite budget event_id is missing".to_string())
149 })?;
150 let admission = request.admission_binding.as_ref().ok_or_else(|| {
151 BudgetStoreError::Invariant("composite admission binding is missing".to_string())
152 })?;
153
154 let mut connection = self.connection()?;
155 let transaction = self.begin_write(&mut connection)?;
156 self.validate_joint_authority(request.authority.as_ref())?;
157 if let Some(existing) = Self::load_mutation_event(&transaction, event_id)? {
158 let stored = load_authorization_request(&transaction, &existing)?;
159 self.validate_persisted_authority(
160 &transaction,
161 event_id,
162 stored.authority.as_ref(),
163 request.authority.as_ref(),
164 )?;
165 let mut normalized_request = request.clone();
166 normalized_request.authority = stored.authority.clone();
167 if self.serving_owner.is_some() {
168 normalized_request
169 .admission_binding
170 .as_mut()
171 .ok_or_else(|| {
172 BudgetStoreError::Invariant(
173 "composite authorization replay lost admission binding".to_string(),
174 )
175 })?
176 .last_observed_revocation = stored
177 .admission_binding
178 .as_ref()
179 .and_then(|binding| binding.last_observed_revocation.clone());
180 }
181 if stored != normalized_request {
182 return Err(BudgetStoreError::Invariant(format!(
183 "budget event_id `{event_id}` was reused for a different mutation"
184 )));
185 }
186 let decision = self.authorization_decision_from_event(&transaction, &existing)?;
187 let operation = self.bind_authorization_to_admission(
188 &transaction,
189 &request,
190 &decision,
191 binding,
192 false,
193 )?;
194 if binding.is_some() {
195 self.commit_joint_transaction(transaction)?;
196 self.sync_joint_anchor(&connection)?;
197 } else {
198 transaction.rollback()?;
199 }
200 return Ok((decision, operation));
201 }
202 let grant_quota_index = i64::try_from(request.grant_index).map_err(|_| {
203 BudgetStoreError::Invariant("grant_index does not fit sqlite range".to_string())
204 })?;
205 let grant_index = u32::try_from(request.grant_index).map_err(|_| {
206 BudgetStoreError::Invariant("grant_index does not fit quota range".to_string())
207 })?;
208 let durable_grant_quota_exists = transaction.query_row(
209 r#"
210 SELECT EXISTS(
211 SELECT 1 FROM budget_invocation_quotas
212 WHERE profile = 'chio.grant-invocation.v1'
213 AND owner_id = ?1 AND grant_index = ?2
214 )
215 "#,
216 params![&request.capability_id, grant_quota_index],
217 |row| row.get::<_, bool>(0),
218 )?;
219 let request_includes_grant_quota = quotas.iter().any(|quota| {
220 quota.key == BudgetQuotaKey::grant(request.capability_id.clone(), grant_index)
221 });
222 let legacy_grant_limit =
223 legacy_grant_quota_limit(&transaction, &request.capability_id, request.grant_index)?;
224 if durable_grant_quota_exists && !request_includes_grant_quota {
225 return Err(BudgetStoreError::Invariant(format!(
226 "structured authorization omitted durable grant quota for `{}` grant {}",
227 request.capability_id, request.grant_index
228 )));
229 }
230 if let Some(legacy_limit) = legacy_grant_limit {
231 let requested = quotas.iter().find(|quota| {
232 quota.key == BudgetQuotaKey::grant(request.capability_id.clone(), grant_index)
233 });
234 if requested.map(|quota| quota.max_invocations) != Some(legacy_limit) {
235 return Err(BudgetStoreError::Invariant(format!(
236 "structured authorization must preserve legacy grant quota {legacy_limit} for `{}` grant {}",
237 request.capability_id, request.grant_index
238 )));
239 }
240 }
241 let hold_identity_exists = Self::load_hold(&transaction, hold_id)?.is_some()
242 || transaction.query_row(
243 "SELECT EXISTS(SELECT 1 FROM budget_mutation_events WHERE hold_id = ?1)",
244 params![hold_id],
245 |row| row.get::<_, bool>(0),
246 )?;
247 if hold_identity_exists {
248 return Err(BudgetStoreError::Invariant(format!(
249 "budget hold `{hold_id}` was reused for a different authorization"
250 )));
251 }
252 self.validate_observed_revocation(&transaction, admission)?;
253 let mut revoked_member = false;
254 if self.serving_owner.is_some() {
255 for capability_id in admission.revocation_set.ids() {
256 revoked_member |= transaction.query_row(
257 "SELECT EXISTS(SELECT 1 FROM revoked_capabilities WHERE capability_id = ?1)",
258 params![capability_id],
259 |row| row.get::<_, bool>(0),
260 )?;
261 }
262 }
263 let operation_exists = transaction.query_row(
264 r#"
265 SELECT EXISTS(
266 SELECT 1 FROM budget_mutation_events
267 WHERE operation_id = ?1
268 AND authorization_outcome IS NOT 'denied'
269 )
270 "#,
271 params![&admission.operation_id],
272 |row| row.get::<_, bool>(0),
273 )?;
274 if operation_exists {
275 return Err(BudgetStoreError::Invariant(format!(
276 "budget operation `{}` was reused for a different authorization",
277 admission.operation_id
278 )));
279 }
280
281 let current =
282 load_usage_or_default(&transaction, &request.capability_id, request.grant_index)?;
283 let committed = checked_committed_cost_units(
284 current.total_cost_exposed,
285 current.total_cost_realized_spend,
286 )?;
287 let requested_total = committed
288 .checked_add(request.requested_exposure_units)
289 .ok_or_else(|| {
290 BudgetStoreError::Overflow(
291 "committed cost + requested exposure overflowed u64".to_string(),
292 )
293 })?;
294 let mut allowed = !revoked_member
295 && request
296 .max_cost_per_invocation
297 .is_none_or(|max| request.requested_exposure_units <= max)
298 && request
299 .max_total_cost_units
300 .is_none_or(|max| requested_total <= max);
301 let mut quota_before = Vec::with_capacity(quotas.len());
302 for quota in "as {
303 let state = load_quota_state(&transaction, "a.key)?;
304 match state {
305 Some(state) => {
306 if state.maximum != quota.max_invocations {
307 return Err(BudgetStoreError::Invariant(format!(
308 "budget quota `{}` maximum changed from {} to {}",
309 quota.key.owner_id, state.maximum, quota.max_invocations
310 )));
311 }
312 let used = state.reserved.checked_add(state.captured).ok_or_else(|| {
313 BudgetStoreError::Overflow(
314 "reserved + captured quota count overflowed u32".to_string(),
315 )
316 })?;
317 allowed &= used < state.maximum;
318 quota_before.push(state);
319 }
320 None => {
321 let is_grant_quota = quota.key
322 == BudgetQuotaKey::grant(request.capability_id.clone(), grant_index);
323 let mut state = QuotaState::new(quota);
324 if is_grant_quota && current.invocation_count != 0 {
325 if legacy_grant_limit != Some(quota.max_invocations) {
326 return Err(BudgetStoreError::Invariant(
327 "cannot define a grant quota after untracked invocations"
328 .to_string(),
329 ));
330 }
331 if current.invocation_count > quota.max_invocations {
332 return Err(BudgetStoreError::Invariant(format!(
333 "legacy usage for `{}` grant {} exceeds its grant quota",
334 request.capability_id, request.grant_index
335 )));
336 }
337 state.captured = current.invocation_count;
338 }
339 allowed &= state.captured < quota.max_invocations;
340 quota_before.push(state);
341 }
342 }
343 }
344
345 let cumulative_before = request
346 .cumulative_approval
347 .as_ref()
348 .map(|cumulative| load_or_validate_cumulative(&transaction, cumulative))
349 .transpose()?;
350 let cumulative_state = request
351 .cumulative_approval
352 .as_ref()
353 .zip(cumulative_before.as_ref())
354 .map(|(cumulative, account)| {
355 let prospective = account
356 .reserved
357 .checked_add(account.captured)
358 .and_then(|used| used.checked_add(cumulative.requested_authorized.units))
359 .ok_or_else(|| {
360 BudgetStoreError::Overflow(
361 "cumulative authorized units overflowed u64".to_string(),
362 )
363 })?;
364 Ok::<_, BudgetStoreError>(if prospective >= cumulative.effective_threshold.units {
365 BudgetCumulativeApprovalState::PendingApproval
366 } else {
367 BudgetCumulativeApprovalState::Authorized
368 })
369 })
370 .transpose()?;
371
372 let event_seq = allocate_budget_replication_seq(&transaction)?;
373 let recorded_at = unix_now();
374 let mut usage_after = current.clone();
375 let mut quota_after = quota_before.clone();
376 let mut cumulative_after = cumulative_before.clone();
377 if allowed {
378 usage_after.invocation_count =
379 usage_after.invocation_count.checked_add(1).ok_or_else(|| {
380 BudgetStoreError::Overflow("invocation count overflowed u32".to_string())
381 })?;
382 usage_after.total_cost_exposed = usage_after
383 .total_cost_exposed
384 .checked_add(request.requested_exposure_units)
385 .ok_or_else(|| {
386 BudgetStoreError::Overflow("total cost exposure overflowed u64".to_string())
387 })?;
388 usage_after.updated_at = recorded_at;
389 usage_after.seq = event_seq;
390 write_usage(&transaction, &usage_after)?;
391
392 Self::create_hold(
393 &transaction,
394 hold_id,
395 &request.capability_id,
396 request.grant_index,
397 request.requested_exposure_units,
398 request.authority.as_ref(),
399 )?;
400 write_hold_projection(
401 &transaction,
402 hold_id,
403 admission,
404 quotas.len(),
405 cumulative_state,
406 request.requested_exposure_units,
407 )?;
408 for (quota, state) in quotas.iter().zip(&mut quota_after) {
409 state.reserved = state.reserved.checked_add(1).ok_or_else(|| {
410 BudgetStoreError::Overflow(
411 "reserved invocation quota overflowed u32".to_string(),
412 )
413 })?;
414 state.version = state.version.checked_add(1).ok_or_else(|| {
415 BudgetStoreError::Overflow(
416 "invocation quota version overflowed u64".to_string(),
417 )
418 })?;
419 write_quota_state(&transaction, state)?;
420 write_hold_quota(&transaction, hold_id, quota)?;
421 }
422 write_hold_admission_members(&transaction, hold_id, admission)?;
423 if let (Some(cumulative), Some(state), Some(account)) = (
424 request.cumulative_approval.as_ref(),
425 cumulative_state,
426 cumulative_after.as_mut(),
427 ) {
428 account.reserved = account
429 .reserved
430 .checked_add(cumulative.requested_authorized.units)
431 .ok_or_else(|| {
432 BudgetStoreError::Overflow(
433 "reserved cumulative approval overflowed u64".to_string(),
434 )
435 })?;
436 account.version = account.version.checked_add(1).ok_or_else(|| {
437 BudgetStoreError::Overflow(
438 "cumulative approval version overflowed u64".to_string(),
439 )
440 })?;
441 write_cumulative_account(&transaction, account)?;
442 write_cumulative_operation(
443 &transaction,
444 hold_id,
445 cumulative,
446 state,
447 account.version,
448 )?;
449 }
450 }
451
452 let outcome = match (allowed, cumulative_state) {
453 (false, _) => BudgetAuthorizationOutcome::Denied,
454 (true, Some(BudgetCumulativeApprovalState::PendingApproval)) => {
455 BudgetAuthorizationOutcome::ApprovalRequired
456 }
457 (true, _) => BudgetAuthorizationOutcome::Authorized,
458 };
459 let authorization_kind = if quotas.is_empty() && request.cumulative_approval.is_none() {
460 BudgetMutationKind::AuthorizeExposure
461 } else {
462 BudgetMutationKind::ReserveInvocation
463 };
464 let event = Self::append_mutation_event(
465 &transaction,
466 Some(event_id),
467 Some(hold_id),
468 request.authority.as_ref(),
469 &request.capability_id,
470 request.grant_index,
471 authorization_kind,
472 match outcome {
473 BudgetAuthorizationOutcome::Denied => Some(false),
474 BudgetAuthorizationOutcome::ApprovalRequired => None,
475 BudgetAuthorizationOutcome::Authorized => Some(true),
476 },
477 event_seq,
478 allowed.then_some(usage_after.seq),
479 request.requested_exposure_units,
480 0,
481 request.max_invocations,
482 request.max_cost_per_invocation,
483 request.max_total_cost_units,
484 usage_after.invocation_count,
485 usage_after.total_cost_exposed,
486 usage_after.total_cost_realized_spend,
487 )?;
488 write_authorization_event_projection(
489 &transaction,
490 &event.event_id,
491 admission,
492 outcome,
493 "as,
494 "a_before,
495 "a_after,
496 request.cumulative_approval.as_ref(),
497 cumulative_state,
498 cumulative_before.as_ref(),
499 cumulative_after.as_ref(),
500 !request.invocation_quotas.is_empty(),
501 request.requested_exposure_units,
502 )?;
503 self.append_joint_commit(&transaction, authorization_kind, event_id, event_seq)?;
504 let decision = authorization_decision(
505 self,
506 request.clone(),
507 outcome,
508 event_seq,
509 event.recorded_at,
510 usage_after,
511 quota_after,
512 cumulative_state,
513 cumulative_after,
514 )?;
515 let operation =
516 self.bind_authorization_to_admission(&transaction, &request, &decision, binding, true)?;
517 self.commit_joint_transaction(transaction)?;
518 self.sync_joint_anchor(&connection)?;
519 Ok((decision, operation))
520 }
521
522 fn bind_authorization_to_admission(
523 &self,
524 transaction: &Transaction<'_>,
525 request: &BudgetAuthorizeHoldRequest,
526 decision: &BudgetAuthorizeHoldDecision,
527 binding: Option<AdmissionAuthorizationBinding<'_>>,
528 insert_journal: bool,
529 ) -> Result<Option<chio_kernel::admission_operation::AdmissionOperationV1>, BudgetStoreError>
530 {
531 let Some(binding) = binding else {
532 return Ok(None);
533 };
534 let approval_required =
538 matches!(decision, BudgetAuthorizeHoldDecision::ApprovalRequired(_));
539 if !matches!(decision, BudgetAuthorizeHoldDecision::Authorized(_)) && !approval_required {
540 return Ok(Some(binding.operation.clone()));
541 }
542 let admission = request.admission_binding.as_ref().ok_or_else(|| {
543 BudgetStoreError::Invariant("combined authorization omitted admission binding".into())
544 })?;
545 let hold_id = request.hold_id.as_deref().ok_or_else(|| {
546 BudgetStoreError::Invariant("combined authorization omitted hold_id".into())
547 })?;
548 let event_id = request.event_id.as_deref().ok_or_else(|| {
549 BudgetStoreError::Invariant("combined authorization omitted event_id".into())
550 })?;
551 let operation_id = binding.operation.binding().operation_id().as_str();
552 if admission.operation_id != operation_id
553 || binding.operation.binding().capability_id().as_str() != request.capability_id
554 {
555 return Err(BudgetStoreError::Invariant(
556 "combined authorization does not match its admission operation".to_owned(),
557 ));
558 }
559 let requires_payment = binding
560 .operation
561 .binding()
562 .participant_requirements()
563 .payment;
564 let requires_credit_exposure = binding
565 .operation
566 .binding()
567 .participant_requirements()
568 .credit_exposure;
569 let owner = self.serving_owner.as_deref().ok_or_else(|| {
570 BudgetStoreError::Invariant(
571 "combined authorization requires a serving owner".to_owned(),
572 )
573 })?;
574 if requires_payment != binding.payment_journal.is_some() {
575 return Err(BudgetStoreError::Invariant(
576 "combined authorization payment participant does not match operation requirements"
577 .to_owned(),
578 ));
579 }
580 if requires_credit_exposure != binding.credit_exposure.is_some() {
581 return Err(BudgetStoreError::Invariant(
582 "combined authorization credit exposure participant does not match operation requirements"
583 .to_owned(),
584 ));
585 }
586 if approval_required {
587 return Ok(Some(binding.operation.clone()));
593 }
594 if let Some(journal) = binding.payment_journal {
595 let grant_index = u32::try_from(request.grant_index).map_err(|_| {
596 BudgetStoreError::Invariant(
597 "combined authorization grant index exceeds payment journal range".to_owned(),
598 )
599 })?;
600 if journal.operation_id != operation_id
601 || journal.request_namespace_digest
602 != binding
603 .operation
604 .binding()
605 .request_namespace_digest()
606 .as_str()
607 || journal.request_id != binding.operation.binding().request_id().as_str()
608 || journal.capability_id != request.capability_id
609 || journal.grant_index != grant_index
610 || journal.hold_id.as_deref() != Some(hold_id)
611 || journal.amount_units != request.requested_exposure_units
612 || journal.created_at_unix_ms > binding.trusted_now_unix_ms
613 {
614 return Err(BudgetStoreError::Invariant(
615 "payment journal does not match the combined authorization".to_owned(),
616 ));
617 }
618 if insert_journal {
619 insert_payment_journal(transaction, journal)?;
620 owner
621 .append_global_commit(
622 transaction,
623 "payment_hold_placed",
624 "payment",
625 operation_id,
626 journal.journal_version,
627 )
628 .map_err(super::store::map_serving_owner_error)?;
629 } else {
630 let stored = load_payment_journal(transaction, operation_id)?.ok_or_else(|| {
631 BudgetStoreError::Invariant(
632 "combined authorization replay lost its payment journal".to_owned(),
633 )
634 })?;
635 if !stored.matches_hold_replay(journal) {
636 return Err(BudgetStoreError::Invariant(
637 "combined authorization replay does not match its payment journal"
638 .to_owned(),
639 ));
640 }
641 }
642 }
643 let credit_exposure_reservation = binding
644 .credit_exposure
645 .map(|credit_exposure| {
646 credit_exposure.validate().map_err(|error| {
647 BudgetStoreError::Invariant(format!(
648 "credit exposure reservation request is invalid: {error}"
649 ))
650 })?;
651 let bind = credit_exposure.credit_facility_bind.body();
652 if credit_exposure.operation_id != operation_id
653 || credit_exposure.request_id
654 != binding.operation.binding().request_id().as_str()
655 || credit_exposure.authorities.capability_id()
656 != binding.operation.binding().capability_id().as_str()
657 || credit_exposure.amount.units != request.requested_exposure_units
658 {
659 return Err(BudgetStoreError::Invariant(
660 "credit exposure reservation does not match the combined authorization"
661 .to_owned(),
662 ));
663 }
664 credit_exposure
665 .authorities
666 .ensure_current_at(binding.trusted_now_unix_ms / 1_000)
667 .map_err(|error| {
668 BudgetStoreError::Invariant(format!(
669 "credit exposure authority is not current: {error}"
670 ))
671 })?;
672 credit_exposure
673 .credit_facility_bind
674 .ensure_current_at(binding.trusted_now_unix_ms)
675 .map_err(|error| {
676 BudgetStoreError::Invariant(format!(
677 "credit facility bind is not current: {error}"
678 ))
679 })?;
680 let account_version =
681 bind.expected_exposure_version()
682 .checked_add(1)
683 .ok_or_else(|| {
684 BudgetStoreError::Invariant(
685 "credit exposure account version overflowed".to_owned(),
686 )
687 })?;
688 let resource_fence =
689 bind.expected_exposure_fence()
690 .checked_add(1)
691 .ok_or_else(|| {
692 BudgetStoreError::Invariant(
693 "credit exposure resource fence overflowed".to_owned(),
694 )
695 })?;
696 chio_credit::obligation::CreditExposureReservationRecordV1::prepare_reserved(
697 credit_exposure,
698 account_version,
699 resource_fence,
700 )
701 .map_err(|error| {
702 BudgetStoreError::Invariant(format!(
703 "credit exposure reservation could not be prepared: {error}"
704 ))
705 })
706 })
707 .transpose()?;
708 if let Some(reservation) = credit_exposure_reservation.as_ref() {
709 if insert_journal {
710 if crate::admission_operation_store::load_credit_exposure_reservation_tx(
711 transaction,
712 operation_id,
713 )
714 .map_err(|error| map_credit_exposure_error(error, owner.fence.owner_epoch))?
715 .is_some()
716 {
717 return Err(BudgetStoreError::Invariant(
718 "fresh combined authorization found an existing credit exposure reservation"
719 .to_owned(),
720 ));
721 }
722 } else {
723 let stored = crate::admission_operation_store::load_credit_exposure_reservation_tx(
724 transaction,
725 operation_id,
726 )
727 .map_err(|error| map_credit_exposure_error(error, owner.fence.owner_epoch))?
728 .ok_or_else(|| {
729 BudgetStoreError::Invariant(
730 "combined authorization replay lost its credit exposure reservation"
731 .to_owned(),
732 )
733 })?;
734 stored.validate().map_err(|error| {
735 BudgetStoreError::Invariant(format!(
736 "combined authorization replay has an invalid credit exposure reservation: {error}"
737 ))
738 })?;
739 let expected_state = match binding.operation.state() {
740 chio_kernel::admission_operation::AdmissionOperationState::Completed => {
741 chio_credit::obligation::CreditExposureReservationStateV1::Committed
742 }
743 chio_kernel::admission_operation::AdmissionOperationState::CompensatedBeforeDispatch => {
744 chio_credit::obligation::CreditExposureReservationStateV1::ReleasedBeforeDispatch
745 }
746 chio_kernel::admission_operation::AdmissionOperationState::NotAcceptedAfterDispatchCommit
747 | chio_kernel::admission_operation::AdmissionOperationState::OutcomeUnknownAfterDispatch => {
748 chio_credit::obligation::CreditExposureReservationStateV1::OutcomeUnknown
749 }
750 _ => chio_credit::obligation::CreditExposureReservationStateV1::Reserved,
751 };
752 if stored.reservation_digest() != reservation.reservation_digest()
753 || stored.state() != expected_state
754 {
755 return Err(BudgetStoreError::Invariant(
756 "combined authorization replay conflicts with its credit exposure reservation"
757 .to_owned(),
758 ));
759 }
760 }
761 }
762 let commit_index = authorization_commit_index(decision)?;
763 let participant_digest = transaction.query_row(
764 r#"
765 SELECT projection_reference_digest
766 FROM authority_global_commits
767 WHERE projection_kind = 'budget' AND projection_key = ?1
768 AND projection_sequence = ?2
769 "#,
770 params![
771 event_id,
772 budget_u64_to_sqlite(commit_index, "budget authorization sequence")?
773 ],
774 |row| row.get::<_, String>(0),
775 )?;
776 let requirements = binding.operation.binding().participant_requirements();
777 let authorization_source = if requirements.broker_attempt {
778 chio_kernel::admission_operation::AdmissionOperationState::BrokerAttemptRegistered
779 } else {
780 chio_kernel::admission_operation::AdmissionOperationState::Prepared
781 };
782 let operation = if binding.operation.state() == authorization_source {
783 crate::admission_operation_store::advance_budget_authorization_tx(
784 transaction,
785 owner,
786 crate::admission_operation_store::BudgetAuthorizationAdvance {
787 expected: binding.operation,
788 recovery_lease: binding.recovery_lease,
789 hold_id,
790 payment_required: requires_payment,
791 credit_exposure_reservation_digest: credit_exposure_reservation
792 .as_ref()
793 .map(chio_credit::obligation::CreditExposureReservationRecordV1::reservation_digest),
794 participant_digest: &participant_digest,
795 trusted_now_unix_ms: binding.trusted_now_unix_ms,
796 },
797 )
798 } else {
799 crate::admission_operation_store::verify_budget_authorization_replay_tx(
800 transaction,
801 binding.operation,
802 hold_id,
803 requires_payment,
804 credit_exposure_reservation.as_ref().map(
805 chio_credit::obligation::CreditExposureReservationRecordV1::reservation_digest,
806 ),
807 &participant_digest,
808 )
809 };
810 let operation = operation.map_err(|error| match error {
811 chio_kernel::admission_operation::AdmissionOperationStoreError::Fenced => {
812 BudgetStoreError::Fenced {
813 expected_epoch: owner.fence.owner_epoch,
814 actual_epoch: None,
815 }
816 }
817 chio_kernel::admission_operation::AdmissionOperationStoreError::OutcomeUnknown(
818 detail,
819 ) => BudgetStoreError::OutcomeUnknown(detail),
820 error => BudgetStoreError::Invariant(error.to_string()),
821 })?;
822 let expected_credit_digest = credit_exposure_reservation
823 .as_ref()
824 .map(chio_credit::obligation::CreditExposureReservationRecordV1::reservation_digest);
825 if operation
826 .credit_exposure_reservation_digest()
827 .map(chio_kernel::admission_operation::AdmissionDigest::as_str)
828 != expected_credit_digest
829 {
830 return Err(BudgetStoreError::Invariant(
831 "combined authorization lost its credit exposure reservation".to_owned(),
832 ));
833 }
834 if insert_journal {
835 if let Some(reservation) = credit_exposure_reservation.as_ref() {
836 crate::admission_operation_store::reserve_credit_exposure_tx(
837 transaction,
838 reservation,
839 &owner.fence,
840 binding.trusted_now_unix_ms,
841 )
842 .map_err(|error| map_credit_exposure_error(error, owner.fence.owner_epoch))?;
843 }
844 }
845 let stored_operation = crate::admission_operation_store::load_operation_for_participant_tx(
846 transaction,
847 binding.operation.binding().operation_id(),
848 )
849 .map_err(|error| map_credit_exposure_error(error, owner.fence.owner_epoch))?
850 .ok_or_else(|| {
851 BudgetStoreError::Invariant(
852 "combined authorization lost its admission operation".to_owned(),
853 )
854 })?;
855 if stored_operation != operation {
856 return Err(BudgetStoreError::Invariant(
857 "combined authorization admission operation was not durably advanced".to_owned(),
858 ));
859 }
860 Ok(Some(operation))
861 }
862
863 fn authorization_decision_from_event(
864 &self,
865 transaction: &Transaction<'_>,
866 event: &BudgetMutationRecord,
867 ) -> Result<BudgetAuthorizeHoldDecision, BudgetStoreError> {
868 let hold_id = event.hold_id.as_deref().ok_or_else(|| {
869 BudgetStoreError::Invariant("composite authorization lost hold_id".to_string())
870 })?;
871 if let Some(hold) = load_structured_hold(transaction, hold_id)? {
872 if hold.invocation_state == BudgetInvocationState::Reversed {
873 return Err(BudgetStoreError::Invariant(
874 "budget authorization replay references a terminally reversed hold".to_string(),
875 ));
876 }
877 if hold.invocation_state == BudgetInvocationState::Captured {
878 return captured_authorization_decision(self, transaction, &hold);
879 }
880 }
881 let latest = latest_hold_event_seq(transaction, hold_id)?.unwrap_or(0);
882 if latest != event.event_seq {
883 return Err(BudgetStoreError::Invariant(format!(
884 "budget hold `{hold_id}` authorization event was superseded"
885 )));
886 }
887 decision_from_persisted_event(self, transaction, event)
888 }
889
890 fn validate_observed_revocation(
891 &self,
892 transaction: &Transaction<'_>,
893 admission: &BudgetAdmissionBinding,
894 ) -> Result<(), BudgetStoreError> {
895 let Some(observation) = admission.last_observed_revocation.as_ref() else {
896 return Ok(());
897 };
898 let owner = self.serving_owner.as_ref().ok_or_else(|| {
899 BudgetStoreError::Invariant(
900 "revocation commit metadata requires a joint sqlite serving owner".to_string(),
901 )
902 })?;
903 let authority_head = transaction.query_row(
904 "SELECT head_index FROM admission_authority_meta WHERE singleton = 1",
905 [],
906 |row| row.get::<_, i64>(0),
907 )?;
908 let authority_head = u64::try_from(authority_head).map_err(|_| {
909 BudgetStoreError::Invariant("negative sqlite authority commit index".to_string())
910 })?;
911 if observation.authority.authority_id != owner.fence.store_uuid
912 || observation.authority.lease_epoch != owner.fence.owner_epoch
913 || observation.authority.lease_id != owner.fence.lease_id
914 || observation.guarantee_level != BudgetGuaranteeLevel::SingleNodeAtomic
915 || observation.commit_index != authority_head
916 {
917 return Err(BudgetStoreError::Invariant(
918 "fresh revocation commit metadata does not match the active sqlite authority head"
919 .to_string(),
920 ));
921 }
922 Ok(())
923 }
924
925 fn validate_joint_authority(
926 &self,
927 authority: Option<&BudgetEventAuthority>,
928 ) -> Result<(), BudgetStoreError> {
929 let owner = self.serving_owner.as_ref().ok_or_else(|| {
930 BudgetStoreError::Invariant(
931 "structured sqlite mutations require a provisioned serving owner".to_string(),
932 )
933 })?;
934 let expected = BudgetEventAuthority {
935 authority_id: owner.fence.store_uuid.clone(),
936 lease_id: owner.fence.lease_id.clone(),
937 lease_epoch: owner.fence.owner_epoch,
938 };
939 if authority != Some(&expected) {
940 return Err(BudgetStoreError::Invariant(
941 "structured sqlite mutation authority does not match the active serving owner"
942 .to_string(),
943 ));
944 }
945 Ok(())
946 }
947
948 fn validate_persisted_authority(
949 &self,
950 connection: &Connection,
951 identity: &str,
952 persisted: Option<&BudgetEventAuthority>,
953 requested: Option<&BudgetEventAuthority>,
954 ) -> Result<(), BudgetStoreError> {
955 let Some(owner) = self.serving_owner.as_ref() else {
956 SqliteBudgetStore::validate_hold_authority(identity, persisted, requested)?;
957 return Ok(());
958 };
959 self.validate_joint_authority(requested)?;
960 let Some(persisted) = persisted else {
961 return Err(BudgetStoreError::Invariant(format!(
962 "budget authority record `{identity}` has no serving-owner fence"
963 )));
964 };
965 if persisted.authority_id != owner.fence.store_uuid {
966 return Err(BudgetStoreError::Invariant(format!(
967 "budget authority record `{identity}` is outside the active serving-owner fence"
968 )));
969 }
970 crate::serving_owner::verify_historical_budget_authority(connection, persisted).map_err(
971 |_| {
972 BudgetStoreError::Invariant(format!(
973 "budget authority record `{identity}` is outside durable serving lease history"
974 ))
975 },
976 )?;
977 Ok(())
978 }
979}
980
981fn authorization_commit_index(
982 decision: &BudgetAuthorizeHoldDecision,
983) -> Result<u64, BudgetStoreError> {
984 let metadata = match decision {
985 BudgetAuthorizeHoldDecision::Authorized(value) => &value.metadata,
986 BudgetAuthorizeHoldDecision::ApprovalRequired(value) => &value.metadata,
987 BudgetAuthorizeHoldDecision::Denied(value) => &value.metadata,
988 BudgetAuthorizeHoldDecision::AlreadyCaptured(_) => {
989 return Err(BudgetStoreError::Invariant(
990 "combined admission authorization was already captured".to_owned(),
991 ));
992 }
993 };
994 metadata.budget_commit_index.ok_or_else(|| {
995 BudgetStoreError::Invariant(
996 "combined admission authorization omitted its durable sequence".to_owned(),
997 )
998 })
999}
1000
1001fn map_credit_exposure_error(
1002 error: chio_kernel::admission_operation::AdmissionOperationStoreError,
1003 expected_epoch: u64,
1004) -> BudgetStoreError {
1005 match error {
1006 chio_kernel::admission_operation::AdmissionOperationStoreError::Fenced => {
1007 BudgetStoreError::Fenced {
1008 expected_epoch,
1009 actual_epoch: None,
1010 }
1011 }
1012 chio_kernel::admission_operation::AdmissionOperationStoreError::OutcomeUnknown(detail) => {
1013 BudgetStoreError::OutcomeUnknown(detail)
1014 }
1015 error => BudgetStoreError::Invariant(error.to_string()),
1016 }
1017}