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#[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}
34
35#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
37#[serde(rename_all = "snake_case")]
38pub enum EvidenceChildReceiptScope {
39 FullQueryWindow,
41 TimeWindowContextOnly,
44 OmittedNoJoinPath,
47}
48
49#[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#[derive(Debug, Clone, Serialize, Deserialize)]
73#[serde(rename_all = "camelCase")]
74pub struct EvidenceToolReceiptRecord {
75 pub seq: u64,
76 pub receipt: ChioReceipt,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82pub struct EvidenceChildReceiptRecord {
83 pub seq: u64,
84 pub receipt: ChildRequestReceipt,
85}
86
87#[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#[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#[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#[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#[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#[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#[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}