Skip to main content

chio_kernel/
evidence_export.rs

1use std::collections::BTreeMap;
2
3use chio_core::receipt::{ChildRequestReceipt, ChioReceipt};
4use serde::{Deserialize, Serialize};
5
6use crate::capability_lineage::{CapabilityLineageError, CapabilitySnapshot};
7use crate::checkpoint::{
8    CheckpointError, CheckpointTransparencySummary, KernelCheckpoint, ReceiptInclusionProof,
9};
10use crate::receipt_query::ReceiptQuery;
11use crate::receipt_store::ReceiptStoreError;
12
13pub const EVIDENCE_TRANSPARENCY_CLAIMS_SCHEMA: &str = "chio.evidence_transparency_claims.v1";
14
15/// Full-export query used for offline evidence packaging.
16#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
17#[serde(rename_all = "camelCase")]
18pub struct EvidenceExportQuery {
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub capability_id: Option<String>,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub agent_subject: Option<String>,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub since: Option<u64>,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub until: Option<u64>,
27    /// Phase 1.5 multi-tenant receipt isolation: restrict the export to
28    /// a single tenant so exported evidence bundles carry the tenant
29    /// tag end-to-end. MUST be derived from the operator's authenticated
30    /// tenant claim; callers MUST NOT let the agent choose this value.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub tenant: Option<String>,
33}
34
35/// Coverage mode for child receipts in an export bundle.
36#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
37#[serde(rename_all = "snake_case")]
38pub enum EvidenceChildReceiptScope {
39    /// All child receipts matching the query window are included.
40    FullQueryWindow,
41    /// Child receipts are included only as time-window context because there is
42    /// no capability/agent join path for them yet.
43    TimeWindowContextOnly,
44    /// Child receipts are omitted because the export was capability/agent scoped
45    /// without a capability/agent join path or time-window fallback.
46    OmittedNoJoinPath,
47}
48
49/// Forward-compatible lineage reference slots that outward report and export
50/// surfaces can populate once provenance artifacts become first-class records.
51#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(rename_all = "camelCase")]
53pub struct EvidenceLineageReferences {
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub session_anchor_id: Option<String>,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub request_lineage_id: Option<String>,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub receipt_lineage_statement_id: Option<String>,
60}
61
62impl EvidenceLineageReferences {
63    #[must_use]
64    pub fn is_empty(&self) -> bool {
65        self.session_anchor_id.is_none()
66            && self.request_lineage_id.is_none()
67            && self.receipt_lineage_statement_id.is_none()
68    }
69}
70
71/// Tool receipt plus its stable store sequence.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73#[serde(rename_all = "camelCase")]
74pub struct EvidenceToolReceiptRecord {
75    pub seq: u64,
76    pub receipt: ChioReceipt,
77}
78
79/// Child receipt plus its stable store sequence.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82pub struct EvidenceChildReceiptRecord {
83    pub seq: u64,
84    pub receipt: ChildRequestReceipt,
85}
86
87/// Receipt that was exported but does not currently have checkpoint coverage.
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
89#[serde(rename_all = "camelCase")]
90pub struct EvidenceUncheckpointedReceipt {
91    pub seq: u64,
92    pub receipt_id: String,
93}
94
95/// Live-database retention state captured at export time.
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
97#[serde(rename_all = "camelCase")]
98pub struct EvidenceRetentionMetadata {
99    pub live_db_size_bytes: u64,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub oldest_live_receipt_timestamp: Option<u64>,
102}
103
104/// Audit-only claims that can be made from a local evidence export.
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
106#[serde(rename_all = "camelCase")]
107pub struct EvidenceAuditClaims {
108    pub checkpoint_logs: Vec<String>,
109    pub signed_checkpoints: u64,
110    pub checkpoint_publications: u64,
111    pub checkpoint_witnesses: u64,
112    pub checkpoint_consistency_proofs: u64,
113    pub inclusion_proofs: u64,
114    pub capability_lineage_records: u64,
115}
116
117/// Transparency materials that remain preview-only without a trust anchor.
118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
119#[serde(rename_all = "camelCase")]
120pub struct EvidenceTransparencyPreviewClaim {
121    pub log_id: String,
122    pub claim: String,
123    pub reason: String,
124    pub checkpoint_count: u64,
125    pub witness_count: u64,
126    pub consistency_proof_count: u64,
127    pub log_tree_size: u64,
128}
129
130/// Explicit publication state for bundled checkpoint claims.
131#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
132#[serde(rename_all = "snake_case")]
133pub enum EvidencePublicationState {
134    #[default]
135    TransparencyPreview,
136    TrustAnchored,
137}
138
139impl EvidencePublicationState {
140    #[must_use]
141    pub fn as_str(self) -> &'static str {
142        match self {
143            Self::TransparencyPreview => "transparency_preview",
144            Self::TrustAnchored => "trust_anchored",
145        }
146    }
147}
148
149/// Stable outward separation between audit claims and transparency-preview claims.
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
151#[serde(rename_all = "camelCase")]
152pub struct EvidenceTransparencyClaims {
153    pub schema: String,
154    #[serde(default)]
155    pub publication_state: EvidencePublicationState,
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub trust_anchor: Option<String>,
158    pub audit: EvidenceAuditClaims,
159    #[serde(default, skip_serializing_if = "Vec::is_empty")]
160    pub transparency_preview: Vec<EvidenceTransparencyPreviewClaim>,
161}
162
163impl EvidenceTransparencyClaims {
164    pub fn validate(&self) -> Result<(), String> {
165        if self.schema != EVIDENCE_TRANSPARENCY_CLAIMS_SCHEMA {
166            return Err(format!(
167                "unsupported transparency claims schema: expected {}, got {}",
168                EVIDENCE_TRANSPARENCY_CLAIMS_SCHEMA, self.schema
169            ));
170        }
171        let trust_anchor = self
172            .trust_anchor
173            .as_deref()
174            .map(str::trim)
175            .filter(|anchor| !anchor.is_empty());
176        match self.publication_state {
177            EvidencePublicationState::TransparencyPreview => {
178                if trust_anchor.is_some() {
179                    return Err(
180                        "transparency_preview claims must not declare a trust_anchor".to_string(),
181                    );
182                }
183            }
184            EvidencePublicationState::TrustAnchored => {
185                if trust_anchor.is_none() {
186                    return Err(
187                        "trust_anchored claims require a non-empty trust_anchor".to_string()
188                    );
189                }
190                if !self.transparency_preview.is_empty() {
191                    return Err(
192                        "trust_anchored claims must not retain transparency_preview entries"
193                            .to_string(),
194                    );
195                }
196            }
197        }
198        Ok(())
199    }
200
201    #[must_use]
202    pub fn is_trust_anchored(&self) -> bool {
203        self.publication_state == EvidencePublicationState::TrustAnchored
204    }
205}
206
207/// Complete evidence bundle assembled from a local SQLite store.
208#[derive(Debug, Clone, Serialize, Deserialize)]
209#[serde(rename_all = "camelCase")]
210pub struct EvidenceExportBundle {
211    pub query: EvidenceExportQuery,
212    pub tool_receipts: Vec<EvidenceToolReceiptRecord>,
213    pub child_receipts: Vec<EvidenceChildReceiptRecord>,
214    pub child_receipt_scope: EvidenceChildReceiptScope,
215    pub checkpoints: Vec<KernelCheckpoint>,
216    pub capability_lineage: Vec<CapabilitySnapshot>,
217    pub inclusion_proofs: Vec<ReceiptInclusionProof>,
218    pub uncheckpointed_receipts: Vec<EvidenceUncheckpointedReceipt>,
219    pub retention: EvidenceRetentionMetadata,
220}
221
222#[derive(Debug, thiserror::Error)]
223pub enum EvidenceExportError {
224    #[error("receipt store error: {0}")]
225    ReceiptStore(#[from] ReceiptStoreError),
226
227    #[error("capability lineage error: {0}")]
228    CapabilityLineage(#[from] CapabilityLineageError),
229
230    #[error("checkpoint error: {0}")]
231    Checkpoint(#[from] CheckpointError),
232
233    #[error("core error: {0}")]
234    Core(#[from] chio_core::Error),
235
236    #[error("sqlite error: {0}")]
237    Sqlite(#[from] rusqlite::Error),
238
239    #[error("json error: {0}")]
240    Json(#[from] serde_json::Error),
241}
242
243impl EvidenceExportQuery {
244    pub fn as_receipt_query(&self, cursor: Option<u64>) -> ReceiptQuery {
245        ReceiptQuery {
246            capability_id: self.capability_id.clone(),
247            tool_server: None,
248            tool_name: None,
249            outcome: None,
250            since: self.since,
251            until: self.until,
252            min_cost: None,
253            max_cost: None,
254            cursor,
255            limit: crate::MAX_QUERY_LIMIT,
256            agent_subject: self.agent_subject.clone(),
257            tenant_filter: self.tenant.clone(),
258        }
259    }
260
261    fn has_subject_or_capability_scope(&self) -> bool {
262        self.capability_id.is_some() || self.agent_subject.is_some()
263    }
264
265    fn has_time_window(&self) -> bool {
266        self.since.is_some() || self.until.is_some()
267    }
268
269    #[must_use]
270    pub fn child_receipt_scope(&self) -> EvidenceChildReceiptScope {
271        if self.has_subject_or_capability_scope() {
272            if self.has_time_window() {
273                EvidenceChildReceiptScope::TimeWindowContextOnly
274            } else {
275                EvidenceChildReceiptScope::OmittedNoJoinPath
276            }
277        } else {
278            EvidenceChildReceiptScope::FullQueryWindow
279        }
280    }
281}
282
283#[derive(Default)]
284struct EvidenceTransparencyLogStats {
285    checkpoint_count: u64,
286    witness_count: u64,
287    consistency_proof_count: u64,
288    log_tree_size: u64,
289}
290
291fn trusted_publication_anchor(
292    publications: &[crate::checkpoint::CheckpointPublication],
293    requested_trust_anchor: Option<&str>,
294) -> Option<String> {
295    let requested_trust_anchor = requested_trust_anchor
296        .map(str::trim)
297        .filter(|trust_anchor| !trust_anchor.is_empty());
298    if publications.is_empty() {
299        return None;
300    }
301
302    let mut shared_trust_anchor = None::<String>;
303    for publication in publications {
304        let binding = publication.trust_anchor_binding.as_ref()?;
305        if binding.validate().is_err() {
306            return None;
307        }
308        if binding.publication_identity.kind
309            == chio_core::receipt::CheckpointPublicationIdentityKind::LocalLog
310            && binding.publication_identity.identity != publication.log_id
311        {
312            return None;
313        }
314        match shared_trust_anchor.as_deref() {
315            Some(existing) if existing != binding.trust_anchor_ref => return None,
316            None => shared_trust_anchor = Some(binding.trust_anchor_ref.clone()),
317            Some(_) => {}
318        }
319    }
320
321    match (shared_trust_anchor, requested_trust_anchor) {
322        (Some(shared), Some(requested)) if shared == requested => Some(shared),
323        (Some(_), Some(_)) => None,
324        (Some(shared), None) => Some(shared),
325        (None, _) => None,
326    }
327}
328
329#[must_use]
330pub fn build_evidence_transparency_claims(
331    bundle: &EvidenceExportBundle,
332    transparency: &CheckpointTransparencySummary,
333    trust_anchor: Option<&str>,
334) -> EvidenceTransparencyClaims {
335    let trust_anchor = trusted_publication_anchor(&transparency.publications, trust_anchor);
336    let mut by_log = BTreeMap::<String, EvidenceTransparencyLogStats>::new();
337
338    for publication in &transparency.publications {
339        let stats = by_log.entry(publication.log_id.clone()).or_default();
340        stats.checkpoint_count += 1;
341        stats.log_tree_size = stats.log_tree_size.max(publication.log_tree_size);
342    }
343    for witness in &transparency.witnesses {
344        by_log
345            .entry(witness.log_id.clone())
346            .or_default()
347            .witness_count += 1;
348    }
349    for proof in &transparency.consistency_proofs {
350        let stats = by_log.entry(proof.log_id.clone()).or_default();
351        stats.consistency_proof_count += 1;
352        stats.log_tree_size = stats.log_tree_size.max(proof.to_log_tree_size);
353    }
354
355    let checkpoint_logs = by_log.keys().cloned().collect::<Vec<_>>();
356    let transparency_preview = if trust_anchor.is_some() {
357        Vec::new()
358    } else {
359        by_log
360            .into_iter()
361            .map(|(log_id, stats)| EvidenceTransparencyPreviewClaim {
362                log_id,
363                claim: "append_only_local_checkpoint_log".to_string(),
364                reason: "no trust anchor is attached, so log identity and prefix growth remain transparency-preview claims".to_string(),
365                checkpoint_count: stats.checkpoint_count,
366                witness_count: stats.witness_count,
367                consistency_proof_count: stats.consistency_proof_count,
368                log_tree_size: stats.log_tree_size,
369            })
370            .collect()
371    };
372
373    EvidenceTransparencyClaims {
374        schema: EVIDENCE_TRANSPARENCY_CLAIMS_SCHEMA.to_string(),
375        publication_state: if trust_anchor.is_some() {
376            EvidencePublicationState::TrustAnchored
377        } else {
378            EvidencePublicationState::TransparencyPreview
379        },
380        trust_anchor,
381        audit: EvidenceAuditClaims {
382            checkpoint_logs,
383            signed_checkpoints: bundle.checkpoints.len() as u64,
384            checkpoint_publications: transparency.publications.len() as u64,
385            checkpoint_witnesses: transparency.witnesses.len() as u64,
386            checkpoint_consistency_proofs: transparency.consistency_proofs.len() as u64,
387            inclusion_proofs: bundle.inclusion_proofs.len() as u64,
388            capability_lineage_records: bundle.capability_lineage.len() as u64,
389        },
390        transparency_preview,
391    }
392}
393
394#[cfg(test)]
395#[allow(clippy::unwrap_used)]
396mod tests {
397    use super::*;
398    use crate::checkpoint::{
399        build_checkpoint, build_checkpoint_transparency, build_checkpoint_with_previous,
400    };
401    use chio_core::crypto::Keypair;
402
403    #[test]
404    fn evidence_lineage_references_detect_when_empty() {
405        let references = EvidenceLineageReferences::default();
406        assert!(references.is_empty());
407        assert_eq!(
408            serde_json::to_value(references).unwrap(),
409            serde_json::json!({})
410        );
411    }
412
413    #[test]
414    fn evidence_export_query_child_scope_reserves_time_window_context_until_lineage_joins_exist() {
415        assert_eq!(
416            EvidenceExportQuery {
417                capability_id: Some("cap-1".to_string()),
418                since: Some(10),
419                ..EvidenceExportQuery::default()
420            }
421            .child_receipt_scope(),
422            EvidenceChildReceiptScope::TimeWindowContextOnly
423        );
424    }
425
426    #[test]
427    fn evidence_export_marks_unanchored_publication_as_transparency_preview() {
428        let keypair = Keypair::generate();
429        let first = build_checkpoint(1, 1, 2, &[b"one".to_vec(), b"two".to_vec()], &keypair)
430            .expect("first checkpoint");
431        let second = build_checkpoint_with_previous(
432            2,
433            3,
434            4,
435            &[b"three".to_vec(), b"four".to_vec()],
436            &keypair,
437            Some(&first),
438        )
439        .expect("second checkpoint");
440        let bundle = EvidenceExportBundle {
441            query: EvidenceExportQuery::default(),
442            tool_receipts: Vec::new(),
443            child_receipts: Vec::new(),
444            child_receipt_scope: EvidenceChildReceiptScope::FullQueryWindow,
445            checkpoints: vec![first.clone(), second.clone()],
446            capability_lineage: Vec::new(),
447            inclusion_proofs: Vec::new(),
448            uncheckpointed_receipts: Vec::new(),
449            retention: EvidenceRetentionMetadata {
450                live_db_size_bytes: 0,
451                oldest_live_receipt_timestamp: None,
452            },
453        };
454        let transparency =
455            build_checkpoint_transparency(&[first, second]).expect("transparency summary");
456
457        let claims = build_evidence_transparency_claims(&bundle, &transparency, None);
458        assert_eq!(
459            claims.publication_state,
460            EvidencePublicationState::TransparencyPreview
461        );
462        assert!(claims.trust_anchor.is_none());
463        assert_eq!(claims.audit.signed_checkpoints, 2);
464        assert_eq!(claims.audit.checkpoint_consistency_proofs, 1);
465        assert_eq!(claims.transparency_preview.len(), 1);
466        assert_eq!(
467            claims.transparency_preview[0].claim,
468            "append_only_local_checkpoint_log"
469        );
470        assert_eq!(claims.transparency_preview[0].log_tree_size, 4);
471
472        let anchored_claims =
473            build_evidence_transparency_claims(&bundle, &transparency, Some("witness-root"));
474        assert_eq!(
475            anchored_claims.publication_state,
476            EvidencePublicationState::TransparencyPreview
477        );
478        assert!(anchored_claims.trust_anchor.is_none());
479        assert_eq!(anchored_claims.transparency_preview.len(), 1);
480    }
481
482    #[test]
483    fn evidence_export_marks_bound_publication_as_trust_anchored() {
484        let keypair = Keypair::generate();
485        let first = build_checkpoint(1, 1, 2, &[b"one".to_vec(), b"two".to_vec()], &keypair)
486            .expect("first checkpoint");
487        let second = build_checkpoint_with_previous(
488            2,
489            3,
490            4,
491            &[b"three".to_vec(), b"four".to_vec()],
492            &keypair,
493            Some(&first),
494        )
495        .expect("second checkpoint");
496        let bundle = EvidenceExportBundle {
497            query: EvidenceExportQuery::default(),
498            tool_receipts: Vec::new(),
499            child_receipts: Vec::new(),
500            child_receipt_scope: EvidenceChildReceiptScope::FullQueryWindow,
501            checkpoints: vec![first.clone(), second.clone()],
502            capability_lineage: Vec::new(),
503            inclusion_proofs: Vec::new(),
504            uncheckpointed_receipts: Vec::new(),
505            retention: EvidenceRetentionMetadata {
506                live_db_size_bytes: 0,
507                oldest_live_receipt_timestamp: None,
508            },
509        };
510        let mut transparency =
511            build_checkpoint_transparency(&[first.clone(), second.clone()]).expect("summary");
512        let binding = chio_core::receipt::CheckpointPublicationTrustAnchorBinding {
513            publication_identity: chio_core::receipt::CheckpointPublicationIdentity::new(
514                chio_core::receipt::CheckpointPublicationIdentityKind::LocalLog,
515                transparency.publications[0].log_id.clone(),
516            ),
517            trust_anchor_identity: chio_core::receipt::CheckpointTrustAnchorIdentity::new(
518                chio_core::receipt::CheckpointTrustAnchorIdentityKind::TransparencyRoot,
519                "root-set-1",
520            ),
521            trust_anchor_ref: "witness-root".to_string(),
522            signer_cert_ref: "cert-chain-1".to_string(),
523            publication_profile_version: "phase4-pilot".to_string(),
524        };
525        transparency.publications = vec![
526            crate::checkpoint::build_trust_anchored_checkpoint_publication(&first, binding.clone())
527                .expect("first anchored publication"),
528            crate::checkpoint::build_trust_anchored_checkpoint_publication(&second, binding)
529                .expect("second anchored publication"),
530        ];
531
532        let anchored_claims =
533            build_evidence_transparency_claims(&bundle, &transparency, Some("witness-root"));
534        assert_eq!(
535            anchored_claims.publication_state,
536            EvidencePublicationState::TrustAnchored
537        );
538        assert_eq!(
539            anchored_claims.trust_anchor.as_deref(),
540            Some("witness-root")
541        );
542        assert!(anchored_claims.transparency_preview.is_empty());
543    }
544
545    #[test]
546    fn evidence_transparency_claims_reject_invalid_publication_state_combinations() {
547        let anchored_without_anchor = EvidenceTransparencyClaims {
548            schema: EVIDENCE_TRANSPARENCY_CLAIMS_SCHEMA.to_string(),
549            publication_state: EvidencePublicationState::TrustAnchored,
550            trust_anchor: None,
551            audit: EvidenceAuditClaims {
552                checkpoint_logs: Vec::new(),
553                signed_checkpoints: 0,
554                checkpoint_publications: 0,
555                checkpoint_witnesses: 0,
556                checkpoint_consistency_proofs: 0,
557                inclusion_proofs: 0,
558                capability_lineage_records: 0,
559            },
560            transparency_preview: Vec::new(),
561        };
562        assert!(anchored_without_anchor
563            .validate()
564            .unwrap_err()
565            .contains("trust_anchor"));
566    }
567}