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