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