Skip to main content

chio_kernel/kernel/
reconciliation.rs

1//! Reconcile-by-nonce: the entry point where a mediated pre-execution
2//! authorization becomes an authoritative spend.
3//!
4//! The mediated `/v1/evaluate` route reserves a durable budget hold at the
5//! worst-case cost and mints a signed execution nonce bound to that hold
6//! (`reserved_hold_id`). The caller executes the real tool downstream at a
7//! server that shares the budget store, then presents the nonce back here with
8//! the measured realized cost. This module settles the exact reserved hold at
9//! that realized cost, releases the reserved-minus-realized difference back to
10//! the grant, and signs a completed, authoritative mediated-spend receipt.
11//!
12//! It also exposes the reserved-hold TTL reaper: a wrapper that settles expired,
13//! unreconciled reserved holds at their reserved worst-case (forfeit). The TTL
14//! deadline itself is stamped from the minted nonce's exact expiry at reserve
15//! time, so a hold never expires before its own nonce. Fail-closed on the money
16//! path.
17
18use chio_log_redact::redacted;
19
20use super::*;
21
22use crate::budget_store::{
23    BudgetCaptureInvocationRequest, BudgetHoldSnapshot, BudgetInvocationCaptureDecision,
24    BudgetReconcileHoldRequest,
25};
26use crate::execution_nonce::{
27    consume_execution_nonce, verify_execution_nonce_without_consume, SignedExecutionNonce,
28};
29
30/// Canonical inert currency stamped onto the signed receipt for a zero-exposure
31/// invocation reconcile. Such a reserve carries no reserved currency, so its
32/// realized currency is never validated (see step 3); using this fixed,
33/// attacker-uncontrollable value keeps an unchecked caller-supplied string off
34/// the signed artifact while still marking the receipt as carrying no monetary
35/// envelope.
36const INVOCATION_RECONCILE_RECEIPT_CURRENCY: &str = "";
37
38impl ChioKernel {
39    /// Settle every expired, unreconciled reserved budget hold at its reserved
40    /// worst-case, forfeiting the reserved amount to realized spend. In the
41    /// two-phase reserve/reconcile flow the only evidence a spend occurred is the
42    /// caller's reconcile; an expired-and-unreconciled hold may correspond to a
43    /// call that executed and spent, so releasing it would under-count real spend
44    /// and fail open for a cumulative spend cap. Self-healing and fail-closed: a
45    /// still-valid reserved hold and any reconciled/reversed/released hold are
46    /// never touched, and a settled hold is idempotent under repeated reaps.
47    /// Returns the number of holds settled. The sidecar drives this on a timer
48    /// (startup wiring is a later task); this method is the reachable primitive.
49    pub fn reap_expired_reserved_budget_holds(
50        &self,
51        now_unix_secs: i64,
52    ) -> Result<usize, KernelError> {
53        // Reserved holds this reap will settle (open, past their reserved expiry)
54        // still hold their delegated child's sibling-sum share admitted. Capture
55        // that set before settling so the parent's headroom is released once, and
56        // only, for the holds the store actually forfeits. The predicate mirrors
57        // the store reaper's contract (open + reserved_until <= now).
58        let tracked = self.tracked_reserved_sibling_hold_ids();
59        let (expiring, already_closed) = self.with_budget_store(|store| {
60            let mut expiring = Vec::new();
61            let mut already_closed = Vec::new();
62            for hold_id in &tracked {
63                match store.get_budget_hold(hold_id)? {
64                    Some(hold)
65                        if hold.disposition.is_open()
66                            && hold
67                                .reserved_until
68                                .is_some_and(|until| until <= now_unix_secs) =>
69                    {
70                        expiring.push(hold_id.clone());
71                    }
72                    Some(hold) if !hold.disposition.is_open() => {
73                        already_closed.push(hold_id.clone());
74                    }
75                    None => already_closed.push(hold_id.clone()),
76                    Some(_) => {}
77                }
78            }
79            Ok((expiring, already_closed))
80        })?;
81        for hold_id in already_closed {
82            self.release_reserved_sibling_share_for_hold(&hold_id);
83        }
84        // Run the store reap but do NOT propagate its error yet. If the store
85        // settled some holds before failing partway through the sweep, those
86        // closed holds' sibling shares must still be released, or a closed hold's
87        // admitted share leaks (the next sweep no longer sees it as open) and
88        // wrongly denies valid sibling reservations until restart.
89        let reap_result =
90            self.with_budget_store(|store| Ok(store.reap_expired_reserved_holds(now_unix_secs)?));
91
92        // Release the sibling share for exactly those expiring holds the store
93        // actually closed. Re-query each hold's disposition: a hold now closed or
94        // missing was forfeited, so its share is freed; a hold still open was not
95        // settled by the store, so its share is retained. Fail-closed on the
96        // re-query itself: a hold whose disposition cannot be read keeps its share
97        // held, so a read error never frees a share for a still-open hold.
98        for hold_id in &expiring {
99            let closed = match self.with_budget_store(|store| Ok(store.get_budget_hold(hold_id)?)) {
100                Ok(Some(hold)) => !hold.disposition.is_open(),
101                Ok(None) => true,
102                Err(_) => false,
103            };
104            if closed {
105                self.release_reserved_sibling_share_for_hold(hold_id);
106            }
107        }
108
109        // Propagate any store reap error only after the settled holds' shares are
110        // released.
111        let settled = reap_result?;
112        Ok(settled)
113    }
114
115    /// Reconcile the reserved budget hold named by a presented execution nonce
116    /// at its measured realized cost, producing an authoritative mediated-spend
117    /// receipt. Fail-closed at every step.
118    ///
119    /// Order of checks:
120    /// 1. The presented `arguments` must hash to the nonce's bound parameter
121    ///    hash (the caller must have executed the exact call the nonce authorizes).
122    /// 2. The nonce must name a reserved hold (`reserved_hold_id`).
123    /// 3. The realized currency must equal the currency the grant/hold was
124    ///    authorized in. This is checked BEFORE the nonce is consumed, so a
125    ///    mismatch is rejected fail-closed without burning the nonce or settling,
126    ///    and an unchecked caller-supplied currency never reaches a signed receipt.
127    /// 4. The nonce is VERIFIED (schema, expiry, signature under the kernel key,
128    ///    and that it has not already been consumed) but is NOT yet marked
129    ///    consumed. A forged or tampered nonce fails the signature check; an
130    ///    already-settled nonce fails the replay check; neither is ever marked.
131    /// 5. The named hold must still be open. A missing or already-closed hold is
132    ///    rejected. This open-to-closed settle is the mutual-exclusion point: two
133    ///    concurrent presentations of the same nonce cannot both settle because
134    ///    the store closes the hold atomically (the second finds it closed and is
135    ///    rejected here), so deferring the nonce mark opens no double-settle window.
136    /// 6. The hold is settled at `min(realized_cost, reserved)` -- realized cost
137    ///    is CLAMPED to the reserved worst-case, since the payer never authorized
138    ///    more than the reserved envelope. A realized cost of zero settles the
139    ///    hold at zero, releasing the entire reserved amount back to the grant.
140    /// 7. The nonce is marked consumed (single-use replay) ONLY after the settle
141    ///    succeeds. A transient store error at step 6 therefore leaves the nonce
142    ///    unconsumed, so the caller can re-present the same signed nonce and settle
143    ///    at realized cost instead of forfeiting the reservation. Once the hold is
144    ///    closed a replay is already rejected at step 5, so marking after
145    ///    settlement is safe.
146    /// 8. A completed allow receipt is signed with the reconciled hold lineage
147    ///    and the nonce id, so `is_authoritative_spend_receipt` accepts it.
148    pub fn reconcile_reserved_authorization_by_nonce(
149        &self,
150        presented_nonce: &SignedExecutionNonce,
151        arguments: &serde_json::Value,
152        realized_cost: &ToolInvocationCost,
153    ) -> Result<ToolCallResponse, KernelError> {
154        // (1) The caller must present the exact arguments the nonce authorized.
155        // The nonce binding carries the signed parameter hash; comparing it to
156        // the presented arguments ties the realized-cost claim to the signed call.
157        let action = ToolCallAction::from_parameters(arguments.clone()).map_err(|e| {
158            KernelError::ReceiptSigningFailed(format!(
159                "failed to hash arguments for reconcile-by-nonce binding: {e}"
160            ))
161        })?;
162        if action.parameter_hash != presented_nonce.nonce.bound_to.parameter_hash {
163            return Err(KernelError::Internal(
164                "reconcile-by-nonce arguments do not match the nonce parameter binding".to_string(),
165            ));
166        }
167
168        // (2) The nonce must name a reserved hold. Read the signed hold id up
169        // front so the caller-supplied realized currency can be validated against
170        // the reserved grant currency BEFORE the nonce is verified and consumed.
171        let reserved_hold_id = presented_nonce.reserved_hold_id().ok_or_else(|| {
172            KernelError::Internal(
173                "presented nonce does not name a reserved budget hold; nothing to reconcile"
174                    .to_string(),
175            )
176        })?;
177        let bound_capability_id = presented_nonce.nonce.bound_to.capability_id.clone();
178
179        // (3) Currency check (fail-closed): reject a realized currency that
180        // differs from the currency the grant/hold was authorized in, before
181        // settling or signing, so an unchecked caller-supplied currency is never
182        // stamped onto a signed authoritative receipt. Doing this ahead of nonce
183        // consumption means a mismatch does not burn the nonce (the caller can
184        // retry with the correct currency). Defers to the signature check below
185        // when the named hold is absent, so a forged/tampered nonce is still
186        // rejected as a nonce error rather than masked by hold-not-found.
187        self.with_budget_store(|store| {
188            let Some(hold) = store.get_budget_hold(reserved_hold_id)? else {
189                return Ok(());
190            };
191            if hold.authorized_exposure_units == 0 {
192                return Ok(());
193            }
194            match hold.reserved_currency.as_deref() {
195                Some(reserved) if reserved == realized_cost.currency => Ok(()),
196                Some(reserved) => Err(KernelError::Internal(format!(
197                    "reconcile-by-nonce realized currency `{}` does not match the reserved grant currency `{reserved}`",
198                    realized_cost.currency
199                ))),
200                // A non-monetary invocation reservation carries zero exposure and
201                // no currency, so there is no monetary envelope to validate; it
202                // settles the invocation at zero. A monetary hold always records
203                // its currency when reserved, so a missing currency alongside a
204                // non-zero exposure is a corrupted reserved hold and stays
205                // fail-closed.
206                None => Err(KernelError::Internal(format!(
207                    "reconcile-by-nonce cannot validate realized currency for reserved hold `{reserved_hold_id}`: no reserved grant currency recorded"
208                ))),
209            }
210        })?;
211
212        // (4) Verify the nonce but do NOT consume it yet. Verifying against the
213        // nonce's own binding makes the binding self-check trivially pass while
214        // the signature check (over the full body including reserved_hold_id)
215        // still rejects any forgery or tamper, and the replay peek rejects an
216        // already-consumed nonce. The single-use mark is deferred to step 7 so a
217        // transient settle error below leaves the nonce replayable for a retry.
218        let store = self.execution_nonce_store.as_deref().ok_or_else(|| {
219            KernelError::Internal(
220                "execution nonce store is not installed; cannot reconcile by nonce".to_string(),
221            )
222        })?;
223        let now_unix = current_unix_timestamp();
224        let now = i64::try_from(now_unix).unwrap_or(i64::MAX);
225        verify_execution_nonce_without_consume(
226            presented_nonce,
227            &self.config.keypair.public_key(),
228            &presented_nonce.nonce.bound_to,
229            now,
230            store,
231        )
232        .map_err(|error| {
233            KernelError::Internal(format!("reconcile-by-nonce rejected the nonce: {error}"))
234        })?;
235
236        // (5)+(6) Look up the exact hold and reconcile it, all under one budget
237        // store lock so the open-state check and the settle are atomic.
238        let realized_units = realized_cost.units;
239        let (hold, committed_before, reconcile, guarantee_level, budget_profile, metering_profile) =
240            self.with_budget_store(|store| {
241                let hold: BudgetHoldSnapshot =
242                    store.get_budget_hold(reserved_hold_id)?.ok_or_else(|| {
243                        KernelError::Internal(format!(
244                            "reserved budget hold `{reserved_hold_id}` not found for reconcile-by-nonce"
245                        ))
246                    })?;
247                if !hold.disposition.is_open() {
248                    return Err(KernelError::Internal(format!(
249                        "reserved budget hold `{reserved_hold_id}` is {} and cannot be reconciled",
250                        hold.disposition.as_str()
251                    )));
252                }
253                if hold.capability_id != bound_capability_id {
254                    return Err(KernelError::Internal(format!(
255                        "reserved budget hold `{reserved_hold_id}` capability does not match the nonce binding"
256                    )));
257                }
258
259                let exposed = hold.remaining_exposure_units;
260                // CLAMP: the payer authorized only the reserved worst-case.
261                let realized = realized_units.min(exposed);
262                let committed_before = match store.get_usage(&hold.capability_id, hold.grant_index)? {
263                    Some(usage) => usage.committed_cost_units()?,
264                    None => exposed,
265                };
266                let capture = store.capture_invocation_reservations(BudgetCaptureInvocationRequest {
267                    capability_id: hold.capability_id.clone(),
268                    grant_index: hold.grant_index,
269                    hold_id: hold.hold_id.clone(),
270                    event_id: format!("{}:capture-invocation", hold.hold_id),
271                    trusted_time: None,
272                    authority: hold.authority.clone(),
273                })?;
274                let capture = match capture {
275                    BudgetInvocationCaptureDecision::Captured(mutation)
276                    | BudgetInvocationCaptureDecision::AlreadyCaptured(mutation) => mutation,
277                };
278                let reconcile = if exposed == 0 {
279                    capture
280                } else {
281                    store.reconcile_budget_hold(BudgetReconcileHoldRequest {
282                        capability_id: hold.capability_id.clone(),
283                        grant_index: hold.grant_index,
284                        exposed_cost_units: exposed,
285                        realized_spend_units: realized,
286                        hold_id: Some(hold.hold_id.clone()),
287                        event_id: Some(format!("{}:reconcile", hold.hold_id)),
288                        authority: hold.authority.clone(),
289                    })?
290                };
291                Ok((
292                    hold,
293                    committed_before,
294                    reconcile,
295                    store.budget_guarantee_level(),
296                    store.budget_authority_profile(),
297                    store.budget_metering_profile(),
298                ))
299            })?;
300
301        // (7) The hold settled successfully and is now closed, so take the
302        // single-use nonce mark. Deferring it to here means a transient store
303        // error at the settle above left the nonce unconsumed, so the caller can
304        // re-present the same signed nonce and settle at realized cost rather than
305        // forfeiting the reservation. A consume failure now is non-fatal: the
306        // settle is already irreversible and the closed hold alone rejects any
307        // replay (a second presentation fails the open-hold check at step 5), so
308        // log it and continue rather than deny a spend that truly committed.
309        if let Err(error) = consume_execution_nonce(
310            store,
311            presented_nonce.nonce_id(),
312            presented_nonce.expires_at(),
313        ) {
314            warn!(
315                nonce_id = %presented_nonce.nonce_id(),
316                hold_id = %hold.hold_id,
317                reason = %redacted!(&error),
318                "failed to mark the reconcile nonce consumed after an irreversible settlement; \
319                 the closed hold still rejects any replay"
320            );
321        }
322
323        // The reserved hold is now settled (closed), so release the sibling-sum
324        // share it kept admitted, freeing the parent's headroom for a sibling.
325        // Done before the receipt is built so a later signing error still frees
326        // the headroom; a no-op for a root hold that never held a share.
327        self.release_reserved_sibling_share_for_hold(reserved_hold_id);
328
329        let exposed = hold.remaining_exposure_units;
330        let realized = realized_units.min(exposed);
331
332        // Currency stamped onto the signed receipt. A monetary hold validated the
333        // realized currency against the reserved grant currency in step 3, so it is
334        // safe to echo. A zero-exposure invocation reserve carries no reserved
335        // currency and never validated the realized currency, so normalize it to the
336        // inert value rather than land an unchecked caller-supplied string on a
337        // signed artifact (the step-3 guarantee).
338        let receipt_currency = if hold.authorized_exposure_units == 0 {
339            INVOCATION_RECONCILE_RECEIPT_CURRENCY.to_string()
340        } else {
341            realized_cost.currency.clone()
342        };
343
344        // Reconstruct the authorize lineage from the reserved hold so the
345        // receipt records the same guarantee/authority context the reserving
346        // authorization committed, plus the reconcile terminal and the nonce id.
347        let authorize_metadata = BudgetCommitMetadata {
348            authority: hold.authority.clone(),
349            guarantee_level,
350            budget_profile,
351            metering_profile,
352            budget_commit_index: None,
353            event_id: Some(format!("{}:authorize", hold.hold_id)),
354            recorded_at_unix_seconds: Some(now_unix),
355        };
356        let charge = BudgetChargeResult {
357            grant_index: hold.grant_index,
358            cost_charged: exposed,
359            currency: receipt_currency.clone(),
360            budget_total: exposed,
361            new_committed_cost_units: committed_before,
362            budget_hold_id: hold.hold_id.clone(),
363            authorize_metadata,
364            invocation_capture: None,
365        };
366        let budget_metadata = self.budget_execution_receipt_metadata(
367            &charge,
368            Some(("reconciled", &reconcile)),
369            Some(presented_nonce.nonce_id()),
370        );
371
372        // Report the GRANT's budget and delegation lineage, recorded on the reserved
373        // hold at reserve time, so dashboards and reports see the grant ceiling and
374        // true lineage rather than this single reservation's exposure. A grant with a
375        // per-invocation cap but no `max_total_cost` records u64::MAX as its sentinel
376        // ceiling; that sentinel must never surface on a signed receipt, so treat it
377        // (and a hold reserved before these fields existed, or a zero-exposure
378        // invocation reserve) as having no recorded ceiling and fall back to this
379        // reservation's bounded exposure and the nonce subject.
380        let grant_budget_total = hold
381            .reserved_budget_total
382            .filter(|&total| total != u64::MAX)
383            .unwrap_or(exposed);
384        // Remaining is the grant ceiling minus the grant's TOTAL committed spend
385        // after this settle (committed_before - exposed + realized), not just this
386        // reconcile's realized cost. Subtracting only the realized cost would ignore
387        // every other reservation or spend already committed on the grant and
388        // overstate the remaining budget. Mirrors the inline unmeasured-cost path.
389        let committed_after = reconcile.committed_cost_units_after;
390        let financial = FinancialReceiptMetadata {
391            grant_index: hold.grant_index as u32,
392            cost_charged: realized,
393            currency: receipt_currency,
394            budget_remaining: grant_budget_total.saturating_sub(committed_after),
395            budget_total: grant_budget_total,
396            delegation_depth: hold.reserved_delegation_depth.unwrap_or(0),
397            root_budget_holder: hold
398                .reserved_root_budget_holder
399                .clone()
400                .unwrap_or_else(|| presented_nonce.nonce.bound_to.subject_id.clone()),
401            // Stamp the rail transaction id captured for a prepaid MustPrepay
402            // reservation (recorded on the reserved hold at reserve time), so the
403            // authoritative reconciled receipt ties the spend to the payment that
404            // funded it. `None` for a mediated reserve that carried no prepayment.
405            payment_reference: hold.reserved_payment_reference.clone(),
406            settlement_status: SettlementStatus::Settled,
407            cost_breakdown: realized_cost.breakdown.clone(),
408            oracle_evidence: None,
409            attempted_cost: None,
410        };
411        let metadata = merge_metadata_objects(
412            Some(serde_json::json!({ "financial": financial })),
413            Some(budget_metadata),
414        );
415
416        let receipt_content = receipt_content_for_output(None, None)?;
417        let receipt = self.build_and_sign_receipt(ReceiptParams {
418            request_id: presented_nonce.reserving_request_id(),
419            capability_id: &bound_capability_id,
420            tool_name: &presented_nonce.nonce.bound_to.tool_name,
421            server_id: &presented_nonce.nonce.bound_to.tool_server,
422            decision: Decision::Allow,
423            action,
424            content_hash: receipt_content.content_hash,
425            canonical_content: receipt_content.canonical_content,
426            metadata,
427            timestamp: now_unix,
428            trust_level: chio_core::receipt::kinds::TrustLevel::Mediated,
429            tenant_id: None,
430        })?;
431
432        let request_id = presented_nonce
433            .reserving_request_id()
434            .map(str::to_string)
435            .unwrap_or_else(|| receipt.id.clone());
436
437        // The nonce is already consumed and the hold closed: settlement is
438        // IRREVERSIBLE by this point. If the durable receipt persist fails now, a
439        // retry cannot recreate the receipt (the nonce is a replay, the hold is
440        // closed), so the caller would be left with no authoritative receipt for a
441        // spend that really settled. Return the signed authoritative receipt and log
442        // the persist failure rather than surfacing only the error. A settlement
443        // FAILURE earlier (forged/replayed nonce, closed hold, currency mismatch)
444        // still fails closed above, before this point.
445        if let Err(error) = self.record_chio_receipt(&receipt) {
446            warn!(
447                request_id = %request_id,
448                hold_id = %hold.hold_id,
449                nonce_id = %presented_nonce.nonce_id(),
450                receipt_id = %receipt.id,
451                reason = %redacted!(&error),
452                "durable receipt persistence failed after an irreversible reconcile settlement; \
453                 returning the signed authoritative receipt"
454            );
455        }
456
457        info!(
458            request_id = %request_id,
459            hold_id = %hold.hold_id,
460            nonce_id = %presented_nonce.nonce_id(),
461            realized,
462            reserved = exposed,
463            "reconciled reserved authorization by nonce"
464        );
465
466        Ok(ToolCallResponse {
467            request_id,
468            verdict: Verdict::Allow,
469            output: None,
470            reason: None,
471            terminal_state: OperationTerminalState::Completed,
472            receipt,
473            execution_nonce: None,
474        })
475    }
476}