1use std::collections::BTreeMap;
2
3use chio_core::merkle::MerkleTree;
4use chio_core::receipt::checkpoint::CheckpointPublicationTrustAnchorBinding;
5use chio_kernel::capability_lineage::CapabilitySnapshot;
6use chio_kernel::checkpoint::{
7 bind_checkpoint_publication_trust_anchor, build_inclusion_proof,
8 validate_checkpoint_transparency, CheckpointPublication, CheckpointTransparencySummary,
9 KernelCheckpoint, ReceiptInclusionProof,
10};
11use chio_kernel::evidence_export::{
12 EvidenceChildReceiptRecord, EvidenceChildReceiptScope, EvidenceExportBundle,
13 EvidenceExportError, EvidenceExportQuery, EvidenceRetentionMetadata, EvidenceToolReceiptRecord,
14 EvidenceUncheckpointedReceipt,
15};
16use chio_kernel::ReceiptStoreError;
17use rusqlite::{params, Connection, OptionalExtension};
18
19use crate::receipt_store::{sqlite_u64, SqliteReceiptStore};
20
21impl SqliteReceiptStore {
22 pub fn build_evidence_export_bundle(
29 &self,
30 query: &EvidenceExportQuery,
31 ) -> Result<EvidenceExportBundle, EvidenceExportError> {
32 let (bundle, _) = self.collect_evidence_export_bundle(query)?;
33 Ok(bundle)
34 }
35
36 pub fn build_evidence_export_bundle_with_transparency(
39 &self,
40 query: &EvidenceExportQuery,
41 ) -> Result<(EvidenceExportBundle, CheckpointTransparencySummary), EvidenceExportError> {
42 let (bundle, transparency) = self.collect_evidence_export_bundle(query)?;
43 let transparency = self.enrich_evidence_export_transparency_summary(transparency)?;
44 Ok((bundle, transparency))
45 }
46
47 fn collect_evidence_export_bundle(
48 &self,
49 query: &EvidenceExportQuery,
50 ) -> Result<(EvidenceExportBundle, CheckpointTransparencySummary), EvidenceExportError> {
51 query.validate_read_boundary()?;
52 let query = query.normalized_for_read_boundary();
53 let tool_receipts = self.collect_tool_receipts_for_export(&query)?;
54 let child_receipt_scope = self.resolve_child_receipt_scope(&query);
55 let child_receipts = self.collect_child_receipts_for_export(&query, child_receipt_scope)?;
56 let (checkpoints, transparency) = self.collect_checkpoints_for_export(&tool_receipts)?;
57 let capability_lineage = self.collect_lineage_for_export(&tool_receipts)?;
58 let (inclusion_proofs, uncheckpointed_receipts) =
59 self.collect_inclusion_proofs_for_export(&tool_receipts, &checkpoints)?;
60 let retention = match &query.read_boundary {
61 Some(chio_kernel::ReceiptReadBoundary::TenantScoped { tenant }) => {
62 EvidenceRetentionMetadata {
63 live_db_size_bytes: None,
64 oldest_live_receipt_timestamp: self
65 .oldest_receipt_timestamp_for_tenant(tenant)?,
66 }
67 }
68 Some(chio_kernel::ReceiptReadBoundary::AdminAll) | None => EvidenceRetentionMetadata {
69 live_db_size_bytes: Some(self.db_size_bytes()?),
70 oldest_live_receipt_timestamp: self.oldest_receipt_timestamp()?,
71 },
72 };
73
74 Ok((
75 EvidenceExportBundle {
76 query: query.clone(),
77 tool_receipts,
78 child_receipts,
79 child_receipt_scope,
80 checkpoints,
81 capability_lineage,
82 inclusion_proofs,
83 uncheckpointed_receipts,
84 retention,
85 },
86 transparency,
87 ))
88 }
89
90 pub fn build_evidence_export_transparency_summary(
91 &self,
92 checkpoints: &[KernelCheckpoint],
93 ) -> Result<CheckpointTransparencySummary, EvidenceExportError> {
94 let summary = validate_checkpoint_transparency(checkpoints)?;
95 self.enrich_evidence_export_transparency_summary(summary)
96 }
97
98 fn enrich_evidence_export_transparency_summary(
99 &self,
100 mut summary: CheckpointTransparencySummary,
101 ) -> Result<CheckpointTransparencySummary, EvidenceExportError> {
102 if summary.publications.is_empty() {
103 return Ok(summary);
104 }
105
106 let connection = self.connection()?;
107 for publication in &mut summary.publications {
108 let persisted =
109 load_checkpoint_publication_core(&connection, publication.checkpoint_seq)?
110 .ok_or_else(|| {
111 EvidenceExportError::ReceiptStore(ReceiptStoreError::Conflict(format!(
112 "checkpoint {} is missing persisted publication metadata",
113 publication.checkpoint_seq
114 )))
115 })?;
116 if !publication_core_matches(publication, &persisted) {
117 return Err(EvidenceExportError::ReceiptStore(
118 ReceiptStoreError::Conflict(format!(
119 "checkpoint {} publication metadata diverges from persisted projection",
120 publication.checkpoint_seq
121 )),
122 ));
123 }
124 if let Some(binding) = load_checkpoint_publication_trust_anchor_binding(
125 &connection,
126 publication.checkpoint_seq,
127 )? {
128 *publication =
129 bind_checkpoint_publication_trust_anchor(publication.clone(), binding)?;
130 }
131 }
132
133 Ok(summary)
134 }
135
136 fn collect_tool_receipts_for_export(
137 &self,
138 query: &EvidenceExportQuery,
139 ) -> Result<Vec<EvidenceToolReceiptRecord>, EvidenceExportError> {
140 let mut cursor = None;
141 let mut records = Vec::new();
142
143 loop {
144 let page = self.query_receipts(&query.as_receipt_query(cursor))?;
145 if page.receipts.is_empty() {
146 break;
147 }
148 let next_cursor = page.next_cursor;
149 let page_records = page
150 .receipts
151 .into_iter()
152 .map(|stored| {
153 let seq = self.claim_log_entry_seq_for_receipt_id(&stored.receipt.id)?;
154 Ok(EvidenceToolReceiptRecord {
155 seq,
156 receipt: stored.receipt,
157 })
158 })
159 .collect::<Result<Vec<_>, EvidenceExportError>>()?;
160 records.extend(page_records);
161 match next_cursor {
162 Some(next) => cursor = Some(next),
163 None => break,
164 }
165 }
166
167 Ok(records)
168 }
169
170 fn claim_log_entry_seq_for_receipt_id(
171 &self,
172 receipt_id: &str,
173 ) -> Result<u64, EvidenceExportError> {
174 let connection = self.connection()?;
175 let seq = connection
176 .query_row(
177 "SELECT entry_seq FROM claim_receipt_log_entries WHERE receipt_id = ?1",
178 params![receipt_id],
179 |row| row.get::<_, i64>(0),
180 )
181 .optional()?
182 .ok_or_else(|| {
183 EvidenceExportError::ReceiptStore(ReceiptStoreError::Conflict(format!(
184 "receipt {receipt_id} is missing from claim receipt log"
185 )))
186 })?;
187 sqlite_u64(seq, "claim receipt log entry_seq").map_err(EvidenceExportError::ReceiptStore)
188 }
189
190 fn resolve_child_receipt_scope(
191 &self,
192 query: &EvidenceExportQuery,
193 ) -> EvidenceChildReceiptScope {
194 query.child_receipt_scope()
195 }
196
197 fn collect_child_receipts_for_export(
198 &self,
199 query: &EvidenceExportQuery,
200 scope: EvidenceChildReceiptScope,
201 ) -> Result<Vec<EvidenceChildReceiptRecord>, EvidenceExportError> {
202 if matches!(scope, EvidenceChildReceiptScope::OmittedNoJoinPath) {
203 return Ok(Vec::new());
204 }
205
206 let since = query.since.map(|value| value as i64);
207 let until = query.until.map(|value| value as i64);
208 let connection = self.connection()?;
209 let mut statement = connection.prepare(
210 r#"
211 SELECT seq, raw_json
212 FROM chio_child_receipts
213 WHERE (?1 IS NULL OR timestamp >= ?1)
214 AND (?2 IS NULL OR timestamp <= ?2)
215 ORDER BY seq ASC
216 "#,
217 )?;
218 let rows = statement.query_map(params![since, until], |row| {
219 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
220 })?;
221
222 rows.map(|row| {
223 let (seq, raw_json) = row?;
224 let seq = seq.max(0) as u64;
225 Ok(EvidenceChildReceiptRecord {
226 seq,
227 receipt: crate::receipt_store::decode_verified_child_receipt(
228 &raw_json,
229 "persisted child receipt",
230 Some(seq),
231 )?,
232 })
233 })
234 .collect()
235 }
236
237 fn collect_checkpoints_for_export(
238 &self,
239 tool_receipts: &[EvidenceToolReceiptRecord],
240 ) -> Result<(Vec<KernelCheckpoint>, CheckpointTransparencySummary), EvidenceExportError> {
241 if tool_receipts.is_empty() {
242 return Ok((Vec::new(), CheckpointTransparencySummary::default()));
243 }
244 let min_seq = tool_receipts
245 .iter()
246 .map(|record| record.seq)
247 .min()
248 .unwrap_or(0);
249 let max_seq = tool_receipts
250 .iter()
251 .map(|record| record.seq)
252 .max()
253 .unwrap_or(0);
254
255 let connection = self.connection()?;
256 let mut statement = connection.prepare(
257 r#"
258 SELECT checkpoint_seq
259 FROM kernel_checkpoints
260 WHERE checkpoint_seq <= (
261 SELECT MAX(checkpoint_seq)
262 FROM kernel_checkpoints
263 WHERE batch_end_seq >= ?1
264 AND batch_start_seq <= ?2
265 )
266 ORDER BY checkpoint_seq ASC
267 "#,
268 )?;
269 let rows = statement.query_map(params![min_seq as i64, max_seq as i64], |row| {
270 row.get::<_, i64>(0)
271 })?;
272
273 let mut checkpoints = Vec::new();
274 for row in rows {
275 let checkpoint_seq = row?.max(0) as u64;
276 if let Some(checkpoint) = self.load_checkpoint_by_seq(checkpoint_seq)? {
277 checkpoints.push(checkpoint);
278 }
279 }
280
281 let transparency = validate_checkpoint_transparency(&checkpoints)?;
282 Ok((checkpoints, transparency))
283 }
284
285 fn collect_lineage_for_export(
286 &self,
287 tool_receipts: &[EvidenceToolReceiptRecord],
288 ) -> Result<Vec<CapabilitySnapshot>, EvidenceExportError> {
289 let mut snapshots = BTreeMap::<String, CapabilitySnapshot>::new();
290 for record in tool_receipts {
291 for snapshot in self.get_combined_delegation_chain(&record.receipt.capability_id)? {
292 snapshot.validate_for_transport()?;
293 snapshots
294 .entry(snapshot.capability_id.clone())
295 .or_insert(snapshot);
296 }
297 }
298 Ok(snapshots.into_values().collect())
299 }
300
301 fn collect_inclusion_proofs_for_export(
302 &self,
303 tool_receipts: &[EvidenceToolReceiptRecord],
304 checkpoints: &[KernelCheckpoint],
305 ) -> Result<
306 (
307 Vec<ReceiptInclusionProof>,
308 Vec<EvidenceUncheckpointedReceipt>,
309 ),
310 EvidenceExportError,
311 > {
312 let exported_by_seq = tool_receipts
313 .iter()
314 .map(|record| (record.seq, record.receipt.id.as_str()))
315 .collect::<BTreeMap<_, _>>();
316 let mut proofs = Vec::new();
317 let mut covered_seqs = BTreeMap::<u64, ()>::new();
318
319 for checkpoint in checkpoints {
320 if exported_by_seq
321 .range(checkpoint.body.batch_start_seq..=checkpoint.body.batch_end_seq)
322 .next()
323 .is_none()
324 {
325 continue;
331 }
332 let canonical_bytes = self.receipts_canonical_bytes_range(
333 checkpoint.body.batch_start_seq,
334 checkpoint.body.batch_end_seq,
335 )?;
336 if canonical_bytes.is_empty() {
337 continue;
338 }
339
340 let leaves = canonical_bytes
341 .iter()
342 .map(|(_, bytes)| bytes.clone())
343 .collect::<Vec<_>>();
344 let tree = MerkleTree::from_leaves(&leaves)?;
345 let leaf_index_by_seq = canonical_bytes
346 .iter()
347 .enumerate()
348 .map(|(index, (seq, _))| (*seq, index))
349 .collect::<BTreeMap<_, _>>();
350
351 for (seq, _) in exported_by_seq
352 .range(checkpoint.body.batch_start_seq..=checkpoint.body.batch_end_seq)
353 {
354 if let Some(leaf_index) = leaf_index_by_seq.get(seq) {
355 proofs.push(build_inclusion_proof(
356 &tree,
357 *leaf_index,
358 checkpoint.body.checkpoint_seq,
359 *seq,
360 )?);
361 covered_seqs.insert(*seq, ());
362 }
363 }
364 }
365
366 let uncheckpointed_receipts = tool_receipts
367 .iter()
368 .filter(|record| !covered_seqs.contains_key(&record.seq))
369 .map(|record| EvidenceUncheckpointedReceipt {
370 seq: record.seq,
371 receipt_id: record.receipt.id.clone(),
372 })
373 .collect();
374
375 Ok((proofs, uncheckpointed_receipts))
376 }
377}
378
379#[derive(Debug, Clone, PartialEq, Eq)]
380struct PersistedCheckpointPublicationCore {
381 publication_schema: String,
382 merkle_root: String,
383 published_at: u64,
384 kernel_key: String,
385 log_tree_size: u64,
386 entry_start_seq: u64,
387 entry_end_seq: u64,
388 previous_checkpoint_sha256: Option<String>,
389}
390
391fn load_checkpoint_publication_core(
392 connection: &Connection,
393 checkpoint_seq: u64,
394) -> Result<Option<PersistedCheckpointPublicationCore>, EvidenceExportError> {
395 let checkpoint_seq = i64::try_from(checkpoint_seq).map_err(|_| {
396 EvidenceExportError::ReceiptStore(ReceiptStoreError::Conflict(
397 "checkpoint_seq exceeds SQLite INTEGER range".to_string(),
398 ))
399 })?;
400 connection
401 .query_row(
402 r#"
403 SELECT publication_schema, merkle_root, published_at, kernel_key,
404 log_tree_size, entry_start_seq, entry_end_seq, previous_checkpoint_sha256
405 FROM checkpoint_publication_metadata
406 WHERE checkpoint_seq = ?1
407 "#,
408 params![checkpoint_seq],
409 |row| {
410 Ok(PersistedCheckpointPublicationCore {
411 publication_schema: row.get::<_, String>(0)?,
412 merkle_root: row.get::<_, String>(1)?,
413 published_at: row.get::<_, i64>(2)?.max(0) as u64,
414 kernel_key: row.get::<_, String>(3)?,
415 log_tree_size: row.get::<_, i64>(4)?.max(0) as u64,
416 entry_start_seq: row.get::<_, i64>(5)?.max(0) as u64,
417 entry_end_seq: row.get::<_, i64>(6)?.max(0) as u64,
418 previous_checkpoint_sha256: row.get::<_, Option<String>>(7)?,
419 })
420 },
421 )
422 .optional()
423 .map_err(EvidenceExportError::from)
424}
425
426fn load_checkpoint_publication_trust_anchor_binding(
427 connection: &Connection,
428 checkpoint_seq: u64,
429) -> Result<Option<CheckpointPublicationTrustAnchorBinding>, EvidenceExportError> {
430 let checkpoint_seq = i64::try_from(checkpoint_seq).map_err(|_| {
431 EvidenceExportError::ReceiptStore(ReceiptStoreError::Conflict(
432 "checkpoint_seq exceeds SQLite INTEGER range".to_string(),
433 ))
434 })?;
435 let binding_json = connection
436 .query_row(
437 r#"
438 SELECT binding_json
439 FROM checkpoint_publication_trust_anchor_bindings
440 WHERE checkpoint_seq = ?1
441 "#,
442 params![checkpoint_seq],
443 |row| row.get::<_, String>(0),
444 )
445 .optional()?;
446 binding_json
447 .map(|value| serde_json::from_str::<CheckpointPublicationTrustAnchorBinding>(&value))
448 .transpose()
449 .map_err(EvidenceExportError::from)
450}
451
452fn publication_core_matches(
453 publication: &CheckpointPublication,
454 persisted: &PersistedCheckpointPublicationCore,
455) -> bool {
456 publication.schema == persisted.publication_schema
457 && publication.merkle_root.to_hex() == persisted.merkle_root
458 && publication.published_at == persisted.published_at
459 && publication.kernel_key.to_hex() == persisted.kernel_key
460 && publication.log_tree_size == persisted.log_tree_size
461 && publication.entry_start_seq == persisted.entry_start_seq
462 && publication.entry_end_seq == persisted.entry_end_seq
463 && publication.previous_checkpoint_sha256 == persisted.previous_checkpoint_sha256
464}
465
466#[cfg(test)]
467#[allow(clippy::expect_used, clippy::unwrap_used)]
468mod tests {
469 use std::time::{SystemTime, UNIX_EPOCH};
470
471 use chio_core::capability::{
472 attenuation::{DelegationLink, DelegationLinkBody},
473 scope::{ChioScope, Operation, ToolGrant},
474 token::{CapabilityToken, CapabilityTokenBody},
475 };
476 use chio_core::crypto::Keypair;
477 use chio_core::receipt::{
478 body::ChioReceipt, body::ChioReceiptBody, decision::Decision, decision::ToolCallAction,
479 lineage::ChildRequestReceipt, lineage::ChildRequestReceiptBody,
480 };
481 use chio_core::session::{OperationKind, OperationTerminalState, RequestId, SessionId};
482 use chio_kernel::checkpoint::validate_checkpoint_transparency;
483 use chio_kernel::checkpoint::{build_checkpoint_with_previous, checkpoint_chain_leaf_hash};
484 use chio_kernel::{build_checkpoint, ReceiptStore};
485
486 use super::*;
487
488 fn unique_db_path(prefix: &str) -> std::path::PathBuf {
489 let nonce = SystemTime::now()
490 .duration_since(UNIX_EPOCH)
491 .expect("time before epoch")
492 .as_nanos();
493 std::env::temp_dir().join(format!("{prefix}-{nonce}.sqlite3"))
494 }
495
496 fn capability_with_id(
497 id: &str,
498 subject: &Keypair,
499 issuer: &Keypair,
500 parent_capability_id: Option<&str>,
501 ) -> CapabilityToken {
502 let mut delegation_chain = Vec::new();
503 if let Some(parent) = parent_capability_id {
504 delegation_chain.push(
505 DelegationLink::sign(
506 DelegationLinkBody {
507 capability_id: parent.to_string(),
508 delegator: issuer.public_key(),
509 delegatee: subject.public_key(),
510 attenuations: Vec::new(),
511 timestamp: 100,
512 scope_hash: None,
513 aggregate_budget: None,
514 cumulative_approval: None,
515 },
516 issuer,
517 )
518 .expect("sign delegation link"),
519 );
520 }
521 CapabilityToken::sign(
522 CapabilityTokenBody {
523 id: id.to_string(),
524 issuer: issuer.public_key(),
525 subject: subject.public_key(),
526 scope: ChioScope {
527 grants: vec![ToolGrant {
528 server_id: "shell".to_string(),
529 tool_name: "bash".to_string(),
530 operations: vec![Operation::Invoke],
531 constraints: vec![],
532 max_invocations: None,
533 max_cost_per_invocation: None,
534 max_total_cost: None,
535 dpop_required: None,
536 }],
537 ..ChioScope::default()
538 },
539 issued_at: 100,
540 expires_at: 10_000,
541 delegation_chain,
542 aggregate_invocation_budget: None,
543 },
544 issuer,
545 )
546 .expect("sign capability")
547 }
548
549 fn receipt_with_ts(id: &str, capability_id: &str, timestamp: u64) -> ChioReceipt {
550 receipt_with_ts_and_tenant(id, capability_id, timestamp, None)
551 }
552
553 fn evidence_receipt_keypair() -> Keypair {
554 Keypair::from_seed(&[0x42; 32])
555 }
556
557 fn receipt_with_ts_and_tenant(
558 id: &str,
559 capability_id: &str,
560 timestamp: u64,
561 tenant_id: Option<&str>,
562 ) -> ChioReceipt {
563 let keypair = evidence_receipt_keypair();
564 ChioReceipt::sign(
565 ChioReceiptBody {
566 id: id.to_string(),
567 timestamp,
568 capability_id: capability_id.to_string(),
569 tool_server: "shell".to_string(),
570 tool_name: "bash".to_string(),
571 action: ToolCallAction::from_parameters(
572 serde_json::json!({"cmd":"echo hi", "receipt": id}),
573 )
574 .expect("action"),
575 decision: Some(Decision::Allow),
576 receipt_kind: Default::default(),
577 boundary_class: Default::default(),
578 observation_outcome: None,
579 tool_origin: Default::default(),
580 redaction_mode: Default::default(),
581 actor_chain: Vec::new(),
582 content_hash: format!("content-{id}"),
583 policy_hash: "policy-1".to_string(),
584 evidence: Vec::new(),
585 metadata: None,
586 trust_level: chio_core::receipt::kinds::TrustLevel::default(),
587 tenant_id: tenant_id.map(ToOwned::to_owned),
588 kernel_key: keypair.public_key(),
589 bbs_projection_version: None,
590 },
591 &keypair,
592 )
593 .expect("sign receipt")
594 }
595
596 fn child_receipt_with_ts(id: &str, timestamp: u64) -> ChildRequestReceipt {
597 let keypair = Keypair::generate();
598 ChildRequestReceipt::sign(
599 ChildRequestReceiptBody {
600 id: id.to_string(),
601 timestamp,
602 session_id: SessionId::new("sess-evidence"),
603 parent_request_id: RequestId::new("parent-evidence"),
604 request_id: RequestId::new(format!("request-{id}")),
605 operation_kind: OperationKind::CreateMessage,
606 terminal_state: OperationTerminalState::Completed,
607 outcome_hash: format!("outcome-{id}"),
608 policy_hash: "policy-evidence".to_string(),
609 metadata: None,
610 kernel_key: keypair.public_key(),
611 },
612 &keypair,
613 )
614 .expect("sign child receipt")
615 }
616
617 #[test]
618 fn builds_bundle_with_receipts_lineage_and_proofs() {
619 let path = unique_db_path("evidence-export");
620 let store = SqliteReceiptStore::open(&path).unwrap();
621 let issuer = Keypair::generate();
622 let subject = Keypair::generate();
623 let root = capability_with_id("cap-root", &subject, &issuer, None);
624 let child = capability_with_id("cap-child", &subject, &issuer, Some("cap-root"));
625
626 store.record_capability_snapshot(&root, None).unwrap();
627 store
628 .record_capability_snapshot(&child, Some("cap-root"))
629 .unwrap();
630
631 let seq1 = store
632 .append_chio_receipt_returning_seq(&receipt_with_ts("rcpt-1", "cap-child", 100))
633 .unwrap();
634 let seq2 = store
635 .append_chio_receipt_returning_seq(&receipt_with_ts("rcpt-2", "cap-child", 101))
636 .unwrap();
637 store
638 .append_child_receipt(&child_receipt_with_ts("child-1", 100))
639 .unwrap();
640
641 let canonical = store.receipts_canonical_bytes_range(seq1, seq2).unwrap();
642 let checkpoint = build_checkpoint(
643 1,
644 seq1,
645 seq2,
646 &canonical
647 .into_iter()
648 .map(|(_, bytes)| bytes)
649 .collect::<Vec<_>>(),
650 &evidence_receipt_keypair(),
651 )
652 .unwrap();
653 store.store_checkpoint(&checkpoint).unwrap();
654
655 let bundle = store
656 .build_evidence_export_bundle(&EvidenceExportQuery::admin_all())
657 .unwrap();
658
659 assert_eq!(bundle.tool_receipts.len(), 2);
660 assert_eq!(bundle.child_receipts.len(), 1);
661 assert_eq!(
662 bundle.child_receipt_scope,
663 EvidenceChildReceiptScope::FullQueryWindow
664 );
665 assert_eq!(bundle.checkpoints.len(), 1);
666 assert_eq!(bundle.inclusion_proofs.len(), 2);
667 assert!(bundle.uncheckpointed_receipts.is_empty());
668 assert_eq!(bundle.capability_lineage.len(), 2);
669 assert!(bundle
670 .capability_lineage
671 .iter()
672 .all(|snapshot| snapshot.signed_capability.is_some()));
673 assert!(bundle
674 .retention
675 .live_db_size_bytes
676 .is_some_and(|size| size > 0));
677
678 let transparency = validate_checkpoint_transparency(&bundle.checkpoints).unwrap();
679 assert_eq!(transparency.publications.len(), 1);
680 assert!(transparency.witnesses.is_empty());
681 assert!(transparency.equivocations.is_empty());
682
683 let _ = std::fs::remove_file(path);
684 }
685
686 #[test]
687 fn scoped_export_includes_checkpoint_prefix_to_genesis() {
688 let path = unique_db_path("evidence-export-checkpoint-prefix");
689 let store = SqliteReceiptStore::open(&path).unwrap();
690 let checkpoint_key = evidence_receipt_keypair();
691
692 let seq1 = store
693 .append_chio_receipt_returning_seq(&receipt_with_ts("rcpt-1", "cap-1", 100))
694 .unwrap();
695 let seq2 = store
696 .append_chio_receipt_returning_seq(&receipt_with_ts("rcpt-2", "cap-1", 101))
697 .unwrap();
698 let seq3 = store
699 .append_chio_receipt_returning_seq(&receipt_with_ts("rcpt-3", "cap-1", 102))
700 .unwrap();
701 let seq4 = store
702 .append_chio_receipt_returning_seq(&receipt_with_ts("rcpt-4", "cap-1", 103))
703 .unwrap();
704
705 let first_bytes = store.receipts_canonical_bytes_range(seq1, seq2).unwrap();
706 let first = build_checkpoint(
707 1,
708 seq1,
709 seq2,
710 &first_bytes
711 .into_iter()
712 .map(|(_, bytes)| bytes)
713 .collect::<Vec<_>>(),
714 &checkpoint_key,
715 )
716 .unwrap();
717 store.store_checkpoint(&first).unwrap();
718
719 let second_bytes = store.receipts_canonical_bytes_range(seq3, seq4).unwrap();
720 let second = build_checkpoint_with_previous(
721 2,
722 seq3,
723 seq4,
724 &second_bytes
725 .into_iter()
726 .map(|(_, bytes)| bytes)
727 .collect::<Vec<_>>(),
728 &checkpoint_key,
729 Some(&first),
730 &[checkpoint_chain_leaf_hash(&first.body).unwrap()],
731 )
732 .unwrap();
733 store.store_checkpoint(&second).unwrap();
734
735 let mut query = EvidenceExportQuery::admin_all();
736 query.since = Some(102);
737 let (bundle, transparency) = store
738 .build_evidence_export_bundle_with_transparency(&query)
739 .unwrap();
740
741 assert_eq!(bundle.tool_receipts.len(), 2);
742 assert_eq!(
743 bundle
744 .checkpoints
745 .iter()
746 .map(|checkpoint| checkpoint.body.checkpoint_seq)
747 .collect::<Vec<_>>(),
748 vec![1, 2],
749 "a scoped export must carry the predecessor prefix to checkpoint 1"
750 );
751 assert_eq!(transparency.publications.len(), 2);
752 assert_eq!(transparency.witnesses.len(), 1);
753 assert!(transparency.equivocations.is_empty());
754
755 let _ = std::fs::remove_file(path);
756 }
757
758 #[test]
759 fn scoped_export_skips_archived_checkpoint_batches_without_selected_receipts() {
760 let path = unique_db_path("evidence-export-archived-prefix");
761 let archive = unique_db_path("evidence-export-archived-prefix-archive");
762 let store = SqliteReceiptStore::open(&path).unwrap();
763 let checkpoint_key = evidence_receipt_keypair();
764
765 let seq1 = store
766 .append_chio_receipt_returning_seq(&receipt_with_ts("rcpt-1", "cap-1", 100))
767 .unwrap();
768 let seq2 = store
769 .append_chio_receipt_returning_seq(&receipt_with_ts("rcpt-2", "cap-1", 101))
770 .unwrap();
771 let seq3 = store
772 .append_chio_receipt_returning_seq(&receipt_with_ts("rcpt-3", "cap-1", 102))
773 .unwrap();
774 let seq4 = store
775 .append_chio_receipt_returning_seq(&receipt_with_ts("rcpt-4", "cap-1", 103))
776 .unwrap();
777
778 let first_bytes = store.receipts_canonical_bytes_range(seq1, seq2).unwrap();
779 let first = build_checkpoint(
780 1,
781 seq1,
782 seq2,
783 &first_bytes
784 .into_iter()
785 .map(|(_, bytes)| bytes)
786 .collect::<Vec<_>>(),
787 &checkpoint_key,
788 )
789 .unwrap();
790 store.store_checkpoint(&first).unwrap();
791
792 let second_bytes = store.receipts_canonical_bytes_range(seq3, seq4).unwrap();
793 let second = build_checkpoint_with_previous(
794 2,
795 seq3,
796 seq4,
797 &second_bytes
798 .into_iter()
799 .map(|(_, bytes)| bytes)
800 .collect::<Vec<_>>(),
801 &checkpoint_key,
802 Some(&first),
803 &[checkpoint_chain_leaf_hash(&first.body).unwrap()],
804 )
805 .unwrap();
806 store.store_checkpoint(&second).unwrap();
807
808 let archived = store
809 .archive_receipts_before(102, archive.to_str().unwrap())
810 .unwrap();
811 assert_eq!(archived, 2);
812
813 let mut query = EvidenceExportQuery::admin_all();
814 query.since = Some(102);
815 let bundle = store.build_evidence_export_bundle(&query).unwrap();
816
817 assert_eq!(bundle.checkpoints.len(), 2);
818 assert_eq!(bundle.inclusion_proofs.len(), 2);
819 assert!(bundle.uncheckpointed_receipts.is_empty());
820 assert!(bundle
821 .inclusion_proofs
822 .iter()
823 .all(|proof| proof.checkpoint_seq == 2));
824
825 let _ = std::fs::remove_file(path);
826 let _ = std::fs::remove_file(archive);
827 }
828
829 #[test]
830 fn evidence_export_requires_explicit_receipt_read_boundary() {
831 let path = unique_db_path("evidence-export-read-boundary");
832 let store = SqliteReceiptStore::open(&path).unwrap();
833 store
834 .append_chio_receipt_returning_seq(&receipt_with_ts("rcpt-1", "cap-1", 100))
835 .unwrap();
836
837 let error = store
838 .build_evidence_export_bundle(&EvidenceExportQuery::default())
839 .expect_err("missing read boundary must fail closed");
840 assert!(error.to_string().contains("receipt read boundary"));
841
842 let _ = std::fs::remove_file(path);
843 }
844
845 #[test]
846 fn tenant_scoped_evidence_export_cannot_see_other_tenants() {
847 let path = unique_db_path("evidence-export-tenant");
848 let store = SqliteReceiptStore::open(&path).unwrap();
849 store.with_strict_tenant_isolation(true);
850 store
851 .append_chio_receipt_returning_seq(&receipt_with_ts_and_tenant(
852 "rcpt-a",
853 "cap-a",
854 100,
855 Some("tenant-a"),
856 ))
857 .unwrap();
858 store
859 .append_chio_receipt_returning_seq(&receipt_with_ts_and_tenant(
860 "rcpt-b",
861 "cap-b",
862 100,
863 Some("tenant-b"),
864 ))
865 .unwrap();
866
867 let bundle = store
868 .build_evidence_export_bundle(&EvidenceExportQuery::tenant_scoped("tenant-a"))
869 .unwrap();
870
871 assert_eq!(bundle.query.tenant.as_deref(), Some("tenant-a"));
872 assert_eq!(bundle.tool_receipts.len(), 1);
873 assert_eq!(
874 bundle.tool_receipts[0].receipt.tenant_id.as_deref(),
875 Some("tenant-a")
876 );
877 assert_eq!(bundle.retention.live_db_size_bytes, None);
878 assert_eq!(bundle.retention.oldest_live_receipt_timestamp, Some(100));
879
880 let _ = std::fs::remove_file(path);
881 }
882
883 #[test]
884 fn tenant_scoped_evidence_export_omits_child_receipts_without_tenant_join() {
885 let path = unique_db_path("evidence-export-tenant-child");
886 let store = SqliteReceiptStore::open(&path).unwrap();
887 store.with_strict_tenant_isolation(true);
888 store
889 .append_chio_receipt_returning_seq(&receipt_with_ts_and_tenant(
890 "rcpt-a",
891 "cap-a",
892 100,
893 Some("tenant-a"),
894 ))
895 .unwrap();
896 store
897 .append_chio_receipt_returning_seq(&receipt_with_ts_and_tenant(
898 "rcpt-b",
899 "cap-b",
900 100,
901 Some("tenant-b"),
902 ))
903 .unwrap();
904 store
905 .append_child_receipt(&child_receipt_with_ts("child-tenant-unknown", 100))
906 .unwrap();
907
908 let bundle = store
909 .build_evidence_export_bundle(&EvidenceExportQuery::tenant_scoped("tenant-a"))
910 .unwrap();
911
912 assert_eq!(
913 bundle.child_receipt_scope,
914 EvidenceChildReceiptScope::OmittedNoJoinPath
915 );
916 assert!(bundle.child_receipts.is_empty());
917
918 let _ = std::fs::remove_file(path);
919 }
920
921 #[test]
922 fn omits_child_receipts_for_capability_scoped_export_without_time_window() {
923 let path = unique_db_path("evidence-export-scope");
924 let store = SqliteReceiptStore::open(&path).unwrap();
925 let issuer = Keypair::generate();
926 let subject = Keypair::generate();
927 let capability = capability_with_id("cap-scoped", &subject, &issuer, None);
928 store.record_capability_snapshot(&capability, None).unwrap();
929 store
930 .append_chio_receipt_returning_seq(&receipt_with_ts("rcpt-1", "cap-scoped", 100))
931 .unwrap();
932 store
933 .append_child_receipt(&child_receipt_with_ts("child-1", 100))
934 .unwrap();
935
936 let bundle = store
937 .build_evidence_export_bundle(&EvidenceExportQuery {
938 capability_id: Some("cap-scoped".to_string()),
939 ..EvidenceExportQuery::admin_all()
940 })
941 .unwrap();
942
943 assert_eq!(
944 bundle.child_receipt_scope,
945 EvidenceChildReceiptScope::OmittedNoJoinPath
946 );
947 assert!(bundle.child_receipts.is_empty());
948
949 let _ = std::fs::remove_file(path);
950 }
951}