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}