Skip to main content

chio_kernel/
receipt_store.rs

1use chio_core::canonical::CanonicalBytes;
2use chio_core::capability::token::CapabilityToken;
3use chio_core::credit::CreditBondRow;
4use chio_core::crypto::Keypair;
5use chio_core::receipt::{body::ChioReceipt, lineage::ChildRequestReceipt};
6use chio_log_redact::redacted;
7
8use crate::capability_lineage::CapabilitySnapshot;
9use crate::checkpoint::KernelCheckpoint;
10
11/// Configuration for receipt retention and archival.
12///
13/// When set on `KernelConfig`, the kernel can archive aged-out or oversized
14/// receipt databases to a separate read-only SQLite file while keeping archived
15/// receipts verifiable against their Merkle checkpoint roots.
16#[derive(Debug, Clone)]
17pub struct RetentionConfig {
18    /// Number of days to retain receipts in the live database. Default: 90.
19    pub retention_days: u64,
20    /// Maximum size in bytes before the live database is rotated. Default: 10 GB.
21    pub max_size_bytes: u64,
22    /// Path for the archive SQLite file. Must be writable on first rotation.
23    pub archive_path: String,
24    /// Optional tenant scope for retention. When set, rotation only archives
25    /// receipts for this tenant and leaves other tenant evidence untouched.
26    pub tenant_id: Option<String>,
27    /// How often the kernel maintenance task evaluates rotation, in seconds.
28    /// Default: 3600 (one hour).
29    pub check_interval_secs: u64,
30    /// Internal: set by `archive_receipts_before` to bypass the day/size
31    /// threshold and rotate at an explicit cutoff. Not part of any wire form
32    /// (no serialized representation of `RetentionConfig` exists).
33    pub explicit_cutoff_unix_secs: Option<u64>,
34}
35
36impl Default for RetentionConfig {
37    fn default() -> Self {
38        Self {
39            retention_days: 90,
40            max_size_bytes: 10_737_418_240,
41            archive_path: "receipts-archive.sqlite3".to_string(),
42            tenant_id: None,
43            check_interval_secs: 3_600,
44            explicit_cutoff_unix_secs: None,
45        }
46    }
47}
48
49/// Owns the retention maintenance worker thread; signals stop and joins on
50/// drop. Spawned by [`crate::kernel::ChioKernel::try_set_receipt_store_handle`]
51/// when `KernelConfig.retention_config` is `Some`, so retention rotation runs
52/// on an interval without operator-driven polling.
53///
54/// The worker thread NEVER PANICS: a rotation call is wrapped in
55/// `catch_unwind` so a panic inside the receipt store's rotation path is
56/// caught, logged, and retried on the next interval rather than unwinding the
57/// worker thread (which would silently and permanently stop maintenance).
58pub struct RetentionMaintenanceHandle {
59    stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
60    join: Option<std::thread::JoinHandle<()>>,
61}
62
63impl RetentionMaintenanceHandle {
64    /// Spawn the maintenance worker. `store` is a dedicated `Arc` clone held
65    /// by the worker thread for its lifetime, independent of the kernel's own
66    /// `receipt_store` handle.
67    pub(crate) fn spawn(store: std::sync::Arc<dyn ReceiptStore>, config: RetentionConfig) -> Self {
68        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
69        let worker_stop = std::sync::Arc::clone(&stop);
70        let interval = std::time::Duration::from_secs(config.check_interval_secs.max(1));
71        let join = std::thread::spawn(move || {
72            while !worker_stop.load(std::sync::atomic::Ordering::SeqCst) {
73                // Sleep in short slices so shutdown is responsive.
74                let mut waited = std::time::Duration::ZERO;
75                let slice = std::time::Duration::from_millis(200);
76                while waited < interval && !worker_stop.load(std::sync::atomic::Ordering::SeqCst) {
77                    std::thread::sleep(slice);
78                    waited += slice;
79                }
80                if worker_stop.load(std::sync::atomic::Ordering::SeqCst) {
81                    break;
82                }
83                // Never panic: a rotation error OR a caught panic is surfaced
84                // as a warning and retried next interval, rather than
85                // crashing the worker thread (and, unwrapped, the kernel).
86                let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
87                    store.rotate_receipts(&config)
88                }));
89                // Persist the outcome into health so a persistent rotation
90                // failure (an unwritable archive path, a missing/replaced
91                // archive that no longer backs the ledger) is observable outside
92                // this log: a store serving under a retention policy that is not
93                // being honored must not keep reporting healthy. A success clears
94                // the prior failure.
95                match outcome {
96                    Ok(Ok(_archived)) => {
97                        store.record_retention_rotation_outcome(None);
98                    }
99                    Ok(Err(error)) => {
100                        store.record_retention_rotation_outcome(Some(&error.to_string()));
101                        tracing::warn!(
102                            target: "chio::retention",
103                            error = %redacted!(&error),
104                            "receipt rotation failed; will retry next interval"
105                        );
106                    }
107                    Err(_panic) => {
108                        store.record_retention_rotation_outcome(Some("receipt rotation panicked"));
109                        tracing::warn!(
110                            target: "chio::retention",
111                            "receipt rotation panicked; will retry next interval"
112                        );
113                    }
114                }
115            }
116        });
117        Self {
118            stop,
119            join: Some(join),
120        }
121    }
122}
123
124impl Drop for RetentionMaintenanceHandle {
125    fn drop(&mut self) {
126        self.stop.store(true, std::sync::atomic::Ordering::SeqCst);
127        if let Some(join) = self.join.take() {
128            let _ = join.join();
129        }
130    }
131}
132
133#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
134#[serde(rename_all = "camelCase")]
135pub struct ReceiptWriterCounters {
136    pub accepted_total: u64,
137    pub committed_total: u64,
138    pub failed_total: u64,
139    pub saturated_total: u64,
140    pub inflight: u64,
141    /// Caller-visible writer deadlines exceeded. This is separate from
142    /// `failed_total`, which counts only the actor's terminal failed outcomes.
143    #[serde(default)]
144    pub timed_out_total: u64,
145    /// Timed-out commands that are still queued or running on the actor.
146    #[serde(default)]
147    pub timed_out_inflight: u64,
148    /// Commands still queued in the commit-actor channel, not yet pulled for
149    /// processing. Unlike `inflight`, this excludes work the actor has already
150    /// drained and is committing, so it is the honest saturation signal: the
151    /// channel is full only once this reaches its capacity.
152    #[serde(default)]
153    pub queue_depth: u64,
154    #[serde(default)]
155    pub last_commit_unix_ms: Option<u64>,
156    /// Wall-clock (unix-ms) of the first append this writer ever accepted, set
157    /// once. It anchors the stall clock before the first successful commit, so a
158    /// writer that wedges before ever committing is still measured against the
159    /// stall threshold instead of appearing to make progress forever.
160    #[serde(default)]
161    pub first_accept_unix_ms: Option<u64>,
162    #[serde(default)]
163    pub last_error: Option<String>,
164}
165
166#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
167#[serde(rename_all = "camelCase")]
168pub struct ReceiptWalCheckpointReport {
169    pub busy: u64,
170    pub log_frames: u64,
171    pub checkpointed_frames: u64,
172}
173
174#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
175#[serde(rename_all = "camelCase")]
176pub struct ReceiptFlushReport {
177    pub writer: ReceiptWriterCounters,
178    pub latest_committed_entry_seq: u64,
179    #[serde(default)]
180    pub latest_checkpoint_seq: Option<u64>,
181    pub latest_checkpointed_entry_seq: u64,
182    #[serde(default)]
183    pub uncheckpointed_start_seq: Option<u64>,
184    #[serde(default)]
185    pub uncheckpointed_end_seq: Option<u64>,
186    #[serde(default)]
187    pub wal_checkpoint: Option<ReceiptWalCheckpointReport>,
188    #[serde(default)]
189    pub db_size_bytes: Option<u64>,
190}
191
192#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
193#[serde(rename_all = "camelCase")]
194pub struct ReceiptCheckpointRange {
195    pub start_seq: u64,
196    pub end_seq: u64,
197}
198
199#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
200#[serde(rename_all = "camelCase")]
201pub struct ReceiptCheckpointStatusReport {
202    pub healthy: bool,
203    pub latest_committed_entry_seq: u64,
204    #[serde(default)]
205    pub latest_checkpoint_seq: Option<u64>,
206    pub latest_checkpointed_entry_seq: u64,
207    #[serde(default)]
208    pub next_range: Option<ReceiptCheckpointRange>,
209    #[serde(default)]
210    pub checkpoint_error: Option<String>,
211    /// Current receipt-retention archival high-water mark (the highest
212    /// `entry_seq` archived so far), or `None` if retention has never run.
213    /// Reported even when retention is disabled so unbounded growth is
214    /// visible in health/status output.
215    #[serde(default)]
216    pub retention_watermark_entry_seq: Option<u64>,
217}
218
219#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
220#[serde(rename_all = "camelCase")]
221pub struct ReceiptStoreHealthReport {
222    pub healthy: bool,
223    pub writer: ReceiptWriterCounters,
224    /// Current writer-liveness label ("healthy", "wedged", "dead", ...).
225    /// Serialized so an operator health surface can distinguish a slow-but-live
226    /// writer from a wedged one. Optional for backward compatibility.
227    #[serde(default = "receipt_writer_liveness_unknown_label")]
228    pub writer_liveness: String,
229    pub latest_committed_entry_seq: u64,
230    #[serde(default)]
231    pub latest_checkpoint_seq: Option<u64>,
232    pub latest_checkpointed_entry_seq: u64,
233    #[serde(default)]
234    pub uncheckpointed_start_seq: Option<u64>,
235    #[serde(default)]
236    pub uncheckpointed_end_seq: Option<u64>,
237    #[serde(default)]
238    pub checkpoint_error: Option<String>,
239    #[serde(default)]
240    pub db_size_bytes: Option<u64>,
241    /// Current receipt-retention archival high-water mark (the highest
242    /// `entry_seq` archived so far), or `None` if retention has never run.
243    /// Reported even when retention is disabled so unbounded growth is
244    /// visible in health output.
245    #[serde(default)]
246    pub retention_watermark_entry_seq: Option<u64>,
247    /// Last error from the background retention maintenance worker, or `None`
248    /// when the most recent rotation succeeded (or retention is not configured).
249    /// A persistent value means the store is serving under a retention policy
250    /// that is not being honored; `healthy` is `false` while it is set so
251    /// operators and automation can alert on a silently-failing background task.
252    #[serde(default)]
253    pub retention_error: Option<String>,
254    /// Supervised health of the commit-writer thread. A durable store reports this
255    /// so a dead or degraded writer can never be masked by a last-batch success.
256    #[serde(default)]
257    pub writer_level: chio_supervisor::HealthLevel,
258    /// Cumulative writer restarts observed by the supervisor. Non-zero after any
259    /// writer fault, even once the writer recovers.
260    #[serde(default)]
261    pub writer_restart_total: u64,
262}
263
264#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
265#[serde(rename_all = "camelCase")]
266pub struct ReceiptCheckpointCreateReport {
267    pub created: bool,
268    #[serde(default)]
269    pub checkpoint_seq: Option<u64>,
270    #[serde(default)]
271    pub batch_start_seq: Option<u64>,
272    #[serde(default)]
273    pub batch_end_seq: Option<u64>,
274    pub latest_committed_entry_seq: u64,
275    pub latest_checkpointed_entry_seq: u64,
276}
277
278#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
279#[serde(rename_all = "camelCase")]
280pub struct AuthorizationReceiptConsumption {
281    pub authorization_receipt_id: String,
282    pub consumer_receipt_id: String,
283    pub request_id: String,
284    pub session_id: String,
285    pub tool_call_id: String,
286    /// Tenant id copied from the live authorization receipt. ACP authorization
287    /// receipts may legitimately carry `tenant_id: None` for non-enterprise
288    /// (single-tenant or local) deployments; the consumption record mirrors
289    /// the receipt's tenant scope (including `None`) for binding integrity.
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub tenant_id: Option<String>,
292    pub parameter_hash: String,
293    pub consumed_at_unix_ms: u64,
294}
295
296#[derive(Debug, thiserror::Error)]
297pub enum ReceiptStoreError {
298    #[error("sqlite error: {0}")]
299    Sqlite(#[from] rusqlite::Error),
300
301    #[error("sqlite pool error: {0}")]
302    Pool(String),
303
304    #[error("{operation} timed out after {timeout_ms}ms")]
305    Timeout { operation: String, timeout_ms: u64 },
306
307    #[error("serialization error: {0}")]
308    Json(#[from] serde_json::Error),
309
310    #[error("failed to prepare receipt store directory: {0}")]
311    Io(#[from] std::io::Error),
312
313    #[error("crypto decode error: {0}")]
314    CryptoDecode(String),
315
316    #[error("canonical json error: {0}")]
317    Canonical(String),
318
319    #[error("invalid outcome filter: {0}")]
320    InvalidOutcome(String),
321
322    #[error("receipt read boundary error: {0}")]
323    ReadBoundary(String),
324
325    #[error("conflict: {0}")]
326    Conflict(String),
327
328    #[error("not found: {0}")]
329    NotFound(String),
330
331    #[error("unsupported receipt-store operation: {0}")]
332    Unsupported(String),
333
334    #[error("receipt-store mutation was fenced")]
335    Fenced,
336
337    #[error("receipt-store durable outcome is unknown: {0}")]
338    OutcomeUnknown(String),
339
340    #[error("retention co-archival incomplete for {table}: {live} live rows, {archived} archived; aborting delete to preserve inclusion-proof integrity")]
341    RetentionArchiveIncomplete {
342        table: &'static str,
343        live: u64,
344        archived: u64,
345    },
346
347    #[error(
348        "retention watermark regression: attempted {attempted}, current high-water mark {current}"
349    )]
350    RetentionWatermarkRegression { attempted: u64, current: u64 },
351
352    #[error("claim receipt log projection is missing over a checkpointed or archived range (watermark {watermark}); the entry ordering cannot be safely regenerated to match committed checkpoint boundaries; restore the claim_receipt_log_entries projection from a backup taken before it was lost")]
353    ArchivedRangeProjection { watermark: u64 },
354
355    #[error("tenant-scoped retention is not expressible as a prefix watermark and is unsupported; no data was modified")]
356    RetentionTenantScopeUnsupported,
357
358    #[error("receipt commit writer is not serving after {restarts} restart(s): {last_error}")]
359    WriterDead { restarts: u64, last_error: String },
360}
361
362/// Atomic sidecar projections supported by a receipt store.
363#[derive(Debug, Clone, Copy, PartialEq, Eq)]
364pub enum AtomicReceiptProjection {
365    /// The store cannot atomically append a receipt and settlement observation.
366    Unsupported,
367    /// The store supports the first settlement-observation projection.
368    SettlementObservationV1,
369}
370
371/// Initial settlement work committed with a receipt.
372#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
373pub struct PendingSettlementObservation {
374    /// Earliest time at which a worker may claim the observation.
375    pub next_visible_at_ms: u64,
376}
377
378/// Point-in-time liveness of a receipt store's commit writer. `Unknown` keeps
379/// stores with no async writer behaving exactly as they did before liveness
380/// existed: the pre-dispatch readiness gate treats it as permissive. A store
381/// that does have an async writer reports a concrete verdict, which the gate
382/// samples directly even when no background watchdog is running.
383#[derive(Clone, Copy, Debug, PartialEq, Eq)]
384pub enum ReceiptWriterLiveness {
385    Healthy,
386    Saturated,
387    Wedged,
388    Dead,
389    Unknown,
390}
391
392impl ReceiptWriterLiveness {
393    /// Whether the pre-dispatch gate may admit while the writer is in this
394    /// state. Only a proven-healthy or not-yet-probed writer is permissive; a
395    /// saturated, wedged, or dead writer denies.
396    pub fn healthy(self) -> bool {
397        matches!(self, Self::Healthy | Self::Unknown)
398    }
399
400    pub fn as_label(self) -> &'static str {
401        match self {
402            Self::Healthy => "healthy",
403            Self::Saturated => "saturated",
404            Self::Wedged => "wedged",
405            Self::Dead => "dead",
406            Self::Unknown => "unknown",
407        }
408    }
409}
410
411fn receipt_writer_liveness_unknown_label() -> String {
412    ReceiptWriterLiveness::Unknown.as_label().to_string()
413}
414
415pub trait ReceiptStore: Send + Sync {
416    fn append_chio_receipt(&self, receipt: &ChioReceipt) -> Result<(), ReceiptStoreError>;
417    fn admission_projection_capabilities(
418        &self,
419    ) -> crate::admission_operation::AdmissionProjectionCapabilities {
420        crate::admission_operation::AdmissionProjectionCapabilities::default()
421    }
422    fn commit_admission_projection(
423        &self,
424        _projection: &crate::admission_operation::AdmissionTerminalProjection,
425    ) -> Result<crate::admission_operation::AdmissionTerminal, ReceiptStoreError> {
426        Err(ReceiptStoreError::Unsupported(
427            "atomic admission terminal projection".to_string(),
428        ))
429    }
430    /// Identify the durable writer shared with a settlement outcome store.
431    fn settlement_store_binding(&self) -> Option<chio_settle::SettlementStoreBinding> {
432        None
433    }
434    /// Report the atomic receipt-sidecar projection implemented by this store.
435    fn atomic_receipt_projection(&self) -> AtomicReceiptProjection {
436        AtomicReceiptProjection::Unsupported
437    }
438    /// Whether the atomic receipt projection also honors the supplied append
439    /// deadline. Settlement runtime installation requires this capability so a
440    /// legacy atomic-only store cannot reintroduce an unbounded writer wait.
441    fn supports_atomic_receipt_projection_with_timeout(&self) -> bool {
442        false
443    }
444    /// Atomically append a receipt and its initial settlement observation.
445    ///
446    /// The default fails without appending either record.
447    fn append_chio_receipt_with_pending_observation(
448        &self,
449        _receipt: &ChioReceipt,
450        _pending: &PendingSettlementObservation,
451    ) -> Result<(), ReceiptStoreError> {
452        Err(ReceiptStoreError::Unsupported(
453            "atomic settlement observation projection".to_string(),
454        ))
455    }
456    /// Atomically append a receipt and its initial settlement observation while
457    /// honoring the receipt-append deadline on stores with an async writer.
458    ///
459    /// The default fails without invoking the legacy unbounded atomic method.
460    /// Stores that advertise `supports_atomic_receipt_projection_with_timeout`
461    /// must override this method and enforce `budget`.
462    fn append_chio_receipt_with_pending_observation_and_timeout(
463        &self,
464        _receipt: &ChioReceipt,
465        _pending: &PendingSettlementObservation,
466        _budget: std::time::Duration,
467    ) -> Result<Option<u64>, ReceiptStoreError> {
468        Err(ReceiptStoreError::Unsupported(
469            "timeout-aware atomic settlement observation projection".to_string(),
470        ))
471    }
472    /// Load a chio receipt by id. The provided default returns `None`; a store
473    /// backing a store-authoritative deployment MUST override this (and
474    /// `load_child_receipt`) with a real point lookup.
475    ///
476    /// The kernel consults this BEFORE the bounded in-memory receipt mirror and
477    /// falls back to the mirror only on a genuine `Ok(None)` miss. An append-only
478    /// or remote store that does NOT override this therefore relies entirely on
479    /// the mirror for point lookups: once the mirror evicts a receipt (past
480    /// `receipt_mirror_capacity`), governed call-chain validation of an older
481    /// `parent_receipt_id` misses both the store and the mirror and fails closed
482    /// (a deny of the dependent claim, never a false allow). A store-authoritative
483    /// remote deployment that must resolve older parent receipts MUST implement
484    /// this point load so bounded-mirror eviction does not cause false denials.
485    fn load_chio_receipt(
486        &self,
487        _receipt_id: &str,
488    ) -> Result<Option<ChioReceipt>, ReceiptStoreError> {
489        Ok(None)
490    }
491    /// Load a child-request receipt by id. Provided default returns `None`; a
492    /// store used for a store-authoritative deployment must override both this
493    /// and `load_chio_receipt`. A miss is a fail-closed deny
494    /// of the dependent call-chain claim, never a false allow. The same
495    /// bounded-mirror eviction caveat documented on `load_chio_receipt` applies.
496    fn load_child_receipt(
497        &self,
498        _receipt_id: &str,
499    ) -> Result<Option<ChildRequestReceipt>, ReceiptStoreError> {
500        Ok(None)
501    }
502    fn append_chio_receipt_canonical(
503        &self,
504        receipt: &ChioReceipt,
505        _canonical: &CanonicalBytes,
506    ) -> Result<(), ReceiptStoreError> {
507        self.append_chio_receipt(receipt)
508    }
509    fn append_chio_receipt_returning_seq(
510        &self,
511        receipt: &ChioReceipt,
512    ) -> Result<Option<u64>, ReceiptStoreError> {
513        self.append_chio_receipt(receipt)?;
514        Ok(None)
515    }
516    /// Append a receipt, failing closed with `ReceiptStoreError::Timeout` if the
517    /// commit round trip exceeds `budget`. The default ignores the budget and
518    /// keeps the unbounded behavior for stores without an async writer; a store
519    /// with a commit actor overrides this so a wedged writer cannot pin the
520    /// kernel-wide receipt write lock indefinitely.
521    fn append_chio_receipt_with_timeout(
522        &self,
523        receipt: &ChioReceipt,
524        _budget: std::time::Duration,
525    ) -> Result<Option<u64>, ReceiptStoreError> {
526        self.append_chio_receipt_returning_seq(receipt)
527    }
528    /// Point-in-time writer liveness, assessed against the operator-configured
529    /// stall threshold. Default `Unknown` keeps stores with no async writer, or
530    /// no watchdog wired, permissive at the pre-dispatch gate; such stores
531    /// ignore the threshold.
532    fn writer_liveness(&self, _stall_threshold: std::time::Duration) -> ReceiptWriterLiveness {
533        ReceiptWriterLiveness::Unknown
534    }
535    fn append_chio_receipt_consuming_authorization(
536        &self,
537        _receipt: &ChioReceipt,
538        _consumption: &AuthorizationReceiptConsumption,
539    ) -> Result<(), ReceiptStoreError> {
540        Err(ReceiptStoreError::Conflict(
541            "durable authorization receipt consumption is not supported by this receipt store"
542                .to_string(),
543        ))
544    }
545    fn append_child_receipt(&self, receipt: &ChildRequestReceipt) -> Result<(), ReceiptStoreError>;
546    fn append_child_receipt_returning_seq(
547        &self,
548        receipt: &ChildRequestReceipt,
549    ) -> Result<Option<u64>, ReceiptStoreError> {
550        self.append_child_receipt(receipt)?;
551        Ok(None)
552    }
553    /// Append a child receipt, failing closed with `ReceiptStoreError::Timeout`
554    /// if the commit round trip exceeds `budget`. The default ignores the budget
555    /// and keeps the unbounded behavior for stores without an async writer; a
556    /// store with a commit actor overrides this so a wedged writer cannot pin the
557    /// kernel-wide receipt write lock while nested-flow child receipts drain.
558    fn append_child_receipt_with_timeout(
559        &self,
560        receipt: &ChildRequestReceipt,
561        _budget: std::time::Duration,
562    ) -> Result<Option<u64>, ReceiptStoreError> {
563        self.append_child_receipt_returning_seq(receipt)
564    }
565
566    fn receipts_canonical_bytes_range(
567        &self,
568        _start_seq: u64,
569        _end_seq: u64,
570    ) -> Result<Vec<(u64, Vec<u8>)>, ReceiptStoreError> {
571        Err(ReceiptStoreError::Conflict(
572            "receipt canonical byte ranges are not supported by this receipt store".to_string(),
573        ))
574    }
575
576    fn flush_receipt_writes(&self) -> Result<ReceiptFlushReport, ReceiptStoreError> {
577        Err(ReceiptStoreError::Conflict(
578            "receipt writer flush is not supported by this receipt store".to_string(),
579        ))
580    }
581
582    fn flush_receipt_writes_with_timeout(
583        &self,
584        _timeout: std::time::Duration,
585    ) -> Result<ReceiptFlushReport, ReceiptStoreError> {
586        self.flush_receipt_writes()
587    }
588
589    fn receipt_store_health(&self) -> Result<ReceiptStoreHealthReport, ReceiptStoreError> {
590        Err(ReceiptStoreError::Conflict(
591            "receipt store health is not supported by this receipt store".to_string(),
592        ))
593    }
594
595    /// Whether the store's commit writer has degraded to the point that durable
596    /// persistence can no longer be trusted, so evaluations must fail closed before
597    /// dispatch rather than after executing a tool with no receipt path.
598    ///
599    /// The default is `false`: a store with no supervised background writer has
600    /// nothing to trip. Stores that supervise a writer thread override this to read
601    /// the writer's health flag.
602    fn writer_serving_closed(&self) -> bool {
603        false
604    }
605
606    fn latest_committed_entry_seq(&self) -> Result<u64, ReceiptStoreError> {
607        Err(ReceiptStoreError::Conflict(
608            "receipt committed sequence reporting is not supported by this receipt store"
609                .to_string(),
610        ))
611    }
612
613    fn latest_checkpointed_entry_seq(&self) -> Result<u64, ReceiptStoreError> {
614        Err(ReceiptStoreError::Conflict(
615            "receipt checkpoint sequence reporting is not supported by this receipt store"
616                .to_string(),
617        ))
618    }
619
620    fn next_checkpoint_range(
621        &self,
622        _max_batch: u64,
623    ) -> Result<Option<ReceiptCheckpointRange>, ReceiptStoreError> {
624        Err(ReceiptStoreError::Conflict(
625            "receipt checkpoint ranges are not supported by this receipt store".to_string(),
626        ))
627    }
628
629    fn receipt_checkpoint_status(
630        &self,
631        _max_batch: Option<u64>,
632    ) -> Result<ReceiptCheckpointStatusReport, ReceiptStoreError> {
633        Err(ReceiptStoreError::Conflict(
634            "receipt checkpoint status is not supported by this receipt store".to_string(),
635        ))
636    }
637
638    fn store_checkpoint(&self, _checkpoint: &KernelCheckpoint) -> Result<(), ReceiptStoreError> {
639        Err(ReceiptStoreError::Conflict(
640            "receipt checkpoint storage is not supported by this receipt store".to_string(),
641        ))
642    }
643
644    fn create_next_receipt_checkpoint(
645        &self,
646        _max_batch: u64,
647        _keypair: &Keypair,
648    ) -> Result<ReceiptCheckpointCreateReport, ReceiptStoreError> {
649        Err(ReceiptStoreError::Conflict(
650            "receipt checkpoint creation is not supported by this receipt store".to_string(),
651        ))
652    }
653
654    fn load_checkpoint_by_seq(
655        &self,
656        _checkpoint_seq: u64,
657    ) -> Result<Option<KernelCheckpoint>, ReceiptStoreError> {
658        Ok(None)
659    }
660
661    fn load_latest_checkpoint(&self) -> Result<Option<KernelCheckpoint>, ReceiptStoreError> {
662        let mut checkpoint_seq = 1;
663        let mut latest = None;
664        loop {
665            let Some(checkpoint) = self.load_checkpoint_by_seq(checkpoint_seq)? else {
666                return Ok(latest);
667            };
668            if checkpoint.body.checkpoint_seq != checkpoint_seq {
669                return Err(ReceiptStoreError::Conflict(format!(
670                    "checkpoint loader returned checkpoint {} for requested sequence {}",
671                    checkpoint.body.checkpoint_seq, checkpoint_seq
672                )));
673            }
674            checkpoint_seq = checkpoint
675                .body
676                .checkpoint_seq
677                .checked_add(1)
678                .ok_or_else(|| {
679                    ReceiptStoreError::Conflict(
680                        "checkpoint_seq overflow while loading latest".to_string(),
681                    )
682                })?;
683            latest = Some(checkpoint);
684        }
685    }
686
687    fn supports_kernel_signed_checkpoints(&self) -> bool {
688        false
689    }
690
691    /// Install a background checkpoint signer on stores that build their own
692    /// checkpoints on the writer thread. Returns `Ok(false)` when
693    /// the store does not support background checkpointing (default).
694    fn enable_background_checkpoints(
695        &self,
696        _keypair: Keypair,
697        _max_batch: u64,
698    ) -> Result<bool, ReceiptStoreError> {
699        Ok(false)
700    }
701
702    /// Whether this store actually implements retention rotation
703    /// (`rotate_receipts`). Default `false`: the default `rotate_receipts` is a
704    /// fail-closed default, so a kernel configured with `retention_config` uses
705    /// this to refuse attaching a store that cannot rotate, rather than serving
706    /// traffic under a retention policy whose background worker would only log
707    /// "not supported" on every interval and never archive.
708    fn supports_retention(&self) -> bool {
709        false
710    }
711
712    /// Whether this store can honor a TENANT-SCOPED retention config
713    /// (`RetentionConfig.tenant_id` set). Default `false`: prefix-watermark
714    /// retention archives a contiguous checkpointed prefix of the WHOLE log and
715    /// cannot carve out a single tenant, so a tenant-scoped `rotate_receipts`
716    /// fails closed. A kernel configured with a tenant-scoped retention policy
717    /// uses this to refuse attaching a store that could never archive under it,
718    /// rather than spawning a worker that only logs "unsupported" every interval.
719    fn supports_tenant_scoped_retention(&self) -> bool {
720        false
721    }
722
723    /// Archive receipts that have aged out under `config` (day/size
724    /// threshold, or an explicit cutoff). Returns the number of archived
725    /// tool-receipt rows. Default: unsupported (fail-closed) for stores that
726    /// do not implement retention.
727    fn rotate_receipts(&self, _config: &RetentionConfig) -> Result<u64, ReceiptStoreError> {
728        Err(ReceiptStoreError::Conflict(
729            "receipt retention is not supported by this receipt store".to_string(),
730        ))
731    }
732
733    /// Record the outcome of a background retention rotation so a persistent
734    /// failure is observable in `receipt_store_health` (and any health/flush
735    /// report) rather than living only in logs. `None` clears a prior failure
736    /// after a successful rotation; `Some(message)` records a rotation error or
737    /// panic so a silently-failing background retention task marks the store
738    /// unhealthy and surfaces the cause to operators and automation. Default
739    /// no-op for stores without a health surface.
740    fn record_retention_rotation_outcome(&self, _failure: Option<&str>) {}
741
742    fn record_capability_snapshot(
743        &self,
744        _token: &CapabilityToken,
745        _parent_capability_id: Option<&str>,
746    ) -> Result<(), ReceiptStoreError> {
747        Ok(())
748    }
749
750    /// Record a capability snapshot, failing closed with
751    /// `ReceiptStoreError::Timeout` if the writer round trip exceeds `budget`.
752    /// The default ignores the budget and keeps the unbounded behavior for stores
753    /// without an async writer; a store with a commit actor overrides this so a
754    /// writer that stalls after the pre-dispatch liveness check cannot hang the
755    /// evaluation hot path inside the snapshot write.
756    fn record_capability_snapshot_with_timeout(
757        &self,
758        token: &CapabilityToken,
759        parent_capability_id: Option<&str>,
760        _budget: std::time::Duration,
761    ) -> Result<(), ReceiptStoreError> {
762        self.record_capability_snapshot(token, parent_capability_id)
763    }
764
765    fn get_capability_snapshot(
766        &self,
767        _capability_id: &str,
768    ) -> Result<Option<CapabilitySnapshot>, ReceiptStoreError> {
769        Ok(None)
770    }
771
772    fn get_capability_delegation_chain(
773        &self,
774        _capability_id: &str,
775    ) -> Result<Vec<CapabilitySnapshot>, ReceiptStoreError> {
776        Ok(Vec::new())
777    }
778
779    fn resolve_credit_bond(
780        &self,
781        _bond_id: &str,
782    ) -> Result<Option<CreditBondRow>, ReceiptStoreError> {
783        Ok(None)
784    }
785
786    /// Persist a serialized `SessionAnchor` (JSON form).
787    fn record_session_anchor(
788        &self,
789        _session_id: &str,
790        _anchor_id: &str,
791        _auth_context_fingerprint: &str,
792        _issued_at: u64,
793        _supersedes_anchor_id: Option<&str>,
794        _anchor_json: &serde_json::Value,
795    ) -> Result<(), ReceiptStoreError> {
796        Ok(())
797    }
798
799    /// Persist a serialized `RequestLineageRecord` (JSON form).
800    #[allow(clippy::too_many_arguments)]
801    fn record_request_lineage(
802        &self,
803        _session_id: &str,
804        _request_id: &str,
805        _parent_request_id: Option<&str>,
806        _session_anchor_id: Option<&str>,
807        _recorded_at: u64,
808        _request_fingerprint: Option<&str>,
809        _lineage_json: &serde_json::Value,
810    ) -> Result<(), ReceiptStoreError> {
811        Ok(())
812    }
813
814    /// Persist a serialized `ReceiptLineageStatement` (JSON form).
815    #[allow(clippy::too_many_arguments)]
816    fn record_receipt_lineage_statement(
817        &self,
818        _child_receipt_id: &str,
819        _request_id: Option<&str>,
820        _session_id: Option<&str>,
821        _session_anchor_id: Option<&str>,
822        _parent_request_id: Option<&str>,
823        _parent_receipt_id: Option<&str>,
824        _chain_id: Option<&str>,
825        _recorded_at: u64,
826        _statement_json: &serde_json::Value,
827    ) -> Result<(), ReceiptStoreError> {
828        Ok(())
829    }
830
831    fn get_receipt_lineage_verification(
832        &self,
833        _receipt_id: &str,
834    ) -> Result<Option<ReceiptLineageVerification>, ReceiptStoreError> {
835        Ok(None)
836    }
837
838    fn list_receipt_lineage_statement_links(
839        &self,
840        _receipt_id: &str,
841    ) -> Result<Vec<ReceiptLineageStatementLink>, ReceiptStoreError> {
842        Ok(Vec::new())
843    }
844
845    fn as_any_mut(&self) -> Option<&dyn std::any::Any> {
846        None
847    }
848}
849
850/// Store authority qualified to coordinate admission state and atomically
851/// publish every terminal receipt projection.
852#[derive(Debug, Clone, PartialEq, Eq)]
853pub struct AdmissionBudgetAuthorization {
854    pub decision: crate::budget_store::BudgetAuthorizeHoldDecision,
855    pub operation: crate::admission_operation::AdmissionOperationV1,
856}
857
858#[derive(Debug, Clone, PartialEq, Eq)]
859pub struct AdmissionBudgetCapture {
860    pub decision: crate::budget_store::BudgetInvocationCaptureDecision,
861    pub operation: crate::admission_operation::AdmissionOperationV1,
862}
863
864#[derive(Debug, Clone, Copy)]
865pub struct AdmissionPaymentJournalAdvance<'a> {
866    pub operation: &'a crate::admission_operation::AdmissionOperationV1,
867    pub recovery_lease: &'a crate::admission_operation::AdmissionRecoveryLease,
868    pub expected: &'a crate::payment::PaymentJournalRecord,
869    pub transition: &'a crate::payment::PaymentJournalTransition,
870    pub release_evidence: Option<&'a crate::tool_outcome::MonetaryReleaseEvidenceV1>,
871    pub active_fence: &'a crate::admission_operation::StoreMutationFence,
872    pub trusted_now_unix_ms: u64,
873}
874
875#[derive(Debug, Clone)]
876pub struct AdmissionPaymentSettlementBegin<'a> {
877    pub operation: &'a crate::admission_operation::AdmissionOperationV1,
878    pub recovery_lease: &'a crate::admission_operation::AdmissionRecoveryLease,
879    pub expected: &'a crate::payment::PaymentJournalRecord,
880    pub transition: Option<&'a crate::payment::PaymentJournalTransition>,
881    pub release_evidence: Option<&'a crate::tool_outcome::MonetaryReleaseEvidenceV1>,
882    pub budget_reconcile: crate::budget_store::BudgetReconcileHoldRequest,
883    pub active_fence: &'a crate::admission_operation::StoreMutationFence,
884    pub trusted_now_unix_ms: u64,
885}
886
887#[derive(Debug, Clone, PartialEq, Eq)]
888pub struct AdmissionPaymentSettlement {
889    pub journal: crate::payment::PaymentJournalRecord,
890    pub budget: crate::budget_store::BudgetReconcileHoldDecision,
891    pub budget_already_reconciled: bool,
892}
893
894#[derive(Debug, thiserror::Error)]
895pub enum AdmissionBudgetAuthorizationError {
896    #[error("combined admission budget authorization is unavailable: {0}")]
897    Unavailable(String),
898    #[error("combined admission budget authorization was fenced")]
899    Fenced,
900    #[error("combined admission budget authorization durable outcome is unknown: {0}")]
901    OutcomeUnknown(String),
902    #[error("combined admission budget authorization invariant failed: {0}")]
903    Invariant(String),
904    #[error(transparent)]
905    Operation(#[from] crate::admission_operation::AdmissionOperationError),
906}
907
908#[derive(Debug, thiserror::Error)]
909pub enum AdmissionPaymentJournalError {
910    #[error("qualified payment journal is unavailable: {0}")]
911    Unavailable(String),
912    #[error("qualified payment journal mutation was fenced")]
913    Fenced,
914    #[error("qualified payment journal compare-and-set conflicted: {0}")]
915    Conflict(String),
916    #[error("qualified payment journal durable outcome is unknown: {0}")]
917    OutcomeUnknown(String),
918    #[error("qualified payment journal invariant failed: {0}")]
919    Invariant(String),
920}
921
922pub const ADMISSION_TERMINAL_PROJECTION_DESCRIPTOR_KIND: &str =
923    "chio.admission.terminal-projection.v1";
924
925#[derive(Debug, Clone, PartialEq, Eq)]
926pub struct ThresholdApprovalReplayReservationV1 {
927    proposal: chio_core::capability::governance::ThresholdApprovalProposal,
928    tokens: Vec<chio_core::capability::governance::GovernedApprovalToken>,
929    verified_set: chio_core::capability::governance::VerifiedApprovalSetBody,
930}
931
932impl ThresholdApprovalReplayReservationV1 {
933    pub fn new(
934        proposal: chio_core::capability::governance::ThresholdApprovalProposal,
935        mut tokens: Vec<chio_core::capability::governance::GovernedApprovalToken>,
936        verified_set: chio_core::capability::governance::VerifiedApprovalSetBody,
937    ) -> Result<Self, crate::admission_operation::AdmissionOperationStoreError> {
938        use std::collections::HashSet;
939
940        if tokens.is_empty()
941            || tokens.len()
942                > chio_core::capability::threshold_approval::MAX_THRESHOLD_APPROVAL_TOKENS
943        {
944            return Err(
945                crate::admission_operation::AdmissionOperationStoreError::Invariant(format!(
946                    "threshold approval replay reservation must contain between 1 and {} tokens",
947                    chio_core::capability::threshold_approval::MAX_THRESHOLD_APPROVAL_TOKENS
948                )),
949            );
950        }
951        if !proposal.verify_signature().map_err(|error| {
952            crate::admission_operation::AdmissionOperationStoreError::Invariant(error.to_string())
953        })? {
954            return Err(
955                crate::admission_operation::AdmissionOperationStoreError::Invariant(
956                    "threshold approval replay proposal signature is invalid".to_owned(),
957                ),
958            );
959        }
960        let proposal_hash = proposal.artifact_digest().map_err(|error| {
961            crate::admission_operation::AdmissionOperationStoreError::Invariant(error.to_string())
962        })?;
963        let mut token_ids = HashSet::new();
964        let mut approvers = HashSet::new();
965        let mut tokens_with_digests = Vec::with_capacity(tokens.len());
966        for token in tokens.drain(..) {
967            if token.id.is_empty()
968                || token.id.trim() != token.id
969                || token.threshold_proposal_hash.as_deref() != Some(proposal_hash.as_str())
970                || token.request_id != proposal.body.request_id
971                || token.governed_intent_hash != proposal.body.governed_intent_hash
972                || token.subject != proposal.body.subject
973                || token.decision
974                    != chio_core::capability::governance::GovernedApprovalDecision::Approved
975                || token.issued_at < proposal.body.proposal_created_at
976                || token.issued_at >= proposal.body.proposal_deadline
977                || token.expires_at > proposal.body.proposal_deadline
978                || !token_ids.insert(token.id.clone())
979                || !approvers.insert(token.approver.to_hex())
980            {
981                return Err(
982                    crate::admission_operation::AdmissionOperationStoreError::Invariant(
983                        "threshold approval replay tokens do not form a distinct proposal set"
984                            .to_owned(),
985                    ),
986                );
987            }
988            if !token.verify_signature().map_err(|error| {
989                crate::admission_operation::AdmissionOperationStoreError::Invariant(
990                    error.to_string(),
991                )
992            })? {
993                return Err(
994                    crate::admission_operation::AdmissionOperationStoreError::Invariant(
995                        "threshold approval replay token signature is invalid".to_owned(),
996                    ),
997                );
998            }
999            let digest = token.artifact_digest().map_err(|error| {
1000                crate::admission_operation::AdmissionOperationStoreError::Invariant(
1001                    error.to_string(),
1002                )
1003            })?;
1004            tokens_with_digests.push((digest, token));
1005        }
1006        tokens_with_digests.sort_by(|left, right| left.0.cmp(&right.0));
1007        if tokens_with_digests
1008            .windows(2)
1009            .any(|pair| pair[0].0 == pair[1].0)
1010            || tokens_with_digests
1011                .iter()
1012                .map(|(digest, _)| digest)
1013                .ne(verified_set.token_digests.iter())
1014        {
1015            return Err(
1016                crate::admission_operation::AdmissionOperationStoreError::Invariant(
1017                    "threshold approval replay token digests do not match the verified set"
1018                        .to_owned(),
1019                ),
1020            );
1021        }
1022        let reconstructed = chio_core::capability::governance::VerifiedApprovalSetBody::new(
1023            verified_set.token_digests.clone(),
1024            &proposal,
1025        )
1026        .map_err(|error| {
1027            crate::admission_operation::AdmissionOperationStoreError::Invariant(error.to_string())
1028        })?;
1029        if reconstructed != verified_set {
1030            return Err(
1031                crate::admission_operation::AdmissionOperationStoreError::Invariant(
1032                    "threshold approval replay set does not match its signed proposal".to_owned(),
1033                ),
1034            );
1035        }
1036        Ok(Self {
1037            proposal,
1038            tokens: tokens_with_digests
1039                .into_iter()
1040                .map(|(_, token)| token)
1041                .collect(),
1042            verified_set,
1043        })
1044    }
1045
1046    #[must_use]
1047    pub const fn proposal(&self) -> &chio_core::capability::governance::ThresholdApprovalProposal {
1048        &self.proposal
1049    }
1050
1051    #[must_use]
1052    pub fn tokens(&self) -> &[chio_core::capability::governance::GovernedApprovalToken] {
1053        &self.tokens
1054    }
1055
1056    #[must_use]
1057    pub const fn verified_set(
1058        &self,
1059    ) -> &chio_core::capability::governance::VerifiedApprovalSetBody {
1060        &self.verified_set
1061    }
1062}
1063
1064pub trait QualifiedAdmissionProjectionStore:
1065    ReceiptStore + crate::admission_operation::QualifiedAdmissionOperationStore
1066{
1067    fn load_payment_journal(
1068        &self,
1069        operation_id: &str,
1070        active_fence: &crate::admission_operation::StoreMutationFence,
1071    ) -> Result<Option<crate::payment::PaymentJournalRecord>, AdmissionPaymentJournalError>;
1072
1073    fn advance_payment_journal(
1074        &self,
1075        advance: AdmissionPaymentJournalAdvance<'_>,
1076    ) -> Result<crate::payment::PaymentJournalRecord, AdmissionPaymentJournalError>;
1077
1078    fn begin_payment_settlement(
1079        &self,
1080        begin: AdmissionPaymentSettlementBegin<'_>,
1081    ) -> Result<AdmissionPaymentSettlement, AdmissionPaymentJournalError>;
1082
1083    #[allow(clippy::too_many_arguments)]
1084    fn authorize_budget_and_commit_admission(
1085        &self,
1086        operation: &crate::admission_operation::AdmissionOperationV1,
1087        recovery_lease: &crate::admission_operation::AdmissionRecoveryLease,
1088        request: crate::budget_store::BudgetAuthorizeHoldRequest,
1089        payment_journal: Option<crate::payment::PaymentJournalRecord>,
1090        credit_exposure: Option<chio_credit::obligation::CreditExposureReservationRequest>,
1091        active_fence: &crate::admission_operation::StoreMutationFence,
1092        trusted_now_unix_ms: u64,
1093    ) -> Result<AdmissionBudgetAuthorization, AdmissionBudgetAuthorizationError>;
1094
1095    fn capture_invocation_and_commit_dispatch(
1096        &self,
1097        operation: &crate::admission_operation::AdmissionOperationV1,
1098        recovery_lease: &crate::admission_operation::AdmissionRecoveryLease,
1099        request: crate::budget_store::BudgetCaptureInvocationRequest,
1100        active_fence: &crate::admission_operation::StoreMutationFence,
1101        trusted_now_unix_ms: u64,
1102    ) -> Result<AdmissionBudgetCapture, crate::admission_operation::AdmissionCaptureError>;
1103
1104    fn reserve_threshold_approval_and_commit_admission(
1105        &self,
1106        _command: &crate::admission_operation::AdmissionOperationCommand,
1107        _reservation: &ThresholdApprovalReplayReservationV1,
1108        _trusted_now_unix_ms: u64,
1109    ) -> Result<
1110        crate::admission_operation::AdmissionCommandResult,
1111        crate::admission_operation::AdmissionOperationStoreError,
1112    > {
1113        Err(
1114            crate::admission_operation::AdmissionOperationStoreError::Unavailable(
1115                "durable threshold approval replay reservation is unsupported".to_owned(),
1116            ),
1117        )
1118    }
1119
1120    fn list_admission_receipts_after(
1121        &self,
1122        after_receipt_id: Option<&str>,
1123        limit: usize,
1124    ) -> Result<Vec<ChioReceipt>, ReceiptStoreError>;
1125}
1126
1127pub trait AnchoredAdmissionProjectionStore: QualifiedAdmissionProjectionStore {
1128    fn stage_anchored_terminal_projection(
1129        &self,
1130        advance: &chio_core::economic_continuity::VerifiedEconomicStateBatchAdvance,
1131        recovery_lease: &crate::admission_operation::AdmissionRecoveryLease,
1132        envelope: &crate::admission_operation::SignedAdmissionTerminalProjectionV1,
1133        active_fence: &crate::admission_operation::StoreMutationFence,
1134        trusted_now_unix_ms: u64,
1135    ) -> Result<(), ReceiptStoreError>;
1136
1137    fn qualify_anchored_terminal_projection(
1138        &self,
1139        batch_id: &str,
1140        active_fence: &crate::admission_operation::StoreMutationFence,
1141        trusted_now_unix_ms: u64,
1142    ) -> Result<(), ReceiptStoreError>;
1143
1144    fn record_anchored_terminal_projection(
1145        &self,
1146        advance: &chio_core::economic_continuity::VerifiedEconomicStateBatchAdvance,
1147        committed: &chio_core::economic_continuity::VerifiedEconomicStateView,
1148        pins: &chio_core::economic_continuity::EconomicStateAnchorPins,
1149        active_fence: &crate::admission_operation::StoreMutationFence,
1150        trusted_now_unix_ms: u64,
1151    ) -> Result<(), ReceiptStoreError>;
1152
1153    fn commit_anchored_terminal_projection(
1154        &self,
1155        batch_id: &str,
1156        active_fence: &crate::admission_operation::StoreMutationFence,
1157        trusted_now_unix_ms: u64,
1158    ) -> Result<crate::admission_operation::AdmissionTerminal, ReceiptStoreError>;
1159}
1160
1161#[cfg(test)]
1162mod tests {
1163    use super::*;
1164
1165    use std::sync::atomic::{AtomicUsize, Ordering};
1166
1167    use chio_core::receipt::{
1168        body::ChioReceiptBody, decision::Decision, decision::ToolCallAction, kinds::TrustLevel,
1169    };
1170
1171    struct AppendOnlyStore;
1172
1173    impl ReceiptStore for AppendOnlyStore {
1174        fn append_chio_receipt(&self, _receipt: &ChioReceipt) -> Result<(), ReceiptStoreError> {
1175            Ok(())
1176        }
1177
1178        fn append_child_receipt(
1179            &self,
1180            _receipt: &ChildRequestReceipt,
1181        ) -> Result<(), ReceiptStoreError> {
1182            Ok(())
1183        }
1184    }
1185
1186    struct CountingAppendStore {
1187        append_calls: AtomicUsize,
1188    }
1189
1190    impl ReceiptStore for CountingAppendStore {
1191        fn append_chio_receipt(&self, _receipt: &ChioReceipt) -> Result<(), ReceiptStoreError> {
1192            self.append_calls.fetch_add(1, Ordering::SeqCst);
1193            Ok(())
1194        }
1195
1196        fn append_child_receipt(
1197            &self,
1198            _receipt: &ChildRequestReceipt,
1199        ) -> Result<(), ReceiptStoreError> {
1200            Ok(())
1201        }
1202    }
1203
1204    struct LegacyAtomicOnlyStore {
1205        atomic_append_calls: AtomicUsize,
1206    }
1207
1208    impl ReceiptStore for LegacyAtomicOnlyStore {
1209        fn append_chio_receipt(&self, _receipt: &ChioReceipt) -> Result<(), ReceiptStoreError> {
1210            Ok(())
1211        }
1212
1213        fn atomic_receipt_projection(&self) -> AtomicReceiptProjection {
1214            AtomicReceiptProjection::SettlementObservationV1
1215        }
1216
1217        fn append_chio_receipt_with_pending_observation(
1218            &self,
1219            _receipt: &ChioReceipt,
1220            _pending: &PendingSettlementObservation,
1221        ) -> Result<(), ReceiptStoreError> {
1222            self.atomic_append_calls.fetch_add(1, Ordering::SeqCst);
1223            Ok(())
1224        }
1225
1226        fn append_child_receipt(
1227            &self,
1228            _receipt: &ChildRequestReceipt,
1229        ) -> Result<(), ReceiptStoreError> {
1230            Ok(())
1231        }
1232    }
1233
1234    fn signed_receipt() -> ChioReceipt {
1235        let keypair = Keypair::generate();
1236        let action = match ToolCallAction::from_parameters(serde_json::json!({})) {
1237            Ok(action) => action,
1238            Err(error) => panic!("test action construction failed: {error}"),
1239        };
1240        let body = ChioReceiptBody {
1241            id: "receipt-1".to_string(),
1242            timestamp: 1,
1243            capability_id: "capability-1".to_string(),
1244            tool_server: "server".to_string(),
1245            tool_name: "tool".to_string(),
1246            action,
1247            decision: Some(Decision::Allow),
1248            receipt_kind: Default::default(),
1249            boundary_class: Default::default(),
1250            observation_outcome: None,
1251            tool_origin: Default::default(),
1252            redaction_mode: Default::default(),
1253            actor_chain: Vec::new(),
1254            content_hash: "content".to_string(),
1255            policy_hash: "policy".to_string(),
1256            evidence: Vec::new(),
1257            metadata: None,
1258            trust_level: TrustLevel::default(),
1259            tenant_id: None,
1260            kernel_key: keypair.public_key(),
1261            bbs_projection_version: None,
1262        };
1263        match ChioReceipt::sign(body, &keypair) {
1264            Ok(receipt) => receipt,
1265            Err(error) => panic!("test receipt signing failed: {error}"),
1266        }
1267    }
1268
1269    #[test]
1270    fn unsupported_atomic_projection_never_falls_back_to_receipt_only_append() {
1271        let store = CountingAppendStore {
1272            append_calls: AtomicUsize::new(0),
1273        };
1274        let receipt = signed_receipt();
1275
1276        assert_eq!(
1277            store.atomic_receipt_projection(),
1278            AtomicReceiptProjection::Unsupported
1279        );
1280        assert!(!store.supports_atomic_receipt_projection_with_timeout());
1281        assert_eq!(store.settlement_store_binding(), None);
1282        let pending = PendingSettlementObservation {
1283            next_visible_at_ms: 1,
1284        };
1285        assert!(matches!(
1286            store.append_chio_receipt_with_pending_observation(&receipt, &pending),
1287            Err(ReceiptStoreError::Unsupported(_))
1288        ));
1289        assert!(matches!(
1290            store.append_chio_receipt_with_pending_observation_and_timeout(
1291                &receipt,
1292                &pending,
1293                std::time::Duration::from_millis(1),
1294            ),
1295            Err(ReceiptStoreError::Unsupported(_))
1296        ));
1297        assert_eq!(store.append_calls.load(Ordering::SeqCst), 0);
1298    }
1299
1300    #[test]
1301    fn timed_atomic_default_never_calls_the_legacy_unbounded_projection() {
1302        let store = LegacyAtomicOnlyStore {
1303            atomic_append_calls: AtomicUsize::new(0),
1304        };
1305        let receipt = signed_receipt();
1306        let pending = PendingSettlementObservation {
1307            next_visible_at_ms: 1,
1308        };
1309
1310        assert_eq!(
1311            store.atomic_receipt_projection(),
1312            AtomicReceiptProjection::SettlementObservationV1
1313        );
1314        assert!(!store.supports_atomic_receipt_projection_with_timeout());
1315        assert!(matches!(
1316            store.append_chio_receipt_with_pending_observation_and_timeout(
1317                &receipt,
1318                &pending,
1319                std::time::Duration::from_millis(1),
1320            ),
1321            Err(ReceiptStoreError::Unsupported(_))
1322        ));
1323        assert_eq!(store.atomic_append_calls.load(Ordering::SeqCst), 0);
1324    }
1325
1326    #[test]
1327    fn unsupported_durability_surfaces_fail_closed() -> Result<(), Box<dyn std::error::Error>> {
1328        let store = AppendOnlyStore;
1329        let checkpoint = crate::checkpoint::build_checkpoint(
1330            1,
1331            1,
1332            1,
1333            &[b"receipt".to_vec()],
1334            &Keypair::generate(),
1335        )?;
1336
1337        assert!(matches!(
1338            store.receipts_canonical_bytes_range(1, 1),
1339            Err(ReceiptStoreError::Conflict(message))
1340                if message.contains("receipt canonical byte ranges are not supported")
1341        ));
1342        assert!(matches!(
1343            store.flush_receipt_writes(),
1344            Err(ReceiptStoreError::Conflict(message))
1345                if message.contains("receipt writer flush is not supported")
1346        ));
1347        assert!(matches!(
1348            store.flush_receipt_writes_with_timeout(std::time::Duration::from_millis(1)),
1349            Err(ReceiptStoreError::Conflict(message))
1350                if message.contains("receipt writer flush is not supported")
1351        ));
1352        assert!(matches!(
1353            store.receipt_store_health(),
1354            Err(ReceiptStoreError::Conflict(message))
1355                if message.contains("receipt store health is not supported")
1356        ));
1357        assert!(matches!(
1358            store.latest_committed_entry_seq(),
1359            Err(ReceiptStoreError::Conflict(message))
1360                if message.contains("receipt committed sequence reporting is not supported")
1361        ));
1362        assert!(matches!(
1363            store.latest_checkpointed_entry_seq(),
1364            Err(ReceiptStoreError::Conflict(message))
1365                if message.contains("receipt checkpoint sequence reporting is not supported")
1366        ));
1367        assert!(matches!(
1368            store.next_checkpoint_range(1),
1369            Err(ReceiptStoreError::Conflict(message))
1370                if message.contains("receipt checkpoint ranges are not supported")
1371        ));
1372        assert!(matches!(
1373            store.receipt_checkpoint_status(Some(1)),
1374            Err(ReceiptStoreError::Conflict(message))
1375                if message.contains("receipt checkpoint status is not supported")
1376        ));
1377        assert!(matches!(
1378            store.store_checkpoint(&checkpoint),
1379            Err(ReceiptStoreError::Conflict(message))
1380                if message.contains("receipt checkpoint storage is not supported")
1381        ));
1382        assert!(matches!(
1383            store.rotate_receipts(&RetentionConfig::default()),
1384            Err(ReceiptStoreError::Conflict(message))
1385                if message.contains("receipt retention is not supported")
1386        ));
1387        Ok(())
1388    }
1389
1390    /// A store whose background rotation always fails, recording the outcome the
1391    /// maintenance worker hands it and reflecting it in health, so a persistent
1392    /// retention failure is observable rather than silently healthy.
1393    #[derive(Default)]
1394    struct FailingRetentionStore {
1395        retention_error: std::sync::Mutex<Option<String>>,
1396        rotations: std::sync::atomic::AtomicU64,
1397    }
1398
1399    impl ReceiptStore for FailingRetentionStore {
1400        fn append_chio_receipt(&self, _receipt: &ChioReceipt) -> Result<(), ReceiptStoreError> {
1401            Ok(())
1402        }
1403
1404        fn append_child_receipt(
1405            &self,
1406            _receipt: &ChildRequestReceipt,
1407        ) -> Result<(), ReceiptStoreError> {
1408            Ok(())
1409        }
1410
1411        fn supports_retention(&self) -> bool {
1412            true
1413        }
1414
1415        fn rotate_receipts(&self, _config: &RetentionConfig) -> Result<u64, ReceiptStoreError> {
1416            self.rotations
1417                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1418            Err(ReceiptStoreError::Conflict(
1419                "archive path is unwritable".to_string(),
1420            ))
1421        }
1422
1423        fn record_retention_rotation_outcome(&self, failure: Option<&str>) {
1424            if let Ok(mut guard) = self.retention_error.lock() {
1425                *guard = failure.map(ToString::to_string);
1426            }
1427        }
1428
1429        fn receipt_store_health(&self) -> Result<ReceiptStoreHealthReport, ReceiptStoreError> {
1430            let retention_error = self.retention_error.lock().ok().and_then(|g| g.clone());
1431            Ok(ReceiptStoreHealthReport {
1432                healthy: retention_error.is_none(),
1433                retention_error,
1434                ..ReceiptStoreHealthReport::default()
1435            })
1436        }
1437    }
1438
1439    #[test]
1440    fn background_retention_failure_surfaces_in_health() {
1441        let store = std::sync::Arc::new(FailingRetentionStore::default());
1442        // A store with no rotation attempt yet reports healthy.
1443        assert!(store.receipt_store_health().expect("health report").healthy);
1444
1445        let config = RetentionConfig {
1446            check_interval_secs: 1,
1447            ..RetentionConfig::default()
1448        };
1449        let handle = RetentionMaintenanceHandle::spawn(store.clone(), config);
1450
1451        // The worker sleeps one interval (in 200ms slices) before its first
1452        // rotation, then records the failure into health. Poll until it appears.
1453        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1454        while std::time::Instant::now() < deadline
1455            && store
1456                .receipt_store_health()
1457                .expect("health report")
1458                .retention_error
1459                .is_none()
1460        {
1461            std::thread::sleep(std::time::Duration::from_millis(50));
1462        }
1463
1464        let report = store.receipt_store_health().expect("health report");
1465        drop(handle);
1466        assert!(
1467            store.rotations.load(std::sync::atomic::Ordering::SeqCst) > 0,
1468            "the maintenance worker never attempted a rotation"
1469        );
1470        assert!(
1471            !report.healthy,
1472            "a persistently failing background rotation must mark the store unhealthy"
1473        );
1474        let message = report
1475            .retention_error
1476            .expect("the background rotation failure must surface in health");
1477        assert!(
1478            message.contains("archive path is unwritable"),
1479            "unexpected retention error: {message}"
1480        );
1481    }
1482}
1483
1484#[derive(Debug, Clone)]
1485pub struct StoredToolReceipt {
1486    pub seq: u64,
1487    pub receipt: ChioReceipt,
1488}
1489
1490#[derive(Debug, Clone)]
1491pub struct StoredChildReceipt {
1492    pub seq: u64,
1493    pub receipt: ChildRequestReceipt,
1494}
1495
1496#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1497#[serde(rename_all = "camelCase")]
1498pub struct ReceiptLineageVerification {
1499    pub receipt_id: String,
1500    #[serde(default, skip_serializing_if = "Option::is_none")]
1501    pub request_id: Option<String>,
1502    #[serde(default, skip_serializing_if = "Option::is_none")]
1503    pub session_id: Option<String>,
1504    #[serde(default, skip_serializing_if = "Option::is_none")]
1505    pub session_anchor_id: Option<String>,
1506    pub session_anchor_verified: bool,
1507    pub parent_request_verified: bool,
1508    pub parent_receipt_verified: bool,
1509    pub replay_protected: bool,
1510}
1511
1512impl ReceiptLineageVerification {
1513    #[must_use]
1514    pub fn delegated_call_chain_bound(&self) -> bool {
1515        self.parent_receipt_verified
1516            || (self.session_anchor_verified && self.parent_request_verified)
1517    }
1518}
1519
1520#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1521#[serde(rename_all = "camelCase")]
1522pub struct ReceiptLineageStatementLink {
1523    #[serde(default, skip_serializing_if = "Option::is_none")]
1524    pub statement_id: Option<String>,
1525    pub child_receipt_id: String,
1526    #[serde(default, skip_serializing_if = "Option::is_none")]
1527    pub child_request_id: Option<String>,
1528    #[serde(default, skip_serializing_if = "Option::is_none")]
1529    pub parent_receipt_id: Option<String>,
1530    #[serde(default, skip_serializing_if = "Option::is_none")]
1531    pub parent_request_id: Option<String>,
1532    #[serde(default, skip_serializing_if = "Option::is_none")]
1533    pub session_id: Option<String>,
1534    #[serde(default, skip_serializing_if = "Option::is_none")]
1535    pub session_anchor_id: Option<String>,
1536    #[serde(default, skip_serializing_if = "Option::is_none")]
1537    pub chain_id: Option<String>,
1538    pub recorded_at: u64,
1539}
1540
1541#[derive(Debug, Clone)]
1542pub struct FederatedEvidenceShareImport {
1543    pub share_id: String,
1544    pub manifest_hash: String,
1545    pub exported_at: u64,
1546    pub issuer: String,
1547    pub partner: String,
1548    pub signer_public_key: String,
1549    pub require_proofs: bool,
1550    pub query_json: String,
1551    pub tool_receipts: Vec<StoredToolReceipt>,
1552    pub capability_lineage: Vec<CapabilitySnapshot>,
1553}
1554
1555#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
1556#[serde(rename_all = "camelCase")]
1557pub struct FederatedEvidenceShareSummary {
1558    pub share_id: String,
1559    pub manifest_hash: String,
1560    pub imported_at: u64,
1561    pub exported_at: u64,
1562    pub issuer: String,
1563    pub partner: String,
1564    pub signer_public_key: String,
1565    pub require_proofs: bool,
1566    pub tool_receipts: u64,
1567    pub capability_lineage: u64,
1568}