Skip to main content

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