Skip to main content

chio_kernel/
evidence_export.rs

1use std::collections::BTreeMap;
2
3use chio_core::receipt::{body::ChioReceipt, lineage::ChildRequestReceipt};
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, ReceiptReadBoundary, ReceiptReadContext};
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    /// Multi-tenant receipt isolation: restrict the export to a single tenant
28    /// so exported evidence bundles carry the tenant tag end-to-end. MUST be
29    /// derived from the operator's authenticated tenant claim; callers MUST
30    /// NOT let the agent choose this value.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub tenant: Option<String>,
33    /// Explicit read boundary for receipt export. Local exports must not infer
34    /// admin scope from an omitted tenant.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub read_boundary: Option<ReceiptReadBoundary>,
37}
38
39/// Coverage mode for child receipts in an export bundle.
40#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
41#[serde(rename_all = "snake_case")]
42pub enum EvidenceChildReceiptScope {
43    /// All child receipts matching the query window are included.
44    FullQueryWindow,
45    /// Child receipts are included only as time-window context because there is
46    /// no capability/agent join path for them yet.
47    TimeWindowContextOnly,
48    /// Child receipts are omitted because the export was capability/agent scoped
49    /// without a capability/agent join path or time-window fallback.
50    OmittedNoJoinPath,
51}
52
53/// Forward-compatible lineage reference slots that outward report and export
54/// surfaces can populate once provenance artifacts become first-class records.
55#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
56#[serde(rename_all = "camelCase")]
57pub struct EvidenceLineageReferences {
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub session_anchor_id: Option<String>,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub request_lineage_id: Option<String>,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub receipt_lineage_statement_id: Option<String>,
64}
65
66impl EvidenceLineageReferences {
67    #[must_use]
68    pub fn is_empty(&self) -> bool {
69        self.session_anchor_id.is_none()
70            && self.request_lineage_id.is_none()
71            && self.receipt_lineage_statement_id.is_none()
72    }
73}
74
75/// Tool receipt plus its stable store sequence.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(rename_all = "camelCase")]
78pub struct EvidenceToolReceiptRecord {
79    pub seq: u64,
80    pub receipt: ChioReceipt,
81}
82
83/// Child receipt plus its stable store sequence.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct EvidenceChildReceiptRecord {
87    pub seq: u64,
88    pub receipt: ChildRequestReceipt,
89}
90
91/// Receipt that was exported but does not currently have checkpoint coverage.
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
93#[serde(rename_all = "camelCase")]
94pub struct EvidenceUncheckpointedReceipt {
95    pub seq: u64,
96    pub receipt_id: String,
97}
98
99/// Live-database retention state captured at export time.
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
101#[serde(rename_all = "camelCase")]
102pub struct EvidenceRetentionMetadata {
103    #[serde(default)]
104    pub live_db_size_bytes: Option<u64>,
105    #[serde(default)]
106    pub oldest_live_receipt_timestamp: Option<u64>,
107}
108
109/// Audit-only claims that can be made from a local evidence export.
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
111#[serde(rename_all = "camelCase")]
112pub struct EvidenceAuditClaims {
113    pub checkpoint_logs: Vec<String>,
114    pub signed_checkpoints: u64,
115    pub checkpoint_publications: u64,
116    pub checkpoint_witnesses: u64,
117    pub checkpoint_consistency_proofs: u64,
118    pub inclusion_proofs: u64,
119    pub capability_lineage_records: u64,
120}
121
122/// Transparency materials that remain preview-only without a trust anchor.
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
124#[serde(rename_all = "camelCase")]
125pub struct EvidenceTransparencyPreviewClaim {
126    pub log_id: String,
127    pub claim: String,
128    pub reason: String,
129    pub checkpoint_count: u64,
130    pub witness_count: u64,
131    pub consistency_proof_count: u64,
132    pub log_tree_size: u64,
133}
134
135/// Explicit publication state for bundled checkpoint claims.
136#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
137#[serde(rename_all = "snake_case")]
138pub enum EvidencePublicationState {
139    #[default]
140    TransparencyPreview,
141    TrustAnchored,
142}
143
144impl EvidencePublicationState {
145    #[must_use]
146    pub fn as_str(self) -> &'static str {
147        match self {
148            Self::TransparencyPreview => "transparency_preview",
149            Self::TrustAnchored => "trust_anchored",
150        }
151    }
152}
153
154/// Stable outward separation between audit claims and transparency-preview claims.
155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
156#[serde(rename_all = "camelCase")]
157pub struct EvidenceTransparencyClaims {
158    pub schema: String,
159    #[serde(default)]
160    pub publication_state: EvidencePublicationState,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub trust_anchor: Option<String>,
163    pub audit: EvidenceAuditClaims,
164    #[serde(default, skip_serializing_if = "Vec::is_empty")]
165    pub transparency_preview: Vec<EvidenceTransparencyPreviewClaim>,
166}
167
168impl EvidenceTransparencyClaims {
169    pub fn validate(&self) -> Result<(), String> {
170        if self.schema != EVIDENCE_TRANSPARENCY_CLAIMS_SCHEMA {
171            return Err(format!(
172                "unsupported transparency claims schema: expected {}, got {}",
173                EVIDENCE_TRANSPARENCY_CLAIMS_SCHEMA, self.schema
174            ));
175        }
176        let trust_anchor = self
177            .trust_anchor
178            .as_deref()
179            .map(str::trim)
180            .filter(|anchor| !anchor.is_empty());
181        match self.publication_state {
182            EvidencePublicationState::TransparencyPreview => {
183                if trust_anchor.is_some() {
184                    return Err(
185                        "transparency_preview claims must not declare a trust_anchor".to_string(),
186                    );
187                }
188            }
189            EvidencePublicationState::TrustAnchored => {
190                if trust_anchor.is_none() {
191                    return Err(
192                        "trust_anchored claims require a non-empty trust_anchor".to_string()
193                    );
194                }
195                if !self.transparency_preview.is_empty() {
196                    return Err(
197                        "trust_anchored claims must not retain transparency_preview entries"
198                            .to_string(),
199                    );
200                }
201            }
202        }
203        Ok(())
204    }
205
206    #[must_use]
207    pub fn is_trust_anchored(&self) -> bool {
208        self.publication_state == EvidencePublicationState::TrustAnchored
209    }
210}
211
212/// Complete evidence bundle assembled from a local SQLite store.
213#[derive(Debug, Clone, Serialize, Deserialize)]
214#[serde(rename_all = "camelCase")]
215pub struct EvidenceExportBundle {
216    pub query: EvidenceExportQuery,
217    pub tool_receipts: Vec<EvidenceToolReceiptRecord>,
218    pub child_receipts: Vec<EvidenceChildReceiptRecord>,
219    pub child_receipt_scope: EvidenceChildReceiptScope,
220    pub checkpoints: Vec<KernelCheckpoint>,
221    pub capability_lineage: Vec<CapabilitySnapshot>,
222    pub inclusion_proofs: Vec<ReceiptInclusionProof>,
223    pub uncheckpointed_receipts: Vec<EvidenceUncheckpointedReceipt>,
224    pub retention: EvidenceRetentionMetadata,
225}
226
227#[derive(Debug, thiserror::Error)]
228pub enum EvidenceExportError {
229    #[error("receipt store error: {0}")]
230    ReceiptStore(#[from] ReceiptStoreError),
231
232    #[error("capability lineage error: {0}")]
233    CapabilityLineage(#[from] CapabilityLineageError),
234
235    #[error("checkpoint error: {0}")]
236    Checkpoint(#[from] CheckpointError),
237
238    #[error("core error: {0}")]
239    Core(#[from] chio_core::Error),
240
241    #[error("sqlite error: {0}")]
242    Sqlite(#[from] rusqlite::Error),
243
244    #[error("json error: {0}")]
245    Json(#[from] serde_json::Error),
246
247    #[error("receipt read boundary error: {0}")]
248    ReadBoundary(String),
249}
250
251impl EvidenceExportQuery {
252    pub fn as_receipt_query(&self, cursor: Option<u64>) -> ReceiptQuery {
253        let tenant_filter = self.effective_tenant_filter();
254        let read_context = match &self.read_boundary {
255            Some(ReceiptReadBoundary::AdminAll) => Some(ReceiptReadContext::admin_service()),
256            Some(ReceiptReadBoundary::TenantScoped { tenant }) => {
257                Some(ReceiptReadContext::authenticated_tenant(tenant.clone()))
258            }
259            None => None,
260        };
261        ReceiptQuery {
262            capability_id: self.capability_id.clone(),
263            tool_server: None,
264            tool_name: None,
265            outcome: None,
266            since: self.since,
267            until: self.until,
268            min_cost: None,
269            max_cost: None,
270            cost_currency: None,
271            cursor,
272            limit: crate::MAX_QUERY_LIMIT,
273            agent_subject: self.agent_subject.clone(),
274            tenant_filter,
275            read_context,
276        }
277    }
278
279    #[must_use]
280    pub fn admin_all() -> Self {
281        Self {
282            read_boundary: Some(ReceiptReadBoundary::AdminAll),
283            ..Self::default()
284        }
285    }
286
287    #[must_use]
288    pub fn tenant_scoped(tenant: impl Into<String>) -> Self {
289        let tenant = tenant.into();
290        Self {
291            tenant: Some(tenant.clone()),
292            read_boundary: Some(ReceiptReadBoundary::TenantScoped { tenant }),
293            ..Self::default()
294        }
295    }
296
297    pub fn validate_read_boundary(&self) -> Result<(), EvidenceExportError> {
298        match &self.read_boundary {
299            Some(ReceiptReadBoundary::AdminAll) => {
300                if self
301                    .tenant
302                    .as_deref()
303                    .is_some_and(|tenant| tenant.trim().is_empty())
304                {
305                    return Err(EvidenceExportError::ReadBoundary(
306                        "admin-all evidence export tenant filter requires a non-empty tenant"
307                            .to_string(),
308                    ));
309                }
310                Ok(())
311            }
312            Some(ReceiptReadBoundary::TenantScoped { tenant }) => {
313                let tenant = tenant.trim();
314                if tenant.is_empty() {
315                    return Err(EvidenceExportError::ReadBoundary(
316                        "tenant-scoped evidence export requires a non-empty tenant".to_string(),
317                    ));
318                }
319                if self
320                    .tenant
321                    .as_deref()
322                    .is_some_and(|query_tenant| query_tenant != tenant)
323                {
324                    return Err(EvidenceExportError::ReadBoundary(
325                        "tenant-scoped evidence export tenant does not match query tenant"
326                            .to_string(),
327                    ));
328                }
329                Ok(())
330            }
331            None => Err(EvidenceExportError::ReadBoundary(
332                "evidence export requires an explicit receipt read boundary".to_string(),
333            )),
334        }
335    }
336
337    #[must_use]
338    pub fn normalized_for_read_boundary(&self) -> Self {
339        let mut normalized = self.clone();
340        if let Some(ReceiptReadBoundary::TenantScoped { tenant }) = &self.read_boundary {
341            normalized.tenant = Some(tenant.clone());
342        }
343        normalized
344    }
345
346    fn effective_tenant_filter(&self) -> Option<String> {
347        match &self.read_boundary {
348            Some(ReceiptReadBoundary::TenantScoped { tenant }) => Some(tenant.clone()),
349            _ => self.tenant.clone(),
350        }
351    }
352
353    fn has_subject_or_capability_scope(&self) -> bool {
354        self.capability_id.is_some() || self.agent_subject.is_some()
355    }
356
357    fn has_time_window(&self) -> bool {
358        self.since.is_some() || self.until.is_some()
359    }
360
361    #[must_use]
362    pub fn child_receipt_scope(&self) -> EvidenceChildReceiptScope {
363        if self
364            .effective_tenant_filter()
365            .as_deref()
366            .is_some_and(|tenant| !tenant.trim().is_empty())
367        {
368            return EvidenceChildReceiptScope::OmittedNoJoinPath;
369        }
370        if matches!(
371            self.read_boundary,
372            Some(ReceiptReadBoundary::TenantScoped { .. })
373        ) {
374            return EvidenceChildReceiptScope::OmittedNoJoinPath;
375        }
376        if self.has_subject_or_capability_scope() {
377            if self.has_time_window() {
378                EvidenceChildReceiptScope::TimeWindowContextOnly
379            } else {
380                EvidenceChildReceiptScope::OmittedNoJoinPath
381            }
382        } else {
383            EvidenceChildReceiptScope::FullQueryWindow
384        }
385    }
386}
387
388#[derive(Default)]
389struct EvidenceTransparencyLogStats {
390    checkpoint_count: u64,
391    witness_count: u64,
392    consistency_proof_count: u64,
393    log_tree_size: u64,
394}
395
396fn trusted_publication_anchor(
397    publications: &[crate::checkpoint::CheckpointPublication],
398    requested_trust_anchor: Option<&str>,
399) -> Option<String> {
400    let requested_trust_anchor = requested_trust_anchor
401        .map(str::trim)
402        .filter(|trust_anchor| !trust_anchor.is_empty());
403    if publications.is_empty() {
404        return None;
405    }
406
407    let mut shared_trust_anchor = None::<String>;
408    for publication in publications {
409        let binding = publication.trust_anchor_binding.as_ref()?;
410        if binding.validate().is_err() {
411            return None;
412        }
413        if binding.publication_identity.kind
414            == chio_core::receipt::checkpoint::CheckpointPublicationIdentityKind::LocalLog
415            && binding.publication_identity.identity != publication.log_id
416        {
417            return None;
418        }
419        match shared_trust_anchor.as_deref() {
420            Some(existing) if existing != binding.trust_anchor_ref => return None,
421            None => shared_trust_anchor = Some(binding.trust_anchor_ref.clone()),
422            Some(_) => {}
423        }
424    }
425
426    match (shared_trust_anchor, requested_trust_anchor) {
427        (Some(shared), Some(requested)) if shared == requested => Some(shared),
428        (Some(_), Some(_)) => None,
429        (Some(shared), None) => Some(shared),
430        (None, _) => None,
431    }
432}
433
434#[must_use]
435pub fn build_evidence_transparency_claims(
436    bundle: &EvidenceExportBundle,
437    transparency: &CheckpointTransparencySummary,
438    trust_anchor: Option<&str>,
439) -> EvidenceTransparencyClaims {
440    let trust_anchor = trusted_publication_anchor(&transparency.publications, trust_anchor);
441    let mut by_log = BTreeMap::<String, EvidenceTransparencyLogStats>::new();
442
443    for publication in &transparency.publications {
444        let stats = by_log.entry(publication.log_id.clone()).or_default();
445        stats.checkpoint_count += 1;
446        stats.log_tree_size = stats.log_tree_size.max(publication.log_tree_size);
447    }
448    for witness in &transparency.witnesses {
449        by_log
450            .entry(witness.log_id.clone())
451            .or_default()
452            .witness_count += 1;
453    }
454    for proof in &transparency.consistency_proofs {
455        let stats = by_log.entry(proof.log_id.clone()).or_default();
456        stats.consistency_proof_count += 1;
457        stats.log_tree_size = stats.log_tree_size.max(proof.to_log_tree_size);
458    }
459
460    let checkpoint_logs = by_log.keys().cloned().collect::<Vec<_>>();
461    let transparency_preview = if trust_anchor.is_some() {
462        Vec::new()
463    } else {
464        by_log
465            .into_iter()
466            .map(|(log_id, stats)| EvidenceTransparencyPreviewClaim {
467                log_id,
468                claim: "append_only_local_checkpoint_log".to_string(),
469                reason: "no trust anchor is attached, so log identity and prefix growth remain transparency-preview claims".to_string(),
470                checkpoint_count: stats.checkpoint_count,
471                witness_count: stats.witness_count,
472                consistency_proof_count: stats.consistency_proof_count,
473                log_tree_size: stats.log_tree_size,
474            })
475            .collect()
476    };
477
478    EvidenceTransparencyClaims {
479        schema: EVIDENCE_TRANSPARENCY_CLAIMS_SCHEMA.to_string(),
480        publication_state: if trust_anchor.is_some() {
481            EvidencePublicationState::TrustAnchored
482        } else {
483            EvidencePublicationState::TransparencyPreview
484        },
485        trust_anchor,
486        audit: EvidenceAuditClaims {
487            checkpoint_logs,
488            signed_checkpoints: bundle.checkpoints.len() as u64,
489            checkpoint_publications: transparency.publications.len() as u64,
490            checkpoint_witnesses: transparency.witnesses.len() as u64,
491            checkpoint_consistency_proofs: transparency.consistency_proofs.len() as u64,
492            inclusion_proofs: bundle.inclusion_proofs.len() as u64,
493            capability_lineage_records: bundle.capability_lineage.len() as u64,
494        },
495        transparency_preview,
496    }
497}
498
499#[cfg(test)]
500#[allow(clippy::unwrap_used)]
501mod tests {
502    use super::*;
503    use crate::checkpoint::{
504        build_checkpoint, build_checkpoint_transparency, build_checkpoint_with_previous,
505    };
506    use chio_core::crypto::Keypair;
507
508    #[test]
509    fn evidence_lineage_references_detect_when_empty() {
510        let references = EvidenceLineageReferences::default();
511        assert!(references.is_empty());
512        assert_eq!(
513            serde_json::to_value(references).unwrap(),
514            serde_json::json!({})
515        );
516    }
517
518    #[test]
519    fn evidence_export_query_child_scope_reserves_time_window_context_until_lineage_joins_exist() {
520        assert_eq!(
521            EvidenceExportQuery {
522                capability_id: Some("cap-1".to_string()),
523                since: Some(10),
524                ..EvidenceExportQuery::default()
525            }
526            .child_receipt_scope(),
527            EvidenceChildReceiptScope::TimeWindowContextOnly
528        );
529    }
530
531    #[test]
532    fn tenant_scoped_evidence_export_omits_child_receipts_without_join_path() {
533        assert_eq!(
534            EvidenceExportQuery::tenant_scoped("tenant-a").child_receipt_scope(),
535            EvidenceChildReceiptScope::OmittedNoJoinPath
536        );
537    }
538
539    #[test]
540    fn evidence_export_query_allows_admin_all_with_tenant_filter() {
541        EvidenceExportQuery {
542            tenant: Some("tenant-a".to_string()),
543            read_boundary: Some(ReceiptReadBoundary::AdminAll),
544            ..EvidenceExportQuery::default()
545        }
546        .validate_read_boundary()
547        .expect("admin-all evidence export may narrow to a tenant filter");
548    }
549
550    #[test]
551    fn evidence_export_query_requires_explicit_read_boundary() {
552        let err = EvidenceExportQuery::default()
553            .validate_read_boundary()
554            .expect_err("missing read boundary must fail closed");
555
556        assert!(
557            matches!(err, EvidenceExportError::ReadBoundary(message) if message
558            == "evidence export requires an explicit receipt read boundary")
559        );
560    }
561
562    #[test]
563    fn evidence_export_query_rejects_empty_admin_tenant_filter() {
564        let err = EvidenceExportQuery {
565            tenant: Some("   ".to_string()),
566            read_boundary: Some(ReceiptReadBoundary::AdminAll),
567            ..EvidenceExportQuery::default()
568        }
569        .validate_read_boundary()
570        .expect_err("blank admin tenant filter must fail closed");
571
572        assert!(
573            matches!(err, EvidenceExportError::ReadBoundary(message) if message
574            == "admin-all evidence export tenant filter requires a non-empty tenant")
575        );
576    }
577
578    #[test]
579    fn evidence_export_query_rejects_empty_tenant_scoped_boundary() {
580        let err = EvidenceExportQuery::tenant_scoped("   ")
581            .validate_read_boundary()
582            .expect_err("blank tenant-scoped boundary must fail closed");
583
584        assert!(
585            matches!(err, EvidenceExportError::ReadBoundary(message) if message
586            == "tenant-scoped evidence export requires a non-empty tenant")
587        );
588    }
589
590    #[test]
591    fn evidence_export_query_rejects_tenant_scoped_boundary_mismatch() {
592        let err = EvidenceExportQuery {
593            tenant: Some("tenant-a".to_string()),
594            read_boundary: Some(ReceiptReadBoundary::TenantScoped {
595                tenant: "tenant-b".to_string(),
596            }),
597            ..EvidenceExportQuery::default()
598        }
599        .validate_read_boundary()
600        .expect_err("tenant-scoped boundary must match query tenant");
601
602        assert!(
603            matches!(err, EvidenceExportError::ReadBoundary(message) if message
604            == "tenant-scoped evidence export tenant does not match query tenant")
605        );
606    }
607
608    #[test]
609    fn evidence_export_query_normalizes_tenant_from_scoped_boundary() {
610        let normalized = EvidenceExportQuery {
611            tenant: None,
612            read_boundary: Some(ReceiptReadBoundary::TenantScoped {
613                tenant: "tenant-a".to_string(),
614            }),
615            ..EvidenceExportQuery::default()
616        }
617        .normalized_for_read_boundary();
618
619        assert_eq!(normalized.tenant.as_deref(), Some("tenant-a"));
620    }
621
622    #[test]
623    fn admin_all_evidence_export_uses_remote_safe_read_context() {
624        let receipt_query = EvidenceExportQuery {
625            tenant: Some("tenant-a".to_string()),
626            read_boundary: Some(ReceiptReadBoundary::AdminAll),
627            ..EvidenceExportQuery::default()
628        }
629        .as_receipt_query(None);
630
631        assert_eq!(receipt_query.tenant_filter.as_deref(), Some("tenant-a"));
632        assert_eq!(
633            receipt_query.read_context,
634            Some(ReceiptReadContext::admin_service())
635        );
636    }
637
638    #[test]
639    fn admin_all_tenant_filtered_export_omits_child_receipts_without_join_path() {
640        assert_eq!(
641            EvidenceExportQuery {
642                tenant: Some("tenant-a".to_string()),
643                read_boundary: Some(ReceiptReadBoundary::AdminAll),
644                ..EvidenceExportQuery::default()
645            }
646            .child_receipt_scope(),
647            EvidenceChildReceiptScope::OmittedNoJoinPath
648        );
649    }
650
651    #[test]
652    fn evidence_export_marks_unanchored_publication_as_transparency_preview() {
653        let keypair = Keypair::generate();
654        let first = build_checkpoint(1, 1, 2, &[b"one".to_vec(), b"two".to_vec()], &keypair)
655            .expect("first checkpoint");
656        let second = build_checkpoint_with_previous(
657            2,
658            3,
659            4,
660            &[b"three".to_vec(), b"four".to_vec()],
661            &keypair,
662            Some(&first),
663            &[crate::checkpoint::checkpoint_chain_leaf_hash(&first.body)
664                .expect("first chain leaf")],
665        )
666        .expect("second checkpoint");
667        let bundle = EvidenceExportBundle {
668            query: EvidenceExportQuery::default(),
669            tool_receipts: Vec::new(),
670            child_receipts: Vec::new(),
671            child_receipt_scope: EvidenceChildReceiptScope::FullQueryWindow,
672            checkpoints: vec![first.clone(), second.clone()],
673            capability_lineage: Vec::new(),
674            inclusion_proofs: Vec::new(),
675            uncheckpointed_receipts: Vec::new(),
676            retention: EvidenceRetentionMetadata {
677                live_db_size_bytes: Some(0),
678                oldest_live_receipt_timestamp: None,
679            },
680        };
681        let transparency =
682            build_checkpoint_transparency(&[first, second]).expect("transparency summary");
683
684        let claims = build_evidence_transparency_claims(&bundle, &transparency, None);
685        assert_eq!(
686            claims.publication_state,
687            EvidencePublicationState::TransparencyPreview
688        );
689        assert!(claims.trust_anchor.is_none());
690        assert_eq!(claims.audit.signed_checkpoints, 2);
691        assert_eq!(claims.audit.checkpoint_consistency_proofs, 1);
692        assert_eq!(claims.transparency_preview.len(), 1);
693        assert_eq!(
694            claims.transparency_preview[0].claim,
695            "append_only_local_checkpoint_log"
696        );
697        assert_eq!(claims.transparency_preview[0].log_tree_size, 4);
698
699        let anchored_claims =
700            build_evidence_transparency_claims(&bundle, &transparency, Some("witness-root"));
701        assert_eq!(
702            anchored_claims.publication_state,
703            EvidencePublicationState::TransparencyPreview
704        );
705        assert!(anchored_claims.trust_anchor.is_none());
706        assert_eq!(anchored_claims.transparency_preview.len(), 1);
707    }
708
709    #[test]
710    fn evidence_export_marks_bound_publication_as_trust_anchored() {
711        let keypair = Keypair::generate();
712        let first = build_checkpoint(1, 1, 2, &[b"one".to_vec(), b"two".to_vec()], &keypair)
713            .expect("first checkpoint");
714        let second = build_checkpoint_with_previous(
715            2,
716            3,
717            4,
718            &[b"three".to_vec(), b"four".to_vec()],
719            &keypair,
720            Some(&first),
721            &[crate::checkpoint::checkpoint_chain_leaf_hash(&first.body)
722                .expect("first chain leaf")],
723        )
724        .expect("second checkpoint");
725        let bundle = EvidenceExportBundle {
726            query: EvidenceExportQuery::default(),
727            tool_receipts: Vec::new(),
728            child_receipts: Vec::new(),
729            child_receipt_scope: EvidenceChildReceiptScope::FullQueryWindow,
730            checkpoints: vec![first.clone(), second.clone()],
731            capability_lineage: Vec::new(),
732            inclusion_proofs: Vec::new(),
733            uncheckpointed_receipts: Vec::new(),
734            retention: EvidenceRetentionMetadata {
735                live_db_size_bytes: Some(0),
736                oldest_live_receipt_timestamp: None,
737            },
738        };
739        let mut transparency =
740            build_checkpoint_transparency(&[first.clone(), second.clone()]).expect("summary");
741        let binding = chio_core::receipt::checkpoint::CheckpointPublicationTrustAnchorBinding {
742            publication_identity: chio_core::receipt::checkpoint::CheckpointPublicationIdentity::new(
743                chio_core::receipt::checkpoint::CheckpointPublicationIdentityKind::LocalLog,
744                transparency.publications[0].log_id.clone(),
745            ),
746            trust_anchor_identity: chio_core::receipt::checkpoint::CheckpointTrustAnchorIdentity::new(
747                chio_core::receipt::checkpoint::CheckpointTrustAnchorIdentityKind::TransparencyRoot,
748                "root-set-1",
749            ),
750            trust_anchor_ref: "witness-root".to_string(),
751            signer_cert_ref: "cert-chain-1".to_string(),
752            publication_profile_version: "phase4-pilot".to_string(),
753        };
754        transparency.publications = vec![
755            crate::checkpoint::build_trust_anchored_checkpoint_publication(&first, binding.clone())
756                .expect("first anchored publication"),
757            crate::checkpoint::build_trust_anchored_checkpoint_publication(&second, binding)
758                .expect("second anchored publication"),
759        ];
760
761        let anchored_claims =
762            build_evidence_transparency_claims(&bundle, &transparency, Some("witness-root"));
763        assert_eq!(
764            anchored_claims.publication_state,
765            EvidencePublicationState::TrustAnchored
766        );
767        assert_eq!(
768            anchored_claims.trust_anchor.as_deref(),
769            Some("witness-root")
770        );
771        assert!(anchored_claims.transparency_preview.is_empty());
772    }
773
774    #[test]
775    fn evidence_transparency_claims_reject_invalid_publication_state_combinations() {
776        let anchored_without_anchor = EvidenceTransparencyClaims {
777            schema: EVIDENCE_TRANSPARENCY_CLAIMS_SCHEMA.to_string(),
778            publication_state: EvidencePublicationState::TrustAnchored,
779            trust_anchor: None,
780            audit: EvidenceAuditClaims {
781                checkpoint_logs: Vec::new(),
782                signed_checkpoints: 0,
783                checkpoint_publications: 0,
784                checkpoint_witnesses: 0,
785                checkpoint_consistency_proofs: 0,
786                inclusion_proofs: 0,
787                capability_lineage_records: 0,
788            },
789            transparency_preview: Vec::new(),
790        };
791        assert!(anchored_without_anchor
792            .validate()
793            .unwrap_err()
794            .contains("trust_anchor"));
795    }
796}