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