Skip to main content

treeship_core/verify/
mod.rs

1//! Cross-verification: check a Session Receipt against an Agent Certificate.
2//!
3//! Answers a single question: did the session stay inside the certificate's
4//! authorized envelope? Specifically:
5//!
6//! 1. Do the receipt and certificate reference the same ship?
7//! 2. Was the certificate valid (not expired, not pre-dated) at session time?
8//! 3. Was every tool called during the session present in the certificate's
9//!    authorized tool list?
10//!
11//! This function is the reusable library primitive. The `treeship verify
12//! --certificate` CLI calls it, `@treeship/verify` will call it through WASM
13//! in v0.9.1, and third-party dashboards embedding Treeship verification call
14//! it directly. All of them get the same semantics.
15
16/// Card resolution verification (the certificate-chain walk behind
17/// `resolve --hub` and `verify-presentation`). Lives in core so the CLI, the
18/// WASM verifier, and the SDKs run the same code path.
19pub mod anchoring;
20pub mod authority_use;
21pub mod resolution;
22pub mod workflow_conformance;
23
24/// Presentation verification primitives (the challenge-response canonical and
25/// its check). Same reason: one code path across CLI, WASM, and SDKs.
26pub mod presentation;
27
28/// Session-join challenge canonical (the live-liveness gate `session
29/// countersign --challenge` uses). Same shape as `presentation`, scoped to
30/// proving the joining agent is live at countersign time, not just at join
31/// time.
32pub mod session_join_challenge;
33
34use crate::agent::AgentCertificate;
35use crate::session::package::{VerifyCheck, VerifyStatus};
36use crate::session::receipt::SessionReceipt;
37
38/// Receipt-level checks derivable from the receipt JSON alone (no on-disk
39/// package). Runs Merkle root recomputation, inclusion proof verification,
40/// leaf-count parity, and timeline ordering. Shared between the CLI's
41/// URL-fetch path and the WASM `verify_receipt` export so both surfaces
42/// apply the same rules.
43///
44/// Signature checks on individual envelopes are NOT part of this function:
45/// a raw receipt JSON does not carry envelope bytes. Use the local-storage
46/// artifact-ID verify path for signature verification.
47pub fn verify_receipt_json_checks(receipt: &SessionReceipt) -> Vec<VerifyCheck> {
48    use crate::merkle::MerkleTree;
49
50    let mut checks: Vec<VerifyCheck> = Vec::new();
51
52    if !receipt.artifacts.is_empty() {
53        // The receipt's declared merkle_version drives both recomputation
54        // and per-proof dispatch. Construct the tree through the
55        // validating `with_version` so an unknown version surfaces as a
56        // fail check rather than silently falling back to v1 hashing.
57        let version = receipt.merkle.merkle_version;
58        let mut tree = match MerkleTree::with_version(version) {
59            Ok(t) => t,
60            Err(e) => {
61                checks.push(VerifyCheck::fail(
62                    "merkle_root",
63                    &format!("receipt declared unknown merkle_version: {e}"),
64                ));
65                // Still emit the other checks below — but we cannot
66                // recompute the root, so skip the merkle-specific work.
67                return finish_with_leaf_count_and_timeline(receipt, checks);
68            }
69        };
70        for a in &receipt.artifacts {
71            tree.append(&a.artifact_id);
72        }
73        let root_bytes = tree.root();
74        let recomputed_root = root_bytes.map(|r| format!("mroot_{}", hex::encode(r)));
75        let root_hex = root_bytes.map(hex::encode).unwrap_or_default();
76
77        if recomputed_root == receipt.merkle.root {
78            checks.push(VerifyCheck::pass(
79                "merkle_root",
80                "Merkle root matches recomputed value",
81            ));
82        } else {
83            checks.push(VerifyCheck::fail(
84                "merkle_root",
85                &format!(
86                    "recomputed {recomputed_root:?} != receipt {:?}",
87                    receipt.merkle.root
88                ),
89            ));
90        }
91
92        let proof_total = receipt.merkle.inclusion_proofs.len();
93        let mut proofs_passed = 0usize;
94        let mut drift_detected = false;
95        for entry in &receipt.merkle.inclusion_proofs {
96            // Per-proof version must match the receipt section's
97            // declared version. Smuggling a v1-flavored proof inside a
98            // v2-declared receipt would otherwise dispatch through the
99            // wrong hashing path; reject loudly.
100            if entry.proof.merkle_version != version {
101                drift_detected = true;
102                continue;
103            }
104            if MerkleTree::verify_proof(version, &root_hex, &entry.artifact_id, &entry.proof) {
105                proofs_passed += 1;
106            }
107        }
108        if drift_detected {
109            checks.push(VerifyCheck::fail(
110                "inclusion_proofs",
111                &format!("per-proof merkle_version drift detected (section declares v{version})",),
112            ));
113        } else if proof_total == 0 {
114            // Artifacts are present but the receipt carries no inclusion
115            // proofs: there is nothing to run, and a check that ran nothing
116            // must not report pass ("0/0 passed" is the vacuous-verifier
117            // shape the policy bans — see the chain_linkage note below,
118            // where this same class was fixed once before).
119            checks.push(VerifyCheck::warn(
120                "inclusion_proofs",
121                "no inclusion proofs present to verify",
122            ));
123        } else if proofs_passed == proof_total {
124            checks.push(VerifyCheck::pass(
125                "inclusion_proofs",
126                &format!("{proofs_passed}/{proof_total} inclusion proofs passed"),
127            ));
128        } else {
129            checks.push(VerifyCheck::fail(
130                "inclusion_proofs",
131                &format!("{proofs_passed}/{proof_total} inclusion proofs passed"),
132            ));
133        }
134    } else {
135        checks.push(VerifyCheck::warn("merkle_root", "No artifacts to verify"));
136    }
137
138    finish_with_leaf_count_and_timeline(receipt, checks)
139}
140
141/// Tail of `verify_receipt_json_checks` shared between the happy path and
142/// the early-return path used when an unknown merkle version aborts the
143/// Merkle-specific block.
144fn finish_with_leaf_count_and_timeline(
145    receipt: &SessionReceipt,
146    mut checks: Vec<VerifyCheck>,
147) -> Vec<VerifyCheck> {
148    if receipt.merkle.leaf_count == receipt.artifacts.len() {
149        checks.push(VerifyCheck::pass(
150            "leaf_count",
151            "Leaf count matches artifact count",
152        ));
153    } else {
154        checks.push(VerifyCheck::fail(
155            "leaf_count",
156            &format!(
157                "leaf_count {} != artifact count {}",
158                receipt.merkle.leaf_count,
159                receipt.artifacts.len()
160            ),
161        ));
162    }
163
164    let ordered = receipt.timeline.windows(2).all(|w| {
165        (&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
166            <= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
167    });
168    if ordered {
169        checks.push(VerifyCheck::pass(
170            "timeline_order",
171            "Timeline is correctly ordered",
172        ));
173    } else {
174        checks.push(VerifyCheck::fail(
175            "timeline_order",
176            "Timeline entries are not in deterministic order",
177        ));
178    }
179
180    // P0 #7 (audit): the previous implementation pushed an unconditional
181    // `chain_linkage = pass` row regardless of receipt contents. That
182    // advertised a check that never ran — a verifier output row that
183    // could not fail is worse than no row at all. Each `TimelineEntry`
184    // currently carries `event_id` + `sequence_no` but no `prev_event_id`
185    // field, so the receipt JSON has no per-event linkage we can
186    // recompute. `timeline_order` above already validates the only
187    // ordering signal the receipt actually contains.
188    //
189    // TODO: real chain-linkage check (post-launch). Would require adding
190    // `prev_event_id` to `TimelineEntry` and a format-version bump —
191    // tracked separately from this audit lane.
192
193    checks
194}
195
196/// Convenience: true iff every check in the list is Pass or Warn.
197pub fn checks_ok(checks: &[VerifyCheck]) -> bool {
198    checks.iter().all(|c| c.status != VerifyStatus::Fail)
199}
200
201/// Result of cross-verifying a receipt against a certificate.
202#[derive(Debug, Clone)]
203pub struct CrossVerifyResult {
204    /// Whether the ship IDs match, don't match, or cannot be determined.
205    pub ship_id_status: ShipIdStatus,
206    /// Certificate validity relative to the cross-verify `now` timestamp.
207    pub certificate_status: CertificateStatus,
208    /// Tools that were called AND in the certificate's authorized list.
209    pub authorized_tool_calls: Vec<String>,
210    /// Tools that were called but NOT in the certificate's authorized list.
211    /// Any entry here means the session exceeded its authorized envelope.
212    pub unauthorized_tool_calls: Vec<String>,
213    /// Tools authorized by the certificate but never actually called. Not a
214    /// failure; useful context for reviewers ("agent had permission to touch
215    /// the database but didn't").
216    pub authorized_tools_never_called: Vec<String>,
217}
218
219impl CrossVerifyResult {
220    /// True iff every check passed: ship IDs match, certificate was valid at
221    /// the check time, zero unauthorized tool calls.
222    pub fn ok(&self) -> bool {
223        matches!(self.ship_id_status, ShipIdStatus::Match)
224            && matches!(self.certificate_status, CertificateStatus::Valid)
225            && self.unauthorized_tool_calls.is_empty()
226    }
227}
228
229/// Ship ID comparison outcome.
230#[derive(Debug, Clone, PartialEq, Eq)]
231pub enum ShipIdStatus {
232    /// Receipt's ship_id equals certificate's identity.ship_id.
233    Match,
234    /// Receipt's ship_id does not equal certificate's identity.ship_id.
235    Mismatch {
236        receipt: String,
237        certificate: String,
238    },
239    /// Receipt has no ship_id (pre-v0.9.0 or a non-ship actor URI). Treated
240    /// as a verification failure by `ok()`; callers who accept legacy
241    /// receipts should inspect the status explicitly.
242    Unknown,
243}
244
245/// Certificate validity at the cross-verify time.
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub enum CertificateStatus {
248    Valid,
249    /// Current time is past `valid_until`.
250    Expired {
251        valid_until: String,
252        now: String,
253    },
254    /// Current time is before `issued_at`.
255    NotYetValid {
256        issued_at: String,
257        now: String,
258    },
259}
260
261/// Cross-verify a receipt against an agent certificate.
262///
263/// `now_rfc3339` is an RFC 3339 timestamp representing "now" from the caller's
264/// point of view. Using explicit time makes this function deterministic and
265/// testable. The CLI passes `std::time::SystemTime::now()`; unit tests pass
266/// a fixed value.
267pub fn cross_verify_receipt_and_certificate(
268    receipt: &SessionReceipt,
269    certificate: &AgentCertificate,
270    now_rfc3339: &str,
271) -> CrossVerifyResult {
272    let ship_id_status = compare_ship_ids(
273        receipt.session.ship_id.as_deref(),
274        &certificate.identity.ship_id,
275    );
276    let certificate_status = classify_certificate_validity(certificate, now_rfc3339);
277    let (authorized_tool_calls, unauthorized_tool_calls, authorized_tools_never_called) =
278        classify_tool_usage(receipt, certificate);
279
280    CrossVerifyResult {
281        ship_id_status,
282        certificate_status,
283        authorized_tool_calls,
284        unauthorized_tool_calls,
285        authorized_tools_never_called,
286    }
287}
288
289fn compare_ship_ids(receipt: Option<&str>, certificate: &str) -> ShipIdStatus {
290    match receipt {
291        Some(r) if r == certificate => ShipIdStatus::Match,
292        Some(r) => ShipIdStatus::Mismatch {
293            receipt: r.to_string(),
294            certificate: certificate.to_string(),
295        },
296        None => ShipIdStatus::Unknown,
297    }
298}
299
300fn classify_certificate_validity(certificate: &AgentCertificate, now: &str) -> CertificateStatus {
301    // RFC 3339 lexical ordering agrees with chronological ordering when the
302    // timestamps use the same timezone suffix. Treeship issues and validates
303    // timestamps in UTC (`Z`), so string comparison is sufficient here.
304    let identity = &certificate.identity;
305    if now < identity.issued_at.as_str() {
306        return CertificateStatus::NotYetValid {
307            issued_at: identity.issued_at.clone(),
308            now: now.to_string(),
309        };
310    }
311    if now > identity.valid_until.as_str() {
312        return CertificateStatus::Expired {
313            valid_until: identity.valid_until.clone(),
314            now: now.to_string(),
315        };
316    }
317    CertificateStatus::Valid
318}
319
320/// Returns (authorized_calls, unauthorized_calls, authorized_never_called).
321/// Each list is sorted and deduplicated.
322fn classify_tool_usage(
323    receipt: &SessionReceipt,
324    certificate: &AgentCertificate,
325) -> (Vec<String>, Vec<String>, Vec<String>) {
326    use std::collections::BTreeSet;
327
328    let authorized: BTreeSet<String> = certificate
329        .capabilities
330        .tools
331        .iter()
332        .map(|t| t.name.clone())
333        .collect();
334
335    // Called tools come from receipt.tool_usage.actual. Legacy receipts or
336    // receipts with no tool_usage field are treated as "no tool calls".
337    let called: BTreeSet<String> = receipt
338        .tool_usage
339        .as_ref()
340        .map(|u| u.actual.iter().map(|e| e.tool_name.clone()).collect())
341        .unwrap_or_default();
342
343    let authorized_calls: Vec<String> = called.intersection(&authorized).cloned().collect();
344    let unauthorized_calls: Vec<String> = called.difference(&authorized).cloned().collect();
345    let never_called: Vec<String> = authorized.difference(&called).cloned().collect();
346
347    (authorized_calls, unauthorized_calls, never_called)
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use crate::agent::{
354        AgentCapabilities, AgentDeclaration, AgentIdentity, CertificateSignature, ToolCapability,
355        CERTIFICATE_SCHEMA_VERSION, CERTIFICATE_TYPE,
356    };
357    use crate::session::manifest::{LifecycleMode, Participants, SessionStatus};
358    use crate::session::receipt::{SessionReceipt, SessionSection, ToolUsage, ToolUsageEntry};
359    use crate::session::render::RenderConfig;
360    use crate::session::side_effects::SideEffects;
361
362    fn certificate(
363        ship_id: &str,
364        tools: &[&str],
365        issued: &str,
366        valid_until: &str,
367    ) -> AgentCertificate {
368        AgentCertificate {
369            r#type: CERTIFICATE_TYPE.into(),
370            schema_version: Some(CERTIFICATE_SCHEMA_VERSION.into()),
371            identity: AgentIdentity {
372                agent_name: "agent-007".into(),
373                ship_id: ship_id.into(),
374                public_key: "pk_b64".into(),
375                issuer: format!("ship://{ship_id}"),
376                issued_at: issued.into(),
377                valid_until: valid_until.into(),
378                model: None,
379                description: None,
380            },
381            capabilities: AgentCapabilities {
382                tools: tools
383                    .iter()
384                    .map(|n| ToolCapability {
385                        name: (*n).into(),
386                        description: None,
387                    })
388                    .collect(),
389                api_endpoints: vec![],
390                mcp_servers: vec![],
391            },
392            declaration: AgentDeclaration {
393                bounded_actions: tools.iter().map(|s| (*s).into()).collect(),
394                forbidden: vec![],
395                escalation_required: vec![],
396            },
397            signature: CertificateSignature {
398                algorithm: "ed25519".into(),
399                key_id: "key_1".into(),
400                public_key: "pk_b64".into(),
401                signature: "sig_b64".into(),
402                signed_fields: "identity+capabilities+declaration".into(),
403            },
404        }
405    }
406
407    fn receipt(ship_id: Option<&str>, tools_called: &[(&str, u32)]) -> SessionReceipt {
408        let tool_usage = if tools_called.is_empty() {
409            None
410        } else {
411            Some(ToolUsage {
412                declared: vec![],
413                actual: tools_called
414                    .iter()
415                    .map(|(n, c)| ToolUsageEntry {
416                        tool_name: (*n).into(),
417                        count: *c,
418                    })
419                    .collect(),
420                unauthorized: vec![],
421            })
422        };
423        SessionReceipt {
424            type_: crate::session::receipt::RECEIPT_TYPE.into(),
425            schema_version: Some(crate::session::receipt::RECEIPT_SCHEMA_VERSION.into()),
426            custody: None,
427            session: SessionSection {
428                id: "ssn_test".into(),
429                name: None,
430                mode: LifecycleMode::Manual,
431                started_at: "2026-04-10T00:00:00Z".into(),
432                ended_at: Some("2026-04-10T00:30:00Z".into()),
433                status: SessionStatus::Completed,
434                duration_ms: Some(1_800_000),
435                ship_id: ship_id.map(str::to_string),
436                workflow_ref: None,
437                narrative: None,
438                total_tokens_in: 0,
439                total_tokens_out: 0,
440                room: None,
441            },
442            participants: Participants::default(),
443            hosts: vec![],
444            tools: vec![],
445            agent_graph: Default::default(),
446            timeline: vec![],
447            side_effects: SideEffects::default(),
448            artifacts: vec![],
449            proofs: Default::default(),
450            merkle: Default::default(),
451            render: RenderConfig {
452                title: None,
453                theme: None,
454                sections: RenderConfig::default_sections(),
455                generate_preview: true,
456            },
457            tool_usage,
458            authority: None,
459        }
460    }
461
462    const NOW: &str = "2026-04-18T10:00:00Z";
463    const ISSUED: &str = "2026-04-01T00:00:00Z";
464    const VALID_UNTIL: &str = "2027-04-01T00:00:00Z";
465
466    #[test]
467    fn all_tool_calls_authorized_passes() {
468        let cert = certificate("ship_a", &["Bash", "Read"], ISSUED, VALID_UNTIL);
469        let rec = receipt(Some("ship_a"), &[("Bash", 4), ("Read", 2)]);
470        let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
471        assert_eq!(r.ship_id_status, ShipIdStatus::Match);
472        assert_eq!(r.certificate_status, CertificateStatus::Valid);
473        assert_eq!(r.authorized_tool_calls, vec!["Bash", "Read"]);
474        assert!(r.unauthorized_tool_calls.is_empty());
475        assert!(r.authorized_tools_never_called.is_empty());
476        assert!(r.ok());
477    }
478
479    #[test]
480    fn unauthorized_tool_call_flagged_and_blocks_ok() {
481        let cert = certificate("ship_a", &["Read"], ISSUED, VALID_UNTIL);
482        let rec = receipt(Some("ship_a"), &[("Read", 1), ("Write", 1)]);
483        let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
484        assert_eq!(r.authorized_tool_calls, vec!["Read"]);
485        assert_eq!(r.unauthorized_tool_calls, vec!["Write"]);
486        assert!(r.authorized_tools_never_called.is_empty());
487        assert!(!r.ok(), "unauthorized call must block ok()");
488    }
489
490    #[test]
491    fn tools_authorized_but_never_called_reported_and_still_ok() {
492        let cert = certificate(
493            "ship_a",
494            &["Bash", "Read", "DropDatabase"],
495            ISSUED,
496            VALID_UNTIL,
497        );
498        let rec = receipt(Some("ship_a"), &[("Bash", 1)]);
499        let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
500        assert_eq!(r.authorized_tool_calls, vec!["Bash"]);
501        assert!(r.unauthorized_tool_calls.is_empty());
502        assert_eq!(
503            r.authorized_tools_never_called,
504            vec!["DropDatabase".to_string(), "Read".to_string()]
505        );
506        assert!(r.ok(), "unused authorization is not a failure");
507    }
508
509    #[test]
510    fn mismatched_ship_ids_blocks_ok() {
511        let cert = certificate("ship_a", &["Bash"], ISSUED, VALID_UNTIL);
512        let rec = receipt(Some("ship_b"), &[("Bash", 1)]);
513        let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
514        assert_eq!(
515            r.ship_id_status,
516            ShipIdStatus::Mismatch {
517                receipt: "ship_b".into(),
518                certificate: "ship_a".into()
519            }
520        );
521        assert!(!r.ok());
522    }
523
524    #[test]
525    fn expired_certificate_blocks_ok() {
526        let cert = certificate("ship_a", &["Bash"], ISSUED, "2026-04-10T00:00:00Z");
527        let rec = receipt(Some("ship_a"), &[("Bash", 1)]);
528        let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
529        assert_eq!(
530            r.certificate_status,
531            CertificateStatus::Expired {
532                valid_until: "2026-04-10T00:00:00Z".into(),
533                now: NOW.into()
534            }
535        );
536        assert!(!r.ok());
537    }
538
539    #[test]
540    fn not_yet_valid_certificate_blocks_ok() {
541        let cert = certificate(
542            "ship_a",
543            &["Bash"],
544            "2027-01-01T00:00:00Z",
545            "2028-01-01T00:00:00Z",
546        );
547        let rec = receipt(Some("ship_a"), &[("Bash", 1)]);
548        let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
549        assert!(matches!(
550            r.certificate_status,
551            CertificateStatus::NotYetValid { .. }
552        ));
553        assert!(!r.ok());
554    }
555
556    #[test]
557    fn legacy_receipt_without_ship_id_is_unknown_and_blocks_ok() {
558        let cert = certificate("ship_a", &["Bash"], ISSUED, VALID_UNTIL);
559        let rec = receipt(None, &[("Bash", 1)]); // pre-v0.9.0 receipt
560        let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
561        assert_eq!(r.ship_id_status, ShipIdStatus::Unknown);
562        assert!(!r.ok(), "unknown ship_id must block ok() by default");
563    }
564
565    #[test]
566    fn no_tool_calls_in_receipt_yields_empty_lists() {
567        let cert = certificate("ship_a", &["Bash"], ISSUED, VALID_UNTIL);
568        let rec = receipt(Some("ship_a"), &[]);
569        let r = cross_verify_receipt_and_certificate(&rec, &cert, NOW);
570        assert!(r.authorized_tool_calls.is_empty());
571        assert!(r.unauthorized_tool_calls.is_empty());
572        assert_eq!(r.authorized_tools_never_called, vec!["Bash"]);
573        assert!(r.ok());
574    }
575
576    // P0 #7 regression guard: `verify_receipt_json_checks` previously pushed
577    // an unconditional `chain_linkage = pass` row that advertised a check
578    // which never ran. The fix removed the row; this test pins it down so a
579    // future "helpful" refactor cannot silently restore the lie. We exercise
580    // both branches of the function: the empty-artifacts path and the
581    // populated-artifacts path. Neither must emit a check named
582    // `"chain_linkage"`.
583    #[test]
584    fn chain_linkage_check_never_emitted() {
585        use crate::session::receipt::{ArtifactEntry, TimelineEntry};
586
587        // Branch 1: empty artifacts + empty timeline (the warn-only path).
588        let rec_empty = receipt(Some("ship_a"), &[]);
589        let checks_empty = verify_receipt_json_checks(&rec_empty);
590        assert!(
591            !checks_empty.iter().any(|c| c.name == "chain_linkage"),
592            "chain_linkage check must not be emitted (empty receipt). got: {:?}",
593            checks_empty.iter().map(|c| &c.name).collect::<Vec<_>>(),
594        );
595
596        // Branch 2: a receipt with real artifacts and timeline entries so
597        // the merkle/inclusion/leaf-count/timeline branches all run.
598        let mut rec_full = receipt(Some("ship_a"), &[]);
599        rec_full.artifacts = vec![
600            ArtifactEntry {
601                artifact_id: "art_aaaa".into(),
602                payload_type: "treeship.dev/v0/action".into(),
603                digest: None,
604                signed_at: None,
605            },
606            ArtifactEntry {
607                artifact_id: "art_bbbb".into(),
608                payload_type: "treeship.dev/v0/action".into(),
609                digest: None,
610                signed_at: None,
611            },
612        ];
613        rec_full.merkle.leaf_count = 2;
614        rec_full.timeline = vec![
615            TimelineEntry {
616                sequence_no: 1,
617                timestamp: "2026-04-10T00:00:01Z".into(),
618                event_id: "evt_1".into(),
619                event_type: "tool.call".into(),
620                agent_instance_id: "ai_1".into(),
621                agent_name: "a".into(),
622                host_id: "h_1".into(),
623                summary: None,
624            },
625            TimelineEntry {
626                sequence_no: 2,
627                timestamp: "2026-04-10T00:00:02Z".into(),
628                event_id: "evt_2".into(),
629                event_type: "tool.call".into(),
630                agent_instance_id: "ai_1".into(),
631                agent_name: "a".into(),
632                host_id: "h_1".into(),
633                summary: None,
634            },
635        ];
636
637        let checks_full = verify_receipt_json_checks(&rec_full);
638        assert!(
639            !checks_full.iter().any(|c| c.name == "chain_linkage"),
640            "chain_linkage check must not be emitted (populated receipt). got: {:?}",
641            checks_full.iter().map(|c| &c.name).collect::<Vec<_>>(),
642        );
643    }
644
645    // ── Adversarial regression coverage for verify_receipt_json_checks ──
646
647    /// Build a small receipt populated with a real v2 merkle tree + one
648    /// inclusion proof so the tests below can mutate fields and
649    /// observe whether `verify_receipt_json_checks` catches the drift.
650    fn receipt_with_v2_merkle() -> SessionReceipt {
651        use crate::merkle::MerkleTree;
652        use crate::session::receipt::{ArtifactEntry, InclusionProofEntry, MerkleSection};
653
654        let mut tree = MerkleTree::new();
655        tree.append("art_a");
656        tree.append("art_b");
657        let root_bytes = tree.root().unwrap();
658        let inclusion = tree.inclusion_proof(0).unwrap();
659
660        let mut rec = receipt(Some("ship_a"), &[]);
661        rec.artifacts = vec![
662            ArtifactEntry {
663                artifact_id: "art_a".into(),
664                payload_type: "test".into(),
665                digest: None,
666                signed_at: None,
667            },
668            ArtifactEntry {
669                artifact_id: "art_b".into(),
670                payload_type: "test".into(),
671                digest: None,
672                signed_at: None,
673            },
674        ];
675        rec.merkle = MerkleSection {
676            leaf_count: 2,
677            root: Some(format!("mroot_{}", hex::encode(root_bytes))),
678            checkpoint_id: None,
679            inclusion_proofs: vec![InclusionProofEntry {
680                artifact_id: "art_a".into(),
681                leaf_index: 0,
682                proof: inclusion,
683            }],
684            merkle_version: crate::merkle::MERKLE_VERSION_V2,
685        };
686        rec
687    }
688
689    #[test]
690    fn unknown_merkle_version_rejected_at_verify() {
691        // Receipt declares merkle_version = 99 on its merkle section.
692        // verify_receipt_json_checks must surface a hard fail rather
693        // than silently treating it as v1.
694        let mut rec = receipt_with_v2_merkle();
695        rec.merkle.merkle_version = 99;
696
697        let checks = verify_receipt_json_checks(&rec);
698        let merkle_root = checks
699            .iter()
700            .find(|c| c.name == "merkle_root")
701            .expect("merkle_root check should be emitted");
702        assert_eq!(
703            merkle_root.status,
704            VerifyStatus::Fail,
705            "unknown merkle_version must hard-fail, got: {:?}",
706            merkle_root,
707        );
708        assert!(
709            merkle_root.detail.contains("unknown merkle_version"),
710            "fail message should explain the unknown version, got: {}",
711            merkle_root.detail,
712        );
713    }
714
715    #[test]
716    fn per_proof_version_drift_rejected() {
717        // Receipt section claims v2 but one inclusion proof has
718        // merkle_version smuggled down to v1. The verifier must refuse
719        // to dispatch through the weaker hashing.
720        let mut rec = receipt_with_v2_merkle();
721        rec.merkle.inclusion_proofs[0].proof.merkle_version = crate::merkle::MERKLE_VERSION_V1;
722
723        let checks = verify_receipt_json_checks(&rec);
724        let proofs = checks
725            .iter()
726            .find(|c| c.name == "inclusion_proofs")
727            .expect("inclusion_proofs check should be emitted");
728        assert_eq!(
729            proofs.status,
730            VerifyStatus::Fail,
731            "per-proof merkle_version drift must hard-fail, got: {:?}",
732            proofs,
733        );
734    }
735}