1use std::collections::BTreeMap;
2
3use chio_core::merkle::MerkleTree;
4use chio_core::receipt::CheckpointPublicationTrustAnchorBinding;
5use chio_kernel::capability_lineage::CapabilitySnapshot;
6use chio_kernel::checkpoint::{
7 build_inclusion_proof, build_trust_anchored_checkpoint_publication,
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::SqliteReceiptStore;
20
21impl SqliteReceiptStore {
22 pub fn build_evidence_export_bundle(
29 &self,
30 query: &EvidenceExportQuery,
31 ) -> Result<EvidenceExportBundle, EvidenceExportError> {
32 let tool_receipts = self.collect_tool_receipts_for_export(query)?;
33 let child_receipt_scope = self.resolve_child_receipt_scope(query);
34 let child_receipts = self.collect_child_receipts_for_export(query, child_receipt_scope)?;
35 let checkpoints = self.collect_checkpoints_for_export(&tool_receipts)?;
36 let capability_lineage = self.collect_lineage_for_export(&tool_receipts)?;
37 let (inclusion_proofs, uncheckpointed_receipts) =
38 self.collect_inclusion_proofs_for_export(&tool_receipts, &checkpoints)?;
39 let retention = EvidenceRetentionMetadata {
40 live_db_size_bytes: self.db_size_bytes()?,
41 oldest_live_receipt_timestamp: self.oldest_receipt_timestamp()?,
42 };
43
44 Ok(EvidenceExportBundle {
45 query: query.clone(),
46 tool_receipts,
47 child_receipts,
48 child_receipt_scope,
49 checkpoints,
50 capability_lineage,
51 inclusion_proofs,
52 uncheckpointed_receipts,
53 retention,
54 })
55 }
56
57 pub fn build_evidence_export_transparency_summary(
58 &self,
59 checkpoints: &[KernelCheckpoint],
60 ) -> Result<CheckpointTransparencySummary, EvidenceExportError> {
61 let mut summary = validate_checkpoint_transparency(checkpoints)?;
62 if summary.publications.is_empty() {
63 return Ok(summary);
64 }
65
66 let checkpoint_by_seq = checkpoints
67 .iter()
68 .map(|checkpoint| (checkpoint.body.checkpoint_seq, checkpoint))
69 .collect::<BTreeMap<_, _>>();
70 let connection = self.connection()?;
71 for publication in &mut summary.publications {
72 let Some(checkpoint) = checkpoint_by_seq.get(&publication.checkpoint_seq).copied()
73 else {
74 return Err(EvidenceExportError::ReceiptStore(
75 ReceiptStoreError::Conflict(format!(
76 "checkpoint {} is missing while deriving publication summary",
77 publication.checkpoint_seq
78 )),
79 ));
80 };
81 let persisted =
82 load_checkpoint_publication_core(&connection, publication.checkpoint_seq)?
83 .ok_or_else(|| {
84 EvidenceExportError::ReceiptStore(ReceiptStoreError::Conflict(format!(
85 "checkpoint {} is missing persisted publication metadata",
86 publication.checkpoint_seq
87 )))
88 })?;
89 if !publication_core_matches(publication, &persisted) {
90 return Err(EvidenceExportError::ReceiptStore(
91 ReceiptStoreError::Conflict(format!(
92 "checkpoint {} publication metadata diverges from persisted projection",
93 publication.checkpoint_seq
94 )),
95 ));
96 }
97 if let Some(binding) = load_checkpoint_publication_trust_anchor_binding(
98 &connection,
99 publication.checkpoint_seq,
100 )? {
101 *publication = build_trust_anchored_checkpoint_publication(checkpoint, binding)?;
102 }
103 }
104
105 Ok(summary)
106 }
107
108 fn collect_tool_receipts_for_export(
109 &self,
110 query: &EvidenceExportQuery,
111 ) -> Result<Vec<EvidenceToolReceiptRecord>, EvidenceExportError> {
112 let mut cursor = None;
113 let mut records = Vec::new();
114
115 loop {
116 let page = self.query_receipts(&query.as_receipt_query(cursor))?;
117 if page.receipts.is_empty() {
118 break;
119 }
120 let next_cursor = page.next_cursor;
121 records.extend(
122 page.receipts
123 .into_iter()
124 .map(|stored| EvidenceToolReceiptRecord {
125 seq: stored.seq,
126 receipt: stored.receipt,
127 }),
128 );
129 match next_cursor {
130 Some(next) => cursor = Some(next),
131 None => break,
132 }
133 }
134
135 Ok(records)
136 }
137
138 fn resolve_child_receipt_scope(
139 &self,
140 query: &EvidenceExportQuery,
141 ) -> EvidenceChildReceiptScope {
142 query.child_receipt_scope()
143 }
144
145 fn collect_child_receipts_for_export(
146 &self,
147 query: &EvidenceExportQuery,
148 scope: EvidenceChildReceiptScope,
149 ) -> Result<Vec<EvidenceChildReceiptRecord>, EvidenceExportError> {
150 if matches!(scope, EvidenceChildReceiptScope::OmittedNoJoinPath) {
151 return Ok(Vec::new());
152 }
153
154 let since = query.since.map(|value| value as i64);
155 let until = query.until.map(|value| value as i64);
156 let connection = self.connection()?;
157 let mut statement = connection.prepare(
158 r#"
159 SELECT seq, raw_json
160 FROM chio_child_receipts
161 WHERE (?1 IS NULL OR timestamp >= ?1)
162 AND (?2 IS NULL OR timestamp <= ?2)
163 ORDER BY seq ASC
164 "#,
165 )?;
166 let rows = statement.query_map(params![since, until], |row| {
167 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
168 })?;
169
170 rows.map(|row| {
171 let (seq, raw_json) = row?;
172 let seq = seq.max(0) as u64;
173 Ok(EvidenceChildReceiptRecord {
174 seq,
175 receipt: crate::receipt_store::decode_verified_child_receipt(
176 &raw_json,
177 "persisted child receipt",
178 Some(seq),
179 )?,
180 })
181 })
182 .collect()
183 }
184
185 fn collect_checkpoints_for_export(
186 &self,
187 tool_receipts: &[EvidenceToolReceiptRecord],
188 ) -> Result<Vec<KernelCheckpoint>, EvidenceExportError> {
189 let (Some(min_seq), Some(max_seq)) = (
190 tool_receipts.first().map(|record| record.seq),
191 tool_receipts.last().map(|record| record.seq),
192 ) else {
193 return Ok(Vec::new());
194 };
195
196 let connection = self.connection()?;
197 let mut statement = connection.prepare(
198 r#"
199 SELECT checkpoint_seq
200 FROM kernel_checkpoints
201 WHERE batch_end_seq >= ?1
202 AND batch_start_seq <= ?2
203 ORDER BY checkpoint_seq ASC
204 "#,
205 )?;
206 let rows = statement.query_map(params![min_seq as i64, max_seq as i64], |row| {
207 row.get::<_, i64>(0)
208 })?;
209
210 let mut checkpoints = Vec::new();
211 for row in rows {
212 let checkpoint_seq = row?.max(0) as u64;
213 if let Some(checkpoint) = self.load_checkpoint_by_seq(checkpoint_seq)? {
214 checkpoints.push(checkpoint);
215 }
216 }
217
218 validate_checkpoint_transparency(&checkpoints)?;
219
220 Ok(checkpoints)
221 }
222
223 fn collect_lineage_for_export(
224 &self,
225 tool_receipts: &[EvidenceToolReceiptRecord],
226 ) -> Result<Vec<CapabilitySnapshot>, EvidenceExportError> {
227 let mut snapshots = BTreeMap::<String, CapabilitySnapshot>::new();
228 for record in tool_receipts {
229 for snapshot in self.get_combined_delegation_chain(&record.receipt.capability_id)? {
230 snapshots
231 .entry(snapshot.capability_id.clone())
232 .or_insert(snapshot);
233 }
234 }
235 Ok(snapshots.into_values().collect())
236 }
237
238 fn collect_inclusion_proofs_for_export(
239 &self,
240 tool_receipts: &[EvidenceToolReceiptRecord],
241 checkpoints: &[KernelCheckpoint],
242 ) -> Result<
243 (
244 Vec<ReceiptInclusionProof>,
245 Vec<EvidenceUncheckpointedReceipt>,
246 ),
247 EvidenceExportError,
248 > {
249 let exported_by_seq = tool_receipts
250 .iter()
251 .map(|record| (record.seq, record.receipt.id.as_str()))
252 .collect::<BTreeMap<_, _>>();
253 let mut proofs = Vec::new();
254 let mut covered_seqs = BTreeMap::<u64, ()>::new();
255
256 for checkpoint in checkpoints {
257 let canonical_bytes = self.receipts_canonical_bytes_range(
258 checkpoint.body.batch_start_seq,
259 checkpoint.body.batch_end_seq,
260 )?;
261 if canonical_bytes.is_empty() {
262 continue;
263 }
264
265 let leaves = canonical_bytes
266 .iter()
267 .map(|(_, bytes)| bytes.clone())
268 .collect::<Vec<_>>();
269 let tree = MerkleTree::from_leaves(&leaves)?;
270 let leaf_index_by_seq = canonical_bytes
271 .iter()
272 .enumerate()
273 .map(|(index, (seq, _))| (*seq, index))
274 .collect::<BTreeMap<_, _>>();
275
276 for (seq, _) in exported_by_seq
277 .range(checkpoint.body.batch_start_seq..=checkpoint.body.batch_end_seq)
278 {
279 if let Some(leaf_index) = leaf_index_by_seq.get(seq) {
280 proofs.push(build_inclusion_proof(
281 &tree,
282 *leaf_index,
283 checkpoint.body.checkpoint_seq,
284 *seq,
285 )?);
286 covered_seqs.insert(*seq, ());
287 }
288 }
289 }
290
291 let uncheckpointed_receipts = tool_receipts
292 .iter()
293 .filter(|record| !covered_seqs.contains_key(&record.seq))
294 .map(|record| EvidenceUncheckpointedReceipt {
295 seq: record.seq,
296 receipt_id: record.receipt.id.clone(),
297 })
298 .collect();
299
300 Ok((proofs, uncheckpointed_receipts))
301 }
302}
303
304#[derive(Debug, Clone, PartialEq, Eq)]
305struct PersistedCheckpointPublicationCore {
306 publication_schema: String,
307 merkle_root: String,
308 published_at: u64,
309 kernel_key: String,
310 log_tree_size: u64,
311 entry_start_seq: u64,
312 entry_end_seq: u64,
313 previous_checkpoint_sha256: Option<String>,
314}
315
316fn load_checkpoint_publication_core(
317 connection: &Connection,
318 checkpoint_seq: u64,
319) -> Result<Option<PersistedCheckpointPublicationCore>, EvidenceExportError> {
320 let checkpoint_seq = i64::try_from(checkpoint_seq).map_err(|_| {
321 EvidenceExportError::ReceiptStore(ReceiptStoreError::Conflict(
322 "checkpoint_seq exceeds SQLite INTEGER range".to_string(),
323 ))
324 })?;
325 connection
326 .query_row(
327 r#"
328 SELECT publication_schema, merkle_root, published_at, kernel_key,
329 log_tree_size, entry_start_seq, entry_end_seq, previous_checkpoint_sha256
330 FROM checkpoint_publication_metadata
331 WHERE checkpoint_seq = ?1
332 "#,
333 params![checkpoint_seq],
334 |row| {
335 Ok(PersistedCheckpointPublicationCore {
336 publication_schema: row.get::<_, String>(0)?,
337 merkle_root: row.get::<_, String>(1)?,
338 published_at: row.get::<_, i64>(2)?.max(0) as u64,
339 kernel_key: row.get::<_, String>(3)?,
340 log_tree_size: row.get::<_, i64>(4)?.max(0) as u64,
341 entry_start_seq: row.get::<_, i64>(5)?.max(0) as u64,
342 entry_end_seq: row.get::<_, i64>(6)?.max(0) as u64,
343 previous_checkpoint_sha256: row.get::<_, Option<String>>(7)?,
344 })
345 },
346 )
347 .optional()
348 .map_err(EvidenceExportError::from)
349}
350
351fn load_checkpoint_publication_trust_anchor_binding(
352 connection: &Connection,
353 checkpoint_seq: u64,
354) -> Result<Option<CheckpointPublicationTrustAnchorBinding>, EvidenceExportError> {
355 let checkpoint_seq = i64::try_from(checkpoint_seq).map_err(|_| {
356 EvidenceExportError::ReceiptStore(ReceiptStoreError::Conflict(
357 "checkpoint_seq exceeds SQLite INTEGER range".to_string(),
358 ))
359 })?;
360 let binding_json = connection
361 .query_row(
362 r#"
363 SELECT binding_json
364 FROM checkpoint_publication_trust_anchor_bindings
365 WHERE checkpoint_seq = ?1
366 "#,
367 params![checkpoint_seq],
368 |row| row.get::<_, String>(0),
369 )
370 .optional()?;
371 binding_json
372 .map(|value| serde_json::from_str::<CheckpointPublicationTrustAnchorBinding>(&value))
373 .transpose()
374 .map_err(EvidenceExportError::from)
375}
376
377fn publication_core_matches(
378 publication: &CheckpointPublication,
379 persisted: &PersistedCheckpointPublicationCore,
380) -> bool {
381 publication.schema == persisted.publication_schema
382 && publication.merkle_root.to_hex() == persisted.merkle_root
383 && publication.published_at == persisted.published_at
384 && publication.kernel_key.to_hex() == persisted.kernel_key
385 && publication.log_tree_size == persisted.log_tree_size
386 && publication.entry_start_seq == persisted.entry_start_seq
387 && publication.entry_end_seq == persisted.entry_end_seq
388 && publication.previous_checkpoint_sha256 == persisted.previous_checkpoint_sha256
389}
390
391#[cfg(test)]
392#[allow(clippy::expect_used, clippy::unwrap_used)]
393mod tests {
394 use std::time::{SystemTime, UNIX_EPOCH};
395
396 use chio_core::capability::{
397 CapabilityToken, CapabilityTokenBody, ChioScope, DelegationLink, DelegationLinkBody,
398 Operation, ToolGrant,
399 };
400 use chio_core::crypto::Keypair;
401 use chio_core::receipt::{
402 ChildRequestReceipt, ChildRequestReceiptBody, ChioReceipt, ChioReceiptBody, Decision,
403 ToolCallAction,
404 };
405 use chio_core::session::{OperationKind, OperationTerminalState, RequestId, SessionId};
406 use chio_kernel::checkpoint::validate_checkpoint_transparency;
407 use chio_kernel::{build_checkpoint, ReceiptStore};
408
409 use super::*;
410
411 fn unique_db_path(prefix: &str) -> std::path::PathBuf {
412 let nonce = SystemTime::now()
413 .duration_since(UNIX_EPOCH)
414 .expect("time before epoch")
415 .as_nanos();
416 std::env::temp_dir().join(format!("{prefix}-{nonce}.sqlite3"))
417 }
418
419 fn capability_with_id(
420 id: &str,
421 subject: &Keypair,
422 issuer: &Keypair,
423 parent_capability_id: Option<&str>,
424 ) -> CapabilityToken {
425 let mut delegation_chain = Vec::new();
426 if let Some(parent) = parent_capability_id {
427 delegation_chain.push(
428 DelegationLink::sign(
429 DelegationLinkBody {
430 capability_id: parent.to_string(),
431 delegator: issuer.public_key(),
432 delegatee: subject.public_key(),
433 attenuations: Vec::new(),
434 timestamp: 100,
435 },
436 issuer,
437 )
438 .expect("sign delegation link"),
439 );
440 }
441 CapabilityToken::sign(
442 CapabilityTokenBody {
443 id: id.to_string(),
444 issuer: issuer.public_key(),
445 subject: subject.public_key(),
446 scope: ChioScope {
447 grants: vec![ToolGrant {
448 server_id: "shell".to_string(),
449 tool_name: "bash".to_string(),
450 operations: vec![Operation::Invoke],
451 constraints: vec![],
452 max_invocations: None,
453 max_cost_per_invocation: None,
454 max_total_cost: None,
455 dpop_required: None,
456 }],
457 ..ChioScope::default()
458 },
459 issued_at: 100,
460 expires_at: 10_000,
461 delegation_chain,
462 },
463 issuer,
464 )
465 .expect("sign capability")
466 }
467
468 fn receipt_with_ts(id: &str, capability_id: &str, timestamp: u64) -> ChioReceipt {
469 let keypair = Keypair::generate();
470 ChioReceipt::sign(
471 ChioReceiptBody {
472 id: id.to_string(),
473 timestamp,
474 capability_id: capability_id.to_string(),
475 tool_server: "shell".to_string(),
476 tool_name: "bash".to_string(),
477 action: ToolCallAction::from_parameters(serde_json::json!({"cmd":"echo hi"}))
478 .expect("action"),
479 decision: Decision::Allow,
480 content_hash: "content-1".to_string(),
481 policy_hash: "policy-1".to_string(),
482 evidence: Vec::new(),
483 metadata: None,
484 trust_level: chio_core::TrustLevel::default(),
485 tenant_id: None,
486 kernel_key: keypair.public_key(),
487 },
488 &keypair,
489 )
490 .expect("sign receipt")
491 }
492
493 fn child_receipt_with_ts(id: &str, timestamp: u64) -> ChildRequestReceipt {
494 let keypair = Keypair::generate();
495 ChildRequestReceipt::sign(
496 ChildRequestReceiptBody {
497 id: id.to_string(),
498 timestamp,
499 session_id: SessionId::new("sess-evidence"),
500 parent_request_id: RequestId::new("parent-evidence"),
501 request_id: RequestId::new(format!("request-{id}")),
502 operation_kind: OperationKind::CreateMessage,
503 terminal_state: OperationTerminalState::Completed,
504 outcome_hash: format!("outcome-{id}"),
505 policy_hash: "policy-evidence".to_string(),
506 metadata: None,
507 kernel_key: keypair.public_key(),
508 },
509 &keypair,
510 )
511 .expect("sign child receipt")
512 }
513
514 #[test]
515 fn builds_bundle_with_receipts_lineage_and_proofs() {
516 let path = unique_db_path("evidence-export");
517 let mut store = SqliteReceiptStore::open(&path).unwrap();
518 let issuer = Keypair::generate();
519 let subject = Keypair::generate();
520 let root = capability_with_id("cap-root", &subject, &issuer, None);
521 let child = capability_with_id("cap-child", &subject, &issuer, Some("cap-root"));
522
523 store.record_capability_snapshot(&root, None).unwrap();
524 store
525 .record_capability_snapshot(&child, Some("cap-root"))
526 .unwrap();
527
528 let seq1 = store
529 .append_chio_receipt_returning_seq(&receipt_with_ts("rcpt-1", "cap-child", 100))
530 .unwrap();
531 let seq2 = store
532 .append_chio_receipt_returning_seq(&receipt_with_ts("rcpt-2", "cap-child", 101))
533 .unwrap();
534 store
535 .append_child_receipt(&child_receipt_with_ts("child-1", 100))
536 .unwrap();
537
538 let canonical = store.receipts_canonical_bytes_range(seq1, seq2).unwrap();
539 let checkpoint = build_checkpoint(
540 1,
541 seq1,
542 seq2,
543 &canonical
544 .into_iter()
545 .map(|(_, bytes)| bytes)
546 .collect::<Vec<_>>(),
547 &issuer,
548 )
549 .unwrap();
550 store.store_checkpoint(&checkpoint).unwrap();
551
552 let bundle = store
553 .build_evidence_export_bundle(&EvidenceExportQuery::default())
554 .unwrap();
555
556 assert_eq!(bundle.tool_receipts.len(), 2);
557 assert_eq!(bundle.child_receipts.len(), 1);
558 assert_eq!(
559 bundle.child_receipt_scope,
560 EvidenceChildReceiptScope::FullQueryWindow
561 );
562 assert_eq!(bundle.checkpoints.len(), 1);
563 assert_eq!(bundle.inclusion_proofs.len(), 2);
564 assert!(bundle.uncheckpointed_receipts.is_empty());
565 assert_eq!(bundle.capability_lineage.len(), 2);
566 assert!(bundle.retention.live_db_size_bytes > 0);
567
568 let transparency = validate_checkpoint_transparency(&bundle.checkpoints).unwrap();
569 assert_eq!(transparency.publications.len(), 1);
570 assert!(transparency.witnesses.is_empty());
571 assert!(transparency.equivocations.is_empty());
572
573 let _ = std::fs::remove_file(path);
574 }
575
576 #[test]
577 fn omits_child_receipts_for_capability_scoped_export_without_time_window() {
578 let path = unique_db_path("evidence-export-scope");
579 let mut store = SqliteReceiptStore::open(&path).unwrap();
580 let issuer = Keypair::generate();
581 let subject = Keypair::generate();
582 let capability = capability_with_id("cap-scoped", &subject, &issuer, None);
583 store.record_capability_snapshot(&capability, None).unwrap();
584 store
585 .append_chio_receipt_returning_seq(&receipt_with_ts("rcpt-1", "cap-scoped", 100))
586 .unwrap();
587 store
588 .append_child_receipt(&child_receipt_with_ts("child-1", 100))
589 .unwrap();
590
591 let bundle = store
592 .build_evidence_export_bundle(&EvidenceExportQuery {
593 capability_id: Some("cap-scoped".to_string()),
594 ..EvidenceExportQuery::default()
595 })
596 .unwrap();
597
598 assert_eq!(
599 bundle.child_receipt_scope,
600 EvidenceChildReceiptScope::OmittedNoJoinPath
601 );
602 assert!(bundle.child_receipts.is_empty());
603
604 let _ = std::fs::remove_file(path);
605 }
606}