Skip to main content

chio_kernel/kernel/
validation.rs

1//! `ChioKernel` capability and budget validation.
2//!
3//! Holds capability issuance/revocation, tool-server event drains, portable
4//! verdict helpers, and budget charge/reconcile helpers. Governed-admission
5//! validation lives in `governed_validation.rs`.
6
7use 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    /// Issue a new capability for an agent.
32    ///
33    /// The kernel delegates issuance to the configured capability authority.
34    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    /// Revoke a capability and all descendants in its delegation subtree.
66    ///
67    /// When a root capability is revoked, every capability whose
68    /// `delegation_chain` contains the revoked ID will also be rejected
69    /// on presentation (the kernel checks all chain entries against the
70    /// revocation store).
71    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    /// Whether a capability id is present in the installed revocation store.
78    ///
79    /// Callers that authorize a token minted outside the kernel (an HTTP
80    /// authority projecting a presented capability, for example) use this to
81    /// check the caller's token against revocations, since the kernel's own
82    /// revocation check runs against the internal capability it evaluates.
83    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    /// Read-only access to the receipt log.
88    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    /// Set the configured capability-token crypto floor.
255    ///
256    /// Boot paths that load `policy.crypto_floor` must call this before
257    /// accepting traffic when they do not use [`Self::with_hybrid_signing_backend`].
258    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    /// Verify the capability's signature against the trusted CA keys or the
267    /// kernel's own key (for locally-issued capabilities).
268    /// Resolve the trusted-issuer set for capability verification.
269    ///
270    /// This combines the configured CA public keys, the capability
271    /// authority's trusted keys, and the kernel's own public key. The
272    /// method is also used by the chio-kernel-core delegation path
273    /// so the portable TCB verifier sees the same trust set as the
274    /// inline check.
275    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    /// Spec: PROTOCOL.md requires production kernels to route every
290    /// capability admission through `verify_capability_full`; this wrapper
291    /// is the kernel-side enforcement of that MUST.
292    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    /// Resolve the signed root token that the negotiated family-budget verifiers
324    /// require for a delegated capability.
325    ///
326    /// Migration prerequisite: once a peer negotiates either family budget feature,
327    /// every delegated capability from that peer needs a receipt-store snapshot
328    /// carrying `signed_capability` for its root. Snapshots written before signed
329    /// token retention carry no signed token, so enabling a feature against a store
330    /// that still holds them denies those capabilities with "has no signed token
331    /// evidence", which is distinct from the missing-row and tamper reasons.
332    /// Backfill signed root snapshots before turning either feature on.
333    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    /// The hosted `evaluate_tool_call_*` paths route the full chain
374    /// through [`Self::verify_capability_full_pre_admit`], which runs
375    /// the production verifier with `NoopBudgetRegistry` so a token
376    /// that subsequently fails any other check does not consume the
377    /// parent's share. This method then performs the deferred admit
378    /// against the kernel's long-lived `budget_registry`. The lock is
379    /// held only for the duration of the admit call, matching the
380    /// portable hot path.
381    ///
382    /// Errors are returned as plain strings so the caller can route
383    /// them through the existing deny-response paths without taking
384    /// a `KernelError` dependency on the verifier shape.
385    ///
386    /// The split between pre-admit verification and budget admission
387    /// enforces the ordering rule "signature first, admit last", so a
388    /// denied request never starves later valid siblings.
389    /// Admit `cap`'s sibling-sum budget share under its parent, acquiring one
390    /// per-evaluation holder lease on the child admission edge.
391    ///
392    /// Returns `Ok(true)` when THIS evaluation acquired a lease: the capability
393    /// has a parent and the admit succeeded, whether it INSERTED a fresh edge
394    /// or took an additional holder on an edge an overlapping evaluation
395    /// already inserted (an idempotent re-admit). Returns `Ok(false)` only when
396    /// the capability has no parent to admit against (no lease exists to
397    /// release). A failed admit (oversubscribe / cap-exceed / different share)
398    /// acquires NO lease and returns `Err`.
399    ///
400    /// The lease increment happens inside `try_admit_child` under the registry
401    /// lock, so it is atomic with the insert/holder decision (no TOCTOU). The
402    /// boolean tells a pre-dispatch cleanup path whether THIS evaluation holds a
403    /// lease it must release: exactly the evaluations that acquired a lease
404    /// release one (via `release_admitted_capability_budget`), and the edge is
405    /// freed only when the last holder releases. Reference counting - rather
406    /// than a "newly inserted" owner flag - is required because overlapping
407    /// evaluations of the same delegated capability concurrently depend on the
408    /// single registry edge; freeing it on the inserting evaluation's cleanup
409    /// would return a re-admitting sibling's live share and let an
410    /// oversubscribing sibling bypass the parent cap.
411    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                    // Fail closed on a poisoned monetary lock: a half-mutated
422                    // budget registry must never admit a child on a lucky recovery.
423                    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            // The admit succeeded against a parent link, so this evaluation now
435            // holds a lease it is responsible for releasing on cleanup.
436            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    /// Run the portable pure-compute verdict path provided by
605    /// `chio-kernel-core`.
606    ///
607    /// This exposes the same synchronous checks the core kernel performs
608    /// (capability signature, issuer trust, time bounds, subject binding,
609    /// scope match, sync guard pipeline) in isolation from the
610    /// `chio-kernel`-only concerns (budget mutation, revocation lookup,
611    /// governed-transaction evaluation, tool dispatch, receipt
612    /// persistence).
613    ///
614    /// Adapters that run the kernel on constrained platforms (wasm32,
615    /// edge workers, mobile via FFI) should prefer this entry point --
616    /// it does not require a tokio runtime, a sqlite database, or any
617    /// IO adapter. The full `evaluate_tool_call_*` API remains the
618    /// authoritative path for the desktop sidecar.
619    ///
620    /// Verified-core boundary note:
621    /// `formal/proof-manifest.toml` treats this shell method as the one
622    /// `chio-kernel` entrypoint inside the current bounded verified core,
623    /// because it delegates directly to `chio_kernel_core::evaluate` after
624    /// supplying trusted issuers and portable guard/context wiring.
625    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            // Fail closed: a negotiation error denies rather than falling back
638            // to the permissive default profile.
639            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                // The monetary lock is poisoned: a panic left the registry in an
666                // unknown state. Deny fail-closed and trip the degraded flag so
667                // later evaluations are denied at the pre-dispatch gate too.
668                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                // Recovering the guard keeps setup working, but tripping the flag
704                // means the next evaluation is denied at the pre-dispatch gate.
705                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    /// Check the revocation store for the capability and its entire
725    /// delegation chain. If any ancestor is revoked, the capability is
726    /// rejected.
727    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        // When the `delegation` feature is on, consult the installed
746        // `RevocationView` snapshot before re-running the validation chain
747        // validation. Fail-closed: a revoked ancestor or leaf denies
748        // dispatch even if the chain is otherwise valid. This is a
749        // no-op (`Ok(())`) when no view is installed.
750        #[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    /// Record that an ambiguous post-dispatch outcome retained a budget or
1184    /// payment hold. On a durable kernel the recovery sweep reconciles the hold
1185    /// later; on a non-durable kernel there is no sweep, so the hold is retained
1186    /// until an operator acts. The `reconciliation` label separates the two.
1187    /// Only a genuine retention is counted; a pure deny that released everything
1188    /// is not.
1189    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    /// Check and decrement the invocation budget for a capability.
1467    ///
1468    /// Returns the matched grant index and the exact pre-execution budget mutation.
1469    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                // Use worst-case max_cost_per_invocation as the pre-execution debit.
1498                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            // No matching grant had any limit -- allow with the first grant's index.
1797            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                        &quoted_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                    // The tool ran (a side effect may have committed) but the
2399                    // stream ended incomplete, so any runtime-admission lease
2400                    // consumed at admission is retained, not released. Mark it
2401                    // so the burned lease is recoverable from the receipt,
2402                    // matching the RequestIncomplete error arm.
2403                    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            &currency,
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            &currency,
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}