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