1use chio_log_redact::redacted;
8
9use self::responses::{AllowResponseNonce, FinalizeToolOutputCostContext};
10use super::*;
11use crate::admission_operation::{
12 AdmissionAttachment, AdmissionDigest, AdmissionIdentifier, AdmissionOperationState,
13};
14use crate::budget_store::{
15 ApprovalRequiredBudgetHold, BudgetAuthorizeCumulativeApprovalRequest,
16 BudgetAuthorizeHoldDecision, BudgetAuthorizeHoldRequest,
17 BudgetCancelCapturedBeforeDispatchRequest, BudgetCaptureInvocationRequest,
18 BudgetCapturedBeforeDispatchCancellationDecision, BudgetCumulativeApprovalAccountKey,
19 BudgetCumulativeApprovalAuthorizationDecision, BudgetCumulativeApprovalRequest,
20 BudgetEventAuthority, BudgetHoldMutationDecision, BudgetInvocationCaptureDecision,
21 BudgetInvocationQuota, BudgetQuotaKey, BudgetQuotaProfile, BudgetReconcileHoldDecision,
22 BudgetReconcileHoldRequest, BudgetReverseHoldDecision, BudgetReverseHoldRequest,
23};
24
25pub(crate) struct ReservedPrepayment {
26 pub(crate) authorization: PaymentAuthorization,
27 pub(crate) payment_reference: Option<String>,
28}
29
30impl ChioKernel {
31 pub fn issue_capability(
35 &self,
36 subject: &chio_core::PublicKey,
37 scope: ChioScope,
38 ttl_seconds: u64,
39 ) -> Result<CapabilityToken, KernelError> {
40 crate::ensure_capability_issuance_supported(&scope)?;
41 let capability =
42 self.capability_authority
43 .issue_capability(subject, scope.clone(), ttl_seconds)?;
44 crate::validate_issued_capability_response(
45 &capability,
46 subject,
47 &scope,
48 ttl_seconds,
49 &self.capability_authority.authority_public_key(),
50 )?;
51
52 info!(
53 capability_id = %capability.id,
54 subject = %subject.to_hex(),
55 ttl = ttl_seconds,
56 issuer = %capability.issuer.to_hex(),
57 "issuing capability"
58 );
59
60 self.record_observed_capability_snapshot(&capability)?;
61
62 Ok(capability)
63 }
64
65 pub fn revoke_capability(&self, capability_id: &CapabilityId) -> Result<(), KernelError> {
72 info!(capability_id = %capability_id, "revoking capability");
73 let _ = self.with_revocation_store(|store| Ok(store.revoke(capability_id)?))?;
74 Ok(())
75 }
76
77 pub fn is_capability_revoked(&self, capability_id: &str) -> Result<bool, KernelError> {
84 self.with_revocation_store(|store| Ok(store.is_revoked(capability_id)?))
85 }
86
87 pub fn receipt_log(&self) -> ReceiptLog {
89 match self.receipt_log.lock() {
90 Ok(log) => log.clone(),
91 Err(poisoned) => poisoned.into_inner().clone(),
92 }
93 }
94
95 pub fn child_receipt_log(&self) -> ChildReceiptLog {
96 match self.child_receipt_log.lock() {
97 Ok(log) => log.clone(),
98 Err(poisoned) => poisoned.into_inner().clone(),
99 }
100 }
101
102 pub fn guard_count(&self) -> usize {
103 self.guards.len()
104 }
105
106 #[must_use]
107 pub fn post_invocation_hook_count(&self) -> usize {
108 self.post_invocation_pipeline.len()
109 }
110
111 pub async fn drain_tool_server_events_async(
112 &self,
113 ) -> Result<Vec<ToolServerEvent>, KernelError> {
114 let mut events = Vec::new();
115 let mut first_error = None;
116 for (server_id, server) in &self.tool_servers {
117 match server.drain_events().await {
118 Ok(mut server_events) => events.append(&mut server_events),
119 Err(error) => {
120 warn!(
121 server_id = %server_id,
122 reason = %redacted!(&error),
123 "failed to drain tool server events"
124 );
125 if first_error.is_none() {
126 first_error = Some(error);
127 }
128 }
129 }
130 }
131 if events.is_empty() {
132 if let Some(error) = first_error {
133 return Err(error);
134 }
135 }
136 Ok(events)
137 }
138
139 pub fn try_drain_tool_server_events(&self) -> Result<Vec<ToolServerEvent>, KernelError> {
140 block_on_async_tool_dispatch(self.drain_tool_server_events_async())
141 }
142
143 pub fn drain_tool_server_events(&self) -> Vec<ToolServerEvent> {
144 match self.try_drain_tool_server_events() {
145 Ok(events) => events,
146 Err(error) => {
147 warn!(
148 reason = %redacted!(&error),
149 "failed to drain tool server events"
150 );
151 Vec::new()
152 }
153 }
154 }
155
156 pub fn register_session_pending_url_elicitation(
157 &self,
158 session_id: &SessionId,
159 elicitation_id: impl Into<String>,
160 related_task_id: Option<String>,
161 ) -> Result<(), KernelError> {
162 self.with_session_mut(session_id, |session| {
163 session.register_pending_url_elicitation(elicitation_id, related_task_id);
164 Ok(())
165 })
166 }
167
168 pub fn register_session_required_url_elicitations(
169 &self,
170 session_id: &SessionId,
171 elicitations: &[CreateElicitationOperation],
172 related_task_id: Option<&str>,
173 ) -> Result<(), KernelError> {
174 self.with_session_mut(session_id, |session| {
175 session.register_required_url_elicitations(elicitations, related_task_id);
176 Ok(())
177 })
178 }
179
180 pub fn queue_session_elicitation_completion(
181 &self,
182 session_id: &SessionId,
183 elicitation_id: &str,
184 ) -> Result<(), KernelError> {
185 self.with_session_mut(session_id, |session| {
186 session.queue_elicitation_completion(elicitation_id);
187 Ok(())
188 })
189 }
190
191 pub fn queue_session_late_event(
192 &self,
193 session_id: &SessionId,
194 event: LateSessionEvent,
195 ) -> Result<(), KernelError> {
196 self.with_session_mut(session_id, |session| {
197 session.queue_late_event(event);
198 Ok(())
199 })
200 }
201
202 pub fn queue_session_tool_server_event(
203 &self,
204 session_id: &SessionId,
205 event: ToolServerEvent,
206 ) -> Result<(), KernelError> {
207 self.with_session_mut(session_id, |session| {
208 session.queue_tool_server_event(event);
209 Ok(())
210 })
211 }
212
213 pub fn queue_session_tool_server_events(
214 &self,
215 session_id: &SessionId,
216 ) -> Result<(), KernelError> {
217 let events = self.try_drain_tool_server_events()?;
218 self.with_session_mut(session_id, |session| {
219 for event in events {
220 session.queue_tool_server_event(event);
221 }
222 Ok(())
223 })
224 }
225
226 pub async fn queue_session_tool_server_events_async(
227 &self,
228 session_id: &SessionId,
229 ) -> Result<(), KernelError> {
230 let events = self.drain_tool_server_events_async().await?;
231 self.with_session_mut(session_id, |session| {
232 for event in events {
233 session.queue_tool_server_event(event);
234 }
235 Ok(())
236 })
237 }
238
239 pub fn drain_session_late_events(
240 &self,
241 session_id: &SessionId,
242 ) -> Result<Vec<LateSessionEvent>, KernelError> {
243 self.with_session_mut(session_id, |session| Ok(session.take_late_events()))
244 }
245
246 pub fn ca_count(&self) -> usize {
247 self.config.ca_public_keys.len()
248 }
249
250 pub fn public_key(&self) -> chio_core::PublicKey {
251 self.config.keypair.public_key()
252 }
253
254 pub fn set_capability_crypto_floor(&mut self, floor: KernelCryptoFloor) {
259 self.capability_crypto_floor = floor;
260 }
261
262 pub fn capability_issuer_is_trusted(&self, issuer: &chio_core::PublicKey) -> bool {
263 self.trusted_issuer_keys().contains(issuer)
264 }
265
266 pub(crate) fn trusted_issuer_keys(&self) -> Vec<chio_core::PublicKey> {
276 let mut trusted = self.config.ca_public_keys.clone();
277 for authority_pk in self.capability_authority.trusted_public_keys() {
278 if !trusted.contains(&authority_pk) {
279 trusted.push(authority_pk);
280 }
281 }
282 let kernel_pk = self.config.keypair.public_key();
283 if !trusted.contains(&kernel_pk) {
284 trusted.push(kernel_pk);
285 }
286 trusted
287 }
288
289 pub(crate) fn verify_capability_full_pre_admit(
293 &self,
294 cap: &CapabilityToken,
295 remote_kernel_id: Option<&str>,
296 now: u64,
297 ) -> Result<(), String> {
298 let trusted = self.trusted_issuer_keys();
299 let clock = chio_kernel_core::FixedClock::new(now);
300 let peer_profile = self.capability_negotiation_for_remote(remote_kernel_id, now)?;
301 let trust_resolver = self.capability_trust_root_resolver_snapshot();
302 let mut budgets = chio_kernel_core::NoopBudgetRegistry;
303 let direct_root = self.negotiated_capability_root(cap, &peer_profile)?;
304
305 chio_kernel_core::verify_capability_full_with_root(
306 cap,
307 &trusted,
308 &clock,
309 capability_crypto_floor(self.capability_crypto_floor),
310 chio_kernel_core::CapabilityFeatureContext {
311 peer: &peer_profile,
312 direct_root: direct_root.as_ref(),
313 },
314 &trust_resolver,
315 &mut budgets,
316 )
317 .map_err(|error| {
318 chio_kernel_core::KernelCoreError::InvalidCapability(error).deny_reason()
319 })?;
320 Ok(())
321 }
322
323 pub(crate) fn negotiated_capability_root(
334 &self,
335 cap: &CapabilityToken,
336 peer: &chio_core::capability::features::CapabilityNegotiation,
337 ) -> Result<Option<CapabilityToken>, String> {
338 let features = &peer.features;
339 let lineage_required = features
340 .get(chio_core::capability::features::AGGREGATE_INVOCATION_BUDGET)
341 .copied()
342 .unwrap_or(false)
343 || features
344 .get(chio_core::capability::features::CUMULATIVE_APPROVAL_BUDGET)
345 .copied()
346 .unwrap_or(false);
347 if !lineage_required || cap.delegation_chain.is_empty() {
348 return Ok(None);
349 }
350
351 let root_id = cap
352 .delegation_chain
353 .first()
354 .map(|link| link.capability_id.as_str())
355 .ok_or_else(|| "delegated capability has no root delegation link".to_string())?;
356 let snapshot = self
357 .with_receipt_store(|store| Ok(store.get_capability_snapshot(root_id)?))
358 .map_err(|error| format!("failed to resolve signed capability root: {error}"))?
359 .flatten()
360 .ok_or_else(|| format!("missing signed capability root snapshot for {root_id}"))?;
361 let signed_root = snapshot.signed_capability.ok_or_else(|| {
362 format!("capability root snapshot {root_id} has no signed token evidence")
363 })?;
364 if signed_root.id != root_id {
365 return Err(format!(
366 "signed capability root {} does not match requested root {root_id}",
367 signed_root.id
368 ));
369 }
370 Ok(Some(signed_root))
371 }
372
373 pub(crate) fn admit_capability_budget(&self, cap: &CapabilityToken) -> Result<bool, String> {
412 if let Some(parent_link) = cap.delegation_chain.last() {
413 self.enforce_restart_reserved_hold_gate()?;
414 use chio_kernel_core::BudgetRegistry;
415 let proposed_share = cap
416 .budget_share_bps
417 .unwrap_or(chio_kernel_core::MAX_BUDGET_SHARE_BPS);
418 let mut budgets = match self.budget_registry.lock() {
419 Ok(guard) => guard,
420 Err(_poisoned) => {
421 self.record_tcb_lock_poison("budget_registry");
424 return Err("budget registry lock poisoned; failing closed".to_string());
425 }
426 };
427 budgets
428 .try_admit_child(
429 parent_link.capability_id.as_str(),
430 cap.id.clone(),
431 proposed_share,
432 )
433 .map_err(|err| err.to_string())?;
434 return Ok(true);
437 }
438
439 Ok(false)
440 }
441
442 pub(crate) fn release_admitted_capability_budget(
443 &self,
444 cap: &CapabilityToken,
445 ) -> Result<(), String> {
446 if let Some(parent_link) = cap.delegation_chain.last() {
447 use chio_kernel_core::BudgetRegistry;
448 let proposed_share = cap
449 .budget_share_bps
450 .unwrap_or(chio_kernel_core::MAX_BUDGET_SHARE_BPS);
451 let mut budgets = match self.budget_registry.lock() {
452 Ok(guard) => guard,
453 Err(_poisoned) => {
454 self.record_tcb_lock_poison("budget_registry");
455 return Err("budget registry lock poisoned; failing closed".to_string());
456 }
457 };
458 budgets
459 .release_child(
460 parent_link.capability_id.as_str(),
461 cap.id.as_str(),
462 proposed_share,
463 )
464 .map_err(|err| err.to_string())?;
465 }
466
467 Ok(())
468 }
469
470 fn lock_reserved_sibling_shares(
471 &self,
472 ) -> std::sync::MutexGuard<'_, HashMap<String, ReservedSiblingShare>> {
473 match self.reserved_sibling_shares.lock() {
474 Ok(guard) => guard,
475 Err(poisoned) => poisoned.into_inner(),
476 }
477 }
478
479 pub(crate) fn tracked_reserved_sibling_hold_ids(&self) -> Vec<String> {
480 self.lock_reserved_sibling_shares()
481 .keys()
482 .cloned()
483 .collect()
484 }
485
486 pub(crate) fn record_reserved_sibling_share(&self, hold_id: &str, cap: &CapabilityToken) {
487 let Some(parent_link) = cap.delegation_chain.last() else {
488 return;
489 };
490 let share_bps = cap
491 .budget_share_bps
492 .unwrap_or(chio_kernel_core::MAX_BUDGET_SHARE_BPS);
493 self.lock_reserved_sibling_shares().insert(
494 hold_id.to_string(),
495 ReservedSiblingShare {
496 parent_token_id: parent_link.capability_id.clone(),
497 child_token_id: cap.id.clone(),
498 share_bps,
499 },
500 );
501 }
502
503 pub(crate) fn release_reserved_sibling_share_for_hold(&self, hold_id: &str) {
504 let Some(entry) = self.lock_reserved_sibling_shares().remove(hold_id) else {
505 return;
506 };
507 use chio_kernel_core::BudgetRegistry;
508 let mut budgets = match self.budget_registry.lock() {
509 Ok(guard) => guard,
510 Err(poisoned) => poisoned.into_inner(),
511 };
512 if let Err(error) = budgets.release_child(
513 &entry.parent_token_id,
514 &entry.child_token_id,
515 entry.share_bps,
516 ) {
517 warn!(
518 hold_id = %hold_id,
519 reason = %redacted!(&error),
520 "failed to release reserved sibling share for a closed hold"
521 );
522 }
523 }
524
525 fn lock_restart_reserved_hold_gate(
526 &self,
527 ) -> std::sync::MutexGuard<'_, RestartReservedHoldGate> {
528 match self.restart_reserved_hold_gate.lock() {
529 Ok(guard) => guard,
530 Err(poisoned) => poisoned.into_inner(),
531 }
532 }
533
534 pub fn arm_restart_reserved_hold_gate(&self) -> Result<(), KernelError> {
535 let gate = match self
536 .with_budget_store(|store| Ok(store.list_open_delegated_reserved_hold_ids()?))?
537 {
538 Some(hold_ids) => {
539 let pending: std::collections::HashSet<String> = hold_ids.into_iter().collect();
540 if pending.is_empty() {
541 RestartReservedHoldGate::Clear
542 } else {
543 RestartReservedHoldGate::PendingHolds(pending)
544 }
545 }
546 None => {
547 let open = self.with_budget_store(|store| Ok(store.count_open_holds()?))?;
548 if open == 0 {
549 RestartReservedHoldGate::Clear
550 } else {
551 RestartReservedHoldGate::PendingOpaqueCount
552 }
553 }
554 };
555 *self.lock_restart_reserved_hold_gate() = gate;
556 Ok(())
557 }
558
559 fn enforce_restart_reserved_hold_gate(&self) -> Result<(), String> {
560 let mut gate = self.lock_restart_reserved_hold_gate();
561 match &*gate {
562 RestartReservedHoldGate::Clear => Ok(()),
563 RestartReservedHoldGate::PendingHolds(pending) => {
564 let mut still_open = std::collections::HashSet::new();
565 for hold_id in pending {
566 let open = self
567 .with_budget_store(|store| {
568 Ok(store
569 .get_budget_hold(hold_id)?
570 .is_some_and(|hold| hold.disposition.is_open()))
571 })
572 .map_err(|error| error.to_string())?;
573 if open {
574 still_open.insert(hold_id.clone());
575 }
576 }
577 if still_open.is_empty() {
578 *gate = RestartReservedHoldGate::Clear;
579 Ok(())
580 } else {
581 let count = still_open.len();
582 *gate = RestartReservedHoldGate::PendingHolds(still_open);
583 Err(format!(
584 "delegated reserved holds from a prior process remain open ({count})"
585 ))
586 }
587 }
588 RestartReservedHoldGate::PendingOpaqueCount => {
589 let open = self
590 .with_budget_store(|store| Ok(store.count_open_holds()?))
591 .map_err(|error| error.to_string())?;
592 if open == 0 {
593 *gate = RestartReservedHoldGate::Clear;
594 Ok(())
595 } else {
596 Err(format!(
597 "open budget holds from a prior process remain ({open}) and cannot be enumerated"
598 ))
599 }
600 }
601 }
602 }
603
604 pub fn evaluate_portable_verdict<'a>(
626 &self,
627 capability: &'a CapabilityToken,
628 request: &chio_kernel_core::PortableToolCallRequest,
629 guards: &'a [&'a dyn chio_kernel_core::Guard],
630 clock: &'a dyn chio_kernel_core::Clock,
631 session_filesystem_roots: Option<&'a [String]>,
632 ) -> chio_kernel_core::EvaluationVerdict {
633 let trusted = self.trusted_issuer_keys();
634 let peer_profile = match self.capability_negotiation_for_remote(None, clock.now_unix_secs())
635 {
636 Ok(profile) => profile,
637 Err(reason) => {
640 return chio_kernel_core::EvaluationVerdict {
641 verdict: chio_kernel_core::Verdict::Deny,
642 reason: Some(format!(
643 "capability negotiation failed; denying fail-closed: {reason}"
644 )),
645 matched_grant_index: None,
646 verified: None,
647 };
648 }
649 };
650 let trust_resolver = self.capability_trust_root_resolver_snapshot();
651 let direct_root = match self.negotiated_capability_root(capability, &peer_profile) {
652 Ok(root) => root,
653 Err(reason) => {
654 return chio_kernel_core::EvaluationVerdict {
655 verdict: chio_kernel_core::Verdict::Deny,
656 reason: Some(reason),
657 matched_grant_index: None,
658 verified: None,
659 };
660 }
661 };
662 let mut budgets = match self.budget_registry.lock() {
663 Ok(guard) => guard,
664 Err(_poisoned) => {
665 self.record_tcb_lock_poison("budget_registry");
669 return chio_kernel_core::EvaluationVerdict {
670 verdict: chio_kernel_core::Verdict::Deny,
671 reason: Some("budget registry lock poisoned; denying fail-closed".to_string()),
672 matched_grant_index: None,
673 verified: None,
674 };
675 }
676 };
677 chio_kernel_core::evaluate_with_full_floor_and_root(
678 chio_kernel_core::EvaluateInput {
679 request,
680 capability,
681 trusted_issuers: &trusted,
682 clock,
683 guards,
684 session_filesystem_roots,
685 },
686 capability_crypto_floor(self.capability_crypto_floor),
687 &peer_profile,
688 direct_root.as_ref(),
689 &trust_resolver,
690 &mut *budgets,
691 )
692 }
693
694 pub fn register_budget_parent(
695 &self,
696 parent_token_id: String,
697 parent_share_bps: u16,
698 ) -> Result<(), chio_kernel_core::BudgetSplitError> {
699 use chio_kernel_core::BudgetRegistry;
700 let mut budgets = match self.budget_registry.lock() {
701 Ok(guard) => guard,
702 Err(poisoned) => {
703 self.record_tcb_lock_poison("budget_registry");
706 poisoned.into_inner()
707 }
708 };
709 budgets.register_parent(parent_token_id, parent_share_bps)
710 }
711
712 pub fn evict_budget_parent(&self, parent_token_id: &str) {
713 use chio_kernel_core::BudgetRegistry;
714 let mut budgets = match self.budget_registry.lock() {
715 Ok(guard) => guard,
716 Err(poisoned) => {
717 self.record_tcb_lock_poison("budget_registry");
718 poisoned.into_inner()
719 }
720 };
721 budgets.evict_parent(parent_token_id);
722 }
723
724 pub(crate) fn check_revocation(&self, cap: &CapabilityToken) -> Result<(), KernelError> {
728 if self.with_revocation_store(|store| Ok(store.is_revoked(&cap.id)?))? {
729 return Err(KernelError::CapabilityRevoked(cap.id.clone()));
730 }
731 for link in &cap.delegation_chain {
732 if self.with_revocation_store(|store| Ok(store.is_revoked(&link.capability_id)?))? {
733 return Err(KernelError::DelegationChainRevoked(
734 link.capability_id.clone(),
735 ));
736 }
737 }
738 Ok(())
739 }
740
741 pub(crate) fn validate_delegation_admission(
742 &self,
743 cap: &CapabilityToken,
744 ) -> Result<(), KernelError> {
745 #[cfg(feature = "delegation")]
751 delegation::consult_revocation_view(cap, self.revocation_view.as_ref())?;
752
753 if cap.delegation_chain.is_empty() {
754 return Ok(());
755 }
756
757 chio_core::capability::attenuation::validate_delegation_chain(
758 &cap.delegation_chain,
759 Some(self.config.max_delegation_depth),
760 )
761 .map_err(|error| KernelError::DelegationInvalid(error.to_string()))?;
762
763 let Some(last_link) = cap.delegation_chain.last() else {
764 return Err(KernelError::DelegationInvalid(
765 "delegation chain disappeared after validation".to_string(),
766 ));
767 };
768 if last_link.delegatee != cap.subject {
769 return Err(KernelError::DelegationInvalid(format!(
770 "leaf capability subject {} does not match final delegation delegatee {}",
771 cap.subject.to_hex(),
772 last_link.delegatee.to_hex()
773 )));
774 }
775
776 let mut ancestor_snapshots = Vec::with_capacity(cap.delegation_chain.len());
777 for (index, link) in cap.delegation_chain.iter().enumerate() {
778 let snapshot = self
779 .with_receipt_store(
780 |store| Ok(store.get_capability_snapshot(&link.capability_id)?),
781 )?
782 .flatten()
783 .ok_or_else(|| {
784 KernelError::DelegationInvalid(format!(
785 "missing capability snapshot for delegation ancestor {} at link index {}",
786 link.capability_id, index
787 ))
788 })?;
789 let expected_depth = index as u64;
790 if snapshot.delegation_depth != expected_depth {
791 return Err(KernelError::DelegationInvalid(format!(
792 "delegation ancestor {} at link index {} has stored depth {}, expected {}",
793 snapshot.capability_id, index, snapshot.delegation_depth, expected_depth
794 )));
795 }
796
797 let expected_parent_capability_id = index
798 .checked_sub(1)
799 .map(|parent_index| cap.delegation_chain[parent_index].capability_id.as_str());
800 if snapshot.parent_capability_id.as_deref() != expected_parent_capability_id {
801 let observed_parent = snapshot.parent_capability_id.as_deref().unwrap_or("<root>");
802 let expected_parent = expected_parent_capability_id.unwrap_or("<root>");
803 return Err(KernelError::DelegationInvalid(format!(
804 "delegation ancestor {} at link index {} is lineage-linked to {}, expected {}",
805 snapshot.capability_id, index, observed_parent, expected_parent
806 )));
807 }
808
809 ancestor_snapshots.push(snapshot);
810 }
811
812 for (index, link) in cap.delegation_chain.iter().enumerate() {
813 let parent_snapshot = &ancestor_snapshots[index];
814 let parent_scope = scope_from_capability_snapshot(parent_snapshot)?;
815
816 if parent_snapshot.subject_key != link.delegator.to_hex() {
817 return Err(KernelError::DelegationInvalid(format!(
818 "delegation link {} delegator {} does not match parent capability subject {}",
819 index,
820 link.delegator.to_hex(),
821 parent_snapshot.subject_key
822 )));
823 }
824 if link.timestamp < parent_snapshot.issued_at
825 || link.timestamp >= parent_snapshot.expires_at
826 {
827 return Err(KernelError::DelegationInvalid(format!(
828 "delegation link {} timestamp {} is outside parent capability {} validity window [{} , {})",
829 index,
830 link.timestamp,
831 parent_snapshot.capability_id,
832 parent_snapshot.issued_at,
833 parent_snapshot.expires_at
834 )));
835 }
836
837 let (
838 child_capability_id,
839 child_subject_key,
840 child_scope,
841 child_issued_at,
842 child_expires_at,
843 child_parent_capability_id,
844 ) = if let Some(next_snapshot) = ancestor_snapshots.get(index + 1) {
845 (
846 next_snapshot.capability_id.clone(),
847 next_snapshot.subject_key.clone(),
848 scope_from_capability_snapshot(next_snapshot)?,
849 next_snapshot.issued_at,
850 next_snapshot.expires_at,
851 next_snapshot.parent_capability_id.clone(),
852 )
853 } else {
854 (
855 cap.id.clone(),
856 cap.subject.to_hex(),
857 cap.scope.clone(),
858 cap.issued_at,
859 cap.expires_at,
860 Some(link.capability_id.clone()),
861 )
862 };
863
864 if child_subject_key != link.delegatee.to_hex() {
865 return Err(KernelError::DelegationInvalid(format!(
866 "delegation link {} delegatee {} does not match child capability subject {}",
867 index,
868 link.delegatee.to_hex(),
869 child_subject_key
870 )));
871 }
872 if child_parent_capability_id.as_deref() != Some(link.capability_id.as_str()) {
873 return Err(KernelError::DelegationInvalid(format!(
874 "child capability {} is not lineage-linked to parent capability {}",
875 child_capability_id, link.capability_id
876 )));
877 }
878 if child_issued_at < link.timestamp {
879 return Err(KernelError::DelegationInvalid(format!(
880 "child capability {} was issued before delegation link {} timestamp",
881 child_capability_id, index
882 )));
883 }
884 if child_issued_at < parent_snapshot.issued_at {
885 return Err(KernelError::DelegationInvalid(format!(
886 "child capability {} predates parent capability {} issuance",
887 child_capability_id, parent_snapshot.capability_id
888 )));
889 }
890 if child_expires_at > parent_snapshot.expires_at {
891 return Err(KernelError::DelegationInvalid(format!(
892 "child capability {} expires after parent capability {}",
893 child_capability_id, parent_snapshot.capability_id
894 )));
895 }
896
897 validate_delegation_scope_step(
898 &parent_snapshot.capability_id,
899 &child_capability_id,
900 &parent_scope,
901 &child_scope,
902 child_expires_at,
903 link,
904 )?;
905 }
906
907 Ok(())
908 }
909
910 fn local_budget_event_authority(&self) -> BudgetEventAuthority {
911 BudgetEventAuthority {
912 authority_id: format!("kernel:{}", self.config.keypair.public_key().to_hex()),
913 lease_id: "single-node".to_string(),
914 lease_epoch: 0,
915 }
916 }
917
918 pub(crate) fn budget_backend_receipt_metadata(&self) -> Result<serde_json::Value, KernelError> {
919 let (guarantee_level, authority_profile, metering_profile) =
920 self.with_budget_store(|store| {
921 Ok((
922 store.budget_guarantee_level().as_str().to_string(),
923 store.budget_authority_profile().as_str().to_string(),
924 store.budget_metering_profile().as_str().to_string(),
925 ))
926 })?;
927 Ok(serde_json::json!({
928 "budget_authority": {
929 "guarantee_level": guarantee_level,
930 "authority_profile": authority_profile,
931 "metering_profile": metering_profile,
932 }
933 }))
934 }
935
936 pub(crate) fn budget_execution_receipt_metadata(
937 &self,
938 charge: &BudgetChargeResult,
939 terminal_event: Option<(&str, &BudgetHoldMutationDecision)>,
940 execution_nonce_id: Option<&str>,
941 ) -> serde_json::Value {
942 let mut budget_authority = serde_json::Map::new();
943 budget_authority.insert(
944 "guarantee_level".to_string(),
945 serde_json::json!(charge.authorize_metadata.guarantee_level.as_str()),
946 );
947 budget_authority.insert(
948 "authority_profile".to_string(),
949 serde_json::json!(charge.authorize_metadata.budget_profile.as_str()),
950 );
951 budget_authority.insert(
952 "metering_profile".to_string(),
953 serde_json::json!(charge.authorize_metadata.metering_profile.as_str()),
954 );
955 budget_authority.insert(
956 "hold_id".to_string(),
957 serde_json::json!(&charge.budget_hold_id),
958 );
959 if let Some(budget_term) = charge.authorize_metadata.budget_term() {
960 budget_authority.insert("budget_term".to_string(), serde_json::json!(budget_term));
961 }
962 if let Some(authority) = charge.authorize_metadata.authority.as_ref() {
963 budget_authority.insert(
964 "authority".to_string(),
965 serde_json::json!({
966 "authority_id": &authority.authority_id,
967 "lease_id": &authority.lease_id,
968 "lease_epoch": authority.lease_epoch,
969 }),
970 );
971 }
972
973 let mut authorize = serde_json::Map::new();
974 if let Some(event_id) = charge.authorize_metadata.event_id.as_ref() {
975 authorize.insert("event_id".to_string(), serde_json::json!(event_id));
976 }
977 if let Some(commit_index) = charge.authorize_metadata.budget_commit_index {
978 authorize.insert(
979 "budget_commit_index".to_string(),
980 serde_json::json!(commit_index),
981 );
982 }
983 authorize.insert(
984 "exposure_units".to_string(),
985 serde_json::json!(charge.cost_charged),
986 );
987 authorize.insert(
988 "committed_cost_units_after".to_string(),
989 serde_json::json!(charge.new_committed_cost_units),
990 );
991 budget_authority.insert(
992 "authorize".to_string(),
993 serde_json::Value::Object(authorize),
994 );
995
996 if let Some(capture) = charge.invocation_capture.as_ref() {
997 let mut invocation_capture = serde_json::Map::new();
998 if let Some(event_id) = capture.metadata.event_id.as_ref() {
999 invocation_capture.insert("event_id".to_string(), serde_json::json!(event_id));
1000 }
1001 if let Some(commit_index) = capture.metadata.budget_commit_index {
1002 invocation_capture.insert(
1003 "budget_commit_index".to_string(),
1004 serde_json::json!(commit_index),
1005 );
1006 }
1007 invocation_capture.insert(
1008 "invocation_count_after".to_string(),
1009 serde_json::json!(capture.invocation_count_after),
1010 );
1011 budget_authority.insert(
1012 "invocation_capture".to_string(),
1013 serde_json::Value::Object(invocation_capture),
1014 );
1015 }
1016
1017 if let Some((disposition, terminal_event)) = terminal_event {
1018 let mut terminal = serde_json::Map::new();
1019 terminal.insert("disposition".to_string(), serde_json::json!(disposition));
1020 if let Some(event_id) = terminal_event.metadata.event_id.as_ref() {
1021 terminal.insert("event_id".to_string(), serde_json::json!(event_id));
1022 }
1023 if let Some(commit_index) = terminal_event.metadata.budget_commit_index {
1024 terminal.insert(
1025 "budget_commit_index".to_string(),
1026 serde_json::json!(commit_index),
1027 );
1028 }
1029 terminal.insert(
1030 "exposure_units".to_string(),
1031 serde_json::json!(terminal_event.exposure_units),
1032 );
1033 terminal.insert(
1034 "realized_spend_units".to_string(),
1035 serde_json::json!(terminal_event.realized_spend_units),
1036 );
1037 terminal.insert(
1038 "committed_cost_units_after".to_string(),
1039 serde_json::json!(terminal_event.committed_cost_units_after),
1040 );
1041 budget_authority.insert("terminal".to_string(), serde_json::Value::Object(terminal));
1042 }
1043
1044 if let Some(nonce_id) = execution_nonce_id {
1045 budget_authority.insert(
1046 "execution_nonce_id".to_string(),
1047 serde_json::json!(nonce_id),
1048 );
1049 budget_authority.insert(
1050 "mediated_spend".to_string(),
1051 serde_json::json!({
1052 "profile": chio_core_types::receipt::authoritative_spend::MEDIATED_SPEND_PROFILE
1053 }),
1054 );
1055 }
1056
1057 serde_json::json!({ "budget_authority": budget_authority })
1058 }
1059
1060 pub(crate) fn merge_budget_receipt_metadata(
1061 &self,
1062 extra_metadata: Option<serde_json::Value>,
1063 budget_metadata: serde_json::Value,
1064 ) -> Option<serde_json::Value> {
1065 merge_metadata_objects(extra_metadata, Some(budget_metadata))
1066 }
1067
1068 pub(crate) fn retained_admission_receipt_metadata(
1069 &self,
1070 budget_mutation: &PreExecutionBudgetMutation,
1071 runtime_metadata: Option<serde_json::Value>,
1072 ) -> Option<serde_json::Value> {
1073 let retained =
1074 self.mark_runtime_admission_reservations_retained_fail_closed(runtime_metadata);
1075 match budget_mutation.charge_result() {
1076 Some(charge) => self.merge_budget_receipt_metadata(
1077 retained,
1078 self.budget_execution_receipt_metadata(charge, None, None),
1079 ),
1080 None => retained,
1081 }
1082 }
1083
1084 pub(crate) fn ambiguous_invocation_capture_receipt_metadata(
1085 &self,
1086 budget_mutation: &PreExecutionBudgetMutation,
1087 runtime_metadata: Option<serde_json::Value>,
1088 ) -> Option<serde_json::Value> {
1089 let retained =
1090 self.mark_runtime_admission_reservations_retained_fail_closed(runtime_metadata);
1091 let Some(charge) = budget_mutation.charge_result() else {
1092 return retained;
1093 };
1094 let mut budget_metadata = self.budget_execution_receipt_metadata(charge, None, None);
1095 if let Some(budget_authority) = budget_metadata
1096 .get_mut("budget_authority")
1097 .and_then(serde_json::Value::as_object_mut)
1098 {
1099 budget_authority.insert(
1100 "invocation_capture".to_string(),
1101 serde_json::json!({
1102 "event_id": charge.capture_invocation_event_id(),
1103 "invocation_capture_ambiguous": true,
1104 "admission_retained": true,
1105 }),
1106 );
1107 }
1108 self.merge_budget_receipt_metadata(retained, budget_metadata)
1109 }
1110
1111 pub(crate) fn ambiguous_cancellation_receipt_metadata(
1112 &self,
1113 charge: &BudgetChargeResult,
1114 runtime_metadata: Option<serde_json::Value>,
1115 ) -> Option<serde_json::Value> {
1116 let mut budget_metadata = self.budget_execution_receipt_metadata(charge, None, None);
1117 if let Some(budget_authority) = budget_metadata
1118 .get_mut("budget_authority")
1119 .and_then(serde_json::Value::as_object_mut)
1120 {
1121 if let Some(capture) = budget_authority
1122 .get_mut("invocation_capture")
1123 .and_then(serde_json::Value::as_object_mut)
1124 {
1125 capture.insert(
1126 "admission_retained".to_string(),
1127 serde_json::Value::Bool(true),
1128 );
1129 }
1130 budget_authority.insert(
1131 "cancel_captured_before_dispatch".to_string(),
1132 serde_json::json!({
1133 "event_id": charge.cancel_captured_before_dispatch_event_id(),
1134 "cancellation_ambiguous": true,
1135 "pre_dispatch_admission_release_attempted": true,
1136 }),
1137 );
1138 }
1139 self.merge_budget_receipt_metadata(runtime_metadata, budget_metadata)
1140 }
1141
1142 pub(crate) fn captured_admission_retained_receipt_metadata(
1143 &self,
1144 charge: &BudgetChargeResult,
1145 runtime_metadata: Option<serde_json::Value>,
1146 ) -> Option<serde_json::Value> {
1147 let mut budget_metadata = self.budget_execution_receipt_metadata(charge, None, None);
1148 if let Some(capture) = budget_metadata
1149 .get_mut("budget_authority")
1150 .and_then(serde_json::Value::as_object_mut)
1151 .and_then(|authority| authority.get_mut("invocation_capture"))
1152 .and_then(serde_json::Value::as_object_mut)
1153 {
1154 capture.insert(
1155 "admission_retained".to_string(),
1156 serde_json::Value::Bool(true),
1157 );
1158 }
1159 self.merge_budget_receipt_metadata(runtime_metadata, budget_metadata)
1160 }
1161
1162 pub(crate) fn ambiguous_dispatch_receipt_metadata(
1163 &self,
1164 budget_mutation: &PreExecutionBudgetMutation,
1165 payment_authorization: Option<&PaymentAuthorization>,
1166 runtime_metadata: Option<serde_json::Value>,
1167 ) -> Option<serde_json::Value> {
1168 let retained = self.retained_admission_receipt_metadata(budget_mutation, runtime_metadata);
1169 match payment_authorization {
1170 Some(authorization) => merge_metadata_objects(
1171 retained,
1172 Some(serde_json::json!({
1173 "financial": {
1174 "payment_reference": authorization.authorization_id,
1175 "payment_authorization_retained": true
1176 }
1177 })),
1178 ),
1179 None => retained,
1180 }
1181 }
1182
1183 pub(crate) fn note_retained_ambiguous_hold(&self, retained: bool) {
1190 if !retained {
1191 return;
1192 }
1193 let reconciliation = if self.durable_admission_runtime.is_some() {
1194 "durable"
1195 } else {
1196 "none"
1197 };
1198 chio_metrics_spec::runtime::families::AMBIGUOUS_DISPATCH_RETAINED_HOLD
1199 .incr(&[reconciliation]);
1200 }
1201
1202 fn cumulative_approval_request_for_grant(
1203 &self,
1204 request: &ToolCallRequest,
1205 matching: &MatchingGrant<'_>,
1206 admission: Option<&DurableToolAdmission>,
1207 now: u64,
1208 ) -> Result<Option<BudgetCumulativeApprovalRequest>, KernelError> {
1209 let cumulative_constraint_count = matching
1210 .grant
1211 .constraints
1212 .iter()
1213 .filter(|constraint| {
1214 matches!(
1215 constraint,
1216 Constraint::RequireCumulativeApprovalAbove { .. }
1217 )
1218 })
1219 .count();
1220 if cumulative_constraint_count == 0 {
1221 return Ok(None);
1222 }
1223 if cumulative_constraint_count != 1 {
1224 return Err(KernelError::GovernedTransactionDenied(
1225 "a matching grant must contain exactly one cumulative approval constraint"
1226 .to_owned(),
1227 ));
1228 }
1229 let admission = admission.ok_or_else(|| {
1230 KernelError::DurableAdmission(
1231 "cumulative approval requires a durable admission operation".to_owned(),
1232 )
1233 })?;
1234 let peer = self
1235 .capability_negotiation_for_remote(request.federated_origin_kernel_id.as_deref(), now)
1236 .map_err(KernelError::GovernedTransactionDenied)?;
1237 if !peer.supports(chio_core::capability::features::CUMULATIVE_APPROVAL_BUDGET) {
1238 return Err(KernelError::GovernedTransactionDenied(
1239 "cumulative approval budgets were not negotiated".to_owned(),
1240 ));
1241 }
1242 let direct_root = self
1243 .negotiated_capability_root(&request.capability, &peer)
1244 .map_err(KernelError::GovernedTransactionDenied)?;
1245 let verified =
1246 chio_core::capability::cumulative_approval::verify_cumulative_approval_constraints(
1247 &request.capability,
1248 &self.trusted_issuer_keys(),
1249 direct_root.as_ref(),
1250 )
1251 .map_err(|error| KernelError::GovernedTransactionDenied(error.to_string()))?;
1252 let mut matching_constraints = verified
1253 .into_iter()
1254 .filter(|constraint| constraint.grant_index == matching.index);
1255 let constraint = matching_constraints.next().ok_or_else(|| {
1256 KernelError::GovernedTransactionDenied(
1257 "cumulative approval verification omitted the matching grant".to_owned(),
1258 )
1259 })?;
1260 if matching_constraints.next().is_some() {
1261 return Err(KernelError::GovernedTransactionDenied(
1262 "cumulative approval verification produced an ambiguous grant".to_owned(),
1263 ));
1264 }
1265 let intent = request.governed_intent.as_ref().ok_or_else(|| {
1266 KernelError::GovernedTransactionDenied(
1267 "cumulative approval requires a governed transaction intent".to_owned(),
1268 )
1269 })?;
1270 if intent.server_id != request.server_id || intent.tool_name != request.tool_name {
1271 return Err(KernelError::GovernedTransactionDenied(
1272 "cumulative approval intent target does not match the request".to_owned(),
1273 ));
1274 }
1275 let requested_authorized = intent.max_amount.clone().ok_or_else(|| {
1276 KernelError::GovernedTransactionDenied(
1277 "cumulative approval intent requires a maximum amount".to_owned(),
1278 )
1279 })?;
1280 if requested_authorized.currency != constraint.threshold.currency {
1281 return Err(KernelError::GovernedTransactionDenied(
1282 "cumulative approval intent currency does not match the capability".to_owned(),
1283 ));
1284 }
1285 Ok(Some(BudgetCumulativeApprovalRequest {
1286 operation_id: admission.operation_id().to_owned(),
1287 account_key: BudgetCumulativeApprovalAccountKey {
1288 authority_id: constraint.authority_id.to_hex(),
1289 owner_id: constraint.owner_id,
1290 approval_budget_id: constraint.approval_budget_id,
1291 approval_budget_epoch: constraint.approval_budget_epoch,
1292 root_grant_hash: constraint.root_grant_hash,
1293 delegation_root_id: constraint.delegation_root_id,
1294 root_binding_digest: constraint.root_binding_digest,
1295 currency: constraint.threshold.currency.clone(),
1296 },
1297 authority_threshold: constraint.authority_threshold,
1298 effective_threshold: constraint.threshold,
1299 requested_authorized,
1300 }))
1301 }
1302
1303 fn ensure_cumulative_approval_proposal(
1304 &self,
1305 request: &ToolCallRequest,
1306 required: &ApprovalRequiredBudgetHold,
1307 admission: &mut DurableToolAdmission,
1308 trusted_now_unix_ms: u64,
1309 ) -> Result<chio_core::capability::governance::ThresholdApprovalProposal, KernelError> {
1310 if admission.operation.state() == AdmissionOperationState::ApprovalRequired {
1311 if admission
1312 .operation
1313 .budget_hold_id()
1314 .is_none_or(|hold_id| hold_id.as_str() != required.hold_id)
1315 {
1316 return Err(KernelError::DurableAdmission(
1317 "retained approval proposal changed its budget hold".to_owned(),
1318 ));
1319 }
1320 return admission
1321 .operation
1322 .threshold_proposal()
1323 .cloned()
1324 .ok_or_else(|| {
1325 KernelError::DurableAdmission(
1326 "approval-required operation omitted its stored proposal".to_owned(),
1327 )
1328 });
1329 }
1330 let now = trusted_now_unix_ms / 1_000;
1331 let requirement = self.threshold_approval_requirement(request, now)?;
1332 let intent = request.governed_intent.as_ref().ok_or_else(|| {
1333 KernelError::GovernedTransactionDenied(
1334 "cumulative approval requires a governed transaction intent".to_owned(),
1335 )
1336 })?;
1337 let intent_hash = intent
1338 .binding_hash()
1339 .map_err(|error| KernelError::GovernedTransactionDenied(error.to_string()))?;
1340 let capability_digest = sha256_hex(
1341 &canonical_json_bytes(&request.capability)
1342 .map_err(|error| KernelError::GovernedTransactionDenied(error.to_string()))?,
1343 );
1344 let proposal_created_at = required.metadata.recorded_at_unix_seconds.ok_or_else(|| {
1345 KernelError::DurableAdmission(
1346 "cumulative approval authorization omitted its durable timestamp".to_owned(),
1347 )
1348 })?;
1349 let proposal_deadline =
1350 chio_core::capability::governance::ThresholdApprovalProposalBody::proposal_deadline(
1351 proposal_created_at,
1352 requirement.timeout_seconds,
1353 request.capability.expires_at,
1354 intent.governed_operation_expires_at(),
1355 )
1356 .map_err(|error| KernelError::GovernedTransactionDenied(error.to_string()))?;
1357 let proposal = chio_core::capability::governance::ThresholdApprovalProposal::sign(
1358 chio_core::capability::governance::ThresholdApprovalProposalBody {
1359 schema: chio_core::capability::governance::THRESHOLD_APPROVAL_PROPOSAL_SCHEMA
1360 .to_owned(),
1361 proposal_id: admission.operation_id().to_owned(),
1362 request_id: request.request_id.clone(),
1363 governed_intent_hash: intent_hash,
1364 subject: request.capability.subject.clone(),
1365 authorizing_capability_digest: capability_digest,
1366 policy_hash: requirement.policy_hash,
1367 threshold: requirement.threshold,
1368 eligible_set_digest: requirement.eligible_set_digest,
1369 proposal_created_at,
1370 proposal_deadline,
1371 policy_authority: self.config.keypair.public_key(),
1372 },
1373 &self.config.keypair,
1374 )
1375 .map_err(|error| KernelError::GovernedTransactionDenied(error.to_string()))?;
1376 let proposal_hash = AdmissionDigest::try_new(
1377 "threshold_proposal_hash",
1378 proposal
1379 .artifact_digest()
1380 .map_err(|error| KernelError::GovernedTransactionDenied(error.to_string()))?,
1381 )?;
1382 admission.operation = self.apply_admission_command(
1383 admission.operation.clone(),
1384 vec![
1385 AdmissionAttachment::ThresholdProposalHash(proposal_hash),
1386 AdmissionAttachment::BudgetHoldId(AdmissionIdentifier::try_new(
1387 "budget_hold_id",
1388 required.hold_id.clone(),
1389 )?),
1390 AdmissionAttachment::ThresholdProposal(Box::new(proposal.clone())),
1391 ],
1392 AdmissionOperationState::ApprovalRequired,
1393 trusted_now_unix_ms,
1394 )?;
1395 Ok(proposal)
1396 }
1397
1398 fn authorize_cumulative_approval(
1399 &self,
1400 request: &ToolCallRequest,
1401 grant_index: usize,
1402 required: &ApprovalRequiredBudgetHold,
1403 admission: &mut DurableToolAdmission,
1404 trusted_now_unix_ms: u64,
1405 ) -> Result<crate::budget_store::BudgetHoldMutationDecision, KernelError> {
1406 let proposal = self.ensure_cumulative_approval_proposal(
1407 request,
1408 required,
1409 admission,
1410 trusted_now_unix_ms,
1411 )?;
1412 if request.threshold_approval_proposal.as_ref() != Some(&proposal) {
1413 return Err(KernelError::GovernedTransactionDenied(
1414 "threshold approval request does not carry the stored proposal".to_owned(),
1415 ));
1416 }
1417 let intent_hash = request
1418 .governed_intent
1419 .as_ref()
1420 .ok_or_else(|| {
1421 KernelError::GovernedTransactionDenied(
1422 "cumulative approval requires a governed transaction intent".to_owned(),
1423 )
1424 })?
1425 .binding_hash()
1426 .map_err(|error| KernelError::GovernedTransactionDenied(error.to_string()))?;
1427 let verified = self.validate_threshold_approval_set(
1428 request,
1429 &request.capability,
1430 &intent_hash,
1431 trusted_now_unix_ms / 1_000,
1432 )?;
1433 let approval_set_digest = verified
1434 .body
1435 .approval_set_hash()
1436 .map_err(|error| KernelError::GovernedTransactionDenied(error.to_string()))?;
1437 let decision = self.with_budget_store(|store| {
1438 Ok(
1439 store.authorize_cumulative_approval(BudgetAuthorizeCumulativeApprovalRequest {
1440 capability_id: request.capability.id.clone(),
1441 grant_index,
1442 operation_id: admission.operation_id().to_owned(),
1443 hold_id: required.hold_id.clone(),
1444 admission_binding: required.admission_binding.clone(),
1445 approval_set_digest,
1446 event_id: format!("{}:authorize-cumulative", required.hold_id),
1447 authority: required.metadata.authority.clone(),
1448 })?,
1449 )
1450 })?;
1451 let mutation = match decision {
1452 BudgetCumulativeApprovalAuthorizationDecision::Authorized(mutation)
1453 | BudgetCumulativeApprovalAuthorizationDecision::AlreadyAuthorized(mutation) => {
1454 mutation
1455 }
1456 };
1457 admission.operation = self.apply_admission_command(
1458 admission.operation.clone(),
1459 Vec::new(),
1460 AdmissionOperationState::BudgetAuthorized,
1461 trusted_now_unix_ms,
1462 )?;
1463 Ok(mutation)
1464 }
1465
1466 pub(crate) fn check_and_increment_budget(
1470 &self,
1471 request: &ToolCallRequest,
1472 cap: &CapabilityToken,
1473 matching_grants: &[MatchingGrant<'_>],
1474 nonce_preflight: bool,
1475 mut durable_admission: Option<&mut DurableToolAdmission>,
1476 trusted_now_unix_ms: u64,
1477 ) -> Result<BudgetAdmissionOutcome, KernelError> {
1478 let mut saw_exhausted_budget = false;
1479 let mut eligible_grant_seen = false;
1480
1481 for matching in matching_grants {
1482 if durable_admission
1483 .as_deref()
1484 .is_some_and(|admission| !admission.permits_matching_grant(matching))
1485 {
1486 continue;
1487 }
1488 eligible_grant_seen = true;
1489 let grant = matching.grant;
1490 let has_monetary =
1491 grant.max_cost_per_invocation.is_some() || grant.max_total_cost.is_some();
1492
1493 if has_monetary
1494 || (nonce_preflight && grant.max_invocations.is_some())
1495 || durable_admission.is_some()
1496 {
1497 let cost_units = grant
1499 .max_cost_per_invocation
1500 .as_ref()
1501 .map(|m| m.units)
1502 .unwrap_or(0);
1503 let currency = grant
1504 .max_cost_per_invocation
1505 .as_ref()
1506 .map(|m| m.currency.clone())
1507 .or_else(|| grant.max_total_cost.as_ref().map(|m| m.currency.clone()))
1508 .unwrap_or_else(|| "USD".to_string());
1509 let max_total = grant.max_total_cost.as_ref().map(|m| m.units);
1510 let max_per = grant.max_cost_per_invocation.as_ref().map(|m| m.units);
1511 let budget_total = max_total.unwrap_or(u64::MAX);
1512 let (budget_hold_id, authorize_event_id, admission_binding, authority) =
1513 if let Some(admission) = durable_admission.as_deref() {
1514 let (binding, authority) = self.durable_budget_binding(admission, cap)?;
1515 (
1516 admission.budget_hold_id(matching.index),
1517 admission.budget_authorize_event_id(matching.index),
1518 Some(binding),
1519 authority,
1520 )
1521 } else {
1522 let budget_hold_id = if nonce_preflight {
1523 format!(
1524 "nonce-preflight-budget-hold:{}:{}:{}",
1525 request.request_id, cap.id, matching.index
1526 )
1527 } else {
1528 format!(
1529 "budget-hold:{}:{}:{}",
1530 request.request_id, cap.id, matching.index
1531 )
1532 };
1533 (
1534 budget_hold_id.clone(),
1535 format!("{budget_hold_id}:authorize"),
1536 None,
1537 self.local_budget_event_authority(),
1538 )
1539 };
1540
1541 let mut invocation_quotas = Vec::with_capacity(3);
1542 if let Some(admission) = durable_admission.as_deref() {
1543 if admission.aggregate_quota().is_some()
1544 || admission.supplemental_quota().is_some()
1545 {
1546 if let Some(max_invocations) = grant.max_invocations {
1547 invocation_quotas.push(BudgetInvocationQuota {
1548 key: BudgetQuotaKey::grant(
1549 cap.id.clone(),
1550 u32::try_from(matching.index).map_err(|_| {
1551 KernelError::DurableAdmission(
1552 "matching grant index exceeds u32".to_string(),
1553 )
1554 })?,
1555 ),
1556 max_invocations,
1557 });
1558 }
1559 }
1560 if let Some(aggregate) = admission.aggregate_quota() {
1561 invocation_quotas.push(aggregate.clone());
1562 }
1563 if let Some(supplemental) = admission.supplemental_quota() {
1564 invocation_quotas.push(BudgetInvocationQuota {
1565 key: BudgetQuotaKey {
1566 profile: BudgetQuotaProfile::SupplementalBrokerCapabilityExecution,
1567 owner_id: supplemental.owner_id().to_string(),
1568 grant_index: None,
1569 },
1570 max_invocations: supplemental.max_invocations(),
1571 });
1572 }
1573 }
1574 invocation_quotas.sort_by(|left, right| left.key.cmp(&right.key));
1575 let cumulative_approval = self.cumulative_approval_request_for_grant(
1576 request,
1577 matching,
1578 durable_admission.as_deref(),
1579 trusted_now_unix_ms / 1_000,
1580 )?;
1581 let authorization_request = BudgetAuthorizeHoldRequest {
1582 capability_id: cap.id.clone(),
1583 grant_index: matching.index,
1584 max_invocations: grant.max_invocations,
1585 invocation_quotas,
1586 cumulative_approval,
1587 admission_binding,
1588 requested_exposure_units: cost_units,
1589 max_cost_per_invocation: max_per,
1590 max_total_cost_units: max_total,
1591 hold_id: Some(budget_hold_id.clone()),
1592 event_id: Some(authorize_event_id),
1593 authority: Some(authority.clone()),
1594 };
1595 let decision = if let Some(admission) = durable_admission.as_deref_mut() {
1596 let payment_journal = if admission.requires_payment() {
1597 let adapter = self.payment_adapter.as_ref().ok_or_else(|| {
1598 KernelError::DurableAdmission(
1599 "durable monetary authorization lost its payment adapter"
1600 .to_owned(),
1601 )
1602 })?;
1603 let rail_mode = adapter.rail_mode().ok_or_else(|| {
1604 KernelError::DurableAdmission(
1605 "durable monetary authorization lost its payment rail mode"
1606 .to_owned(),
1607 )
1608 })?;
1609 let journal = crate::payment::PaymentJournalRecord {
1610 operation_id: admission.operation_id().to_owned(),
1611 journal_version: 1,
1612 request_namespace_digest: admission
1613 .operation()
1614 .binding()
1615 .request_namespace_digest()
1616 .as_str()
1617 .to_owned(),
1618 request_id: admission
1619 .operation()
1620 .binding()
1621 .request_id()
1622 .as_str()
1623 .to_owned(),
1624 capability_id: cap.id.clone(),
1625 grant_index: u32::try_from(matching.index).map_err(|_| {
1626 KernelError::DurableAdmission(
1627 "payment grant index exceeds the durable journal range"
1628 .to_owned(),
1629 )
1630 })?,
1631 hold_id: Some(budget_hold_id.clone()),
1632 rail: adapter.rail_id().to_owned(),
1633 rail_mode,
1634 authorization_id: None,
1635 transaction_id: None,
1636 amount_units: cost_units,
1637 settle_action: None,
1638 settle_amount_units: None,
1639 release_authority: None,
1640 currency: currency.clone(),
1641 state: crate::payment::PaymentJournalState::HoldPlaced,
1642 created_at_unix_ms: trusted_now_unix_ms.max(1),
1643 };
1644 journal
1645 .validate()
1646 .map_err(|error| KernelError::DurableAdmission(error.to_string()))?;
1647 Some(journal)
1648 } else {
1649 None
1650 };
1651 self.authorize_durable_budget_hold(
1652 admission,
1653 authorization_request,
1654 payment_journal,
1655 trusted_now_unix_ms,
1656 )?
1657 } else {
1658 self.with_budget_store(|store| {
1659 Ok(store.authorize_budget_hold(authorization_request)?)
1660 })?
1661 };
1662 match decision {
1663 BudgetAuthorizeHoldDecision::Authorized(authorized) => {
1664 let charge = BudgetChargeResult {
1665 grant_index: matching.index,
1666 cost_charged: cost_units,
1667 currency,
1668 budget_total,
1669 new_committed_cost_units: authorized.committed_cost_units_after,
1670 budget_hold_id: authorized
1671 .hold_id
1672 .unwrap_or_else(|| budget_hold_id.clone()),
1673 authorize_metadata: authorized.metadata,
1674 invocation_capture: None,
1675 };
1676 let mutation = if has_monetary {
1677 PreExecutionBudgetMutation::Charge(charge)
1678 } else {
1679 PreExecutionBudgetMutation::InvocationHold(charge)
1680 };
1681 return Ok(BudgetAdmissionOutcome::Authorized {
1682 grant_index: matching.index,
1683 mutation: Box::new(mutation),
1684 });
1685 }
1686 BudgetAuthorizeHoldDecision::Denied(_) => {
1687 saw_exhausted_budget = true;
1688 }
1689 BudgetAuthorizeHoldDecision::ApprovalRequired(required) => {
1690 let admission = durable_admission.as_deref_mut().ok_or_else(|| {
1691 KernelError::DurableAdmission(
1692 "cumulative approval lost its durable operation".to_owned(),
1693 )
1694 })?;
1695 let proposal = self.ensure_cumulative_approval_proposal(
1696 request,
1697 &required,
1698 admission,
1699 trusted_now_unix_ms,
1700 )?;
1701 if request.approval_tokens.is_empty() {
1702 return Ok(BudgetAdmissionOutcome::PendingApproval {
1703 grant_index: matching.index,
1704 proposal: Box::new(proposal),
1705 });
1706 }
1707 let authorized = self.authorize_cumulative_approval(
1708 request,
1709 matching.index,
1710 &required,
1711 admission,
1712 trusted_now_unix_ms,
1713 )?;
1714 let charge = BudgetChargeResult {
1715 grant_index: matching.index,
1716 cost_charged: cost_units,
1717 currency,
1718 budget_total,
1719 new_committed_cost_units: authorized.committed_cost_units_after,
1720 budget_hold_id: authorized
1721 .hold_id
1722 .unwrap_or_else(|| budget_hold_id.clone()),
1723 authorize_metadata: authorized.metadata,
1724 invocation_capture: None,
1725 };
1726 let mutation = if has_monetary {
1727 PreExecutionBudgetMutation::Charge(charge)
1728 } else {
1729 PreExecutionBudgetMutation::InvocationHold(charge)
1730 };
1731 return Ok(BudgetAdmissionOutcome::Authorized {
1732 grant_index: matching.index,
1733 mutation: Box::new(mutation),
1734 });
1735 }
1736 BudgetAuthorizeHoldDecision::AlreadyCaptured(captured) => {
1737 if !durable_admission
1738 .as_deref()
1739 .is_some_and(DurableToolAdmission::can_resume_captured_hold)
1740 {
1741 return Err(KernelError::CapturedBudgetReplay(cap.id.clone()));
1742 }
1743 let charge = BudgetChargeResult {
1744 grant_index: matching.index,
1745 cost_charged: cost_units,
1746 currency,
1747 budget_total,
1748 new_committed_cost_units: captured.committed_cost_units_after,
1749 budget_hold_id: captured
1750 .hold_id
1751 .clone()
1752 .unwrap_or_else(|| budget_hold_id.clone()),
1753 authorize_metadata: captured.metadata.clone(),
1754 invocation_capture: Some(Box::new(captured)),
1755 };
1756 let mutation = if has_monetary {
1757 PreExecutionBudgetMutation::Charge(charge)
1758 } else {
1759 PreExecutionBudgetMutation::InvocationHold(charge)
1760 };
1761 return Ok(BudgetAdmissionOutcome::Authorized {
1762 grant_index: matching.index,
1763 mutation: Box::new(mutation),
1764 });
1765 }
1766 }
1767 } else {
1768 if grant.max_invocations.is_none() {
1769 return Ok(BudgetAdmissionOutcome::Authorized {
1770 grant_index: matching.index,
1771 mutation: Box::new(PreExecutionBudgetMutation::None),
1772 });
1773 }
1774
1775 if self.with_budget_store(|store| {
1776 Ok(store.try_increment(&cap.id, matching.index, grant.max_invocations)?)
1777 })? {
1778 return Ok(BudgetAdmissionOutcome::Authorized {
1779 grant_index: matching.index,
1780 mutation: Box::new(PreExecutionBudgetMutation::Invocation {
1781 grant_index: matching.index,
1782 }),
1783 });
1784 }
1785 saw_exhausted_budget = true;
1786 }
1787 }
1788
1789 if durable_admission.is_some() && !eligible_grant_seen {
1790 Err(KernelError::DurableAdmission(
1791 "retained budget hold does not identify a matching grant".to_string(),
1792 ))
1793 } else if saw_exhausted_budget {
1794 Err(KernelError::BudgetExhausted(cap.id.clone()))
1795 } else {
1796 let first_index = matching_grants.first().map(|m| m.index).unwrap_or(0);
1798 Ok(BudgetAdmissionOutcome::Authorized {
1799 grant_index: first_index,
1800 mutation: Box::new(PreExecutionBudgetMutation::None),
1801 })
1802 }
1803 }
1804
1805 pub(crate) fn reverse_budget_charge(
1806 &self,
1807 capability_id: &str,
1808 charge: &BudgetChargeResult,
1809 ) -> Result<BudgetReverseHoldDecision, KernelError> {
1810 let authority = charge.authorize_metadata.authority.clone();
1811 self.with_budget_store(|store| {
1812 Ok(store.reverse_budget_hold(BudgetReverseHoldRequest {
1813 capability_id: capability_id.to_string(),
1814 grant_index: charge.grant_index,
1815 reversed_exposure_units: charge.cost_charged,
1816 hold_id: Some(charge.budget_hold_id.clone()),
1817 event_id: Some(charge.reverse_event_id()),
1818 expected_cumulative_approval_state: None,
1819 authority,
1820 })?)
1821 })
1822 }
1823
1824 pub(crate) fn capture_invocation(
1825 &self,
1826 cap: &CapabilityToken,
1827 budget_mutation: &mut PreExecutionBudgetMutation,
1828 ) -> Result<BudgetInvocationCaptureDecision, KernelError> {
1829 let charge = budget_mutation.durable_hold_result_mut().ok_or_else(|| {
1830 KernelError::Internal(
1831 "invocation capture requires an authorized budget hold".to_string(),
1832 )
1833 })?;
1834 let authority = charge.authorize_metadata.authority.clone();
1835 let decision = self.with_budget_store(|store| {
1836 Ok(
1837 store.capture_invocation_reservations(BudgetCaptureInvocationRequest {
1838 capability_id: cap.id.clone(),
1839 grant_index: charge.grant_index,
1840 hold_id: charge.budget_hold_id.clone(),
1841 event_id: charge.capture_invocation_event_id(),
1842 trusted_time: None,
1843 authority,
1844 })?,
1845 )
1846 })?;
1847 let capture = match &decision {
1848 BudgetInvocationCaptureDecision::Captured(capture)
1849 | BudgetInvocationCaptureDecision::AlreadyCaptured(capture) => capture,
1850 };
1851 charge.invocation_capture = Some(Box::new(capture.clone()));
1852 Ok(decision)
1853 }
1854
1855 pub(crate) fn cancel_captured_monetary_before_dispatch(
1856 &self,
1857 capability_id: &str,
1858 charge: &BudgetChargeResult,
1859 ) -> Result<BudgetHoldMutationDecision, KernelError> {
1860 let authority = charge.authorize_metadata.authority.clone();
1861 let decision = self.with_budget_store(|store| {
1862 Ok(store.cancel_captured_before_dispatch(
1863 BudgetCancelCapturedBeforeDispatchRequest {
1864 capability_id: capability_id.to_string(),
1865 grant_index: charge.grant_index,
1866 hold_id: charge.budget_hold_id.clone(),
1867 event_id: charge.cancel_captured_before_dispatch_event_id(),
1868 authority,
1869 },
1870 )?)
1871 })?;
1872 Ok(match decision {
1873 BudgetCapturedBeforeDispatchCancellationDecision::Cancelled(mutation)
1874 | BudgetCapturedBeforeDispatchCancellationDecision::AlreadyCancelled(mutation) => {
1875 mutation
1876 }
1877 })
1878 }
1879
1880 pub(crate) fn reverse_pre_execution_budget_mutation(
1881 &self,
1882 cap: &CapabilityToken,
1883 budget_mutation: &PreExecutionBudgetMutation,
1884 ) -> Result<Option<BudgetReverseHoldDecision>, KernelError> {
1885 match budget_mutation {
1886 PreExecutionBudgetMutation::Charge(charge) => {
1887 self.reverse_budget_charge(&cap.id, charge).map(Some)
1888 }
1889 PreExecutionBudgetMutation::InvocationHold(hold) => {
1890 self.reverse_budget_charge(&cap.id, hold).map(Some)
1891 }
1892 PreExecutionBudgetMutation::Invocation { grant_index } => {
1893 self.with_budget_store(|store| {
1894 Ok(store.reverse_charge_cost(&cap.id, *grant_index, 0)?)
1895 })?;
1896 Ok(None)
1897 }
1898 PreExecutionBudgetMutation::None => Ok(None),
1899 }
1900 }
1901
1902 fn reconcile_budget_charge(
1903 &self,
1904 capability_id: &str,
1905 charge: &BudgetChargeResult,
1906 realized_cost_units: u64,
1907 ) -> Result<BudgetReconcileHoldDecision, KernelError> {
1908 let authority = charge.authorize_metadata.authority.clone();
1909 self.with_budget_store(|store| {
1910 Ok(store.reconcile_budget_hold(BudgetReconcileHoldRequest {
1911 capability_id: capability_id.to_string(),
1912 grant_index: charge.grant_index,
1913 exposed_cost_units: charge.cost_charged,
1914 realized_spend_units: realized_cost_units.min(charge.cost_charged),
1915 hold_id: Some(charge.budget_hold_id.clone()),
1916 event_id: Some(charge.reconcile_event_id()),
1917 authority,
1918 })?)
1919 })
1920 }
1921
1922 pub(crate) fn tool_server_measures_realized_cost(&self, server_id: &str) -> bool {
1923 self.tool_servers
1924 .get(server_id)
1925 .is_none_or(|server| server.measures_realized_cost())
1926 }
1927
1928 #[allow(clippy::too_many_arguments)]
1929 fn finalize_unmeasured_cost_provisional_allow(
1930 &self,
1931 request: &ToolCallRequest,
1932 output: ToolServerOutput,
1933 elapsed: Duration,
1934 timestamp: u64,
1935 charge: BudgetChargeResult,
1936 extra_metadata: Option<serde_json::Value>,
1937 verified_payee_binding: Option<&VerifiedGovernedPayeeBinding>,
1938 ) -> Result<ToolCallResponse, KernelError> {
1939 let cap = &request.capability;
1940 let reverse = if charge.invocation_capture.is_some() {
1941 self.cancel_captured_monetary_before_dispatch(&cap.id, &charge)?
1942 } else {
1943 self.reverse_budget_charge(&cap.id, &charge)?
1944 };
1945 let financial = FinancialReceiptMetadata {
1946 grant_index: charge.grant_index as u32,
1947 cost_charged: 0,
1948 currency: charge.currency.clone(),
1949 budget_remaining: charge
1950 .budget_total
1951 .saturating_sub(reverse.committed_cost_units_after),
1952 budget_total: charge.budget_total,
1953 delegation_depth: cap.delegation_chain.len() as u32,
1954 root_budget_holder: cap.issuer.to_hex(),
1955 payment_reference: None,
1956 settlement_status: SettlementStatus::Pending,
1957 cost_breakdown: None,
1958 oracle_evidence: None,
1959 attempted_cost: None,
1960 };
1961 let limited_output = self.apply_stream_limits(output, elapsed)?;
1962 let tool_call_output = match &limited_output {
1963 ToolServerOutput::Value(value) => ToolCallOutput::Value(value.clone()),
1964 ToolServerOutput::Stream(ToolServerStreamResult::Complete(stream))
1965 | ToolServerOutput::Stream(ToolServerStreamResult::Incomplete { stream, .. }) => {
1966 ToolCallOutput::Stream(stream.clone())
1967 }
1968 };
1969 let budget_metadata =
1970 self.budget_execution_receipt_metadata(&charge, Some(("reversed", &reverse)), None);
1971 let metadata = merge_metadata_objects(
1972 Some(serde_json::json!({ "financial": financial })),
1973 self.merge_budget_receipt_metadata(extra_metadata, budget_metadata),
1974 );
1975
1976 match limited_output {
1977 ToolServerOutput::Value(_)
1978 | ToolServerOutput::Stream(ToolServerStreamResult::Complete(_)) => self
1979 .build_allow_response_with_metadata_and_payee_binding(
1980 request,
1981 tool_call_output,
1982 timestamp,
1983 Some(charge.grant_index),
1984 metadata,
1985 verified_payee_binding,
1986 AllowResponseNonce::Suppressed,
1987 ),
1988 ToolServerOutput::Stream(ToolServerStreamResult::Incomplete { reason, .. }) => self
1989 .build_incomplete_response_with_output_metadata_and_payee_binding(
1990 request,
1991 Some(tool_call_output),
1992 &reason,
1993 timestamp,
1994 Some(charge.grant_index),
1995 self.mark_runtime_admission_reservations_retained_fail_closed(metadata),
1996 verified_payee_binding,
1997 ),
1998 }
1999 }
2000
2001 #[allow(clippy::too_many_arguments)]
2002 pub(crate) fn finalize_budgeted_tool_output_with_cost_and_metadata(
2003 &self,
2004 request: &ToolCallRequest,
2005 output: ToolServerOutput,
2006 elapsed: Duration,
2007 timestamp: u64,
2008 matched_grant_index: usize,
2009 cost_context: FinalizeToolOutputCostContext<'_>,
2010 extra_metadata: Option<serde_json::Value>,
2011 verified_payee_binding: Option<&VerifiedGovernedPayeeBinding>,
2012 ) -> Result<ToolCallResponse, KernelError> {
2013 let FinalizeToolOutputCostContext {
2014 charge_result,
2015 reported_cost,
2016 payment_authorization,
2017 cap,
2018 } = cost_context;
2019 let Some(charge) = charge_result else {
2020 if let Some(authorization) = payment_authorization.as_ref() {
2021 let (quoted_units, quoted_currency) = Self::mustprepay_quoted_amount(request)
2022 .ok_or_else(|| {
2023 KernelError::GovernedTransactionDenied(
2024 "payment authorization omitted its MustPrepay quote".to_string(),
2025 )
2026 })?;
2027 let settlement = if authorization.state.is_final() {
2028 ReceiptSettlement::from_authorization(authorization)
2029 } else {
2030 let adapter = self.payment_adapter.as_ref().ok_or_else(|| {
2031 KernelError::Internal(
2032 "payment authorization present without configured adapter".to_string(),
2033 )
2034 })?;
2035 let result = match adapter.capture(
2036 &authorization.authorization_id,
2037 quoted_units,
2038 "ed_currency,
2039 &request.request_id,
2040 ) {
2041 Ok(result) => result,
2042 Err(error) => {
2043 let _ = adapter
2044 .release(&authorization.authorization_id, &request.request_id);
2045 return self.build_deny_response_with_metadata(
2046 request,
2047 &format!(
2048 "MustPrepay authorization could not be settled after execution: {error}"
2049 ),
2050 timestamp,
2051 Some(matched_grant_index),
2052 extra_metadata,
2053 );
2054 }
2055 };
2056 if result.settlement_status != crate::payment::RailSettlementStatus::Settled {
2057 let _ =
2058 adapter.release(&authorization.authorization_id, &request.request_id);
2059 }
2060 ReceiptSettlement::from_payment_result(&result)
2061 };
2062 if settlement.settlement_status != SettlementStatus::Settled {
2063 return self.build_deny_response_with_metadata(
2064 request,
2065 "MustPrepay authorization could not be settled after execution",
2066 timestamp,
2067 Some(matched_grant_index),
2068 extra_metadata,
2069 );
2070 }
2071 let (payment_reference, settlement_status) = settlement.into_receipt_parts();
2072 let financial = FinancialReceiptMetadata {
2073 grant_index: matched_grant_index as u32,
2074 cost_charged: quoted_units,
2075 currency: quoted_currency,
2076 budget_remaining: 0,
2077 budget_total: quoted_units,
2078 delegation_depth: cap.delegation_chain.len() as u32,
2079 root_budget_holder: cap.issuer.to_hex(),
2080 payment_reference,
2081 settlement_status,
2082 cost_breakdown: None,
2083 oracle_evidence: None,
2084 attempted_cost: None,
2085 };
2086 let metadata = merge_metadata_objects(
2087 extra_metadata,
2088 Some(serde_json::json!({ "financial": financial })),
2089 );
2090 return self.finalize_tool_output_with_metadata_and_payee_binding(
2091 request,
2092 output,
2093 elapsed,
2094 timestamp,
2095 matched_grant_index,
2096 metadata,
2097 verified_payee_binding,
2098 );
2099 }
2100 return self.finalize_tool_output_with_metadata_and_payee_binding(
2101 request,
2102 output,
2103 elapsed,
2104 timestamp,
2105 matched_grant_index,
2106 extra_metadata,
2107 verified_payee_binding,
2108 );
2109 };
2110
2111 if payment_authorization.is_none()
2112 && !self.tool_server_measures_realized_cost(&request.server_id)
2113 {
2114 return self.finalize_unmeasured_cost_provisional_allow(
2115 request,
2116 output,
2117 elapsed,
2118 timestamp,
2119 charge,
2120 extra_metadata,
2121 verified_payee_binding,
2122 );
2123 }
2124
2125 let reported_cost_ref = reported_cost.as_ref();
2126 let mut oracle_evidence = None;
2127 let mut cross_currency_note = None;
2128 let (actual_cost, cross_currency_failed) = if let Some(cost) =
2129 reported_cost_ref.filter(|cost| cost.currency != charge.currency)
2130 {
2131 match self.resolve_cross_currency_cost(cost, &charge.currency, timestamp) {
2132 Ok((converted_units, evidence)) => {
2133 oracle_evidence = Some(evidence);
2134 cross_currency_note = Some(serde_json::json!({
2135 "oracle_conversion": {
2136 "status": "applied",
2137 "reported_currency": cost.currency,
2138 "grant_currency": charge.currency,
2139 "reported_units": cost.units,
2140 "converted_units": converted_units
2141 }
2142 }));
2143 (converted_units, false)
2144 }
2145 Err(error) => {
2146 warn!(
2147 request_id = %request.request_id,
2148 reported_currency = %cost.currency,
2149 charged_currency = %charge.currency,
2150 reason = %redacted!(&error),
2151 "cross-currency reconciliation failed; closing hold at authorized exposure"
2152 );
2153 cross_currency_note = Some(serde_json::json!({
2154 "oracle_conversion": {
2155 "status": "failed",
2156 "reported_currency": cost.currency,
2157 "grant_currency": charge.currency,
2158 "reported_units": cost.units,
2159 "provisional_units": charge.cost_charged,
2160 "reason": error.to_string()
2161 }
2162 }));
2163 (charge.cost_charged, true)
2164 }
2165 }
2166 } else {
2167 (
2168 reported_cost_ref
2169 .map(|cost| cost.units)
2170 .unwrap_or(charge.cost_charged),
2171 false,
2172 )
2173 };
2174
2175 let payment_already_settled = payment_authorization
2176 .as_ref()
2177 .is_some_and(|authorization| authorization.state.is_final());
2178 let cost_overrun =
2179 !cross_currency_failed && actual_cost > charge.cost_charged && charge.cost_charged > 0;
2180
2181 if cost_overrun {
2182 warn!(
2183 request_id = %request.request_id,
2184 reported = actual_cost,
2185 charged = charge.cost_charged,
2186 "tool server reported cost exceeds max_cost_per_invocation; settlement_status=failed"
2187 );
2188 }
2189
2190 let realized_budget_units =
2191 if cross_currency_failed || payment_already_settled || cost_overrun {
2192 charge.cost_charged
2193 } else {
2194 actual_cost.min(charge.cost_charged)
2195 };
2196 let reconcile = self.reconcile_budget_charge(&cap.id, &charge, realized_budget_units)?;
2197 let running_committed_cost_units = reconcile.committed_cost_units_after;
2198
2199 let payment_result = if let Some(authorization) = payment_authorization.as_ref() {
2200 if authorization.state.is_final() || cross_currency_failed || cost_overrun {
2201 None
2202 } else {
2203 let adapter = self.payment_adapter.as_ref().ok_or_else(|| {
2204 KernelError::Internal(
2205 "payment authorization present without configured adapter".to_string(),
2206 )
2207 })?;
2208 Some(if actual_cost == 0 {
2209 adapter.release(&authorization.authorization_id, &request.request_id)
2210 } else {
2211 adapter.capture(
2212 &authorization.authorization_id,
2213 actual_cost,
2214 &charge.currency,
2215 &request.request_id,
2216 )
2217 })
2218 }
2219 } else {
2220 None
2221 };
2222
2223 let settlement = if cross_currency_failed || cost_overrun {
2224 ReceiptSettlement {
2225 payment_reference: payment_authorization
2226 .as_ref()
2227 .map(|authorization| authorization.authorization_id.clone()),
2228 settlement_status: SettlementStatus::Failed,
2229 }
2230 } else if let Some(authorization) = payment_authorization.as_ref() {
2231 if authorization.state.is_final() {
2232 ReceiptSettlement::from_authorization(authorization)
2233 } else if let Some(payment_result) = payment_result.as_ref() {
2234 match payment_result {
2235 Ok(result) => ReceiptSettlement::from_payment_result(result),
2236 Err(error) => {
2237 warn!(
2238 request_id = %request.request_id,
2239 reason = %redacted!(&error),
2240 "post-execution payment settlement failed"
2241 );
2242 ReceiptSettlement {
2243 payment_reference: Some(authorization.authorization_id.clone()),
2244 settlement_status: SettlementStatus::Failed,
2245 }
2246 }
2247 }
2248 } else {
2249 warn!(
2250 request_id = %request.request_id,
2251 authorization_id = %authorization.authorization_id,
2252 "unsettled authorization completed without a payment result"
2253 );
2254 ReceiptSettlement {
2255 payment_reference: Some(authorization.authorization_id.clone()),
2256 settlement_status: SettlementStatus::Failed,
2257 }
2258 }
2259 } else {
2260 ReceiptSettlement::settled()
2261 };
2262 let recorded_cost = if payment_already_settled && !cross_currency_failed && !cost_overrun {
2263 charge.cost_charged
2264 } else {
2265 actual_cost
2266 };
2267
2268 let budget_remaining = charge
2269 .budget_total
2270 .saturating_sub(running_committed_cost_units);
2271 let delegation_depth = cap.delegation_chain.len() as u32;
2272 let root_budget_holder = cap.issuer.to_hex();
2273 let (payment_reference, settlement_status) = settlement.into_receipt_parts();
2274 let payment_breakdown = payment_authorization.as_ref().map(|authorization| {
2275 serde_json::json!({
2276 "payment": {
2277 "authorization_id": authorization.authorization_id,
2278 "adapter_metadata": authorization.metadata,
2279 "preauthorized_units": charge.cost_charged,
2280 "recorded_units": recorded_cost
2281 }
2282 })
2283 });
2284
2285 let financial_meta = FinancialReceiptMetadata {
2286 grant_index: charge.grant_index as u32,
2287 cost_charged: recorded_cost,
2288 currency: charge.currency.clone(),
2289 budget_remaining,
2290 budget_total: charge.budget_total,
2291 delegation_depth,
2292 root_budget_holder,
2293 payment_reference,
2294 settlement_status,
2295 cost_breakdown: merge_metadata_objects(
2296 merge_metadata_objects(
2297 reported_cost_ref.and_then(|cost| cost.breakdown.clone()),
2298 payment_breakdown,
2299 ),
2300 cross_currency_note,
2301 ),
2302 oracle_evidence,
2303 attempted_cost: None,
2304 };
2305
2306 let limited_output = self.apply_stream_limits(output, elapsed)?;
2307 let tool_call_output = match &limited_output {
2308 ToolServerOutput::Value(value) => ToolCallOutput::Value(value.clone()),
2309 ToolServerOutput::Stream(ToolServerStreamResult::Complete(stream)) => {
2310 ToolCallOutput::Stream(stream.clone())
2311 }
2312 ToolServerOutput::Stream(ToolServerStreamResult::Incomplete { stream, .. }) => {
2313 ToolCallOutput::Stream(stream.clone())
2314 }
2315 };
2316
2317 let preminted_execution_nonce = if request.execution_nonce.is_none() {
2318 if let Some(nonce_config) = self.execution_nonce_config.as_ref() {
2319 let action = ToolCallAction::from_parameters(request.arguments.clone()).map_err(
2320 |error| {
2321 KernelError::ReceiptSigningFailed(format!(
2322 "failed to hash parameters for nonce binding: {error}"
2323 ))
2324 },
2325 )?;
2326 let now = i64::try_from(current_unix_timestamp()).unwrap_or(i64::MAX);
2327 let binding = crate::execution_nonce::NonceBinding {
2328 subject_id: cap.subject.to_hex(),
2329 request_id: request.request_id.clone(),
2330 capability_id: cap.id.clone(),
2331 tool_server: request.server_id.clone(),
2332 tool_name: request.tool_name.clone(),
2333 parameter_hash: action.parameter_hash,
2334 };
2335 Some(Box::new(crate::execution_nonce::mint_execution_nonce(
2336 &self.config.keypair,
2337 binding,
2338 nonce_config,
2339 now,
2340 )?))
2341 } else {
2342 None
2343 }
2344 } else {
2345 None
2346 };
2347 let financial_json = Some(serde_json::json!({ "financial": financial_meta }));
2348
2349 match limited_output {
2350 ToolServerOutput::Value(_)
2351 | ToolServerOutput::Stream(ToolServerStreamResult::Complete(_)) => {
2352 let budget_metadata = self.budget_execution_receipt_metadata(
2353 &charge,
2354 Some(("reconciled", &reconcile)),
2355 preminted_execution_nonce
2356 .as_deref()
2357 .map(|nonce| nonce.nonce_id())
2358 .or_else(|| {
2359 request
2360 .execution_nonce
2361 .as_ref()
2362 .map(|nonce| nonce.nonce_id())
2363 }),
2364 );
2365 let merged_extra_metadata = merge_metadata_objects(
2366 financial_json,
2367 self.merge_budget_receipt_metadata(extra_metadata, budget_metadata),
2368 );
2369 self.build_allow_response_with_metadata_and_payee_binding(
2370 request,
2371 tool_call_output,
2372 timestamp,
2373 Some(charge.grant_index),
2374 merged_extra_metadata,
2375 verified_payee_binding,
2376 match preminted_execution_nonce {
2377 Some(nonce) => AllowResponseNonce::Preminted(nonce),
2378 None => AllowResponseNonce::MintForAllow,
2379 },
2380 )
2381 }
2382 ToolServerOutput::Stream(ToolServerStreamResult::Incomplete { reason, .. }) => {
2383 let budget_metadata = self.budget_execution_receipt_metadata(
2384 &charge,
2385 Some(("reconciled", &reconcile)),
2386 None,
2387 );
2388 let merged_extra_metadata = merge_metadata_objects(
2389 financial_json,
2390 self.merge_budget_receipt_metadata(extra_metadata, budget_metadata),
2391 );
2392 self.build_incomplete_response_with_output_metadata_and_payee_binding(
2393 request,
2394 Some(tool_call_output),
2395 &reason,
2396 timestamp,
2397 Some(charge.grant_index),
2398 self.mark_runtime_admission_reservations_retained_fail_closed(
2404 merged_extra_metadata,
2405 ),
2406 verified_payee_binding,
2407 )
2408 }
2409 }
2410 }
2411
2412 fn block_on_price_oracle<T>(
2413 &self,
2414 future: impl Future<Output = Result<T, PriceOracleError>>,
2415 ) -> Result<T, KernelError> {
2416 match tokio::runtime::Handle::try_current() {
2417 Ok(handle) => match handle.runtime_flavor() {
2418 tokio::runtime::RuntimeFlavor::MultiThread => tokio::task::block_in_place(|| {
2419 handle
2420 .block_on(future)
2421 .map_err(|error| KernelError::CrossCurrencyOracle(error.to_string()))
2422 }),
2423 tokio::runtime::RuntimeFlavor::CurrentThread => {
2424 Err(KernelError::CrossCurrencyOracle(
2425 "current-thread tokio runtime cannot synchronously resolve price oracles"
2426 .to_string(),
2427 ))
2428 }
2429 flavor => Err(KernelError::CrossCurrencyOracle(format!(
2430 "unsupported tokio runtime flavor for synchronous oracle resolution: {flavor:?}"
2431 ))),
2432 },
2433 Err(_) => tokio::runtime::Builder::new_current_thread()
2434 .enable_all()
2435 .build()
2436 .map_err(|error| {
2437 KernelError::CrossCurrencyOracle(format!(
2438 "failed to build synchronous oracle runtime: {error}"
2439 ))
2440 })?
2441 .block_on(future)
2442 .map_err(|error| KernelError::CrossCurrencyOracle(error.to_string())),
2443 }
2444 }
2445
2446 pub(crate) fn resolve_cross_currency_cost(
2447 &self,
2448 reported_cost: &ToolInvocationCost,
2449 grant_currency: &str,
2450 timestamp: u64,
2451 ) -> Result<(u64, chio_core::web3::anchors::OracleConversionEvidence), KernelError> {
2452 let oracle =
2453 self.price_oracle
2454 .as_ref()
2455 .ok_or_else(|| KernelError::NoCrossCurrencyOracle {
2456 base: reported_cost.currency.clone(),
2457 quote: grant_currency.to_string(),
2458 })?;
2459 let rate =
2460 self.block_on_price_oracle(oracle.get_rate(&reported_cost.currency, grant_currency))?;
2461 let converted_units =
2462 convert_supported_units(reported_cost.units, &rate, rate.conversion_margin_bps)
2463 .map_err(|error| KernelError::CrossCurrencyOracle(error.to_string()))?;
2464 let evidence = rate
2465 .to_conversion_evidence(
2466 reported_cost.units,
2467 reported_cost.currency.clone(),
2468 grant_currency.to_string(),
2469 converted_units,
2470 timestamp,
2471 )
2472 .map_err(|error| KernelError::CrossCurrencyOracle(error.to_string()))?;
2473 Ok((converted_units, evidence))
2474 }
2475
2476 pub(crate) fn ensure_registered_tool_target(
2477 &self,
2478 request: &ToolCallRequest,
2479 ) -> Result<(), KernelError> {
2480 self.tool_servers.get(&request.server_id).ok_or_else(|| {
2481 KernelError::ToolNotRegistered(format!(
2482 "server \"{}\" / tool \"{}\"",
2483 request.server_id, request.tool_name
2484 ))
2485 })?;
2486 Ok(())
2487 }
2488
2489 pub(crate) fn authorize_payment_if_needed(
2490 &self,
2491 request: &ToolCallRequest,
2492 charge_result: Option<&BudgetChargeResult>,
2493 durable_admission: Option<&DurableToolAdmission>,
2494 trusted_now_unix_ms: u64,
2495 verified_payee_binding: Option<&VerifiedGovernedPayeeBinding>,
2496 ) -> Result<Option<PaymentAuthorization>, PaymentError> {
2497 let (amount_units, currency) = if let Some(amount) = Self::mustprepay_quoted_amount(request)
2498 {
2499 amount
2500 } else if let Some(charge) = charge_result {
2501 (charge.cost_charged, charge.currency.clone())
2502 } else {
2503 return Ok(None);
2504 };
2505 let Some(adapter) = self.payment_adapter.as_ref() else {
2506 if Self::is_governed_mustprepay_request(request) {
2507 return Err(PaymentError::RailError(
2508 "MustPrepay intent reached payment authorization without a configured adapter"
2509 .to_string(),
2510 ));
2511 }
2512 return Ok(None);
2513 };
2514 let durable_journal = durable_admission
2515 .filter(|_| charge_result.is_some())
2516 .map(|admission| {
2517 self.load_durable_payment_journal(admission)
2518 .map_err(|error| PaymentError::RailError(error.to_string()))
2519 })
2520 .transpose()?;
2521 if let Some(journal) = durable_journal.as_ref() {
2522 let rail_mode = adapter.rail_mode().ok_or_else(|| {
2523 PaymentError::RailError("durable payment adapter omitted its rail mode".to_owned())
2524 })?;
2525 if adapter.rail_id() != journal.rail || rail_mode != journal.rail_mode {
2526 return Err(PaymentError::RailError(
2527 "durable payment adapter does not match the persisted rail profile".to_owned(),
2528 ));
2529 }
2530 match (journal.state, journal.rail_mode) {
2531 (
2532 crate::payment::PaymentJournalState::Authorized,
2533 crate::payment::PaymentRailMode::ReversibleHold,
2534 ) => {
2535 return Ok(Some(PaymentAuthorization {
2536 authorization_id: journal.authorization_id.clone().ok_or_else(|| {
2537 PaymentError::RailError(
2538 "authorized payment journal omitted authorization_id".to_owned(),
2539 )
2540 })?,
2541 state: crate::payment::PaymentAuthorizationState::Held,
2542 metadata: serde_json::json!({ "durable_replay": true }),
2543 }));
2544 }
2545 (
2546 crate::payment::PaymentJournalState::Settled,
2547 crate::payment::PaymentRailMode::PrepaidFinal,
2548 ) => {
2549 return Ok(Some(PaymentAuthorization {
2550 authorization_id: journal.authorization_id.clone().ok_or_else(|| {
2551 PaymentError::RailError(
2552 "settled prepayment journal omitted authorization_id".to_owned(),
2553 )
2554 })?,
2555 state: crate::payment::PaymentAuthorizationState::PrepaidFinal,
2556 metadata: serde_json::json!({ "durable_replay": true }),
2557 }));
2558 }
2559 (crate::payment::PaymentJournalState::HoldPlaced, _) => {}
2560 _ => {
2561 return Err(PaymentError::RailError(format!(
2562 "payment journal cannot authorize from state {:?}",
2563 journal.state
2564 )));
2565 }
2566 }
2567 }
2568
2569 let governed = request
2570 .governed_intent
2571 .as_ref()
2572 .map(|intent| {
2573 intent
2574 .binding_hash()
2575 .map(|intent_hash| GovernedPaymentContext {
2576 intent_id: intent.id.clone(),
2577 intent_hash,
2578 purpose: intent.purpose.clone(),
2579 server_id: intent.server_id.clone(),
2580 tool_name: intent.tool_name.clone(),
2581 approval_token_id: request
2582 .approval_token
2583 .as_ref()
2584 .map(|token| token.id.clone())
2585 .or_else(|| {
2586 request
2587 .threshold_approval_proposal
2588 .as_ref()
2589 .map(|proposal| proposal.body.proposal_id.clone())
2590 }),
2591 })
2592 .map_err(|error| {
2593 PaymentError::RailError(format!(
2594 "failed to hash governed intent for payment authorization: {error}"
2595 ))
2596 })
2597 })
2598 .transpose()?;
2599 let commerce = if amount_units == 0 {
2600 None
2601 } else if let Some(commerce) = request
2602 .governed_intent
2603 .as_ref()
2604 .and_then(|intent| intent.commerce.as_ref())
2605 {
2606 let binding = verified_payee_binding.ok_or_else(|| {
2607 PaymentError::RailError(
2608 "governed commerce payment omitted verified payee binding".to_owned(),
2609 )
2610 })?;
2611 let approval_artifact_digest = request
2612 .approval_artifact_digest()
2613 .map_err(|error| PaymentError::RailError(error.to_string()))?
2614 .ok_or_else(|| {
2615 PaymentError::RailError(
2616 "governed commerce payment omitted verified approval".to_owned(),
2617 )
2618 })?;
2619 let intent_hash = governed
2620 .as_ref()
2621 .map(|context| context.intent_hash.as_str());
2622 if binding.beneficiary_id() != commerce.seller
2623 || commerce.settlement_destination_ref.as_deref()
2624 != Some(binding.settlement_destination_ref())
2625 || intent_hash != Some(binding.economic_intent_digest())
2626 || binding.pre_action_authority_digest() != approval_artifact_digest.as_str()
2627 {
2628 return Err(PaymentError::RailError(
2629 "governed commerce payment does not match verified payee binding".to_owned(),
2630 ));
2631 }
2632 Some(CommercePaymentContext {
2633 seller: binding.beneficiary_id().to_owned(),
2634 settlement_destination_ref: binding.settlement_destination_ref().to_owned(),
2635 payee_binding_digest: binding.payee_binding_digest().to_owned(),
2636 pre_action_authority_digest: binding.pre_action_authority_digest().to_owned(),
2637 shared_payment_token_id: commerce.shared_payment_token_id.clone(),
2638 max_amount: request
2639 .governed_intent
2640 .as_ref()
2641 .and_then(|intent| intent.max_amount.clone()),
2642 })
2643 } else {
2644 if verified_payee_binding.is_some() {
2645 return Err(PaymentError::RailError(
2646 "verified payee binding has no governed commerce context".to_owned(),
2647 ));
2648 }
2649 None
2650 };
2651 let payee = verified_payee_binding.map_or_else(
2652 || request.server_id.clone(),
2653 |binding| binding.beneficiary_id().to_owned(),
2654 );
2655
2656 let authorization = adapter.authorize(&PaymentAuthorizeRequest {
2657 amount_units,
2658 currency,
2659 payer: request.agent_id.clone(),
2660 payee,
2661 reference: durable_journal.as_ref().map_or_else(
2662 || request.request_id.clone(),
2663 |journal| journal.operation_id.clone(),
2664 ),
2665 governed,
2666 commerce,
2667 })?;
2668 if let (Some(admission), Some(journal)) = (durable_admission, durable_journal.as_ref()) {
2669 if !journal.rail_mode.accepts(authorization.state) {
2670 return Err(PaymentError::RailError(
2671 "payment authorization state does not match the persisted rail mode".to_owned(),
2672 ));
2673 }
2674 let transition = match authorization.state {
2675 crate::payment::PaymentAuthorizationState::Held => {
2676 crate::payment::PaymentJournalTransition::AuthorizationHeld {
2677 authorization_id: authorization.authorization_id.clone(),
2678 }
2679 }
2680 crate::payment::PaymentAuthorizationState::PrepaidFinal => {
2681 crate::payment::PaymentJournalTransition::PrepaymentSettled {
2682 authorization_id: authorization.authorization_id.clone(),
2683 }
2684 }
2685 };
2686 let advanced = self
2687 .advance_durable_payment_journal(
2688 admission,
2689 journal,
2690 &transition,
2691 trusted_now_unix_ms,
2692 )
2693 .map_err(|error| PaymentError::RailError(error.to_string()))?;
2694 if advanced.authorization_id.as_deref() != Some(&authorization.authorization_id) {
2695 return Err(PaymentError::RailError(
2696 "durable payment journal changed authorization identity".to_owned(),
2697 ));
2698 }
2699 }
2700 Ok(Some(authorization))
2701 }
2702
2703 pub(crate) fn mustprepay_quoted_amount(request: &ToolCallRequest) -> Option<(u64, String)> {
2704 request
2705 .governed_intent
2706 .as_ref()
2707 .and_then(|intent| intent.metered_billing.as_ref())
2708 .filter(|metered| {
2709 metered.settlement_mode
2710 == chio_core::capability::governance::MeteredSettlementMode::MustPrepay
2711 })
2712 .map(|metered| {
2713 (
2714 metered.quote.quoted_cost.units,
2715 metered.quote.quoted_cost.currency.clone(),
2716 )
2717 })
2718 }
2719
2720 pub(crate) fn is_governed_mustprepay_request(request: &ToolCallRequest) -> bool {
2721 Self::mustprepay_quoted_amount(request).is_some()
2722 }
2723
2724 pub(crate) fn ensure_reserved_mustprepay_prepaid(
2725 &self,
2726 request: &ToolCallRequest,
2727 charge_result: Option<&BudgetChargeResult>,
2728 durable_admission: Option<&DurableToolAdmission>,
2729 trusted_now_unix_ms: u64,
2730 verified_payee_binding: Option<&VerifiedGovernedPayeeBinding>,
2731 ) -> Result<Option<ReservedPrepayment>, KernelError> {
2732 if !Self::is_governed_mustprepay_request(request) {
2733 return Ok(None);
2734 }
2735 let authorization = self
2736 .authorize_payment_if_needed(
2737 request,
2738 charge_result,
2739 durable_admission,
2740 trusted_now_unix_ms,
2741 verified_payee_binding,
2742 )
2743 .map_err(|error| {
2744 KernelError::GovernedTransactionDenied(format!(
2745 "MustPrepay prepayment authorization failed before reserving an execution nonce: {error}"
2746 ))
2747 })?
2748 .ok_or_else(|| {
2749 KernelError::GovernedTransactionDenied(
2750 "MustPrepay reservation omitted its payment authorization".to_string(),
2751 )
2752 })?;
2753
2754 if authorization.state.is_final() {
2755 return Ok(Some(ReservedPrepayment {
2756 payment_reference: Some(authorization.authorization_id.clone()),
2757 authorization,
2758 }));
2759 }
2760
2761 let (amount_units, currency) =
2762 Self::mustprepay_quoted_amount(request).ok_or_else(|| {
2763 KernelError::GovernedTransactionDenied(
2764 "MustPrepay reservation omitted its quoted amount".to_string(),
2765 )
2766 })?;
2767 let adapter = self.payment_adapter.as_ref().ok_or_else(|| {
2768 KernelError::GovernedTransactionDenied(
2769 "MustPrepay reservation omitted its payment adapter".to_string(),
2770 )
2771 })?;
2772 let result = match adapter.capture(
2773 &authorization.authorization_id,
2774 amount_units,
2775 ¤cy,
2776 &request.request_id,
2777 ) {
2778 Ok(result) => result,
2779 Err(error) => {
2780 let _ = adapter.release(&authorization.authorization_id, &request.request_id);
2781 return Err(KernelError::GovernedTransactionDenied(format!(
2782 "MustPrepay prepayment capture failed before reserving an execution nonce: {error}"
2783 )));
2784 }
2785 };
2786 if result.settlement_status != crate::payment::RailSettlementStatus::Settled {
2787 let _ = adapter.release(&authorization.authorization_id, &request.request_id);
2788 return Err(KernelError::GovernedTransactionDenied(
2789 "MustPrepay prepayment capture was not confirmed settled".to_string(),
2790 ));
2791 }
2792 Ok(Some(ReservedPrepayment {
2793 authorization: PaymentAuthorization {
2794 state: crate::payment::PaymentAuthorizationState::PrepaidFinal,
2795 ..authorization
2796 },
2797 payment_reference: Some(result.transaction_id),
2798 }))
2799 }
2800
2801 pub(crate) fn refund_reserved_mustprepay_prepayment(
2802 &self,
2803 request: &ToolCallRequest,
2804 prepayment: &PaymentAuthorization,
2805 ) {
2806 let Some((amount_units, currency)) = Self::mustprepay_quoted_amount(request) else {
2807 return;
2808 };
2809 let Some(adapter) = self.payment_adapter.as_ref() else {
2810 return;
2811 };
2812 if let Err(error) = adapter.refund(
2813 &prepayment.authorization_id,
2814 amount_units,
2815 ¤cy,
2816 &request.request_id,
2817 ) {
2818 warn!(
2819 request_id = %request.request_id,
2820 authorization_id = %prepayment.authorization_id,
2821 reason = %redacted!(&error),
2822 "failed to refund captured MustPrepay prepayment after reservation failure"
2823 );
2824 }
2825 }
2826}