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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub tenant: Option<String>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub read_boundary: Option<ReceiptReadBoundary>,
37}
38
39#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
41#[serde(rename_all = "snake_case")]
42pub enum EvidenceChildReceiptScope {
43 FullQueryWindow,
45 TimeWindowContextOnly,
48 OmittedNoJoinPath,
51}
52
53#[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#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(rename_all = "camelCase")]
78pub struct EvidenceToolReceiptRecord {
79 pub seq: u64,
80 pub receipt: ChioReceipt,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct EvidenceChildReceiptRecord {
87 pub seq: u64,
88 pub receipt: ChildRequestReceipt,
89}
90
91#[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#[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#[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#[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#[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#[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#[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}