Skip to main content

chio_store_sqlite/
evidence_export.rs

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    /// 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 (bundle, _) = self.collect_evidence_export_bundle(query)?;
33        Ok(bundle)
34    }
35
36    /// Build a bundle and its transparency summary with one checkpoint
37    /// validation pass.
38    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                // Scoped exports retain the complete checkpoint prefix for
326                // transparency validation. An earlier checkpoint batch may
327                // already have been archived and deleted from the live claim
328                // log, but it does not need to be rebuilt when none of its
329                // receipts are in this export.
330                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}