Skip to main content

treeship_core/session/
package.rs

1//! `.treeship` package builder and reader.
2//!
3//! A `.treeship` package is a directory (or tar archive) containing:
4//!
5//! - `receipt.json`   -- the canonical Session Receipt
6//! - `merkle.json`    -- standalone Merkle tree data
7//! - `render.json`    -- Explorer render hints
8//! - `artifacts/`     -- referenced artifact payloads
9//! - `proofs/`        -- inclusion proofs and zk proofs
10//! - `preview.html`   -- static preview (optional)
11
12use std::path::{Path, PathBuf};
13
14use crate::statements::ApprovalStatement;
15use serde::{Deserialize, Serialize};
16use sha2::{Digest, Sha256};
17
18use super::receipt::{SessionReceipt, RECEIPT_TYPE};
19use crate::statements::{
20    approval_revocation_record_digest, approval_use_record_digest,
21    journal_checkpoint_record_digest, ApprovalRevocation, ApprovalUse, JournalCheckpoint,
22    ReplayCheck, ReplayCheckLevel,
23};
24
25/// Errors from package operations.
26#[derive(Debug)]
27pub enum PackageError {
28    Io(std::io::Error),
29    Json(serde_json::Error),
30    InvalidPackage(String),
31}
32
33impl std::fmt::Display for PackageError {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        match self {
36            Self::Io(e) => write!(f, "package io: {e}"),
37            Self::Json(e) => write!(f, "package json: {e}"),
38            Self::InvalidPackage(msg) => write!(f, "invalid package: {msg}"),
39        }
40    }
41}
42
43impl std::error::Error for PackageError {}
44impl From<std::io::Error> for PackageError {
45    fn from(e: std::io::Error) -> Self {
46        Self::Io(e)
47    }
48}
49impl From<serde_json::Error> for PackageError {
50    fn from(e: serde_json::Error) -> Self {
51        Self::Json(e)
52    }
53}
54
55/// Manifest file inside the package root.
56const RECEIPT_FILE: &str = "receipt.json";
57const MERKLE_FILE: &str = "merkle.json";
58const RENDER_FILE: &str = "render.json";
59const ARTIFACTS_DIR: &str = "artifacts";
60const PROOFS_DIR: &str = "proofs";
61const PREVIEW_FILE: &str = "preview.html";
62
63// Approval Authority package layout (v0.9.9 PR 4).
64// approvals/index.json -- top-level index of every approval evidence
65//                          file in this package
66// approvals/grants/<grant_id>.json    -- copy of the signed
67//                          ApprovalStatement envelope (already in
68//                          artifacts/ via the chain; mirrored here for
69//                          single-directory access during verify)
70// approvals/uses/<use_id>.json        -- ApprovalUse record from the
71//                          local journal at session-close time
72// approvals/checkpoints/<id>.json     -- JournalCheckpoint records that
73//                          cover the included uses (PR 6 Hub
74//                          checkpoint signing extends this)
75const APPROVALS_DIR: &str = "approvals";
76const APPROVALS_GRANTS: &str = "approvals/grants";
77const APPROVALS_USES: &str = "approvals/uses";
78const APPROVALS_CHECKPOINTS: &str = "approvals/checkpoints";
79const APPROVALS_INDEX_FILE: &str = "approvals/index.json";
80
81/// Optional approval evidence to embed in the package alongside the
82/// receipt + artifacts. None means "no approvals consumed during this
83/// session, or none worth exporting." Empty vectors mean "we looked and
84/// found nothing"; the resulting package omits the `approvals/` dir
85/// entirely so absence is unambiguous.
86///
87/// Ownership of the evidence stays with the caller: `session::close`
88/// gathers the grant envelopes from the chain, the uses from the local
89/// journal, and any covering checkpoints, then hands them off here.
90#[derive(Debug, Clone, Default)]
91pub struct ApprovalsBundle {
92    /// Bytes of the signed ApprovalStatement envelopes that authorized
93    /// any consumed uses. Each entry is `(grant_id, raw_envelope_json)`.
94    /// Stored verbatim so the package's verifier can re-check the
95    /// signature without re-serializing.
96    pub grants: Vec<(String, Vec<u8>)>,
97    /// ApprovalUse records pulled from the local journal at close time.
98    /// `action_artifact_id` should be backfilled before passing to
99    /// build_package (see `commands/session.rs`).
100    pub uses: Vec<ApprovalUse>,
101    /// JournalCheckpoints that cover the included uses. Optional; may
102    /// be empty even when uses are present (PR 6 fills these in).
103    pub checkpoints: Vec<JournalCheckpoint>,
104    /// Explicit revocations we wanted to surface (e.g. a use whose
105    /// grant was revoked after consumption -- the package should still
106    /// show the consumed evidence and the revocation alongside).
107    /// Empty in PR 4; reserved.
108    pub revocations: Vec<ApprovalRevocation>,
109
110    /// Bytes of each action artifact's signed envelope that consumed an
111    /// approval. Each entry is `(action_artifact_id, raw_envelope_json)`.
112    /// v0.9.10 PR A: shipped to close the action↔use binding gap. The
113    /// verifier extracts `meta.approval_use_id` from each envelope and
114    /// cross-checks it against the package's use records. Empty in
115    /// pre-v0.9.10 packages; readers must treat absence as "binding
116    /// not asserted by package" rather than "binding present and OK."
117    pub action_envelopes: Vec<(String, Vec<u8>)>,
118}
119
120/// `approvals/index.json` -- top-level inventory of evidence in the
121/// package. Lets a consumer pre-flight what's there before opening
122/// every file; doubles as a stable shape for downstream tooling.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct ApprovalsIndex {
125    /// Stable schema marker so future versions can fan out cleanly.
126    #[serde(rename = "type")]
127    pub type_: String,
128    pub schema_version: u32,
129    /// Stable kebab-case ids of grants present. Order matches
130    /// `grants/` filename order.
131    pub grants: Vec<String>,
132    /// Use ids present.
133    pub uses: Vec<String>,
134    pub checkpoints: Vec<String>,
135    pub revocations: Vec<String>,
136}
137
138impl ApprovalsIndex {
139    pub fn type_string() -> &'static str {
140        "treeship/approvals-index/v1"
141    }
142}
143
144/// Result of building a package.
145pub struct PackageOutput {
146    /// Path to the package directory.
147    pub path: PathBuf,
148    /// SHA-256 digest of the canonical receipt.json.
149    pub receipt_digest: String,
150    /// Merkle root hex (if present).
151    pub merkle_root: Option<String>,
152    /// Number of files in the package.
153    pub file_count: usize,
154}
155
156/// Build a `.treeship` package directory from a composed receipt.
157///
158/// Writes all package files into `output_dir/<session_id>.treeship/`.
159/// Returns metadata about the written package.
160///
161/// Backwards-compatible wrapper: callers that don't have approval
162/// evidence to export pass through here unchanged. Callers that do
163/// (`session::close` with consumed approvals) call
164/// `build_package_with_approvals` directly.
165pub fn build_package(
166    receipt: &SessionReceipt,
167    output_dir: &Path,
168) -> Result<PackageOutput, PackageError> {
169    build_package_with_approvals(receipt, output_dir, None)
170}
171
172/// Like `build_package` but also embeds approval evidence (PR 4 of v0.9.9).
173/// `bundle = None` is identical to `build_package`; the `approvals/`
174/// directory is omitted entirely so absence stays unambiguous.
175pub fn build_package_with_approvals(
176    receipt: &SessionReceipt,
177    output_dir: &Path,
178    bundle: Option<&ApprovalsBundle>,
179) -> Result<PackageOutput, PackageError> {
180    let session_id = &receipt.session.id;
181    let pkg_dir = output_dir.join(format!("{session_id}.treeship"));
182
183    std::fs::create_dir_all(&pkg_dir)?;
184    std::fs::create_dir_all(pkg_dir.join(ARTIFACTS_DIR))?;
185    std::fs::create_dir_all(pkg_dir.join(PROOFS_DIR))?;
186
187    let mut file_count = 0usize;
188
189    // 1. receipt.json -- canonical serialization
190    let receipt_bytes = serde_json::to_vec_pretty(receipt)?;
191    std::fs::write(pkg_dir.join(RECEIPT_FILE), &receipt_bytes)?;
192    file_count += 1;
193
194    let receipt_hash = Sha256::digest(&receipt_bytes);
195    let receipt_digest = format!("sha256:{}", hex::encode(receipt_hash));
196
197    // 2. merkle.json -- standalone copy of the Merkle section
198    let merkle_bytes = serde_json::to_vec_pretty(&receipt.merkle)?;
199    std::fs::write(pkg_dir.join(MERKLE_FILE), &merkle_bytes)?;
200    file_count += 1;
201
202    // 3. render.json
203    let render_bytes = serde_json::to_vec_pretty(&receipt.render)?;
204    std::fs::write(pkg_dir.join(RENDER_FILE), &render_bytes)?;
205    file_count += 1;
206
207    // 4. Write inclusion proofs as individual files
208    for proof_entry in &receipt.merkle.inclusion_proofs {
209        let proof_bytes = serde_json::to_vec_pretty(proof_entry)?;
210        let filename = format!("{}.proof.json", proof_entry.artifact_id);
211        std::fs::write(pkg_dir.join(PROOFS_DIR).join(filename), &proof_bytes)?;
212        file_count += 1;
213    }
214
215    // 5. preview.html stub
216    if receipt.render.generate_preview {
217        let preview = render_preview_html_with_approvals(receipt, bundle);
218        std::fs::write(pkg_dir.join(PREVIEW_FILE), preview.as_bytes())?;
219        file_count += 1;
220    }
221
222    // 6. Approval evidence (v0.9.9 PR 4). Only writes when the caller
223    // supplied a bundle AND that bundle has at least one entry; an empty
224    // bundle behaves the same as None so a session with no consumed
225    // approvals doesn't leave behind an empty `approvals/` directory.
226    if let Some(b) = bundle {
227        if !b.grants.is_empty()
228            || !b.uses.is_empty()
229            || !b.checkpoints.is_empty()
230            || !b.revocations.is_empty()
231            || !b.action_envelopes.is_empty()
232        {
233            std::fs::create_dir_all(pkg_dir.join(APPROVALS_GRANTS))?;
234            std::fs::create_dir_all(pkg_dir.join(APPROVALS_USES))?;
235            std::fs::create_dir_all(pkg_dir.join(APPROVALS_CHECKPOINTS))?;
236            // v0.9.10 PR A: write action envelopes that consumed an
237            // approval. The artifacts/ directory was created earlier
238            // for the package layout but never populated; closing the
239            // action↔use binding gap requires the verifier to be able
240            // to read each consuming action's `meta.approval_use_id`.
241            std::fs::create_dir_all(pkg_dir.join(ARTIFACTS_DIR))?;
242            for (artifact_id, envelope_bytes) in &b.action_envelopes {
243                let safe = sanitize_filename(artifact_id);
244                std::fs::write(
245                    pkg_dir.join(ARTIFACTS_DIR).join(format!("{safe}.json")),
246                    envelope_bytes,
247                )?;
248                file_count += 1;
249            }
250
251            let mut grant_ids = Vec::with_capacity(b.grants.len());
252            for (grant_id, envelope_bytes) in &b.grants {
253                let safe = sanitize_filename(grant_id);
254                std::fs::write(
255                    pkg_dir.join(APPROVALS_GRANTS).join(format!("{safe}.json")),
256                    envelope_bytes,
257                )?;
258                grant_ids.push(grant_id.clone());
259                file_count += 1;
260            }
261
262            let mut use_ids = Vec::with_capacity(b.uses.len());
263            for u in &b.uses {
264                let safe = sanitize_filename(&u.use_id);
265                let bytes = serde_json::to_vec_pretty(u)?;
266                std::fs::write(
267                    pkg_dir.join(APPROVALS_USES).join(format!("{safe}.json")),
268                    &bytes,
269                )?;
270                use_ids.push(u.use_id.clone());
271                file_count += 1;
272            }
273
274            let mut checkpoint_ids = Vec::with_capacity(b.checkpoints.len());
275            for cp in &b.checkpoints {
276                let safe = sanitize_filename(&cp.checkpoint_id);
277                let bytes = serde_json::to_vec_pretty(cp)?;
278                std::fs::write(
279                    pkg_dir
280                        .join(APPROVALS_CHECKPOINTS)
281                        .join(format!("{safe}.json")),
282                    &bytes,
283                )?;
284                checkpoint_ids.push(cp.checkpoint_id.clone());
285                file_count += 1;
286            }
287
288            let mut revocation_ids = Vec::with_capacity(b.revocations.len());
289            for rev in &b.revocations {
290                let safe = sanitize_filename(&rev.revocation_id);
291                let bytes = serde_json::to_vec_pretty(rev)?;
292                std::fs::write(
293                    pkg_dir
294                        .join(APPROVALS_DIR)
295                        .join(format!("revocations-{safe}.json")),
296                    &bytes,
297                )?;
298                revocation_ids.push(rev.revocation_id.clone());
299                file_count += 1;
300            }
301
302            let index = ApprovalsIndex {
303                type_: ApprovalsIndex::type_string().into(),
304                schema_version: 1,
305                grants: grant_ids,
306                uses: use_ids,
307                checkpoints: checkpoint_ids,
308                revocations: revocation_ids,
309            };
310            let index_bytes = serde_json::to_vec_pretty(&index)?;
311            std::fs::write(pkg_dir.join(APPROVALS_INDEX_FILE), &index_bytes)?;
312            file_count += 1;
313        }
314    }
315
316    Ok(PackageOutput {
317        path: pkg_dir,
318        receipt_digest,
319        merkle_root: receipt.merkle.root.clone(),
320        file_count,
321    })
322}
323
324/// Sanitize an id (artifact_id, use_id, checkpoint_id) into a filesystem-safe
325/// filename. Underscores everything that isn't alphanumeric, dash, or dot.
326/// Not a security boundary; the digest chain is the integrity check.
327fn sanitize_filename(s: &str) -> String {
328    s.chars()
329        .map(|c| {
330            if c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' {
331                c
332            } else {
333                '_'
334            }
335        })
336        .collect()
337}
338
339/// Read approval evidence embedded in a package, if any. Returns
340/// `Ok(ApprovalsBundle::default())` when the package has no `approvals/`
341/// directory (the typical case for sessions that didn't consume any
342/// scoped approvals). Errors only on malformed JSON inside files that
343/// the index claims exist.
344///
345/// Quiet on missing-directory by design: PR 4 packages and pre-PR-4
346/// packages should both round-trip through verify without spurious
347/// failures.
348pub fn read_approvals_bundle(pkg_dir: &Path) -> Result<ApprovalsBundle, PackageError> {
349    let approvals_dir = pkg_dir.join(APPROVALS_DIR);
350    if !approvals_dir.is_dir() {
351        return Ok(ApprovalsBundle::default());
352    }
353
354    let mut bundle = ApprovalsBundle::default();
355
356    // Grants are raw envelopes by file; we don't parse here, the
357    // verify layer can re-check the signature.
358    let grants_dir = pkg_dir.join(APPROVALS_GRANTS);
359    if grants_dir.is_dir() {
360        for entry in std::fs::read_dir(&grants_dir)? {
361            let entry = entry?;
362            let path = entry.path();
363            if path.extension().and_then(|s| s.to_str()) != Some("json") {
364                continue;
365            }
366            let id = path
367                .file_stem()
368                .and_then(|s| s.to_str())
369                .unwrap_or("")
370                .to_string();
371            let bytes = std::fs::read(&path)?;
372            bundle.grants.push((id, bytes));
373        }
374    }
375
376    let uses_dir = pkg_dir.join(APPROVALS_USES);
377    if uses_dir.is_dir() {
378        for entry in std::fs::read_dir(&uses_dir)? {
379            let entry = entry?;
380            let path = entry.path();
381            if path.extension().and_then(|s| s.to_str()) != Some("json") {
382                continue;
383            }
384            let bytes = std::fs::read(&path)?;
385            let u: ApprovalUse = serde_json::from_slice(&bytes)?;
386            bundle.uses.push(u);
387        }
388    }
389
390    let cps_dir = pkg_dir.join(APPROVALS_CHECKPOINTS);
391    if cps_dir.is_dir() {
392        for entry in std::fs::read_dir(&cps_dir)? {
393            let entry = entry?;
394            let path = entry.path();
395            if path.extension().and_then(|s| s.to_str()) != Some("json") {
396                continue;
397            }
398            let bytes = std::fs::read(&path)?;
399            let cp: JournalCheckpoint = serde_json::from_slice(&bytes)?;
400            bundle.checkpoints.push(cp);
401        }
402    }
403
404    // v0.9.10 PR A: read action envelopes shipped to support the
405    // action↔use binding check. Pre-v0.9.10 packages have an empty
406    // artifacts/ dir (the dir was created but never populated); the
407    // bundle's `action_envelopes` stays empty in that case, and the
408    // verifier reports the binding row honestly as "not asserted by
409    // package" rather than silently passing.
410    let arts_dir = pkg_dir.join(ARTIFACTS_DIR);
411    if arts_dir.is_dir() {
412        for entry in std::fs::read_dir(&arts_dir)? {
413            let entry = entry?;
414            let path = entry.path();
415            if path.extension().and_then(|s| s.to_str()) != Some("json") {
416                continue;
417            }
418            let id = path
419                .file_stem()
420                .and_then(|s| s.to_str())
421                .unwrap_or("")
422                .to_string();
423            let bytes = std::fs::read(&path)?;
424            bundle.action_envelopes.push((id, bytes));
425        }
426    }
427
428    Ok(bundle)
429}
430
431/// Read and parse a `.treeship` package from disk.
432pub fn read_package(pkg_dir: &Path) -> Result<SessionReceipt, PackageError> {
433    let receipt_path = pkg_dir.join(RECEIPT_FILE);
434    if !receipt_path.exists() {
435        return Err(PackageError::InvalidPackage(format!(
436            "missing {RECEIPT_FILE} in {}",
437            pkg_dir.display()
438        )));
439    }
440    let bytes = std::fs::read(&receipt_path)?;
441    let receipt: SessionReceipt = serde_json::from_slice(&bytes)?;
442
443    if receipt.type_ != RECEIPT_TYPE {
444        return Err(PackageError::InvalidPackage(format!(
445            "unexpected type: {} (expected {RECEIPT_TYPE})",
446            receipt.type_
447        )));
448    }
449
450    Ok(receipt)
451}
452
453/// Verify a `.treeship` package locally.
454///
455/// Returns a list of check results. All must pass for the package to be valid.
456///
457/// Auto-loads the operator's trust roots from
458/// `TrustRootStore::default_path()`. Use
459/// [`verify_package_with_trust`] when the trust store is already in
460/// hand (CLI paths that take a `Ctx`, or tests).
461///
462/// Audit lane J fix-up: `open_default_or_empty` propagates `Malformed`
463/// and `PermissionsTooOpen` errors -- those are operator
464/// misconfiguration that must NOT be silently downgraded to an empty
465/// trust store (an empty store fails verification of any hub-org
466/// checkpoint, which is the right end-state, but the operator needs a
467/// clear "your trust file is broken" diagnostic instead of a misleading
468/// "untrusted issuer" message). Surface the error as a `trust-root`
469/// fail row and stop before doing real work that depends on trust.
470pub fn verify_package(pkg_dir: &Path) -> Result<Vec<VerifyCheck>, PackageError> {
471    let trust = match crate::trust::TrustRootStore::open_default_or_empty() {
472        Ok(t) => t,
473        Err(e) => {
474            // Build a minimal check list so the caller's printer still
475            // renders a coherent failure rather than silently routing
476            // through a fake empty store.
477            return Ok(vec![VerifyCheck::fail(
478                "trust-root",
479                &format!("trust store unreadable: {e}"),
480            )]);
481        }
482    };
483    verify_package_with_trust(pkg_dir, &trust)
484}
485
486/// Like `verify_package` but takes an explicit `TrustRootStore` so the
487/// caller can verify with a constructed-in-memory trust set (tests) or
488/// a non-default location (CLI `--trust-roots`).
489pub fn verify_package_with_trust(
490    pkg_dir: &Path,
491    trust: &crate::trust::TrustRootStore,
492) -> Result<Vec<VerifyCheck>, PackageError> {
493    let mut checks = Vec::new();
494
495    // 1. receipt.json exists and parses
496    let receipt = match read_package(pkg_dir) {
497        Ok(r) => {
498            checks.push(VerifyCheck::pass(
499                "receipt.json",
500                "Parses as valid Session Receipt",
501            ));
502            r
503        }
504        Err(e) => {
505            checks.push(VerifyCheck::fail(
506                "receipt.json",
507                &format!("Failed to parse: {e}"),
508            ));
509            return Ok(checks);
510        }
511    };
512
513    // 2. Type field
514    if receipt.type_ == RECEIPT_TYPE {
515        checks.push(VerifyCheck::pass("type", "Correct receipt type"));
516    } else {
517        checks.push(VerifyCheck::fail(
518            "type",
519            &format!("Expected {RECEIPT_TYPE}, got {}", receipt.type_),
520        ));
521    }
522
523    // 3. Determinism: re-serialize and check digest matches.
524    //
525    // IMPORTANT SCOPE NOTE (do not read this row as integrity): this only
526    // confirms the receipt struct round-trips to the same bytes. It is NOT a
527    // signature check. The Merkle root below covers ONLY the artifact IDs;
528    // the receipt's timeline, side_effects, tool_usage, and narrative are
529    // composed from the (unsigned) event log and are NOT cryptographically
530    // bound by anything in this package. An attacker who edits those fields
531    // and re-serializes will pass determinism and pass the Merkle check.
532    // The authenticated anchor over the whole receipt is the actor-signed
533    // `session.v1` record (which binds receipt_digest) in the agent's chain;
534    // embedding + requiring it here is tracked as a follow-up. Until then,
535    // `package verify` authenticates the ARTIFACTS, not the narrative, and
536    // says so via the explicit scope check below.
537    let receipt_path = pkg_dir.join(RECEIPT_FILE);
538    let on_disk = std::fs::read(&receipt_path)?;
539    let re_serialized = serde_json::to_vec_pretty(&receipt)?;
540    if on_disk == re_serialized {
541        checks.push(VerifyCheck::pass(
542            "determinism",
543            "receipt.json round-trips identically (structural, NOT a signature)",
544        ));
545    } else {
546        // Not a hard failure -- pretty-print whitespace may differ
547        checks.push(VerifyCheck::warn(
548            "determinism",
549            "receipt.json does not byte-match after re-serialization",
550        ));
551    }
552
553    // 3b. Honest scope of what this package authenticates. The receipt body
554    // (timeline / side_effects / tool_usage / narrative) is derived from the
555    // unsigned event log and carries no signature in the package, so a reader
556    // must not mistake a green package for an authenticated ledger of what
557    // the agent did. Only the artifacts + Merkle root are cryptographically
558    // bound.
559    checks.push(VerifyCheck::warn(
560        "receipt_body_binding",
561        "timeline/side-effects/narrative are NOT signed in this package — only the artifacts and Merkle root are cryptographically bound. For an authenticated record of the session, verify the actor-signed session.v1 record (or the published report).",
562    ));
563
564    // 4. Merkle root re-computation
565    if !receipt.artifacts.is_empty() {
566        // Recompute under the receipt's declared merkle version so
567        // legacy (v0.10.2 and earlier, version=1, no domain separation)
568        // receipts continue to verify. New receipts always emit v2.
569        // Construct through the validating `with_version` so an unknown
570        // version surfaces as a hard fail rather than silently falling
571        // back to v1.
572        let version = receipt.merkle.merkle_version;
573        let mut tree = match crate::merkle::MerkleTree::with_version(version) {
574            Ok(t) => t,
575            Err(e) => {
576                checks.push(VerifyCheck::fail(
577                    "merkle_root",
578                    &format!("receipt declared unknown merkle_version: {e}"),
579                ));
580                // Skip the remaining merkle/inclusion work; emit the
581                // leaf_count + timeline tail and return.
582                return Ok(finish_package_checks(checks, &receipt));
583            }
584        };
585        for art in &receipt.artifacts {
586            tree.append(&art.artifact_id);
587        }
588        let root_bytes = tree.root();
589        let recomputed_root = root_bytes.map(|r| format!("mroot_{}", hex::encode(r)));
590        let root_hex = root_bytes.map(hex::encode).unwrap_or_default();
591
592        if recomputed_root == receipt.merkle.root {
593            checks.push(VerifyCheck::pass(
594                "merkle_root",
595                "Merkle root matches recomputed value",
596            ));
597        } else {
598            checks.push(VerifyCheck::fail(
599                "merkle_root",
600                &format!(
601                    "Mismatch: on-disk {:?} vs recomputed {:?}",
602                    receipt.merkle.root, recomputed_root
603                ),
604            ));
605        }
606
607        // 5. Verify each inclusion proof. Per-proof merkle_version must
608        // match the receipt section's declared version — drift is a
609        // hard fail (smuggled v1 proof inside a v2 receipt would
610        // otherwise dispatch through the weaker hashing path).
611        for proof_entry in &receipt.merkle.inclusion_proofs {
612            if proof_entry.proof.merkle_version != version {
613                checks.push(VerifyCheck::fail(
614                    &format!("inclusion:{}", proof_entry.artifact_id),
615                    &format!(
616                        "proof merkle_version {} != receipt section v{}",
617                        proof_entry.proof.merkle_version, version,
618                    ),
619                ));
620                continue;
621            }
622            let verified = crate::merkle::MerkleTree::verify_proof(
623                version,
624                &root_hex,
625                &proof_entry.artifact_id,
626                &proof_entry.proof,
627            );
628            if verified {
629                checks.push(VerifyCheck::pass(
630                    &format!("inclusion:{}", proof_entry.artifact_id),
631                    "Inclusion proof valid",
632                ));
633            } else {
634                checks.push(VerifyCheck::fail(
635                    &format!("inclusion:{}", proof_entry.artifact_id),
636                    "Inclusion proof failed verification",
637                ));
638            }
639        }
640    } else {
641        checks.push(VerifyCheck::warn("merkle_root", "No artifacts to verify"));
642    }
643
644    // 6. Leaf count matches artifacts
645    if receipt.merkle.leaf_count == receipt.artifacts.len() {
646        checks.push(VerifyCheck::pass(
647            "leaf_count",
648            "Leaf count matches artifact count",
649        ));
650    } else {
651        checks.push(VerifyCheck::fail(
652            "leaf_count",
653            &format!(
654                "leaf_count {} != artifact count {}",
655                receipt.merkle.leaf_count,
656                receipt.artifacts.len()
657            ),
658        ));
659    }
660
661    // 7. Timeline ordering (determinism rule: timestamp, sequence_no, event_id)
662    let ordered = receipt.timeline.windows(2).all(|w| {
663        (&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
664            <= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
665    });
666    if ordered {
667        checks.push(VerifyCheck::pass(
668            "timeline_order",
669            "Timeline is correctly ordered",
670        ));
671    } else {
672        checks.push(VerifyCheck::fail(
673            "timeline_order",
674            "Timeline entries are not in deterministic order",
675        ));
676    }
677
678    // event_log completeness: when session::close skipped malformed
679    // event log lines, the count is recorded on receipt.proofs.event_log_skipped.
680    // Surface as WARN (not FAIL) because the receipt is still
681    // cryptographically valid -- we just want a downstream verifier to
682    // know that some evidence was dropped before the receipt was sealed.
683    // A future --strict flag can promote this to FAIL.
684    // Codex adversarial review finding #8.
685    if receipt.proofs.event_log_skipped > 0 {
686        checks.push(VerifyCheck::warn(
687            "event_log_completeness",
688            &format!(
689                "{} event(s) skipped during close (malformed lines in events.jsonl). \
690                 Receipt is cryptographically valid but does not represent the full event stream. \
691                 Inspect close-time stderr or the events.jsonl directly to investigate.",
692                receipt.proofs.event_log_skipped,
693            ),
694        ));
695    }
696
697    if receipt.proofs.reconcile_untracked_truncated > 0 {
698        checks.push(VerifyCheck::warn(
699            "reconcile_completeness",
700            &format!(
701                "untracked git reconcile exceeded cap {} (saw at least {}). \
702                 Per-file synthetic events were skipped and the receipt is bounded, not complete for untracked files.",
703                receipt.proofs.reconcile_untracked_cap,
704                receipt.proofs.reconcile_untracked_truncated,
705            ),
706        ));
707    }
708
709    // AUD-07: the git-diff backstop was disabled between session start and
710    // close (git worked at start — a HEAD was captured — but not at close).
711    // A file changed via a non-AgentWroteFile channel could be missing from
712    // the "Files changed" ledger with no other signal, so this must not read
713    // as a clean, complete audit trail.
714    if receipt.proofs.reconcile_degraded {
715        checks.push(VerifyCheck::warn(
716            "reconcile_degraded",
717            "the git reconcile backstop was UNAVAILABLE at session close although git worked at start \
718             (.git removed, corrupt index, or git not on PATH). Files changed outside a captured \
719             AgentWroteFile event may be MISSING from this receipt's file ledger — treat the \
720             \"Files changed\" list as incomplete.",
721        ));
722    }
723
724    // 8. Approval evidence -- v0.9.9 PR 4. Three independent replay
725    // checks, each emitted as its own VerifyCheck row so the printer
726    // (and downstream tooling) can render them separately.
727    //
728    //   replay-package-local      duplicate uses INSIDE this package
729    //   replay-included-checkpoint  embedded JournalCheckpoints verify standalone
730    //
731    // The local-journal level requires access to the workspace journal,
732    // which the package alone doesn't carry; that check runs in the CLI
733    // verify_package wrapper that has Ctx access. The hub-org level is
734    // reserved for PR 6 -- not claimed without a real Hub checkpoint.
735    let bundle = read_approvals_bundle(pkg_dir).unwrap_or_default();
736    add_approval_evidence_checks(&mut checks, &bundle, trust);
737
738    Ok(checks)
739}
740
741/// Tail of `verify_package`: emit leaf_count and timeline-order checks.
742/// Used by the early-return path when an unknown merkle version aborts
743/// Merkle recomputation — those two checks are independent of the tree
744/// version and still meaningful to surface.
745fn finish_package_checks(
746    mut checks: Vec<VerifyCheck>,
747    receipt: &SessionReceipt,
748) -> Vec<VerifyCheck> {
749    if receipt.merkle.leaf_count == receipt.artifacts.len() {
750        checks.push(VerifyCheck::pass(
751            "leaf_count",
752            "Leaf count matches artifact count",
753        ));
754    } else {
755        checks.push(VerifyCheck::fail(
756            "leaf_count",
757            &format!(
758                "leaf_count {} != artifact count {}",
759                receipt.merkle.leaf_count,
760                receipt.artifacts.len(),
761            ),
762        ));
763    }
764
765    let ordered = receipt.timeline.windows(2).all(|w| {
766        (&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
767            <= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
768    });
769    if ordered {
770        checks.push(VerifyCheck::pass(
771            "timeline_order",
772            "Timeline is correctly ordered",
773        ));
774    } else {
775        checks.push(VerifyCheck::fail(
776            "timeline_order",
777            "Timeline entries are not in deterministic order",
778        ));
779    }
780
781    checks
782}
783
784/// Emit the package-local + included-checkpoint replay checks. Both are
785/// fully offline: package-local scans the embedded uses for duplicates;
786/// included-checkpoint walks the embedded checkpoint records and
787/// re-derives each `record_digest` against its stored value.
788///
789/// The local-journal check is NOT here -- it requires workspace access
790/// and is added by the CLI wrapper in `commands/package.rs` that has the
791/// resolved config_path. Keeping these two pure means an offline tool
792/// (Hub-side validator, third-party verifier) can run the same checks
793/// without needing a Treeship workspace.
794pub(crate) fn add_approval_evidence_checks(
795    checks: &mut Vec<VerifyCheck>,
796    bundle: &ApprovalsBundle,
797    trust: &crate::trust::TrustRootStore,
798) {
799    if bundle.uses.is_empty() && bundle.checkpoints.is_empty() {
800        // Nothing to assert. Stay quiet rather than emit a "skipped"
801        // row -- session packages without approvals shouldn't drag in
802        // approval rows by accident.
803        return;
804    }
805
806    // -- replay-package-local --
807    // Two distinct violation cases inside the package:
808    //   (a) uses sharing (grant_id, nonce_digest) EXCEED max_uses on
809    //       that grant. Two uses of a max_uses=2 grant is fine; three
810    //       is the violation. max_uses is read from the use record's
811    //       own `max_uses` field (a snapshot from consume time).
812    //   (b) two ApprovalUse records with the same use_id -- a copy
813    //       artifact from a corrupt build, never legitimate.
814    use std::collections::HashMap;
815    let mut by_nonce: HashMap<(String, String), Vec<&ApprovalUse>> = HashMap::new();
816    let mut by_use_id: HashMap<&str, Vec<&ApprovalUse>> = HashMap::new();
817    for u in &bundle.uses {
818        by_nonce
819            .entry((u.grant_id.clone(), u.nonce_digest.clone()))
820            .or_default()
821            .push(u);
822        by_use_id.entry(&u.use_id).or_default().push(u);
823    }
824    let over_max: Vec<((String, String), Vec<&ApprovalUse>, u32)> = by_nonce
825        .iter()
826        .filter_map(|(key, uses)| {
827            let max = uses.iter().filter_map(|u| u.max_uses).next()?;
828            if (uses.len() as u32) > max {
829                Some((key.clone(), uses.to_vec(), max))
830            } else {
831                None
832            }
833        })
834        .collect();
835    let dup_use_ids: Vec<(&&str, &Vec<&ApprovalUse>)> =
836        by_use_id.iter().filter(|(_, v)| v.len() > 1).collect();
837
838    if over_max.is_empty() && dup_use_ids.is_empty() {
839        checks.push(VerifyCheck::pass(
840            "replay-package-local",
841            &format!(
842                "no duplicate approval use inside package ({} uses scanned)",
843                bundle.uses.len()
844            ),
845        ));
846    } else {
847        let mut detail = String::from("package-local replay violation:");
848        for ((grant_id, _nd), uses, max) in &over_max {
849            detail.push_str(&format!(
850                " grant {grant_id} consumed {} times in this package (max_uses={max});",
851                uses.len(),
852            ));
853        }
854        for (uid, uses) in &dup_use_ids {
855            detail.push_str(&format!(" use_id {uid} appears {} times;", uses.len()));
856        }
857        checks.push(VerifyCheck::fail("replay-package-local", &detail));
858    }
859
860    // -- replay-included-checkpoint --
861    // For each checkpoint, recompute its record_digest from canonical
862    // form. If the stored digest doesn't match, the checkpoint was
863    // tampered after sealing.
864    if !bundle.checkpoints.is_empty() {
865        let mut tampered = Vec::new();
866        for cp in &bundle.checkpoints {
867            let recomputed = journal_checkpoint_record_digest(cp);
868            if recomputed != cp.record_digest {
869                tampered.push((
870                    cp.checkpoint_id.clone(),
871                    cp.record_digest.clone(),
872                    recomputed,
873                ));
874            }
875        }
876        if tampered.is_empty() {
877            checks.push(VerifyCheck::pass(
878                "replay-included-checkpoint",
879                &format!(
880                    "{} included journal checkpoint(s) verify offline",
881                    bundle.checkpoints.len()
882                ),
883            ));
884        } else {
885            let detail = tampered
886                .iter()
887                .map(|(id, expected, actual)| {
888                    format!("checkpoint {id} tampered (stored {expected}, recomputed {actual})")
889                })
890                .collect::<Vec<_>>()
891                .join("; ");
892            checks.push(VerifyCheck::fail("replay-included-checkpoint", &detail));
893        }
894    }
895
896    // -- approval-use-record-digest --
897    // Each ApprovalUse carries its own record_digest computed over the
898    // canonical form of the record (minus the digest itself). Tampering
899    // any field changes the digest. v0.9.10 PR A renames this from the
900    // older `approval-use-integrity` because the prior label suggested
901    // it covered nonce/action binding -- it didn't, and Codex's v0.9.9
902    // adversarial review flagged the over-claim. The honest scope of
903    // this row is "each use's stored digest matches its canonical
904    // recompute"; the binding checks are now separate rows below.
905    let mut tampered_uses = Vec::new();
906    for u in &bundle.uses {
907        let recomputed = approval_use_record_digest(u);
908        if recomputed != u.record_digest {
909            tampered_uses.push((u.use_id.clone(), u.record_digest.clone(), recomputed));
910        }
911    }
912    if !bundle.uses.is_empty() {
913        if tampered_uses.is_empty() {
914            checks.push(VerifyCheck::pass(
915                "approval-use-record-digest",
916                &format!("{} use record(s) recompute identically", bundle.uses.len()),
917            ));
918        } else {
919            let detail = tampered_uses
920                .iter()
921                .map(|(id, expected, actual)| {
922                    format!("use {id} tampered (stored {expected}, recomputed {actual})")
923                })
924                .collect::<Vec<_>>()
925                .join("; ");
926            checks.push(VerifyCheck::fail("approval-use-record-digest", &detail));
927        }
928    }
929
930    // -- approval-use-nonce-binding --
931    // Cross-check each use's `nonce_digest` against the corresponding
932    // grant's *signed* nonce. v0.9.9 trusted the use's nonce_digest
933    // verbatim, which let an attacker who controls the package mutate
934    // it (and recompute record_digest) to claim consumption of a grant
935    // whose nonce was never actually used. This row closes that gap.
936    //
937    // Discipline: the grant envelope is the source of truth. Before
938    // pulling the raw `nonce` from the grant's payload we verify the
939    // envelope's *content addressing* -- recompute the artifact_id
940    // from the envelope's PAE bytes and confirm it equals the grant_id
941    // the package claims. v0.9.10 PR A round 1 only parsed the
942    // envelope without this check; that left a forgery window where
943    // an attacker could ship an arbitrary unsigned envelope under any
944    // grant_id filename. v0.9.10 PR A round 2 closes the window: only
945    // a bytes-identical envelope produces the same artifact_id under
946    // SHA-256.
947    if !bundle.uses.is_empty() {
948        use crate::attestation::envelope::Envelope;
949        use crate::attestation::{artifact_id_from_pae, pae};
950        use crate::statements::{nonce_digest, ApprovalStatement};
951        let mut grant_nonce_digest: std::collections::HashMap<String, String> =
952            std::collections::HashMap::new();
953        let mut tampered_grants: Vec<String> = Vec::new();
954        for (grant_id, env_bytes) in &bundle.grants {
955            let env = match Envelope::from_json(env_bytes) {
956                Ok(e) => e,
957                Err(_) => {
958                    tampered_grants.push(format!("grant {grant_id} envelope unparseable"));
959                    continue;
960                }
961            };
962            // Content-addressing check: derive the artifact_id from
963            // the envelope's PAE bytes and confirm it matches the
964            // claimed grant_id. If they differ the envelope was
965            // substituted or its bytes were tampered post-sign.
966            let derived = match env.payload_bytes() {
967                Ok(p) => artifact_id_from_pae(&pae(&env.payload_type, &p)),
968                Err(_) => {
969                    tampered_grants.push(format!("grant {grant_id} envelope payload undecodable"));
970                    continue;
971                }
972            };
973            if &derived != grant_id {
974                tampered_grants.push(format!(
975                    "grant {grant_id} envelope content derives to {derived} -- envelope substituted or tampered",
976                ));
977                continue;
978            }
979            let approval: ApprovalStatement = match env.unmarshal_statement() {
980                Ok(a) => a,
981                Err(_) => {
982                    tampered_grants
983                        .push(format!("grant {grant_id} payload not an ApprovalStatement"));
984                    continue;
985                }
986            };
987            grant_nonce_digest.insert(grant_id.clone(), nonce_digest(&approval.nonce));
988        }
989        let mut mismatches: Vec<String> = Vec::new();
990        let mut missing_grants: Vec<String> = Vec::new();
991        for u in &bundle.uses {
992            match grant_nonce_digest.get(&u.grant_id) {
993                Some(expected) => {
994                    if expected != &u.nonce_digest {
995                        mismatches.push(format!(
996                            "use {} claims nonce_digest {} but grant {} signed nonce hashes to {}",
997                            u.use_id, u.nonce_digest, u.grant_id, expected,
998                        ));
999                    }
1000                }
1001                None => {
1002                    missing_grants.push(format!(
1003                        "use {} references grant {} but no usable grant envelope is in the package",
1004                        u.use_id, u.grant_id,
1005                    ));
1006                }
1007            }
1008        }
1009        if mismatches.is_empty() && missing_grants.is_empty() && tampered_grants.is_empty() {
1010            checks.push(VerifyCheck::pass(
1011                "approval-use-nonce-binding",
1012                &format!(
1013                    "{} use record(s) bind to content-addressed grant signed nonces",
1014                    bundle.uses.len(),
1015                ),
1016            ));
1017        } else {
1018            let mut parts: Vec<String> = Vec::new();
1019            if !tampered_grants.is_empty() {
1020                parts.push(tampered_grants.join("; "));
1021            }
1022            if !mismatches.is_empty() {
1023                parts.push(mismatches.join("; "));
1024            }
1025            if !missing_grants.is_empty() {
1026                parts.push(missing_grants.join("; "));
1027            }
1028            checks.push(VerifyCheck::fail(
1029                "approval-use-nonce-binding",
1030                &parts.join("; "),
1031            ));
1032        }
1033    }
1034
1035    // -- approval-use-action-binding --
1036    // Cross-check each consuming action's `meta.approval_use_id`
1037    // against the package's use records. v0.9.9 ignored this pointer
1038    // entirely; the package didn't even ship action envelopes, so the
1039    // verifier could not see the field. v0.9.10 PR A: action envelopes
1040    // ride along in `artifacts/`, and this row pins that every action
1041    // declaring it consumed an approval has a use record for that
1042    // exact use_id, with matching grant_id and matching
1043    // `nonce_digest(approval_nonce)`.
1044    //
1045    // Honesty rule: when bundle.action_envelopes is empty (pre-v0.9.10
1046    // packages, or a v0.9.10 package with no consuming actions
1047    // recorded), this row reports `not asserted by package` rather
1048    // than silent PASS.
1049    if !bundle.uses.is_empty() {
1050        use crate::attestation::envelope::Envelope;
1051        use crate::attestation::{artifact_id_from_pae, pae};
1052        use crate::statements::{nonce_digest, ActionStatement};
1053        if bundle.action_envelopes.is_empty() {
1054            checks.push(VerifyCheck::warn(
1055                "approval-use-action-binding",
1056                "no action envelopes embedded -- action↔use binding not asserted by package (pre-v0.9.10)",
1057            ));
1058        } else {
1059            let use_ids: std::collections::HashSet<&str> =
1060                bundle.uses.iter().map(|u| u.use_id.as_str()).collect();
1061            let mut violations: Vec<String> = Vec::new();
1062            let mut bound_count = 0usize;
1063            for (artifact_id, env_bytes) in &bundle.action_envelopes {
1064                let env = match Envelope::from_json(env_bytes) {
1065                    Ok(e) => e,
1066                    Err(_) => {
1067                        violations.push(format!("action {artifact_id} envelope unparseable"));
1068                        continue;
1069                    }
1070                };
1071                // Content-addressing gate: derive the artifact_id
1072                // from the envelope's PAE bytes and require it to
1073                // match the filename stem the package shipped this
1074                // envelope under. Without this gate an attacker
1075                // controlling the package can write any forged
1076                // unsigned action JSON to artifacts/<id>.json and the
1077                // binding rows would trust it.
1078                let derived = match env.payload_bytes() {
1079                    Ok(p) => artifact_id_from_pae(&pae(&env.payload_type, &p)),
1080                    Err(_) => {
1081                        violations
1082                            .push(format!("action {artifact_id} envelope payload undecodable"));
1083                        continue;
1084                    }
1085                };
1086                if &derived != artifact_id {
1087                    violations.push(format!(
1088                        "action {artifact_id} envelope content derives to {derived} -- envelope substituted or tampered",
1089                    ));
1090                    continue;
1091                }
1092                let action: ActionStatement = match env.unmarshal_statement() {
1093                    Ok(a) => a,
1094                    Err(_) => {
1095                        violations.push(format!("action {artifact_id} not an ActionStatement"));
1096                        continue;
1097                    }
1098                };
1099                let raw_nonce = match action.approval_nonce.as_deref() {
1100                    Some(n) => n,
1101                    None => continue,
1102                };
1103                let claimed_use_id = action
1104                    .meta
1105                    .as_ref()
1106                    .and_then(|m| m.get("approval_use_id"))
1107                    .and_then(|v| v.as_str());
1108                let Some(claimed_use_id) = claimed_use_id else {
1109                    violations.push(format!(
1110                        "action {artifact_id} consumed an approval but its meta has no approval_use_id"
1111                    ));
1112                    continue;
1113                };
1114                if !use_ids.contains(claimed_use_id) {
1115                    violations.push(format!(
1116                        "action {artifact_id} claims approval_use_id={} but no such use is embedded",
1117                        claimed_use_id,
1118                    ));
1119                    continue;
1120                }
1121                let expected = nonce_digest(raw_nonce);
1122                let matched_use = bundle.uses.iter().find(|u| u.use_id == claimed_use_id);
1123                if let Some(u) = matched_use {
1124                    if u.nonce_digest != expected {
1125                        violations.push(format!(
1126                            "action {artifact_id} approval_nonce hashes to {} but use {} stores nonce_digest {}",
1127                            expected, claimed_use_id, u.nonce_digest,
1128                        ));
1129                        continue;
1130                    }
1131                }
1132                bound_count += 1;
1133            }
1134            if violations.is_empty() {
1135                checks.push(VerifyCheck::pass(
1136                    "approval-use-action-binding",
1137                    &format!(
1138                        "{bound_count} consuming action(s) bind cleanly to content-addressed envelope(s)",
1139                    ),
1140                ));
1141            } else {
1142                checks.push(VerifyCheck::fail(
1143                    "approval-use-action-binding",
1144                    &violations.join("; "),
1145                ));
1146            }
1147        }
1148    }
1149
1150    // -- approval-use-chain-continuity --
1151    // v0.9.9 verified each use's individual record_digest but never
1152    // walked the `previous_record_digest` chain across the embedded
1153    // records. An attacker could rewrite an entire chain consistently
1154    // (recomputing each digest along the way) and the per-record
1155    // checks all passed.
1156    //
1157    // Algorithm (v0.9.10 PR A round 2): build a graph of embedded
1158    // records keyed by record_digest, then require the embedded
1159    // records to form a SINGLE linked list with exactly one genesis
1160    // (previous_record_digest == "") and no cycles, forks, or
1161    // disconnected subchains.
1162    //
1163    //   - Dangling prev pointer (not in `owned`) -> fail.
1164    //   - More than one record with prev == ""    -> fail (mid-chain
1165    //     genesis is a forgery primitive).
1166    //   - Two records sharing the same prev       -> fail (fork).
1167    //   - Cycle reached during the walk           -> fail.
1168    //   - Walk doesn't reach every record         -> fail (disconnected
1169    //     subchain).
1170    //
1171    // We can only check *internal* consistency offline -- the package
1172    // doesn't ship the workspace journal's full history, so the chain
1173    // we see may be a contiguous prefix or window. Anchoring against
1174    // a Hub-signed checkpoint is replay-hub-org's job; here we report
1175    // structural consistency only.
1176    if !bundle.uses.is_empty() || !bundle.checkpoints.is_empty() {
1177        use std::collections::{HashMap, HashSet};
1178        // Each record carries a label for diagnostics + its own
1179        // record_digest + previous_record_digest.
1180        struct Node<'a> {
1181            label: String,
1182            digest: &'a str,
1183            prev: &'a str,
1184        }
1185        let mut nodes: Vec<Node> = Vec::new();
1186        for u in &bundle.uses {
1187            nodes.push(Node {
1188                label: format!("use {}", u.use_id),
1189                digest: u.record_digest.as_str(),
1190                prev: u.previous_record_digest.as_str(),
1191            });
1192        }
1193        for cp in &bundle.checkpoints {
1194            nodes.push(Node {
1195                label: format!("checkpoint {}", cp.checkpoint_id),
1196                digest: cp.record_digest.as_str(),
1197                prev: cp.previous_record_digest.as_str(),
1198            });
1199        }
1200
1201        let owned: HashSet<&str> = std::iter::once("")
1202            .chain(nodes.iter().map(|n| n.digest))
1203            .collect();
1204
1205        let mut violations: Vec<String> = Vec::new();
1206        // Dangling prev: pointer not in owned set.
1207        for n in &nodes {
1208            if !owned.contains(n.prev) {
1209                violations.push(format!(
1210                    "{} previous_record_digest {} not anchored in package",
1211                    n.label, n.prev,
1212                ));
1213            }
1214        }
1215        // Genesis count: only one record allowed to have prev == "".
1216        let genesis: Vec<&Node> = nodes.iter().filter(|n| n.prev.is_empty()).collect();
1217        if genesis.len() > 1 {
1218            violations.push(format!(
1219                "{} records claim previous_record_digest='' (genesis): {}",
1220                genesis.len(),
1221                genesis
1222                    .iter()
1223                    .map(|n| n.label.clone())
1224                    .collect::<Vec<_>>()
1225                    .join(", "),
1226            ));
1227        }
1228        // Forks: two records sharing the same non-empty prev.
1229        let mut by_prev: HashMap<&str, Vec<&Node>> = HashMap::new();
1230        for n in &nodes {
1231            by_prev.entry(n.prev).or_default().push(n);
1232        }
1233        for (prev, group) in &by_prev {
1234            if group.len() > 1 && !prev.is_empty() {
1235                violations.push(format!(
1236                    "fork: {} records share previous_record_digest {}: {}",
1237                    group.len(),
1238                    prev,
1239                    group
1240                        .iter()
1241                        .map(|n| n.label.clone())
1242                        .collect::<Vec<_>>()
1243                        .join(", "),
1244                ));
1245            }
1246        }
1247
1248        // Walk from genesis (if exactly one) following digest-as-prev
1249        // links. Detect cycles and unreachable records.
1250        if violations.is_empty() {
1251            let by_digest: HashMap<&str, &Node> = nodes.iter().map(|n| (n.digest, n)).collect();
1252            let next_of: HashMap<&str, &Node> = nodes
1253                .iter()
1254                .filter(|n| !n.prev.is_empty())
1255                .map(|n| (n.prev, n))
1256                .collect();
1257            let start = genesis.first().copied();
1258            let mut visited: HashSet<&str> = HashSet::new();
1259            let mut current = start;
1260            while let Some(node) = current {
1261                if !visited.insert(node.digest) {
1262                    violations.push(format!(
1263                        "cycle detected at {} (record_digest {})",
1264                        node.label, node.digest,
1265                    ));
1266                    break;
1267                }
1268                current = next_of.get(node.digest).copied();
1269            }
1270            // Disconnected: walk didn't include every node.
1271            if violations.is_empty() && visited.len() != nodes.len() {
1272                let unreached: Vec<String> = nodes
1273                    .iter()
1274                    .filter(|n| !visited.contains(n.digest))
1275                    .map(|n| n.label.clone())
1276                    .collect();
1277                if !unreached.is_empty() {
1278                    violations.push(format!(
1279                        "disconnected subchain: {} record(s) not reachable from genesis: {}",
1280                        unreached.len(),
1281                        unreached.join(", "),
1282                    ));
1283                }
1284            }
1285            let _ = by_digest; // reserved for future cross-checks
1286        }
1287
1288        if violations.is_empty() {
1289            checks.push(VerifyCheck::pass(
1290                "approval-use-chain-continuity",
1291                &format!(
1292                    "{} record(s) form a single connected linked list from one genesis with no cycles or forks",
1293                    nodes.len(),
1294                ),
1295            ));
1296        } else {
1297            checks.push(VerifyCheck::fail(
1298                "approval-use-chain-continuity",
1299                &violations.join("; "),
1300            ));
1301        }
1302    }
1303
1304    // -- replay-hub-org -- v0.9.9 PR 6.
1305    // The strongest level Treeship can speak to today. The release
1306    // rule is non-negotiable: PASS only when (1) at least one embedded
1307    // checkpoint declares kind=HubOrg, (2) every required Hub field is
1308    // populated, (3) the signature verifies against the embedded
1309    // public key, AND (4) the checkpoint covers every embedded
1310    // ApprovalUse via covered_use_ids. Anything short of that means
1311    // "no row" or "fail" -- never silent pass.
1312    //
1313    // No row at all when the package has no Hub-kind checkpoint:
1314    // matches the v0.9.9 PR 4-5 behavior where the panel renders
1315    // "- hub-org   not checked (no Hub checkpoint in package)" so a
1316    // reader doesn't misread an absent row as a failure.
1317    let hub_checkpoints: Vec<&JournalCheckpoint> = bundle
1318        .checkpoints
1319        .iter()
1320        .filter(|cp| cp.checkpoint_kind == crate::statements::CheckpointKind::HubOrg)
1321        .collect();
1322    if !hub_checkpoints.is_empty() {
1323        let mut all_ok = true;
1324        let mut details: Vec<String> = Vec::new();
1325        let mut have_valid_signature = false;
1326        // Security-critical failures (untrusted-issuer / tampered /
1327        // not-hub-kind) must FAIL unconditionally, not warn. Audit
1328        // lane J fix-up: previously these emitted WARN and the CLI
1329        // wrapper's --strict promoted to FAIL, which meant the
1330        // headline audit case (self-signed hub-org forgery) passed
1331        // green-but-yellow in default mode. The release rule is
1332        // "trust pinning is on by default"; expressed in this row
1333        // as "any signature/issuer failure is a hard fail."
1334        let mut security_fatal = false;
1335
1336        for cp in &hub_checkpoints {
1337            match crate::statements::verify_hub_checkpoint_signature(cp, trust) {
1338                crate::statements::HubCheckpointVerification::Valid => {
1339                    have_valid_signature = true;
1340                    // Coverage: every embedded use_id MUST appear in
1341                    // this checkpoint's covered_use_ids. A checkpoint
1342                    // that doesn't cover the package's uses cannot
1343                    // promote replay-hub-org for those uses.
1344                    let covered: std::collections::HashSet<&String> =
1345                        cp.covered_use_ids.iter().collect();
1346                    let missing: Vec<String> = bundle
1347                        .uses
1348                        .iter()
1349                        .filter(|u| !covered.contains(&u.use_id))
1350                        .map(|u| u.use_id.clone())
1351                        .collect();
1352                    if missing.is_empty() {
1353                        details.push(format!(
1354                            "{} signed by {} verifies; covers {} use(s)",
1355                            cp.checkpoint_id,
1356                            cp.hub_id,
1357                            cp.covered_use_ids.len(),
1358                        ));
1359                    } else {
1360                        all_ok = false;
1361                        details.push(format!(
1362                            "{} verifies but does not cover {} use(s): {}",
1363                            cp.checkpoint_id,
1364                            missing.len(),
1365                            missing.join(", "),
1366                        ));
1367                    }
1368                }
1369                crate::statements::HubCheckpointVerification::MissingFields(field) => {
1370                    all_ok = false;
1371                    details.push(format!(
1372                        "{} declares kind=hub-org but field `{}` is missing",
1373                        cp.checkpoint_id, field,
1374                    ));
1375                }
1376                crate::statements::HubCheckpointVerification::Tampered => {
1377                    all_ok = false;
1378                    security_fatal = true;
1379                    details.push(format!(
1380                        "{} hub signature failed verification (tampered or wrong key)",
1381                        cp.checkpoint_id,
1382                    ));
1383                }
1384                crate::statements::HubCheckpointVerification::NotHubKind => {
1385                    // Filter ensures this is unreachable; keep the
1386                    // arm so a future filter relaxation doesn't go
1387                    // silent.
1388                    all_ok = false;
1389                    security_fatal = true;
1390                    details.push(format!(
1391                        "{} kind toggled out of hub-org during verify",
1392                        cp.checkpoint_id,
1393                    ));
1394                }
1395                crate::statements::HubCheckpointVerification::UntrustedIssuer => {
1396                    all_ok = false;
1397                    security_fatal = true;
1398                    details.push(format!(
1399                        "{} hub_public_key is not a trusted root (configure via `treeship trust add`)",
1400                        cp.checkpoint_id,
1401                    ));
1402                }
1403            }
1404        }
1405        if all_ok && have_valid_signature {
1406            checks.push(VerifyCheck::pass("replay-hub-org", &details.join("; ")));
1407        } else if security_fatal {
1408            // Untrusted issuer or tampered signature: fail-by-default
1409            // regardless of --strict. Self-signed forgeries must not
1410            // pass yellow.
1411            checks.push(VerifyCheck::fail("replay-hub-org", &details.join("; ")));
1412        } else {
1413            // Hub checkpoint is present but does not satisfy every
1414            // non-security gate (missing-field, coverage gap).
1415            // Default mode warns; the CLI verify wrapper's --strict
1416            // promotes to fail.
1417            checks.push(VerifyCheck::warn("replay-hub-org", &details.join("; ")));
1418        }
1419    }
1420    // No hub-org checkpoints embedded -> no row. The Approval
1421    // Authority panel still renders "- hub-org   not checked".
1422
1423    let _ = ReplayCheckLevel::HubOrg;
1424    let _ = approval_revocation_record_digest as fn(&ApprovalRevocation) -> String;
1425    let _ = ReplayCheck::not_performed;
1426}
1427
1428/// A single verification check result.
1429#[derive(Debug, Clone)]
1430pub struct VerifyCheck {
1431    pub name: String,
1432    pub status: VerifyStatus,
1433    pub detail: String,
1434}
1435
1436/// Status of a verification check.
1437#[derive(Debug, Clone, PartialEq, Eq)]
1438pub enum VerifyStatus {
1439    Pass,
1440    Fail,
1441    Warn,
1442}
1443
1444impl VerifyCheck {
1445    pub fn pass(name: &str, detail: &str) -> Self {
1446        Self {
1447            name: name.into(),
1448            status: VerifyStatus::Pass,
1449            detail: detail.into(),
1450        }
1451    }
1452    pub fn fail(name: &str, detail: &str) -> Self {
1453        Self {
1454            name: name.into(),
1455            status: VerifyStatus::Fail,
1456            detail: detail.into(),
1457        }
1458    }
1459    pub fn warn(name: &str, detail: &str) -> Self {
1460        Self {
1461            name: name.into(),
1462            status: VerifyStatus::Warn,
1463            detail: detail.into(),
1464        }
1465    }
1466}
1467
1468impl VerifyCheck {
1469    pub fn passed(&self) -> bool {
1470        self.status == VerifyStatus::Pass
1471    }
1472}
1473
1474/// HTML template for the self-contained verifier preview.
1475/// Loaded at compile time so the binary carries no runtime file dependencies.
1476const PREVIEW_TEMPLATE: &str = include_str!("preview_template.html");
1477
1478/// Brand display serif (Fraunces, SIL OFL 1.1) — latin variable slice, weights
1479/// 300..500. Embedded as base64 into the self-contained preview so the document
1480/// renders with the brand type offline, no CDN. Body and mono use the system
1481/// stack. See design/fonts/.
1482// Vendored inside the crate, not referenced out of the workspace. `cargo
1483// package` only tarballs files under the crate root, so an `include_bytes!`
1484// reaching up to `design/fonts/` builds fine here and fails to compile once
1485// published -- which is exactly how treeship-core missed crates.io in v0.22.0
1486// while npm and PyPI shipped. Kept in sync with `design/fonts/` by
1487// `scripts/check-vendored-fonts.py`.
1488const FRAUNCES_WOFF2: &[u8] = include_bytes!("../../assets/fonts/fraunces-latin-var.woff2");
1489
1490/// The `data:` URI for the embedded Fraunces woff2, substituted into the
1491/// template's `@font-face`. Standard (not URL-safe) base64: it sits in a CSS
1492/// `url(...)`, not a URL path.
1493fn fraunces_data_uri() -> String {
1494    use base64::engine::general_purpose::STANDARD;
1495    use base64::Engine;
1496    format!("data:font/woff2;base64,{}", STANDARD.encode(FRAUNCES_WOFF2))
1497}
1498
1499/// Generate a self-contained preview.html that embeds the receipt JSON
1500/// and runs Merkle verification client-side using Web Crypto API.
1501///
1502/// The HTML works fully air-gapped: no network calls, no CDN, no server.
1503/// Open it in any modern browser and it automatically verifies the receipt
1504/// and shows pass/fail for each check.
1505pub fn render_preview_html(receipt: &SessionReceipt) -> String {
1506    render_preview_html_with_approvals(receipt, None)
1507}
1508
1509/// What the preview shows under "Approval gates": every grant the package
1510/// embeds under `approvals/grants` and every use under `approvals/uses`.
1511///
1512/// The preview used to read approvals only from the chained artifacts, so a
1513/// session whose approvals were consumed (and therefore exported into the
1514/// `approvals/` directory, where the verifier checks them) rendered "No
1515/// approval gates recorded" while `package verify` printed `PASS
1516/// replay-local-journal`. This is the same evidence the verifier reads,
1517/// summarised for a reader. A grant envelope that does not parse is listed
1518/// by id with `parsed: false` rather than dropped: the reader should see
1519/// that evidence exists even when this page cannot describe it.
1520pub fn preview_approvals_json(bundle: Option<&ApprovalsBundle>) -> serde_json::Value {
1521    let Some(b) = bundle else {
1522        return serde_json::Value::Null;
1523    };
1524    if b.grants.is_empty() && b.uses.is_empty() {
1525        return serde_json::Value::Null;
1526    }
1527    let grants: Vec<serde_json::Value> = b
1528        .grants
1529        .iter()
1530        .map(|(grant_id, bytes)| {
1531            let parsed = crate::attestation::Envelope::from_json(bytes)
1532                .ok()
1533                .and_then(|env| env.unmarshal_statement::<ApprovalStatement>().ok());
1534            match parsed {
1535                Some(st) => serde_json::json!({
1536                    "grant_id": grant_id,
1537                    "parsed": true,
1538                    "approver": st.approver,
1539                    "description": st.description,
1540                    "timestamp": st.timestamp,
1541                    "expires_at": st.expires_at,
1542                    "scope": st.scope.as_ref().map(|sc| serde_json::json!({
1543                        "allowed_actors": sc.allowed_actors,
1544                        "allowed_actions": sc.allowed_actions,
1545                        "allowed_subjects": sc.allowed_subjects,
1546                        "max_uses": sc.max_actions,
1547                        "valid_until": sc.valid_until,
1548                    })),
1549                }),
1550                None => serde_json::json!({ "grant_id": grant_id, "parsed": false }),
1551            }
1552        })
1553        .collect();
1554    let uses: Vec<serde_json::Value> = b
1555        .uses
1556        .iter()
1557        .map(|u| serde_json::to_value(u).unwrap_or(serde_json::Value::Null))
1558        .collect();
1559    serde_json::json!({ "grants": grants, "uses": uses })
1560}
1561
1562/// `render_preview_html`, plus the approval evidence the package embeds.
1563pub fn render_preview_html_with_approvals(
1564    receipt: &SessionReceipt,
1565    bundle: Option<&ApprovalsBundle>,
1566) -> String {
1567    let approvals_json = preview_approvals_json(bundle).to_string();
1568    let safe_approvals = approvals_json.replace('<', r"\u003c");
1569    let receipt_json = serde_json::to_string_pretty(receipt).unwrap_or_else(|_| "{}".to_string());
1570    // Defense-in-depth: escape </script sequences so a malicious receipt
1571    // field cannot break out of the JSON data block. The primary defense
1572    // is type="application/json" which the HTML parser does not execute,
1573    // but this escaping adds a second layer.
1574    // Escape ALL '<' as '\u003c' in the JSON string to prevent any
1575    // case-variant of </script> from breaking out of the data block.
1576    // This is bulletproof: no HTML parser can see a tag open inside the JSON.
1577    let safe_json = receipt_json.replace('<', r"\u003c");
1578
1579    // The only placeholder that must take the receipt JSON is the data
1580    // block. replacen(.., 1) substitutes exactly that first occurrence, so
1581    // even if the token is ever reused elsewhere in the template (e.g. a JS
1582    // placeholder check) the receipt body is never injected into it. The
1583    // template's own placeholder check uses a split sentinel for the same
1584    // reason. The page title is set at runtime from the parsed JSON.
1585    PREVIEW_TEMPLATE
1586        .replacen("__RECEIPT_JSON__", &safe_json, 1)
1587        .replacen("__APPROVALS_JSON__", &safe_approvals, 1)
1588        .replace("__FONT_FRAUNCES__", &fraunces_data_uri())
1589}
1590
1591#[cfg(test)]
1592mod tests {
1593    use super::*;
1594    use crate::session::event::*;
1595    use crate::session::manifest::SessionManifest;
1596    use crate::session::receipt::{ArtifactEntry, ReceiptComposer};
1597
1598    fn make_receipt() -> SessionReceipt {
1599        let manifest = SessionManifest::new(
1600            "ssn_pkg_test".into(),
1601            "agent://test".into(),
1602            "2026-04-05T08:00:00Z".into(),
1603            1743843600000,
1604        );
1605
1606        let mk = |seq: u64, inst: &str, et: EventType| -> SessionEvent {
1607            SessionEvent {
1608                session_id: "ssn_pkg_test".into(),
1609                event_id: format!("evt_{:016x}", seq),
1610                timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
1611                sequence_no: seq,
1612                trace_id: "trace_1".into(),
1613                span_id: format!("span_{seq}"),
1614                parent_span_id: None,
1615                agent_id: format!("agent://{inst}"),
1616                agent_instance_id: inst.into(),
1617                agent_name: inst.into(),
1618                agent_role: None,
1619                host_id: "host_1".into(),
1620                tool_runtime_id: None,
1621                event_type: et,
1622                artifact_ref: None,
1623                meta: None,
1624            }
1625        };
1626
1627        let events = vec![
1628            mk(0, "root", EventType::SessionStarted),
1629            mk(
1630                1,
1631                "root",
1632                EventType::AgentStarted {
1633                    parent_agent_instance_id: None,
1634                },
1635            ),
1636            mk(
1637                2,
1638                "root",
1639                EventType::AgentCalledTool {
1640                    tool_name: "read_file".into(),
1641                    tool_input_digest: None,
1642                    tool_output_digest: None,
1643                    duration_ms: Some(10),
1644                },
1645            ),
1646            mk(
1647                3,
1648                "root",
1649                EventType::AgentCompleted {
1650                    termination_reason: None,
1651                },
1652            ),
1653            mk(
1654                4,
1655                "root",
1656                EventType::SessionClosed {
1657                    summary: Some("Done".into()),
1658                    duration_ms: Some(60000),
1659                },
1660            ),
1661        ];
1662
1663        let artifacts = vec![ArtifactEntry {
1664            artifact_id: "art_001".into(),
1665            payload_type: "action".into(),
1666            digest: None,
1667            signed_at: None,
1668        }];
1669
1670        ReceiptComposer::compose(&manifest, &events, artifacts)
1671    }
1672
1673    #[test]
1674    fn build_and_read_package() {
1675        let receipt = make_receipt();
1676        let tmp = std::env::temp_dir().join(format!("treeship-pkg-test-{}", rand::random::<u32>()));
1677
1678        let output = build_package(&receipt, &tmp).unwrap();
1679        assert!(output.path.exists());
1680        assert!(output.path.join("receipt.json").exists());
1681        assert!(output.path.join("merkle.json").exists());
1682        assert!(output.path.join("render.json").exists());
1683        assert!(output.path.join("preview.html").exists());
1684        assert!(output.receipt_digest.starts_with("sha256:"));
1685        assert!(output.file_count >= 4);
1686
1687        // Read back
1688        let read_back = read_package(&output.path).unwrap();
1689        assert_eq!(read_back.session.id, "ssn_pkg_test");
1690        assert_eq!(read_back.type_, RECEIPT_TYPE);
1691
1692        let _ = std::fs::remove_dir_all(&tmp);
1693    }
1694
1695    #[test]
1696    fn verify_valid_package() {
1697        let receipt = make_receipt();
1698        let tmp =
1699            std::env::temp_dir().join(format!("treeship-pkg-verify-{}", rand::random::<u32>()));
1700
1701        let output = build_package(&receipt, &tmp).unwrap();
1702        let checks = verify_package(&output.path).unwrap();
1703
1704        let fails: Vec<_> = checks
1705            .iter()
1706            .filter(|c| c.status == VerifyStatus::Fail)
1707            .collect();
1708        assert!(fails.is_empty(), "unexpected failures: {fails:?}");
1709
1710        let passes: Vec<_> = checks
1711            .iter()
1712            .filter(|c| c.status == VerifyStatus::Pass)
1713            .collect();
1714        assert!(
1715            passes.len() >= 5,
1716            "expected at least 5 pass checks, got {}",
1717            passes.len()
1718        );
1719
1720        let _ = std::fs::remove_dir_all(&tmp);
1721    }
1722
1723    // AUD-07: a receipt stamped reconcile_degraded must surface a WARN on
1724    // verify, so a consumer is told the file ledger may be incomplete rather
1725    // than reading the package as a clean, complete audit trail.
1726    #[test]
1727    fn verify_warns_when_reconcile_degraded() {
1728        let mut receipt = make_receipt();
1729        receipt.proofs.reconcile_degraded = true;
1730        let tmp =
1731            std::env::temp_dir().join(format!("treeship-pkg-degraded-{}", rand::random::<u32>()));
1732
1733        let output = build_package(&receipt, &tmp).unwrap();
1734        let checks = verify_package(&output.path).unwrap();
1735
1736        let warned = checks
1737            .iter()
1738            .any(|c| c.name == "reconcile_degraded" && c.status == VerifyStatus::Warn);
1739        assert!(warned, "expected a reconcile_degraded WARN, got {checks:?}");
1740        // It is a WARN, not a hard fail (the signatures/Merkle are still valid).
1741        let fails: Vec<_> = checks
1742            .iter()
1743            .filter(|c| c.status == VerifyStatus::Fail)
1744            .collect();
1745        assert!(fails.is_empty(), "must not hard-fail: {fails:?}");
1746
1747        let _ = std::fs::remove_dir_all(&tmp);
1748    }
1749
1750    #[test]
1751    fn verify_no_degraded_warn_when_clean() {
1752        // The default receipt has reconcile_degraded=false: no such WARN.
1753        let receipt = make_receipt();
1754        let tmp =
1755            std::env::temp_dir().join(format!("treeship-pkg-clean-{}", rand::random::<u32>()));
1756        let output = build_package(&receipt, &tmp).unwrap();
1757        let checks = verify_package(&output.path).unwrap();
1758        assert!(
1759            !checks.iter().any(|c| c.name == "reconcile_degraded"),
1760            "clean receipt must not emit a reconcile_degraded check"
1761        );
1762        let _ = std::fs::remove_dir_all(&tmp);
1763    }
1764
1765    #[test]
1766    fn verify_detects_missing_receipt() {
1767        let tmp =
1768            std::env::temp_dir().join(format!("treeship-pkg-empty-{}", rand::random::<u32>()));
1769        std::fs::create_dir_all(&tmp).unwrap();
1770
1771        let err = read_package(&tmp);
1772        assert!(err.is_err());
1773
1774        let _ = std::fs::remove_dir_all(&tmp);
1775    }
1776
1777    #[test]
1778    fn preview_html_renders_approval_evidence_from_the_bundle() {
1779        // The package embeds consumed approvals under approvals/ (that is what
1780        // `package verify` checks as replay-local-journal). The preview must
1781        // show them too: a reader saw "No approval gates recorded" on a
1782        // session whose approval was minted, spent once, and verified.
1783        use crate::attestation::sign::sign;
1784        use crate::attestation::Ed25519Signer;
1785        use crate::statements::ApprovalScope;
1786        use crate::statements::TYPE_APPROVAL_USE;
1787
1788        let receipt = make_receipt();
1789        assert_eq!(preview_approvals_json(None), serde_json::Value::Null);
1790        assert_eq!(
1791            preview_approvals_json(Some(&ApprovalsBundle::default())),
1792            serde_json::Value::Null,
1793            "an empty bundle is the same as none"
1794        );
1795
1796        let signer = Ed25519Signer::generate("key_test_preview").unwrap();
1797        let mut grant = ApprovalStatement::new("human://operator", "nonce-preview-0001");
1798        grant.description = Some("apply change chg-0001: 3% clearance".into());
1799        grant.scope = Some(ApprovalScope {
1800            max_actions: Some(1),
1801            valid_until: None,
1802            allowed_actors: vec!["agent://merchant".into()],
1803            allowed_actions: vec!["commerce.tool.apply_change.intent".into()],
1804            allowed_subjects: vec!["change://chg-0001".into()],
1805            extra: None,
1806        });
1807        let signed = sign("application/vnd.treeship.approval.v1+json", &grant, &signer).unwrap();
1808        let grant_id = signed.artifact_id.to_string();
1809        let grant_bytes = serde_json::to_vec(&signed.envelope).unwrap();
1810
1811        let use_record = ApprovalUse {
1812            type_: TYPE_APPROVAL_USE.into(),
1813            use_id: "use_preview_0001".into(),
1814            grant_id: grant_id.clone(),
1815            grant_digest: signed.digest.clone(),
1816            nonce_digest: "sha256:00".into(),
1817            actor: "agent://merchant".into(),
1818            action: "commerce.tool.apply_change.intent".into(),
1819            subject: "change://chg-0001".into(),
1820            session_id: Some("ssn_pkg_test".into()),
1821            action_artifact_id: Some("art_apply_intent".into()),
1822            receipt_digest: None,
1823            use_number: 1,
1824            max_uses: Some(1),
1825            idempotency_key: None,
1826            created_at: "2026-09-07T10:45:49Z".into(),
1827            expires_at: None,
1828            previous_record_digest: String::new(),
1829            record_digest: String::new(),
1830            signature: None,
1831            signature_alg: None,
1832            signing_key_id: None,
1833        };
1834        let bundle = ApprovalsBundle {
1835            grants: vec![
1836                (grant_id.clone(), grant_bytes),
1837                ("art_garbage".into(), b"not json".to_vec()),
1838            ],
1839            uses: vec![use_record],
1840            ..Default::default()
1841        };
1842
1843        let summary = preview_approvals_json(Some(&bundle));
1844        let grants = summary["grants"].as_array().unwrap();
1845        assert_eq!(grants.len(), 2);
1846        assert_eq!(grants[0]["parsed"], true);
1847        assert_eq!(grants[0]["approver"], "human://operator");
1848        assert_eq!(grants[0]["scope"]["max_uses"], 1);
1849        assert_eq!(
1850            grants[0]["scope"]["allowed_subjects"][0],
1851            "change://chg-0001"
1852        );
1853        // An unparsable grant is listed, not dropped, and says so.
1854        assert_eq!(grants[1]["parsed"], false);
1855        assert_eq!(grants[1]["grant_id"], "art_garbage");
1856        let uses = summary["uses"].as_array().unwrap();
1857        assert_eq!(uses[0]["use_number"], 1);
1858        assert_eq!(uses[0]["action_artifact_id"], "art_apply_intent");
1859
1860        let html = render_preview_html_with_approvals(&receipt, Some(&bundle));
1861        assert!(html.contains("id=\"approvals-data\""));
1862        assert!(html.contains("\"approver\":\"human://operator\""));
1863        assert!(html.contains("\"subject\":\"change://chg-0001\""));
1864        assert!(
1865            !html.contains("__APPROVALS_JSON__"),
1866            "placeholder must be substituted"
1867        );
1868        // Without a bundle the data block is a JSON null, never an empty
1869        // string that would throw in JSON.parse and hide the whole page.
1870        let plain = render_preview_html(&receipt);
1871        assert!(plain.contains("type=\"application/json\">null</script>"));
1872    }
1873
1874    #[test]
1875    fn preview_html_contains_session_info() {
1876        let receipt = make_receipt();
1877        let html = render_preview_html(&receipt);
1878        assert!(html.contains("ssn_pkg_test"));
1879        assert!(html.contains("treeship.dev"));
1880        assert!(html.contains("Timeline"));
1881
1882        // Regression: the receipt JSON must land ONLY in the data block,
1883        // never in the inline JS. A prior bug used replace() (all matches)
1884        // against a template that carried the placeholder token twice (data
1885        // slot + a JS placeholder check), injecting the receipt body into a
1886        // JS string literal. That produced an uncaught SyntaxError, so the
1887        // whole script never ran and the preview hung on "Verifying
1888        // receipt...". The JS check now uses a split sentinel that must
1889        // survive substitution verbatim, and replacen(.., 1) fills only the
1890        // first occurrence.
1891        assert!(
1892            html.contains("'__RECEIPT'+'_JSON__'"),
1893            "JS placeholder check was clobbered by the receipt substitution",
1894        );
1895        assert!(
1896            !html.contains("application/json\">__RECEIPT_JSON__</script>"),
1897            "data slot was not substituted with the receipt JSON",
1898        );
1899        // The session id (a receipt value) must appear inside the data block,
1900        // not leak into executable JS, so a quick structural sanity check:
1901        // there is exactly one unsubstituted token left at most (none here).
1902        assert_eq!(
1903            html.matches("__RECEIPT_JSON__").count(),
1904            0,
1905            "no raw placeholder token should remain after substitution",
1906        );
1907    }
1908}