Skip to main content

chio_store_sqlite/
evidence_export.rs

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    /// Build a local-only evidence export bundle from the current SQLite store.
23    ///
24    /// This method never fabricates joins that the runtime does not persist.
25    /// Tool receipts can be scoped by capability or agent subject. Child
26    /// receipts do not currently have the same attribution fields, so they are
27    /// either included by query window or omitted with an explicit scope flag.
28    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}