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#[derive(Debug, Clone)]
17pub struct RetentionConfig {
18 pub retention_days: u64,
20 pub max_size_bytes: u64,
22 pub archive_path: String,
24 pub tenant_id: Option<String>,
27 pub check_interval_secs: u64,
30 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
49pub struct RetentionMaintenanceHandle {
59 stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
60 join: Option<std::thread::JoinHandle<()>>,
61}
62
63impl RetentionMaintenanceHandle {
64 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 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 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
87 store.rotate_receipts(&config)
88 }));
89 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 #[serde(default)]
144 pub timed_out_total: u64,
145 #[serde(default)]
147 pub timed_out_inflight: u64,
148 #[serde(default)]
153 pub queue_depth: u64,
154 #[serde(default)]
155 pub last_commit_unix_ms: Option<u64>,
156 #[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 #[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 #[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 #[serde(default)]
246 pub retention_watermark_entry_seq: Option<u64>,
247 #[serde(default)]
253 pub retention_error: Option<String>,
254 #[serde(default)]
257 pub writer_level: chio_supervisor::HealthLevel,
258 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
364pub enum AtomicReceiptProjection {
365 Unsupported,
367 SettlementObservationV1,
369}
370
371#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
373pub struct PendingSettlementObservation {
374 pub next_visible_at_ms: u64,
376}
377
378#[derive(Clone, Copy, Debug, PartialEq, Eq)]
384pub enum ReceiptWriterLiveness {
385 Healthy,
386 Saturated,
387 Wedged,
388 Dead,
389 Unknown,
390}
391
392impl ReceiptWriterLiveness {
393 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 fn settlement_store_binding(&self) -> Option<chio_settle::SettlementStoreBinding> {
432 None
433 }
434 fn atomic_receipt_projection(&self) -> AtomicReceiptProjection {
436 AtomicReceiptProjection::Unsupported
437 }
438 fn supports_atomic_receipt_projection_with_timeout(&self) -> bool {
442 false
443 }
444 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 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 fn load_chio_receipt(
486 &self,
487 _receipt_id: &str,
488 ) -> Result<Option<ChioReceipt>, ReceiptStoreError> {
489 Ok(None)
490 }
491 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 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 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 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 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 fn enable_background_checkpoints(
695 &self,
696 _keypair: Keypair,
697 _max_batch: u64,
698 ) -> Result<bool, ReceiptStoreError> {
699 Ok(false)
700 }
701
702 fn supports_retention(&self) -> bool {
709 false
710 }
711
712 fn supports_tenant_scoped_retention(&self) -> bool {
720 false
721 }
722
723 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 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 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 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 #[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 #[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#[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 #[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 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 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}