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