1use super::support::{
2 ensure_checkpoint_transparency_guards, ensure_transparency_projection_guards,
3 insert_receipt_retention_watermark, load_claim_tree_canonical_bytes_range,
4 load_persisted_checkpoint_row, parse_persisted_checkpoint_row, store_kernel_checkpoint_atomic,
5};
6use super::*;
7
8pub(crate) fn receipt_query_sql(
9 query: &ReceiptQuery,
10 tenant_fragment: &str,
11) -> Result<(String, String), ReceiptStoreError> {
12 let currency = query
13 .validated_cost_currency()
14 .map_err(ReceiptStoreError::ReadBoundary)?;
15 let cost_fragment = match (
16 query.min_cost.is_some(),
17 query.max_cost.is_some(),
18 currency.is_some(),
19 ) {
20 (false, false, false) => "AND ?13 IS NULL",
21 (false, false, true) => "AND r.cost_currency = ?13",
22 (true, false, true) => "AND r.cost_currency = ?13 AND r.cost_charged_be >= ?7",
23 (false, true, true) => "AND r.cost_currency = ?13 AND r.cost_charged_be <= ?8",
24 (true, true, true) => {
25 "AND r.cost_currency = ?13 AND r.cost_charged_be >= ?7 AND r.cost_charged_be <= ?8"
26 }
27 _ => {
28 return Err(ReceiptStoreError::ReadBoundary(
29 "receipt query cost bounds require a currency".to_string(),
30 ))
31 }
32 };
33 let from_where = format!(
34 r#"
35 FROM chio_tool_receipts r
36 LEFT JOIN capability_lineage cl ON r.capability_id = cl.capability_id
37 WHERE (?1 IS NULL OR r.capability_id = ?1)
38 AND (?2 IS NULL OR r.tool_server = ?2)
39 AND (?3 IS NULL OR r.tool_name = ?3)
40 AND (?4 IS NULL OR r.decision_kind = ?4)
41 AND (?5 IS NULL OR r.timestamp >= ?5)
42 AND (?6 IS NULL OR r.timestamp <= ?6)
43 {cost_fragment}
44 AND (?9 IS NULL OR COALESCE(r.subject_key, cl.subject_key) = ?9)
45 AND {tenant_fragment}
46 "#
47 );
48 let data_sql = format!(
49 r#"
50 SELECT r.seq, r.raw_json
51 {from_where}
52 AND (?10 IS NULL OR r.seq > ?10)
53 ORDER BY r.seq ASC
54 LIMIT ?11
55 "#
56 );
57 let count_sql = format!("SELECT COUNT(*) {from_where}");
58 Ok((data_sql, count_sql))
59}
60
61impl SqliteReceiptStore {
62 pub fn append_chio_receipt_returning_seq(
63 &self,
64 receipt: &ChioReceipt,
65 ) -> Result<u64, ReceiptStoreError> {
66 let raw_json = serde_json::to_string(receipt)?;
67 self.append_verified_chio_receipt_record(receipt, &raw_json, false)
68 }
69
70 pub fn store_checkpoint(&self, checkpoint: &KernelCheckpoint) -> Result<(), ReceiptStoreError> {
72 let checkpoint = checkpoint.clone();
73 self.writer_handle()
74 .run_write(move |connection| store_kernel_checkpoint_atomic(connection, &checkpoint))
75 }
76
77 pub fn load_checkpoint_by_seq(
79 &self,
80 checkpoint_seq: u64,
81 ) -> Result<Option<KernelCheckpoint>, ReceiptStoreError> {
82 let connection = self.connection()?;
83 ensure_checkpoint_transparency_guards(&connection)?;
84 load_persisted_checkpoint_row(&connection, checkpoint_seq)?
85 .map(parse_persisted_checkpoint_row)
86 .transpose()
87 }
88
89 pub fn receipts_canonical_bytes_range(
93 &self,
94 start_seq: u64,
95 end_seq: u64,
96 ) -> Result<Vec<(u64, Vec<u8>)>, ReceiptStoreError> {
97 let connection = self.connection()?;
98 load_claim_tree_canonical_bytes_range(&connection, start_seq, end_seq)
99 }
100
101 pub fn db_size_bytes(&self) -> Result<u64, ReceiptStoreError> {
106 let page_count: i64 = self
107 .connection()?
108 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
109 let page_size: i64 = self
110 .connection()?
111 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
112 Ok((page_count.max(0) as u64) * (page_size.max(0) as u64))
113 }
114
115 pub fn live_db_size_bytes(&self) -> Result<u64, ReceiptStoreError> {
120 let connection = self.connection()?;
121 live_db_size_bytes_on_connection(&connection)
122 }
123
124 pub fn oldest_receipt_timestamp(&self) -> Result<Option<u64>, ReceiptStoreError> {
127 let ts = self.connection()?.query_row(
128 "SELECT MIN(timestamp) FROM chio_tool_receipts",
129 [],
130 |row| row.get::<_, Option<i64>>(0),
131 )?;
132 Ok(ts.map(|t| t.max(0) as u64))
133 }
134
135 pub fn oldest_receipt_timestamp_for_tenant(
137 &self,
138 tenant_id: &str,
139 ) -> Result<Option<u64>, ReceiptStoreError> {
140 let ts = self.connection()?.query_row(
141 "SELECT MIN(timestamp) FROM chio_tool_receipts WHERE tenant_id = ?1",
142 params![tenant_id],
143 |row| row.get::<_, Option<i64>>(0),
144 )?;
145 Ok(ts.map(|t| t.max(0) as u64))
146 }
147
148 pub fn archive_receipts_before(
154 &self,
155 cutoff_unix_secs: u64,
156 archive_path: &str,
157 ) -> Result<u64, ReceiptStoreError> {
158 let config = RetentionConfig {
159 retention_days: 0,
160 max_size_bytes: u64::MAX,
161 archive_path: archive_path.to_string(),
162 tenant_id: None,
163 explicit_cutoff_unix_secs: Some(cutoff_unix_secs),
164 ..RetentionConfig::default()
165 };
166 self.dispatch_rotate(Box::new(config), Some(cutoff_unix_secs))
170 }
171
172 pub fn rotate_if_needed(&self, config: &RetentionConfig) -> Result<u64, ReceiptStoreError> {
183 self.dispatch_rotate(Box::new(config.clone()), None)
184 }
185
186 fn dispatch_rotate(
187 &self,
188 config: Box<RetentionConfig>,
189 explicit_cutoff: Option<u64>,
190 ) -> Result<u64, ReceiptStoreError> {
191 if config.tenant_id.is_some() {
192 return Err(ReceiptStoreError::RetentionTenantScopeUnsupported);
195 }
196 let config = match explicit_cutoff {
197 Some(cutoff) => {
198 let mut config = config;
199 config.retention_days = 0;
200 config.explicit_cutoff_unix_secs = Some(cutoff);
201 config
202 }
203 None => config,
204 };
205 let (response, result) = std::sync::mpsc::sync_channel(1);
206 let health = &self.receipt_commit_actor.health;
214 health
215 .inflight
216 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
217 if let Err(error) = self
218 .receipt_commit_actor
219 .sender
220 .try_send(ReceiptCommitCommand::Rotate { config, response })
221 {
222 atomic_saturating_sub(&health.inflight, 1);
223 return Err(match error {
224 std::sync::mpsc::TrySendError::Full(_) => receipt_actor_saturated_error(),
225 std::sync::mpsc::TrySendError::Disconnected(_) => receipt_actor_unavailable_error(),
226 });
227 }
228 match result.recv() {
229 Ok(outcome) => outcome,
230 Err(_) => {
231 atomic_saturating_sub(&health.inflight, 1);
232 Err(receipt_actor_unavailable_error())
233 }
234 }
235 }
236
237 pub(crate) fn query_receipts_impl(
241 &self,
242 query: &ReceiptQuery,
243 ) -> Result<ReceiptQueryResult, ReceiptStoreError> {
244 const VALID_OUTCOMES: &[&str] = &["allow", "deny", "cancelled", "incomplete"];
248 if let Some(outcome) = query.outcome.as_deref() {
249 if !VALID_OUTCOMES.contains(&outcome) {
250 return Err(ReceiptStoreError::InvalidOutcome(format!(
251 "unknown outcome filter {:?}; valid values are: allow, deny, cancelled, incomplete",
252 outcome
253 )));
254 }
255 }
256
257 let limit = query.limit.clamp(1, MAX_QUERY_LIMIT);
258
259 let read_scope = query
263 .effective_read_scope()
264 .map_err(ReceiptStoreError::ReadBoundary)?;
265 let tenant_fragment = match (
266 read_scope.tenant.as_deref(),
267 read_scope.include_null_tenant && !self.strict_tenant_isolation_enabled(),
268 ) {
269 (None, _) => "(?12 IS NULL)",
270 (Some(_), true) => "(r.tenant_id = ?12 OR r.tenant_id IS NULL)",
271 (Some(_), false) => "(r.tenant_id = ?12)",
272 };
273
274 let (data_sql, count_sql) = receipt_query_sql(query, tenant_fragment)?;
275
276 let cap_id = query.capability_id.as_deref();
277 let tool_srv = query.tool_server.as_deref();
278 let tool_nm = query.tool_name.as_deref();
279 let outcome = query.outcome.as_deref();
280 let since = query.since.map(|v| v as i64);
281 let until = query.until.map(|v| v as i64);
282 let min_cost = query.min_cost.map(|value| value.to_be_bytes().to_vec());
283 let max_cost = query.max_cost.map(|value| value.to_be_bytes().to_vec());
284 let agent_sub = query.agent_subject.as_deref();
285 let tenant = read_scope.tenant.as_deref();
286 let cost_currency = query.cost_currency.as_deref();
287 let mut connection = self.connection()?;
288 let transaction =
289 connection.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
290 let cursor_i64: Option<i64> = match query.cursor {
296 None => None,
297 Some(c) => match i64::try_from(c) {
298 Ok(v) => Some(v),
299 Err(_) => {
300 let total_count: u64 = transaction
307 .query_row(
308 &count_sql,
309 params![
310 cap_id,
311 tool_srv,
312 tool_nm,
313 outcome,
314 since,
315 until,
316 min_cost,
317 max_cost,
318 agent_sub,
319 None::<i64>,
322 0i64,
323 tenant,
324 cost_currency,
325 ],
326 |row| row.get::<_, i64>(0),
327 )
328 .map(|n| n.max(0) as u64)?;
329 transaction.commit()?;
330 return Ok(ReceiptQueryResult {
331 receipts: Vec::new(),
332 total_count,
333 next_cursor: None,
334 });
335 }
336 },
337 };
338
339 let receipts = {
340 let mut statement = transaction.prepare(&data_sql)?;
341 let rows = statement.query_map(
342 params![
343 cap_id,
344 tool_srv,
345 tool_nm,
346 outcome,
347 since,
348 until,
349 min_cost,
350 max_cost,
351 agent_sub,
352 cursor_i64,
353 limit as i64,
354 tenant,
355 cost_currency,
356 ],
357 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
358 )?;
359
360 let mut receipts = Vec::new();
361 for row in rows {
362 let (seq, raw_json) = row?;
363 let seq = seq.max(0) as u64;
364 let receipt =
365 decode_verified_chio_receipt(&raw_json, "persisted tool receipt", Some(seq))?;
366 receipts.push(StoredToolReceipt { seq, receipt });
367 }
368 receipts
369 };
370
371 let total_count: u64 = transaction
372 .query_row(
373 &count_sql,
374 params![
375 cap_id,
376 tool_srv,
377 tool_nm,
378 outcome,
379 since,
380 until,
381 min_cost,
382 max_cost,
383 agent_sub,
384 None::<i64>,
386 0i64,
387 tenant,
388 cost_currency,
389 ],
390 |row| row.get::<_, i64>(0),
391 )
392 .map(|n| n.max(0) as u64)?;
393
394 let next_cursor = if receipts.len() == limit {
396 receipts.last().map(|r| r.seq)
397 } else {
398 None
399 };
400 transaction.commit()?;
401
402 Ok(ReceiptQueryResult {
403 receipts,
404 total_count,
405 next_cursor,
406 })
407 }
408}
409
410fn compute_archival_watermark(
455 connection: &rusqlite::Connection,
456 cutoff_unix_secs: u64,
457) -> Result<u64, ReceiptStoreError> {
458 let cutoff = sqlite_i64(cutoff_unix_secs, "retention cutoff")?;
459 let settlement_attempts_installed: bool = connection.query_row(
460 "SELECT EXISTS(SELECT 1 FROM main.sqlite_master \
461 WHERE type = 'table' AND name = 'settle_attempts')",
462 [],
463 |row| row.get(0),
464 )?;
465 let active_settlement_guard = if settlement_attempts_installed {
466 r#"
467 AND NOT EXISTS (
468 SELECT 1 FROM settle_attempts sa
469 JOIN claim_receipt_log_entries e ON e.receipt_id = sa.receipt_id
470 WHERE e.entry_seq <= kc.batch_end_seq
471 )
472 "#
473 } else {
474 ""
475 };
476 let query = format!(
477 r#"
478 SELECT COALESCE(MAX(kc.batch_end_seq), 0)
479 FROM kernel_checkpoints kc
480 WHERE NOT EXISTS (
481 SELECT 1 FROM claim_receipt_log_entries e
482 WHERE e.entry_seq <= kc.batch_end_seq
483 AND e.timestamp >= ?1
484 )
485 AND NOT EXISTS (
486 SELECT 1 FROM chio_authorization_receipt_consumptions ac
487 JOIN claim_receipt_log_entries ae ON ae.receipt_id = ac.authorization_receipt_id
488 JOIN claim_receipt_log_entries ce ON ce.receipt_id = ac.consumer_receipt_id
489 WHERE ae.entry_seq <= kc.batch_end_seq
490 AND ce.entry_seq > kc.batch_end_seq
491 )
492 AND NOT EXISTS (
493 SELECT 1 FROM receipt_lineage_statements ls
494 JOIN claim_receipt_log_entries pe ON pe.receipt_id = ls.parent_receipt_id
495 JOIN claim_receipt_log_entries ce ON ce.receipt_id = ls.receipt_id
496 WHERE pe.entry_seq <= kc.batch_end_seq
497 AND ce.entry_seq > kc.batch_end_seq
498 )
499 AND NOT EXISTS (
500 SELECT 1 FROM settlement_reconciliations sr
501 JOIN claim_receipt_log_entries e ON e.receipt_id = sr.receipt_id
502 WHERE e.entry_seq <= kc.batch_end_seq
503 AND sr.reconciliation_state IN ('open', 'retry_scheduled')
504 )
505 AND NOT EXISTS (
506 SELECT 1 FROM metered_billing_reconciliations mbr
507 JOIN claim_receipt_log_entries e ON e.receipt_id = mbr.receipt_id
508 WHERE e.entry_seq <= kc.batch_end_seq
509 AND mbr.reconciliation_state IN ('open', 'retry_scheduled')
510 )
511 {active_settlement_guard}
512 "#
513 );
514 let watermark: i64 = connection.query_row(&query, params![cutoff], |row| row.get(0))?;
515 sqlite_u64(watermark, "retention watermark")
516}
517
518fn resolve_rotation_cutoff(
521 connection: &rusqlite::Connection,
522 config: &RetentionConfig,
523) -> Result<Option<u64>, ReceiptStoreError> {
524 if let Some(cutoff) = config.explicit_cutoff_unix_secs {
525 return Ok(Some(cutoff));
526 }
527 let now = SystemTime::now()
528 .duration_since(UNIX_EPOCH)
529 .map(|d| d.as_secs())
530 .unwrap_or(0);
531 let time_cutoff = now.saturating_sub(config.retention_days.saturating_mul(86_400));
532 let oldest: Option<i64> = connection.query_row(
538 "SELECT MIN(timestamp) FROM claim_receipt_log_entries",
539 [],
540 |row| row.get::<_, Option<i64>>(0),
541 )?;
542 let mut cutoff: Option<u64> = None;
552 if let Some(oldest_ts) = oldest {
553 if (oldest_ts.max(0) as u64) < time_cutoff {
554 cutoff = Some(time_cutoff);
555 }
556 }
557 let size = live_db_size_bytes_on_connection(connection)?;
560 if size > config.max_size_bytes {
561 let median: Option<i64> = connection
562 .query_row(
563 "SELECT timestamp FROM claim_receipt_log_entries ORDER BY timestamp \
564 LIMIT 1 OFFSET (SELECT COUNT(*) FROM claim_receipt_log_entries) / 2",
565 [],
566 |row| row.get(0),
567 )
568 .optional()?;
569 if let Some(median_ts) = median {
570 let size_cutoff = (median_ts.max(0) as u64).saturating_add(1);
580 cutoff = Some(cutoff.map_or(size_cutoff, |current| current.max(size_cutoff)));
581 }
582 }
583 Ok(cutoff)
584}
585
586fn live_db_size_bytes_on_connection(
592 connection: &rusqlite::Connection,
593) -> Result<u64, ReceiptStoreError> {
594 let page_count: i64 = connection.query_row("PRAGMA page_count", [], |row| row.get(0))?;
595 let freelist_count: i64 =
596 connection.query_row("PRAGMA freelist_count", [], |row| row.get(0))?;
597 let page_size: i64 = connection.query_row("PRAGMA page_size", [], |row| row.get(0))?;
598 let live_pages = (page_count - freelist_count).max(0);
599 Ok((live_pages as u64) * (page_size.max(0) as u64))
600}
601
602fn ensure_archive_file_exists(archive_path: &str) -> Result<(), ReceiptStoreError> {
618 let flags = rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE
619 | rusqlite::OpenFlags::SQLITE_OPEN_CREATE
620 | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX;
621 let connection = rusqlite::Connection::open_with_flags(archive_path, flags)?;
622 drop(connection);
623 Ok(())
624}
625
626fn absolute_archive_path(archive_path: &str) -> Result<String, ReceiptStoreError> {
643 let canonical = std::fs::canonicalize(std::path::Path::new(archive_path)).map_err(|error| {
644 ReceiptStoreError::Conflict(format!(
645 "retention archive path {archive_path:?} could not be resolved to an absolute path: \
646 {error}"
647 ))
648 })?;
649 canonical.to_str().map(str::to_owned).ok_or_else(|| {
650 ReceiptStoreError::Conflict(format!(
651 "retention archive path {archive_path:?} is not valid UTF-8 after canonicalization"
652 ))
653 })
654}
655
656fn ensure_durable_distinct_archive_path(
674 connection: &rusqlite::Connection,
675 archive_path: &str,
676) -> Result<(), ReceiptStoreError> {
677 let trimmed = archive_path.trim();
678 let lowered = trimmed.to_ascii_lowercase();
679 if trimmed.is_empty() || lowered == ":memory:" || lowered.contains("mode=memory") {
680 return Err(ReceiptStoreError::Conflict(format!(
681 "retention archive path {archive_path:?} is not a durable database file; refusing to \
682 co-archive evidence into a target destroyed on detach"
683 )));
684 }
685 let main_file: String = connection
689 .query_row(
690 "SELECT file FROM pragma_database_list WHERE name = 'main'",
691 [],
692 |row| row.get::<_, Option<String>>(0),
693 )
694 .optional()?
695 .flatten()
696 .unwrap_or_default();
697 if !main_file.is_empty() && archive_path_aliases_live(&main_file, trimmed) {
698 return Err(ReceiptStoreError::Conflict(format!(
699 "retention archive path {archive_path:?} aliases the live database; refusing to archive \
700 a store into itself"
701 )));
702 }
703 Ok(())
704}
705
706fn ensure_archive_path_matches_ledger(
722 connection: &rusqlite::Connection,
723 archive_path: &str,
724) -> Result<(), ReceiptStoreError> {
725 if let Some(recorded) = super::support::latest_watermark_archive_path(connection)? {
726 if recorded != archive_path {
727 return Err(ReceiptStoreError::Conflict(format!(
728 "retention archive path {archive_path:?} differs from the archive {recorded:?} an \
729 earlier rotation committed to; the archived prefix would be split across two \
730 files. Keep the same archive path or start a fresh store"
731 )));
732 }
733 }
734 Ok(())
735}
736
737fn ensure_committed_prefix_still_backed(
749 connection: &rusqlite::Connection,
750) -> Result<(), ReceiptStoreError> {
751 let Some(current) = super::support::retention_watermark(connection)? else {
752 return Ok(());
753 };
754 if current == 0 {
755 return Ok(());
756 }
757 let backed = match super::support::latest_watermark_archive_path(connection)? {
758 Some(ledger_path) => {
759 super::support::archive_path_backs_prefix(connection, &ledger_path, current)?
760 }
761 None => false,
762 };
763 if !backed {
764 return Err(ReceiptStoreError::Conflict(format!(
765 "retention archive no longer backs the committed watermark {current}; the prior \
766 archive is missing, unreadable, or does not re-derive the signed checkpoint roots. \
767 Refusing to rotate and strand the deleted prefix; restore the archive before \
768 rotating again"
769 )));
770 }
771 Ok(())
772}
773
774fn archive_path_aliases_live(main_file: &str, archive_path: &str) -> bool {
778 if main_file == archive_path {
779 return true;
780 }
781 let resolved = |path: &str| std::fs::canonicalize(std::path::Path::new(path)).ok();
782 if let (Some(main), Some(archive)) = (resolved(main_file), resolved(archive_path)) {
783 if main == archive {
784 return true;
785 }
786 }
787 #[cfg(unix)]
788 {
789 use std::os::unix::fs::MetadataExt;
790 if let (Ok(main), Ok(archive)) = (
791 std::fs::metadata(main_file),
792 std::fs::metadata(archive_path),
793 ) {
794 if main.dev() == archive.dev() && main.ino() == archive.ino() {
795 return true;
796 }
797 }
798 }
799 false
800}
801
802pub(super) fn rotate_on_writer_connection(
816 connection: &mut rusqlite::Connection,
817 config: &RetentionConfig,
818 verified_checkpoint_ceiling: Option<u64>,
819) -> Result<u64, ReceiptStoreError> {
820 if config.tenant_id.is_some() {
821 return Err(ReceiptStoreError::RetentionTenantScopeUnsupported);
822 }
823 super::support::migrate_auto_vacuum_incremental_if_needed(connection)?;
827 super::support::ensure_receipt_retention_tombstones(connection)?;
831 super::support::ensure_receipt_retention_watermark_table(connection)?;
838 let Some(cutoff) = resolve_rotation_cutoff(connection, config)? else {
839 return Ok(0);
840 };
841 archive_range(
842 connection,
843 cutoff,
844 &config.archive_path,
845 verified_checkpoint_ceiling,
846 )
847}
848
849fn archive_range(
855 connection: &mut rusqlite::Connection,
856 cutoff_unix_secs: u64,
857 archive_path: &str,
858 verified_checkpoint_ceiling: Option<u64>,
859) -> Result<u64, ReceiptStoreError> {
860 let watermark = compute_archival_watermark(connection, cutoff_unix_secs)?
864 .min(verified_checkpoint_ceiling.unwrap_or(u64::MAX));
865 if watermark == 0 {
866 return Ok(0); }
868 if let Some(current) = super::support::retention_watermark(connection)? {
875 if watermark <= current {
876 return Ok(0);
877 }
878 }
879 let w = sqlite_i64(watermark, "archival watermark")?;
880
881 ensure_durable_distinct_archive_path(connection, archive_path)?;
882 ensure_archive_file_exists(archive_path)?;
883 let archive_path = absolute_archive_path(archive_path)?;
887 ensure_archive_path_matches_ledger(connection, &archive_path)?;
892 ensure_committed_prefix_still_backed(connection)?;
893 let escaped_path = archive_path.replace('\'', "''");
894 connection.execute_batch(&format!("ATTACH DATABASE '{escaped_path}' AS archive"))?;
895
896 let result = (|| -> Result<u64, ReceiptStoreError> {
897 create_archive_schema(connection)?;
898 let archived = copy_archived_prefix(connection, w)?;
899 verify_co_archival_complete(connection, w)?; delete_archived_prefix_in_tx(connection, w, cutoff_unix_secs, &archive_path)?;
901 Ok(archived)
902 })();
903
904 let detach = connection.execute_batch("DETACH DATABASE archive");
905 let archived = match (result, detach) {
906 (Ok(archived), Ok(())) => archived,
907 (Err(error), _) => return Err(error),
908 (Ok(_), Err(error)) => return Err(error.into()),
909 };
910
911 connection.execute_batch("PRAGMA incremental_vacuum")?;
913 connection.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
914 Ok(archived)
915}
916
917pub(super) fn create_archive_schema(
948 connection: &mut rusqlite::Connection,
949) -> Result<(), ReceiptStoreError> {
950 let transaction =
951 connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
952 let application_id: i32 =
953 transaction.query_row("PRAGMA archive.application_id", [], |row| row.get(0))?;
954 if application_id != 0 && application_id != crate::CHIO_SQLITE_APPLICATION_ID {
955 return Err(ReceiptStoreError::Conflict(format!(
956 "retention archive application_id {application_id:#x} is not a Chio store"
957 )));
958 }
959 let version_table_exists: bool = transaction.query_row(
960 "SELECT EXISTS(SELECT 1 FROM archive.sqlite_master WHERE type = 'table' AND name = 'chio_store_schema_versions')",
961 [],
962 |row| row.get(0),
963 )?;
964 let archive_schema_version = if version_table_exists {
965 transaction
966 .query_row(
967 "SELECT version FROM archive.chio_store_schema_versions WHERE store_key = ?1",
968 [RECEIPT_STORE_SCHEMA_KEY],
969 |row| row.get::<_, i32>(0),
970 )
971 .optional()?
972 .unwrap_or(0)
973 } else {
974 0
975 };
976 if archive_schema_version > RECEIPT_STORE_SUPPORTED_SCHEMA_VERSION {
977 return Err(ReceiptStoreError::Conflict(format!(
978 "retention archive schema version {archive_schema_version} is newer than this binary supports ({RECEIPT_STORE_SUPPORTED_SCHEMA_VERSION})"
979 )));
980 }
981
982 transaction.execute_batch(
983 r#"
984 CREATE TABLE IF NOT EXISTS archive.chio_tool_receipts (
985 seq INTEGER PRIMARY KEY,
986 receipt_id TEXT NOT NULL UNIQUE, timestamp INTEGER NOT NULL,
987 capability_id TEXT NOT NULL, subject_key TEXT, issuer_key TEXT,
988 grant_index INTEGER, tool_server TEXT NOT NULL, tool_name TEXT NOT NULL,
989 decision_kind TEXT NOT NULL, policy_hash TEXT NOT NULL,
990 content_hash TEXT NOT NULL, raw_json TEXT NOT NULL, tenant_id TEXT,
991 cost_currency TEXT CHECK (
992 cost_currency IS NULL OR (
993 typeof(cost_currency) = 'text' AND
994 length(cost_currency) = 3 AND
995 cost_currency NOT GLOB '*[^A-Z]*'
996 )
997 ),
998 cost_charged_be BLOB CHECK (
999 (cost_currency IS NULL AND cost_charged_be IS NULL) OR
1000 (
1001 cost_currency IS NOT NULL AND
1002 typeof(cost_charged_be) = 'blob' AND
1003 length(cost_charged_be) = 8
1004 )
1005 )
1006 );
1007 CREATE TABLE IF NOT EXISTS archive.chio_child_receipts (
1008 seq INTEGER PRIMARY KEY,
1009 receipt_id TEXT NOT NULL UNIQUE, timestamp INTEGER NOT NULL,
1010 session_id TEXT NOT NULL, parent_request_id TEXT NOT NULL,
1011 request_id TEXT NOT NULL, operation_kind TEXT NOT NULL,
1012 terminal_state TEXT NOT NULL, policy_hash TEXT NOT NULL,
1013 outcome_hash TEXT NOT NULL, raw_json TEXT NOT NULL
1014 );
1015 CREATE TABLE IF NOT EXISTS archive.kernel_checkpoints (
1016 id INTEGER PRIMARY KEY, checkpoint_seq INTEGER NOT NULL UNIQUE,
1017 batch_start_seq INTEGER NOT NULL, batch_end_seq INTEGER NOT NULL,
1018 tree_size INTEGER NOT NULL, merkle_root TEXT NOT NULL,
1019 issued_at INTEGER NOT NULL, statement_json TEXT NOT NULL,
1020 signature TEXT NOT NULL, kernel_key TEXT NOT NULL
1021 );
1022 CREATE TABLE IF NOT EXISTS archive.capability_lineage (
1023 capability_id TEXT PRIMARY KEY, subject_key TEXT NOT NULL,
1024 issuer_key TEXT NOT NULL, issued_at INTEGER NOT NULL,
1025 expires_at INTEGER NOT NULL, grants_json TEXT NOT NULL,
1026 delegation_depth INTEGER NOT NULL DEFAULT 0, parent_capability_id TEXT,
1027 federated_parent_capability_id TEXT,
1028 provenance TEXT NOT NULL DEFAULT 'legacy_projection'
1029 CHECK (provenance IN ('signed_token', 'synthetic_anchor', 'legacy_projection')),
1030 signed_capability_json TEXT
1031 );
1032 CREATE TABLE IF NOT EXISTS archive.claim_receipt_log_entries (
1033 entry_seq INTEGER PRIMARY KEY,
1034 receipt_id TEXT NOT NULL UNIQUE, receipt_kind TEXT NOT NULL,
1035 source_seq INTEGER NOT NULL, timestamp INTEGER NOT NULL,
1036 capability_id TEXT, session_id TEXT, parent_request_id TEXT,
1037 request_id TEXT, subject_key TEXT, issuer_key TEXT,
1038 tool_server TEXT, tool_name TEXT, raw_json TEXT NOT NULL
1039 );
1040 CREATE TABLE IF NOT EXISTS archive.settlement_reconciliations (
1041 receipt_id TEXT PRIMARY KEY, reconciliation_state TEXT NOT NULL,
1042 note TEXT, updated_at INTEGER NOT NULL
1043 );
1044 CREATE TABLE IF NOT EXISTS archive.metered_billing_reconciliations (
1045 receipt_id TEXT PRIMARY KEY, adapter_kind TEXT NOT NULL,
1046 evidence_id TEXT NOT NULL, observed_units INTEGER NOT NULL,
1047 billed_cost_units INTEGER NOT NULL, billed_cost_currency TEXT NOT NULL,
1048 evidence_sha256 TEXT, recorded_at INTEGER NOT NULL,
1049 reconciliation_state TEXT NOT NULL, note TEXT, updated_at INTEGER NOT NULL
1050 );
1051 CREATE TABLE IF NOT EXISTS archive.chio_authorization_receipt_consumptions (
1052 authorization_receipt_id TEXT PRIMARY KEY, consumer_receipt_id TEXT NOT NULL,
1053 request_id TEXT NOT NULL, session_id TEXT NOT NULL, tool_call_id TEXT NOT NULL,
1054 tenant_id TEXT, parameter_hash TEXT NOT NULL, consumed_at_unix_ms INTEGER NOT NULL
1055 );
1056 CREATE TABLE IF NOT EXISTS archive.receipt_lineage_statements (
1057 receipt_id TEXT PRIMARY KEY, statement_id TEXT, request_id TEXT,
1058 session_id TEXT, session_anchor_id TEXT, chain_id TEXT,
1059 parent_request_id TEXT, parent_receipt_id TEXT, evidence_class TEXT,
1060 evidence_sources_json TEXT,
1061 verified_session_anchor INTEGER NOT NULL DEFAULT 0,
1062 verified_parent_request INTEGER NOT NULL DEFAULT 0,
1063 verified_parent_receipt INTEGER NOT NULL DEFAULT 0,
1064 replay_protected INTEGER NOT NULL DEFAULT 0,
1065 recorded_at INTEGER NOT NULL, source_kind TEXT NOT NULL,
1066 json_sha256 TEXT NOT NULL, raw_json TEXT NOT NULL
1067 );
1068 CREATE TABLE IF NOT EXISTS archive.checkpoint_tree_heads (
1069 checkpoint_seq INTEGER PRIMARY KEY, batch_start_seq INTEGER NOT NULL,
1070 batch_end_seq INTEGER NOT NULL, tree_size INTEGER NOT NULL,
1071 merkle_root TEXT NOT NULL, issued_at INTEGER NOT NULL, kernel_key TEXT NOT NULL,
1072 previous_checkpoint_sha256 TEXT, statement_json TEXT NOT NULL, signature TEXT NOT NULL
1073 );
1074 CREATE TABLE IF NOT EXISTS archive.checkpoint_predecessor_witnesses (
1075 predecessor_checkpoint_seq INTEGER NOT NULL, witness_checkpoint_seq INTEGER PRIMARY KEY,
1076 previous_checkpoint_sha256 TEXT NOT NULL, witnessed_at INTEGER NOT NULL,
1077 witness_statement_json TEXT NOT NULL
1078 );
1079 CREATE TABLE IF NOT EXISTS archive.checkpoint_publication_metadata (
1080 checkpoint_seq INTEGER PRIMARY KEY, publication_schema TEXT NOT NULL,
1081 merkle_root TEXT NOT NULL, published_at INTEGER NOT NULL, kernel_key TEXT NOT NULL,
1082 log_tree_size INTEGER NOT NULL, entry_start_seq INTEGER NOT NULL,
1083 entry_end_seq INTEGER NOT NULL, previous_checkpoint_sha256 TEXT
1084 );
1085 CREATE TABLE IF NOT EXISTS archive.checkpoint_publication_trust_anchor_bindings (
1086 checkpoint_seq INTEGER PRIMARY KEY, binding_json TEXT NOT NULL
1087 );
1088 "#,
1089 )?;
1090 if archive_schema_version < RECEIPT_COST_PROJECTION_SCHEMA_VERSION {
1091 migrate_archive_receipt_cost_projection(&transaction)?;
1092 }
1093 verify_archive_receipt_cost_projection(&transaction)?;
1094 for (column, definition) in [
1095 ("signed_capability_json", "signed_capability_json TEXT"),
1096 (
1097 "federated_parent_capability_id",
1098 "federated_parent_capability_id TEXT",
1099 ),
1100 (
1101 "provenance",
1102 "provenance TEXT NOT NULL DEFAULT 'legacy_projection' CHECK (provenance IN \
1103 ('signed_token', 'synthetic_anchor', 'legacy_projection'))",
1104 ),
1105 ] {
1106 let has_column = {
1107 let mut statement =
1108 transaction.prepare("PRAGMA archive.table_info(capability_lineage)")?;
1109 let mut rows = statement.query([])?;
1110 let mut found = false;
1111 while let Some(row) = rows.next()? {
1112 if row.get::<_, String>(1)? == column {
1113 found = true;
1114 break;
1115 }
1116 }
1117 found
1118 };
1119 if !has_column {
1120 transaction.execute(
1121 &format!("ALTER TABLE archive.capability_lineage ADD COLUMN {definition}"),
1122 [],
1123 )?;
1124 }
1125 }
1126 transaction.execute(
1127 "UPDATE archive.capability_lineage SET provenance = 'signed_token' \
1128 WHERE provenance = 'legacy_projection' \
1129 AND signed_capability_json IS NOT NULL",
1130 [],
1131 )?;
1132 transaction.execute_batch(&format!(
1133 "PRAGMA archive.application_id = {}; \
1134 CREATE TABLE IF NOT EXISTS archive.chio_store_schema_versions (\
1135 store_key TEXT PRIMARY KEY, version INTEGER NOT NULL\
1136 );",
1137 crate::CHIO_SQLITE_APPLICATION_ID
1138 ))?;
1139 transaction.execute(
1140 "INSERT INTO archive.chio_store_schema_versions (store_key, version) VALUES (?1, ?2) \
1141 ON CONFLICT(store_key) DO UPDATE SET version = excluded.version",
1142 params![
1143 RECEIPT_STORE_SCHEMA_KEY,
1144 RECEIPT_STORE_SUPPORTED_SCHEMA_VERSION
1145 ],
1146 )?;
1147 transaction.commit()?;
1148 Ok(())
1149}
1150
1151pub(super) fn copy_archived_prefix(
1171 connection: &rusqlite::Connection,
1172 w: i64,
1173) -> Result<u64, ReceiptStoreError> {
1174 let before: i64 = connection.query_row(
1175 "SELECT COUNT(*) FROM archive.chio_tool_receipts",
1176 [],
1177 |row| row.get(0),
1178 )?;
1179 connection.execute_batch(&format!(
1180 r#"
1181 INSERT OR IGNORE INTO archive.claim_receipt_log_entries
1182 SELECT * FROM main.claim_receipt_log_entries WHERE entry_seq <= {w};
1183 INSERT OR IGNORE INTO archive.chio_tool_receipts
1184 (seq, receipt_id, timestamp, capability_id, subject_key, issuer_key,
1185 grant_index, tool_server, tool_name, decision_kind, policy_hash,
1186 content_hash, raw_json, tenant_id, cost_currency, cost_charged_be)
1187 SELECT seq, receipt_id, timestamp, capability_id, subject_key, issuer_key,
1188 grant_index, tool_server, tool_name, decision_kind, policy_hash,
1189 content_hash, raw_json, tenant_id, cost_currency, cost_charged_be
1190 FROM main.chio_tool_receipts WHERE seq IN (
1191 SELECT source_seq FROM main.claim_receipt_log_entries
1192 WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt');
1193 INSERT OR IGNORE INTO archive.chio_child_receipts
1194 (seq, receipt_id, timestamp, session_id, parent_request_id, request_id,
1195 operation_kind, terminal_state, policy_hash, outcome_hash, raw_json)
1196 SELECT seq, receipt_id, timestamp, session_id, parent_request_id, request_id,
1197 operation_kind, terminal_state, policy_hash, outcome_hash, raw_json
1198 FROM main.chio_child_receipts WHERE seq IN (
1199 SELECT source_seq FROM main.claim_receipt_log_entries
1200 WHERE entry_seq <= {w} AND receipt_kind = 'child_receipt');
1201 INSERT OR IGNORE INTO archive.kernel_checkpoints
1202 SELECT * FROM main.kernel_checkpoints WHERE batch_end_seq <= {w};
1203 INSERT OR IGNORE INTO archive.capability_lineage
1204 (capability_id, subject_key, issuer_key, issued_at, expires_at,
1205 grants_json, delegation_depth, parent_capability_id,
1206 federated_parent_capability_id, provenance, signed_capability_json)
1207 SELECT DISTINCT cl.capability_id, cl.subject_key, cl.issuer_key,
1208 cl.issued_at, cl.expires_at, cl.grants_json,
1209 cl.delegation_depth, cl.parent_capability_id,
1210 cl.federated_parent_capability_id, cl.provenance,
1211 cl.signed_capability_json
1212 FROM main.capability_lineage cl
1213 INNER JOIN main.chio_tool_receipts r ON r.capability_id = cl.capability_id
1214 WHERE r.seq IN (
1215 SELECT source_seq FROM main.claim_receipt_log_entries
1216 WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt');
1217 -- Refresh (not IGNORE) the mutable reconciliation rows: an earlier copy
1218 -- may hold pre-update bytes for a receipt whose reconciliation state has
1219 -- since advanced, and only replacing it lets a retried rotation pass the
1220 -- write-locked co-archival re-verify instead of stalling on stale bytes.
1221 INSERT OR REPLACE INTO archive.settlement_reconciliations
1222 SELECT * FROM main.settlement_reconciliations WHERE receipt_id IN (
1223 SELECT receipt_id FROM main.claim_receipt_log_entries
1224 WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt');
1225 INSERT OR REPLACE INTO archive.metered_billing_reconciliations
1226 SELECT * FROM main.metered_billing_reconciliations WHERE receipt_id IN (
1227 SELECT receipt_id FROM main.claim_receipt_log_entries
1228 WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt');
1229 INSERT OR IGNORE INTO archive.chio_authorization_receipt_consumptions
1230 SELECT * FROM main.chio_authorization_receipt_consumptions WHERE authorization_receipt_id IN (
1231 SELECT receipt_id FROM main.claim_receipt_log_entries
1232 WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt');
1233 INSERT OR IGNORE INTO archive.receipt_lineage_statements
1234 (receipt_id, statement_id, request_id, session_id, session_anchor_id,
1235 chain_id, parent_request_id, parent_receipt_id, evidence_class,
1236 evidence_sources_json, verified_session_anchor, verified_parent_request,
1237 verified_parent_receipt, replay_protected, recorded_at, source_kind,
1238 json_sha256, raw_json)
1239 SELECT receipt_id, statement_id, request_id, session_id, session_anchor_id,
1240 chain_id, parent_request_id, parent_receipt_id, evidence_class,
1241 evidence_sources_json, verified_session_anchor, verified_parent_request,
1242 verified_parent_receipt, replay_protected, recorded_at, source_kind,
1243 json_sha256, raw_json
1244 FROM main.receipt_lineage_statements WHERE receipt_id IN (
1245 SELECT receipt_id FROM main.claim_receipt_log_entries WHERE entry_seq <= {w});
1246 "#
1247 ))?;
1248 let after: i64 = connection.query_row(
1249 "SELECT COUNT(*) FROM archive.chio_tool_receipts",
1250 [],
1251 |row| row.get(0),
1252 )?;
1253 sqlite_u64((after - before).max(0), "archived tool receipt delta")
1254}
1255
1256fn verify_co_archival_complete(
1283 connection: &rusqlite::Connection,
1284 w: i64,
1285) -> Result<(), ReceiptStoreError> {
1286 let checks: [(&'static str, String, String); 9] = [
1287 (
1288 "chio_tool_receipts",
1297 format!(
1298 "SELECT COUNT(*) FROM main.chio_tool_receipts WHERE seq IN \
1299 (SELECT source_seq FROM main.claim_receipt_log_entries WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt')"
1300 ),
1301 format!(
1302 "SELECT COUNT(*) FROM main.chio_tool_receipts m WHERE m.seq IN \
1303 (SELECT source_seq FROM main.claim_receipt_log_entries WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt') \
1304 AND EXISTS (SELECT 1 FROM archive.chio_tool_receipts a WHERE a.seq = m.seq \
1305 AND a.receipt_id IS m.receipt_id AND a.timestamp IS m.timestamp \
1306 AND a.capability_id IS m.capability_id AND a.subject_key IS m.subject_key \
1307 AND a.issuer_key IS m.issuer_key AND a.grant_index IS m.grant_index \
1308 AND a.tool_server IS m.tool_server AND a.tool_name IS m.tool_name \
1309 AND a.decision_kind IS m.decision_kind AND a.policy_hash IS m.policy_hash \
1310 AND a.content_hash IS m.content_hash AND a.raw_json IS m.raw_json \
1311 AND a.tenant_id IS m.tenant_id \
1312 AND a.cost_currency IS m.cost_currency \
1313 AND a.cost_charged_be IS m.cost_charged_be)"
1314 ),
1315 ),
1316 (
1317 "chio_child_receipts",
1323 format!(
1324 "SELECT COUNT(*) FROM main.chio_child_receipts WHERE seq IN \
1325 (SELECT source_seq FROM main.claim_receipt_log_entries WHERE entry_seq <= {w} AND receipt_kind = 'child_receipt')"
1326 ),
1327 format!(
1328 "SELECT COUNT(*) FROM main.chio_child_receipts m WHERE m.seq IN \
1329 (SELECT source_seq FROM main.claim_receipt_log_entries WHERE entry_seq <= {w} AND receipt_kind = 'child_receipt') \
1330 AND EXISTS (SELECT 1 FROM archive.chio_child_receipts a WHERE a.seq = m.seq \
1331 AND a.receipt_id IS m.receipt_id AND a.timestamp IS m.timestamp \
1332 AND a.session_id IS m.session_id AND a.parent_request_id IS m.parent_request_id \
1333 AND a.request_id IS m.request_id AND a.operation_kind IS m.operation_kind \
1334 AND a.terminal_state IS m.terminal_state AND a.policy_hash IS m.policy_hash \
1335 AND a.outcome_hash IS m.outcome_hash AND a.raw_json IS m.raw_json)"
1336 ),
1337 ),
1338 (
1339 "claim_receipt_log_entries",
1342 format!("SELECT COUNT(*) FROM main.claim_receipt_log_entries WHERE entry_seq <= {w}"),
1343 format!(
1344 "SELECT COUNT(*) FROM main.claim_receipt_log_entries m WHERE m.entry_seq <= {w} \
1345 AND EXISTS (SELECT 1 FROM archive.claim_receipt_log_entries a WHERE a.entry_seq = m.entry_seq \
1346 AND a.receipt_id IS m.receipt_id AND a.receipt_kind IS m.receipt_kind \
1347 AND a.source_seq IS m.source_seq AND a.timestamp IS m.timestamp \
1348 AND a.capability_id IS m.capability_id AND a.session_id IS m.session_id \
1349 AND a.parent_request_id IS m.parent_request_id AND a.request_id IS m.request_id \
1350 AND a.subject_key IS m.subject_key AND a.issuer_key IS m.issuer_key \
1351 AND a.tool_server IS m.tool_server AND a.tool_name IS m.tool_name \
1352 AND a.raw_json IS m.raw_json)"
1353 ),
1354 ),
1355 (
1356 "kernel_checkpoints",
1359 format!("SELECT COUNT(*) FROM main.kernel_checkpoints WHERE batch_end_seq <= {w}"),
1360 format!(
1361 "SELECT COUNT(*) FROM main.kernel_checkpoints m WHERE m.batch_end_seq <= {w} \
1362 AND EXISTS (SELECT 1 FROM archive.kernel_checkpoints a WHERE a.checkpoint_seq = m.checkpoint_seq \
1363 AND a.id IS m.id AND a.batch_start_seq IS m.batch_start_seq \
1364 AND a.batch_end_seq IS m.batch_end_seq AND a.tree_size IS m.tree_size \
1365 AND a.merkle_root IS m.merkle_root AND a.issued_at IS m.issued_at \
1366 AND a.statement_json IS m.statement_json AND a.signature IS m.signature \
1367 AND a.kernel_key IS m.kernel_key)"
1368 ),
1369 ),
1370 (
1371 "settlement_reconciliations",
1374 format!(
1375 "SELECT COUNT(*) FROM main.settlement_reconciliations WHERE receipt_id IN \
1376 (SELECT receipt_id FROM main.claim_receipt_log_entries WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt')"
1377 ),
1378 format!(
1379 "SELECT COUNT(*) FROM main.settlement_reconciliations m WHERE m.receipt_id IN \
1380 (SELECT receipt_id FROM main.claim_receipt_log_entries WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt') \
1381 AND EXISTS (SELECT 1 FROM archive.settlement_reconciliations a WHERE a.receipt_id = m.receipt_id \
1382 AND a.reconciliation_state IS m.reconciliation_state AND a.note IS m.note \
1383 AND a.updated_at IS m.updated_at)"
1384 ),
1385 ),
1386 (
1387 "metered_billing_reconciliations",
1390 format!(
1391 "SELECT COUNT(*) FROM main.metered_billing_reconciliations WHERE receipt_id IN \
1392 (SELECT receipt_id FROM main.claim_receipt_log_entries WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt')"
1393 ),
1394 format!(
1395 "SELECT COUNT(*) FROM main.metered_billing_reconciliations m WHERE m.receipt_id IN \
1396 (SELECT receipt_id FROM main.claim_receipt_log_entries WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt') \
1397 AND EXISTS (SELECT 1 FROM archive.metered_billing_reconciliations a WHERE a.receipt_id = m.receipt_id \
1398 AND a.adapter_kind IS m.adapter_kind AND a.evidence_id IS m.evidence_id \
1399 AND a.observed_units IS m.observed_units AND a.billed_cost_units IS m.billed_cost_units \
1400 AND a.billed_cost_currency IS m.billed_cost_currency AND a.evidence_sha256 IS m.evidence_sha256 \
1401 AND a.recorded_at IS m.recorded_at AND a.reconciliation_state IS m.reconciliation_state \
1402 AND a.note IS m.note AND a.updated_at IS m.updated_at)"
1403 ),
1404 ),
1405 (
1406 "chio_authorization_receipt_consumptions",
1410 format!(
1411 "SELECT COUNT(*) FROM main.chio_authorization_receipt_consumptions WHERE authorization_receipt_id IN \
1412 (SELECT receipt_id FROM main.claim_receipt_log_entries WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt')"
1413 ),
1414 format!(
1415 "SELECT COUNT(*) FROM main.chio_authorization_receipt_consumptions m WHERE m.authorization_receipt_id IN \
1416 (SELECT receipt_id FROM main.claim_receipt_log_entries WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt') \
1417 AND EXISTS (SELECT 1 FROM archive.chio_authorization_receipt_consumptions a \
1418 WHERE a.authorization_receipt_id = m.authorization_receipt_id \
1419 AND a.consumer_receipt_id IS m.consumer_receipt_id AND a.request_id IS m.request_id \
1420 AND a.session_id IS m.session_id AND a.tool_call_id IS m.tool_call_id \
1421 AND a.tenant_id IS m.tenant_id AND a.parameter_hash IS m.parameter_hash \
1422 AND a.consumed_at_unix_ms IS m.consumed_at_unix_ms)"
1423 ),
1424 ),
1425 (
1426 "capability_lineage",
1437 format!(
1438 "SELECT COUNT(*) FROM main.capability_lineage m WHERE m.capability_id IN \
1439 (SELECT r.capability_id FROM main.chio_tool_receipts r WHERE r.seq IN \
1440 (SELECT source_seq FROM main.claim_receipt_log_entries WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt'))"
1441 ),
1442 format!(
1443 "SELECT COUNT(*) FROM main.capability_lineage m WHERE m.capability_id IN \
1444 (SELECT r.capability_id FROM main.chio_tool_receipts r WHERE r.seq IN \
1445 (SELECT source_seq FROM main.claim_receipt_log_entries WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt')) \
1446 AND EXISTS (SELECT 1 FROM archive.capability_lineage a WHERE a.capability_id = m.capability_id \
1447 AND a.subject_key IS m.subject_key AND a.issuer_key IS m.issuer_key \
1448 AND a.issued_at IS m.issued_at AND a.expires_at IS m.expires_at \
1449 AND a.grants_json IS m.grants_json AND a.delegation_depth IS m.delegation_depth \
1450 AND a.parent_capability_id IS m.parent_capability_id \
1451 AND a.federated_parent_capability_id IS m.federated_parent_capability_id \
1452 AND a.provenance IS m.provenance \
1453 AND a.signed_capability_json IS m.signed_capability_json)"
1454 ),
1455 ),
1456 (
1457 "receipt_lineage_statements",
1469 format!(
1470 "SELECT COUNT(*) FROM main.receipt_lineage_statements WHERE receipt_id IN \
1471 (SELECT receipt_id FROM main.claim_receipt_log_entries WHERE entry_seq <= {w})"
1472 ),
1473 format!(
1474 "SELECT COUNT(*) FROM main.receipt_lineage_statements m WHERE m.receipt_id IN \
1475 (SELECT receipt_id FROM main.claim_receipt_log_entries WHERE entry_seq <= {w}) \
1476 AND EXISTS (SELECT 1 FROM archive.receipt_lineage_statements a WHERE a.receipt_id = m.receipt_id \
1477 AND a.statement_id IS m.statement_id AND a.request_id IS m.request_id \
1478 AND a.session_id IS m.session_id AND a.session_anchor_id IS m.session_anchor_id \
1479 AND a.chain_id IS m.chain_id AND a.parent_request_id IS m.parent_request_id \
1480 AND a.parent_receipt_id IS m.parent_receipt_id AND a.evidence_class IS m.evidence_class \
1481 AND a.evidence_sources_json IS m.evidence_sources_json \
1482 AND a.verified_session_anchor IS m.verified_session_anchor \
1483 AND a.verified_parent_request IS m.verified_parent_request \
1484 AND a.verified_parent_receipt IS m.verified_parent_receipt \
1485 AND a.replay_protected IS m.replay_protected AND a.recorded_at IS m.recorded_at \
1486 AND a.source_kind IS m.source_kind AND a.json_sha256 IS m.json_sha256 \
1487 AND a.raw_json IS m.raw_json)"
1488 ),
1489 ),
1490 ];
1491 for (table, live_sql, archive_sql) in checks {
1492 let live: i64 = connection.query_row(&live_sql, [], |row| row.get(0))?;
1493 let archived: i64 = connection.query_row(&archive_sql, [], |row| row.get(0))?;
1494 if archived < live {
1495 return Err(ReceiptStoreError::RetentionArchiveIncomplete {
1496 table,
1497 live: sqlite_u64(live, "live co-archival count")?,
1498 archived: sqlite_u64(archived, "archive co-archival count")?,
1499 });
1500 }
1501 }
1502 Ok(())
1503}
1504
1505pub(super) fn delete_archived_prefix_in_tx(
1518 connection: &mut rusqlite::Connection,
1519 w: i64,
1520 cutoff_unix_secs: u64,
1521 archive_path: &str,
1522) -> Result<(), ReceiptStoreError> {
1523 let now = SystemTime::now()
1524 .duration_since(UNIX_EPOCH)
1525 .map(|d| d.as_secs())
1526 .unwrap_or(0);
1527 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1528 verify_co_archival_complete(&tx, w)?;
1539 ensure_archive_path_matches_ledger(&tx, archive_path)?;
1552 ensure_committed_prefix_still_backed(&tx)?;
1553 tx.execute_batch(&format!(
1554 r#"
1555 DROP TRIGGER IF EXISTS chio_tool_receipts_reject_delete;
1556 DROP TRIGGER IF EXISTS chio_child_receipts_reject_delete;
1557 DROP TRIGGER IF EXISTS claim_receipt_log_entries_reject_delete;
1558
1559 -- Tombstone every archived receipt id BEFORE its claim-log row is
1560 -- deleted so the append path still rejects a reused id once its live
1561 -- UNIQUE(receipt_id) sentinel is gone. Idempotent for a re-run.
1562 INSERT OR IGNORE INTO receipt_retention_tombstones
1563 (receipt_id, receipt_kind, archived_through_entry_seq, tombstoned_at)
1564 SELECT receipt_id, receipt_kind, {w}, {now}
1565 FROM claim_receipt_log_entries WHERE entry_seq <= {w};
1566
1567 -- `compute_archival_watermark` never advances W past a receipt whose
1568 -- settlement or metered-billing reconciliation is still nonterminal, so
1569 -- every reconciliation row removed here has already reached a terminal
1570 -- state and its co-archived copy preserves the closed record. No live,
1571 -- actionable reconciliation loses the receipt its upsert path resolves.
1572 DELETE FROM settlement_reconciliations WHERE receipt_id IN (
1573 SELECT receipt_id FROM claim_receipt_log_entries
1574 WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt');
1575 DELETE FROM metered_billing_reconciliations WHERE receipt_id IN (
1576 SELECT receipt_id FROM claim_receipt_log_entries
1577 WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt');
1578 -- Safe to key this delete on the archived authorization alone:
1579 -- `compute_archival_watermark` never advances W past an authorization
1580 -- whose bound consumer is still live, so every consumption removed here
1581 -- has both its authorization and (when the consumer is a live receipt)
1582 -- its consumer inside the archived prefix. No live consumer loses its
1583 -- binding, and the co-archived copy preserves the archived pair.
1584 DELETE FROM chio_authorization_receipt_consumptions WHERE authorization_receipt_id IN (
1585 SELECT receipt_id FROM claim_receipt_log_entries
1586 WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt');
1587 DELETE FROM chio_tool_receipts WHERE seq IN (
1588 SELECT source_seq FROM claim_receipt_log_entries
1589 WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt');
1590 DELETE FROM chio_child_receipts WHERE seq IN (
1591 SELECT source_seq FROM claim_receipt_log_entries
1592 WHERE entry_seq <= {w} AND receipt_kind = 'child_receipt');
1593 DELETE FROM claim_receipt_log_entries WHERE entry_seq <= {w};
1594 "#
1595 ))?;
1596 insert_receipt_retention_watermark(
1597 &tx,
1598 sqlite_u64(w, "watermark entry_seq")?,
1599 cutoff_unix_secs,
1600 archive_path,
1601 None,
1602 now,
1603 )?;
1604 ensure_transparency_projection_guards(&tx)?; tx.commit()?;
1606 Ok(())
1607}
1608
1609pub(super) fn retention_repair_on_writer(
1620 connection: &mut rusqlite::Connection,
1621 archive_path: &str,
1622) -> Result<u64, ReceiptStoreError> {
1623 let extras: Vec<(i64, String)> = {
1625 let mut stmt = connection.prepare(
1626 "SELECT e.entry_seq, e.receipt_id FROM claim_receipt_log_entries e \
1627 WHERE NOT EXISTS (SELECT 1 FROM chio_tool_receipts t WHERE t.receipt_id = e.receipt_id) \
1628 AND NOT EXISTS (SELECT 1 FROM chio_child_receipts c WHERE c.receipt_id = e.receipt_id) \
1629 ORDER BY e.entry_seq",
1630 )?;
1631 let rows = stmt.query_map([], |row| {
1632 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
1633 })?;
1634 let mut out = Vec::new();
1635 for row in rows {
1636 out.push(row?);
1637 }
1638 out
1639 };
1640 if extras.is_empty() {
1641 return Ok(0);
1642 }
1643 let max_extra_entry_seq = extras.iter().map(|(seq, _)| *seq).max().unwrap_or(0);
1644
1645 ensure_durable_distinct_archive_path(connection, archive_path)?;
1649 ensure_archive_file_exists(archive_path)?;
1650 let archive_path = absolute_archive_path(archive_path)?;
1654 let archive_path = archive_path.as_str();
1655 let escaped = archive_path.replace('\'', "''");
1656 connection.execute_batch(&format!("ATTACH DATABASE '{escaped}' AS archive"))?;
1657 let assert_result = (|| -> Result<u64, ReceiptStoreError> {
1658 for (entry_seq, _) in &extras {
1659 let faithful: i64 = connection.query_row(
1667 "SELECT COUNT(*) FROM main.claim_receipt_log_entries m \
1668 WHERE m.entry_seq = ?1 \
1669 AND EXISTS (SELECT 1 FROM archive.claim_receipt_log_entries a \
1670 WHERE a.entry_seq = m.entry_seq \
1671 AND a.receipt_id IS m.receipt_id AND a.receipt_kind IS m.receipt_kind \
1672 AND a.source_seq IS m.source_seq AND a.timestamp IS m.timestamp \
1673 AND a.capability_id IS m.capability_id AND a.session_id IS m.session_id \
1674 AND a.parent_request_id IS m.parent_request_id AND a.request_id IS m.request_id \
1675 AND a.subject_key IS m.subject_key AND a.issuer_key IS m.issuer_key \
1676 AND a.tool_server IS m.tool_server AND a.tool_name IS m.tool_name \
1677 AND a.raw_json IS m.raw_json)",
1678 params![entry_seq],
1679 |row| row.get(0),
1680 )?;
1681 if faithful == 0 {
1682 return Err(ReceiptStoreError::RetentionArchiveIncomplete {
1683 table: "claim_receipt_log_entries",
1684 live: 1,
1685 archived: 0,
1686 });
1687 }
1688 }
1689 let rounded: Option<i64> = connection.query_row(
1691 "SELECT MIN(batch_end_seq) FROM kernel_checkpoints WHERE batch_end_seq >= ?1",
1692 params![max_extra_entry_seq],
1693 |row| row.get(0),
1694 )?;
1695 let rounded = rounded.ok_or_else(|| {
1696 ReceiptStoreError::Conflict(
1697 "retention repair: extra claim-log rows are not covered by any checkpoint; \
1698 refusing to touch the uncheckpointed suffix"
1699 .to_string(),
1700 )
1701 })?;
1702 let live_in_prefix: i64 = connection.query_row(
1711 "SELECT COUNT(*) FROM claim_receipt_log_entries e \
1712 WHERE e.entry_seq <= ?1 \
1713 AND (EXISTS (SELECT 1 FROM chio_tool_receipts t WHERE t.receipt_id = e.receipt_id) \
1714 OR EXISTS (SELECT 1 FROM chio_child_receipts c WHERE c.receipt_id = e.receipt_id))",
1715 params![rounded],
1716 |row| row.get(0),
1717 )?;
1718 if live_in_prefix > 0 {
1719 return Err(ReceiptStoreError::Conflict(
1720 "retention repair: the checkpoint batch covering the orphaned rows still has \
1721 live source receipts; refusing to watermark a partially archived batch"
1722 .to_string(),
1723 ));
1724 }
1725 let archived_prefix: i64 = connection.query_row(
1736 "SELECT COUNT(*) FROM archive.claim_receipt_log_entries WHERE entry_seq <= ?1",
1737 params![rounded],
1738 |row| row.get(0),
1739 )?;
1740 if archived_prefix < rounded {
1741 return Err(ReceiptStoreError::RetentionArchiveIncomplete {
1742 table: "claim_receipt_log_entries",
1743 live: sqlite_u64(rounded, "repair rounded prefix width")?,
1744 archived: sqlite_u64(archived_prefix.max(0), "repair archived prefix count")?,
1745 });
1746 }
1747 let rounded_u64 = sqlite_u64(rounded, "repair rounded watermark")?;
1768 let watermark_covers = matches!(
1769 super::support::retention_watermark(connection)?,
1770 Some(current) if current >= rounded_u64
1771 );
1772 if watermark_covers {
1773 if let Some(ledger_archive) = super::support::latest_watermark_archive_path(connection)?
1774 {
1775 if ledger_archive != archive_path {
1776 return Err(ReceiptStoreError::Conflict(format!(
1777 "retention repair archive {archive_path:?} differs from the archive \
1778 {ledger_archive:?} that backs the existing watermark; tombstones must be \
1779 stamped from the archive that backs the prefix. Supply the ledger archive \
1780 or start a fresh store"
1781 )));
1782 }
1783 }
1784 }
1785 if !super::support::archive_path_backs_prefix(connection, archive_path, rounded_u64)? {
1786 return Err(ReceiptStoreError::RetentionArchiveIncomplete {
1787 table: "claim_receipt_log_entries",
1788 live: rounded_u64,
1789 archived: 0,
1790 });
1791 }
1792 Ok(rounded_u64)
1793 })();
1794 let rounded_watermark = match assert_result {
1795 Ok(w) => w,
1796 Err(error) => {
1797 let _ = connection.execute_batch("DETACH DATABASE archive");
1801 return Err(error);
1802 }
1803 };
1804
1805 let now = SystemTime::now()
1811 .duration_since(UNIX_EPOCH)
1812 .map(|d| d.as_secs())
1813 .unwrap_or(0);
1814 let removed = extras.len() as u64;
1815 let repair_result = (|| -> Result<(), ReceiptStoreError> {
1816 let rounded_i64 = sqlite_i64(rounded_watermark, "repair rounded watermark")?;
1817 let now_i64 = sqlite_i64(now, "repair tombstone timestamp")?;
1818 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1819 tx.execute_batch("DROP TRIGGER IF EXISTS claim_receipt_log_entries_reject_delete;")?;
1820 super::support::ensure_receipt_retention_tombstones(&tx)?;
1825 tx.execute(
1837 "INSERT OR IGNORE INTO receipt_retention_tombstones \
1838 (receipt_id, receipt_kind, archived_through_entry_seq, tombstoned_at) \
1839 SELECT receipt_id, receipt_kind, ?1, ?2 \
1840 FROM archive.claim_receipt_log_entries WHERE entry_seq <= ?3",
1841 params![rounded_i64, now_i64, rounded_i64],
1842 )?;
1843 for (entry_seq, _) in &extras {
1846 tx.execute(
1847 "DELETE FROM claim_receipt_log_entries WHERE entry_seq = ?1",
1848 params![entry_seq],
1849 )?;
1850 }
1851 super::support::ensure_receipt_retention_watermark_table(&tx)?;
1858 let watermark_covers = matches!(
1866 super::support::retention_watermark(&tx)?,
1867 Some(current) if current >= rounded_watermark
1868 );
1869 if !watermark_covers {
1870 insert_receipt_retention_watermark(
1871 &tx,
1872 rounded_watermark,
1873 now,
1874 archive_path,
1875 None,
1876 now,
1877 )?;
1878 }
1879 ensure_transparency_projection_guards(&tx)?;
1880 tx.commit()?;
1881 Ok(())
1882 })();
1883 let detach = connection.execute_batch("DETACH DATABASE archive");
1884 match (repair_result, detach) {
1885 (Ok(()), Ok(())) => {}
1886 (Err(error), _) => return Err(error),
1887 (Ok(()), Err(error)) => return Err(error.into()),
1888 }
1889
1890 connection.execute_batch("PRAGMA incremental_vacuum")?;
1891 connection.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
1892 Ok(removed)
1893}