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::collections::BTreeSet;
13use std::path::{Path, PathBuf};
14
15use crate::statements::ApprovalStatement;
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18
19use super::receipt::{ArtifactEntry, SessionReceipt, RECEIPT_TYPE};
20use crate::statements::{
21    approval_revocation_record_digest, approval_use_record_digest,
22    journal_checkpoint_record_digest, ApprovalRevocation, ApprovalUse, JournalCheckpoint,
23    ReplayCheck, ReplayCheckLevel,
24};
25
26/// Errors from package operations.
27#[derive(Debug)]
28pub enum PackageError {
29    Io(std::io::Error),
30    Json(serde_json::Error),
31    InvalidPackage(String),
32}
33
34impl std::fmt::Display for PackageError {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        match self {
37            Self::Io(e) => write!(f, "package io: {e}"),
38            Self::Json(e) => write!(f, "package json: {e}"),
39            Self::InvalidPackage(msg) => write!(f, "invalid package: {msg}"),
40        }
41    }
42}
43
44impl std::error::Error for PackageError {}
45impl From<std::io::Error> for PackageError {
46    fn from(e: std::io::Error) -> Self {
47        Self::Io(e)
48    }
49}
50impl From<serde_json::Error> for PackageError {
51    fn from(e: serde_json::Error) -> Self {
52        Self::Json(e)
53    }
54}
55
56/// Manifest file inside the package root.
57const RECEIPT_FILE: &str = "receipt.json";
58const MERKLE_FILE: &str = "merkle.json";
59const RENDER_FILE: &str = "render.json";
60const ARTIFACTS_DIR: &str = "artifacts";
61const PROOFS_DIR: &str = "proofs";
62const PREVIEW_FILE: &str = "preview.html";
63
64// Approval Authority package layout (v0.9.9 PR 4).
65// approvals/index.json -- top-level index of every approval evidence
66//                          file in this package
67// approvals/grants/<grant_id>.json    -- copy of the signed
68//                          ApprovalStatement envelope (already in
69//                          artifacts/ via the chain; mirrored here for
70//                          single-directory access during verify)
71// approvals/uses/<use_id>.json        -- ApprovalUse record from the
72//                          local journal at session-close time
73// approvals/checkpoints/<id>.json     -- JournalCheckpoint records that
74//                          cover the included uses (PR 6 Hub
75//                          checkpoint signing extends this)
76const APPROVALS_DIR: &str = "approvals";
77const APPROVALS_GRANTS: &str = "approvals/grants";
78const APPROVALS_USES: &str = "approvals/uses";
79const APPROVALS_CHECKPOINTS: &str = "approvals/checkpoints";
80const APPROVALS_INDEX_FILE: &str = "approvals/index.json";
81
82/// Optional approval evidence to embed in the package alongside the
83/// receipt + artifacts. None means "no approvals consumed during this
84/// session, or none worth exporting." Empty vectors mean "we looked and
85/// found nothing"; the resulting package omits the `approvals/` dir
86/// entirely so absence is unambiguous.
87///
88/// Ownership of the evidence stays with the caller: `session::close`
89/// gathers the grant envelopes from the chain, the uses from the local
90/// journal, and any covering checkpoints, then hands them off here.
91#[derive(Debug, Clone, Default)]
92pub struct ApprovalsBundle {
93    /// Bytes of the signed ApprovalStatement envelopes that authorized
94    /// any consumed uses. Each entry is `(grant_id, raw_envelope_json)`.
95    /// Stored verbatim so the package's verifier can re-check the
96    /// signature without re-serializing.
97    pub grants: Vec<(String, Vec<u8>)>,
98    /// ApprovalUse records pulled from the local journal at close time.
99    /// `action_artifact_id` should be backfilled before passing to
100    /// build_package (see `commands/session.rs`).
101    pub uses: Vec<ApprovalUse>,
102    /// JournalCheckpoints that cover the included uses. Optional; may
103    /// be empty even when uses are present (PR 6 fills these in).
104    pub checkpoints: Vec<JournalCheckpoint>,
105    /// Explicit revocations we wanted to surface (e.g. a use whose
106    /// grant was revoked after consumption -- the package should still
107    /// show the consumed evidence and the revocation alongside).
108    /// Empty in PR 4; reserved.
109    pub revocations: Vec<ApprovalRevocation>,
110
111    /// Bytes of each action artifact's signed envelope that consumed an
112    /// approval. Each entry is `(action_artifact_id, raw_envelope_json)`.
113    /// v0.9.10 PR A: shipped to close the action↔use binding gap. The
114    /// verifier extracts `meta.approval_use_id` from each envelope and
115    /// cross-checks it against the package's use records. Empty in
116    /// pre-v0.9.10 packages; readers must treat absence as "binding
117    /// not asserted by package" rather than "binding present and OK."
118    pub action_envelopes: Vec<(String, Vec<u8>)>,
119
120    /// Every sealed artifact's signed envelope, `(artifact_id, raw_envelope_json)`,
121    /// so the package verifies its own signatures instead of asking the
122    /// reader to trust the sealed set (audit 2026-09, AUD-31). Written to
123    /// `artifacts/<id>.json`, the same directory the consuming actions above
124    /// already use. Empty in pre-0.31.2 packages.
125    pub sealed_envelopes: Vec<(String, Vec<u8>)>,
126    /// The public half of every key that signed a sealed envelope,
127    /// `(key_id, "ed25519:<base64url>")`, written to `keys.json`. A verifier
128    /// checks each signature against the key the package names, then
129    /// separately reports whether that key is one it has pinned.
130    pub signer_keys: Vec<(String, String)>,
131}
132
133/// `keys.json` at the package root.
134#[derive(Debug, Clone, Serialize, Deserialize, Default)]
135pub struct PackageKeys {
136    pub schema: String,
137    /// key_id -> `ed25519:<base64url public key>`
138    pub keys: std::collections::BTreeMap<String, String>,
139}
140
141pub const KEYS_FILE: &str = "keys.json";
142/// The session's close record (`treeship/receipt/v1`, `session.v1`), signed
143/// over the digest of `receipt.json`, sealed beside the package since 0.31.4
144/// so the sealed set itself is under a signature (audit follow-up AUD-34).
145pub const RECORD_FILE: &str = "record.json";
146
147/// Label the CLI gives this ship's own keys when it adds them as
148/// `session_host` roots for a local verify, so `signer_trust` can say
149/// "this ship's own key" instead of "pinned".
150pub const OWN_KEY_LABEL: &str = "this ship's own key";
151pub const PACKAGE_KEYS_SCHEMA: &str = "treeship/package-keys/v1";
152
153/// `approvals/index.json` -- top-level inventory of evidence in the
154/// package. Lets a consumer pre-flight what's there before opening
155/// every file; doubles as a stable shape for downstream tooling.
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct ApprovalsIndex {
158    /// Stable schema marker so future versions can fan out cleanly.
159    #[serde(rename = "type")]
160    pub type_: String,
161    pub schema_version: u32,
162    /// Stable kebab-case ids of grants present. Order matches
163    /// `grants/` filename order.
164    pub grants: Vec<String>,
165    /// Use ids present.
166    pub uses: Vec<String>,
167    pub checkpoints: Vec<String>,
168    pub revocations: Vec<String>,
169}
170
171impl ApprovalsIndex {
172    pub fn type_string() -> &'static str {
173        "treeship/approvals-index/v1"
174    }
175}
176
177/// Result of building a package.
178pub struct PackageOutput {
179    /// Path to the package directory.
180    pub path: PathBuf,
181    /// SHA-256 digest of the canonical receipt.json.
182    pub receipt_digest: String,
183    /// Merkle root hex (if present).
184    pub merkle_root: Option<String>,
185    /// Number of files in the package.
186    pub file_count: usize,
187}
188
189/// Build a `.treeship` package directory from a composed receipt.
190///
191/// Writes all package files into `output_dir/<session_id>.treeship/`.
192/// Returns metadata about the written package.
193///
194/// Backwards-compatible wrapper: callers that don't have approval
195/// evidence to export pass through here unchanged. Callers that do
196/// (`session::close` with consumed approvals) call
197/// `build_package_with_approvals` directly.
198pub fn build_package(
199    receipt: &SessionReceipt,
200    output_dir: &Path,
201) -> Result<PackageOutput, PackageError> {
202    build_package_with_approvals(receipt, output_dir, None)
203}
204
205/// Like `build_package` but also embeds approval evidence (PR 4 of v0.9.9).
206/// `bundle = None` is identical to `build_package`; the `approvals/`
207/// directory is omitted entirely so absence stays unambiguous.
208pub fn build_package_with_approvals(
209    receipt: &SessionReceipt,
210    output_dir: &Path,
211    bundle: Option<&ApprovalsBundle>,
212) -> Result<PackageOutput, PackageError> {
213    let session_id = &receipt.session.id;
214    let pkg_dir = output_dir.join(format!("{session_id}.treeship"));
215
216    std::fs::create_dir_all(&pkg_dir)?;
217    std::fs::create_dir_all(pkg_dir.join(ARTIFACTS_DIR))?;
218    std::fs::create_dir_all(pkg_dir.join(PROOFS_DIR))?;
219
220    let mut file_count = 0usize;
221
222    // 1. receipt.json -- canonical serialization
223    let receipt_bytes = serde_json::to_vec_pretty(receipt)?;
224    std::fs::write(pkg_dir.join(RECEIPT_FILE), &receipt_bytes)?;
225    file_count += 1;
226
227    let receipt_hash = Sha256::digest(&receipt_bytes);
228    let receipt_digest = format!("sha256:{}", hex::encode(receipt_hash));
229
230    // 2. merkle.json -- standalone copy of the Merkle section
231    let merkle_bytes = serde_json::to_vec_pretty(&receipt.merkle)?;
232    std::fs::write(pkg_dir.join(MERKLE_FILE), &merkle_bytes)?;
233    file_count += 1;
234
235    // 3. render.json
236    let render_bytes = serde_json::to_vec_pretty(&receipt.render)?;
237    std::fs::write(pkg_dir.join(RENDER_FILE), &render_bytes)?;
238    file_count += 1;
239
240    // 4. Write inclusion proofs as individual files
241    for proof_entry in &receipt.merkle.inclusion_proofs {
242        let proof_bytes = serde_json::to_vec_pretty(proof_entry)?;
243        let filename = format!("{}.proof.json", proof_entry.artifact_id);
244        std::fs::write(pkg_dir.join(PROOFS_DIR).join(filename), &proof_bytes)?;
245        file_count += 1;
246    }
247
248    // 5. preview.html stub
249    if receipt.render.generate_preview {
250        let preview = render_preview_html_with_approvals(receipt, bundle);
251        std::fs::write(pkg_dir.join(PREVIEW_FILE), preview.as_bytes())?;
252        file_count += 1;
253    }
254
255    // 6. Approval evidence (v0.9.9 PR 4). Only writes when the caller
256    // supplied a bundle AND that bundle has at least one entry; an empty
257    // bundle behaves the same as None so a session with no consumed
258    // approvals doesn't leave behind an empty `approvals/` directory.
259    if let Some(b) = bundle {
260        // The sealed set's own envelopes and keys, independent of whether
261        // any approval evidence exists.
262        if !b.sealed_envelopes.is_empty() {
263            std::fs::create_dir_all(pkg_dir.join(ARTIFACTS_DIR))?;
264            for (artifact_id, envelope_bytes) in &b.sealed_envelopes {
265                let safe = sanitize_filename(artifact_id);
266                let path = pkg_dir.join(ARTIFACTS_DIR).join(format!("{safe}.json"));
267                if !path.exists() {
268                    std::fs::write(path, envelope_bytes)?;
269                    file_count += 1;
270                }
271            }
272        }
273        if !b.signer_keys.is_empty() {
274            let keys = PackageKeys {
275                schema: PACKAGE_KEYS_SCHEMA.into(),
276                keys: b.signer_keys.iter().cloned().collect(),
277            };
278            std::fs::write(pkg_dir.join(KEYS_FILE), serde_json::to_vec_pretty(&keys)?)?;
279            file_count += 1;
280        }
281        if !b.grants.is_empty()
282            || !b.uses.is_empty()
283            || !b.checkpoints.is_empty()
284            || !b.revocations.is_empty()
285            || !b.action_envelopes.is_empty()
286        {
287            std::fs::create_dir_all(pkg_dir.join(APPROVALS_GRANTS))?;
288            std::fs::create_dir_all(pkg_dir.join(APPROVALS_USES))?;
289            std::fs::create_dir_all(pkg_dir.join(APPROVALS_CHECKPOINTS))?;
290            // v0.9.10 PR A: write action envelopes that consumed an
291            // approval. The artifacts/ directory was created earlier
292            // for the package layout but never populated; closing the
293            // action↔use binding gap requires the verifier to be able
294            // to read each consuming action's `meta.approval_use_id`.
295            std::fs::create_dir_all(pkg_dir.join(ARTIFACTS_DIR))?;
296            for (artifact_id, envelope_bytes) in &b.action_envelopes {
297                let safe = sanitize_filename(artifact_id);
298                std::fs::write(
299                    pkg_dir.join(ARTIFACTS_DIR).join(format!("{safe}.json")),
300                    envelope_bytes,
301                )?;
302                file_count += 1;
303            }
304
305            let mut grant_ids = Vec::with_capacity(b.grants.len());
306            for (grant_id, envelope_bytes) in &b.grants {
307                let safe = sanitize_filename(grant_id);
308                std::fs::write(
309                    pkg_dir.join(APPROVALS_GRANTS).join(format!("{safe}.json")),
310                    envelope_bytes,
311                )?;
312                grant_ids.push(grant_id.clone());
313                file_count += 1;
314            }
315
316            let mut use_ids = Vec::with_capacity(b.uses.len());
317            for u in &b.uses {
318                let safe = sanitize_filename(&u.use_id);
319                let bytes = serde_json::to_vec_pretty(u)?;
320                std::fs::write(
321                    pkg_dir.join(APPROVALS_USES).join(format!("{safe}.json")),
322                    &bytes,
323                )?;
324                use_ids.push(u.use_id.clone());
325                file_count += 1;
326            }
327
328            let mut checkpoint_ids = Vec::with_capacity(b.checkpoints.len());
329            for cp in &b.checkpoints {
330                let safe = sanitize_filename(&cp.checkpoint_id);
331                let bytes = serde_json::to_vec_pretty(cp)?;
332                std::fs::write(
333                    pkg_dir
334                        .join(APPROVALS_CHECKPOINTS)
335                        .join(format!("{safe}.json")),
336                    &bytes,
337                )?;
338                checkpoint_ids.push(cp.checkpoint_id.clone());
339                file_count += 1;
340            }
341
342            let mut revocation_ids = Vec::with_capacity(b.revocations.len());
343            for rev in &b.revocations {
344                let safe = sanitize_filename(&rev.revocation_id);
345                let bytes = serde_json::to_vec_pretty(rev)?;
346                std::fs::write(
347                    pkg_dir
348                        .join(APPROVALS_DIR)
349                        .join(format!("revocations-{safe}.json")),
350                    &bytes,
351                )?;
352                revocation_ids.push(rev.revocation_id.clone());
353                file_count += 1;
354            }
355
356            let index = ApprovalsIndex {
357                type_: ApprovalsIndex::type_string().into(),
358                schema_version: 1,
359                grants: grant_ids,
360                uses: use_ids,
361                checkpoints: checkpoint_ids,
362                revocations: revocation_ids,
363            };
364            let index_bytes = serde_json::to_vec_pretty(&index)?;
365            std::fs::write(pkg_dir.join(APPROVALS_INDEX_FILE), &index_bytes)?;
366            file_count += 1;
367        }
368    }
369
370    Ok(PackageOutput {
371        path: pkg_dir,
372        receipt_digest,
373        merkle_root: receipt.merkle.root.clone(),
374        file_count,
375    })
376}
377
378/// Sanitize an id (artifact_id, use_id, checkpoint_id) into a filesystem-safe
379/// filename. Underscores everything that isn't alphanumeric, dash, or dot.
380/// Not a security boundary; the digest chain is the integrity check.
381fn sanitize_filename(s: &str) -> String {
382    s.chars()
383        .map(|c| {
384            if c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' {
385                c
386            } else {
387                '_'
388            }
389        })
390        .collect()
391}
392
393/// Read approval evidence embedded in a package, if any. Returns
394/// `Ok(ApprovalsBundle::default())` when the package has no `approvals/`
395/// directory (the typical case for sessions that didn't consume any
396/// scoped approvals). Errors only on malformed JSON inside files that
397/// the index claims exist.
398///
399/// Quiet on missing-directory by design: PR 4 packages and pre-PR-4
400/// packages should both round-trip through verify without spurious
401/// failures.
402pub fn read_approvals_bundle(pkg_dir: &Path) -> Result<ApprovalsBundle, PackageError> {
403    let approvals_dir = pkg_dir.join(APPROVALS_DIR);
404    if !approvals_dir.is_dir() {
405        return Ok(ApprovalsBundle::default());
406    }
407
408    let mut bundle = ApprovalsBundle::default();
409
410    // Grants are raw envelopes by file; we don't parse here, the
411    // verify layer can re-check the signature.
412    let grants_dir = pkg_dir.join(APPROVALS_GRANTS);
413    if grants_dir.is_dir() {
414        for entry in std::fs::read_dir(&grants_dir)? {
415            let entry = entry?;
416            let path = entry.path();
417            if path.extension().and_then(|s| s.to_str()) != Some("json") {
418                continue;
419            }
420            let id = path
421                .file_stem()
422                .and_then(|s| s.to_str())
423                .unwrap_or("")
424                .to_string();
425            let bytes = std::fs::read(&path)?;
426            bundle.grants.push((id, bytes));
427        }
428    }
429
430    let uses_dir = pkg_dir.join(APPROVALS_USES);
431    if uses_dir.is_dir() {
432        for entry in std::fs::read_dir(&uses_dir)? {
433            let entry = entry?;
434            let path = entry.path();
435            if path.extension().and_then(|s| s.to_str()) != Some("json") {
436                continue;
437            }
438            let bytes = std::fs::read(&path)?;
439            let u: ApprovalUse = serde_json::from_slice(&bytes)?;
440            bundle.uses.push(u);
441        }
442    }
443
444    let cps_dir = pkg_dir.join(APPROVALS_CHECKPOINTS);
445    if cps_dir.is_dir() {
446        for entry in std::fs::read_dir(&cps_dir)? {
447            let entry = entry?;
448            let path = entry.path();
449            if path.extension().and_then(|s| s.to_str()) != Some("json") {
450                continue;
451            }
452            let bytes = std::fs::read(&path)?;
453            let cp: JournalCheckpoint = serde_json::from_slice(&bytes)?;
454            bundle.checkpoints.push(cp);
455        }
456    }
457
458    // v0.9.10 PR A: read action envelopes shipped to support the
459    // action↔use binding check. Pre-v0.9.10 packages have an empty
460    // artifacts/ dir (the dir was created but never populated); the
461    // bundle's `action_envelopes` stays empty in that case, and the
462    // verifier reports the binding row honestly as "not asserted by
463    // package" rather than silently passing.
464    let arts_dir = pkg_dir.join(ARTIFACTS_DIR);
465    if arts_dir.is_dir() {
466        for entry in std::fs::read_dir(&arts_dir)? {
467            let entry = entry?;
468            let path = entry.path();
469            if path.extension().and_then(|s| s.to_str()) != Some("json") {
470                continue;
471            }
472            let id = path
473                .file_stem()
474                .and_then(|s| s.to_str())
475                .unwrap_or("")
476                .to_string();
477            let bytes = std::fs::read(&path)?;
478            bundle.action_envelopes.push((id, bytes));
479        }
480    }
481
482    Ok(bundle)
483}
484
485/// Read and parse a `.treeship` package from disk.
486pub fn read_package(pkg_dir: &Path) -> Result<SessionReceipt, PackageError> {
487    let receipt_path = pkg_dir.join(RECEIPT_FILE);
488    if !receipt_path.exists() {
489        return Err(PackageError::InvalidPackage(format!(
490            "missing {RECEIPT_FILE} in {}",
491            pkg_dir.display()
492        )));
493    }
494    let bytes = std::fs::read(&receipt_path)?;
495    let receipt: SessionReceipt = serde_json::from_slice(&bytes)?;
496
497    if receipt.type_ != RECEIPT_TYPE {
498        return Err(PackageError::InvalidPackage(format!(
499            "unexpected type: {} (expected {RECEIPT_TYPE})",
500            receipt.type_
501        )));
502    }
503
504    Ok(receipt)
505}
506
507/// Verify a `.treeship` package locally.
508///
509/// Returns a list of check results. All must pass for the package to be valid.
510///
511/// Auto-loads the operator's trust roots from
512/// `TrustRootStore::default_path()`. Use
513/// [`verify_package_with_trust`] when the trust store is already in
514/// hand (CLI paths that take a `Ctx`, or tests).
515///
516/// Audit lane J fix-up: `open_default_or_empty` propagates `Malformed`
517/// and `PermissionsTooOpen` errors -- those are operator
518/// misconfiguration that must NOT be silently downgraded to an empty
519/// trust store (an empty store fails verification of any hub-org
520/// checkpoint, which is the right end-state, but the operator needs a
521/// clear "your trust file is broken" diagnostic instead of a misleading
522/// "untrusted issuer" message). Surface the error as a `trust-root`
523/// fail row and stop before doing real work that depends on trust.
524pub fn verify_package(pkg_dir: &Path) -> Result<Vec<VerifyCheck>, PackageError> {
525    let trust = match crate::trust::TrustRootStore::open_default_or_empty() {
526        Ok(t) => t,
527        Err(e) => {
528            // Build a minimal check list so the caller's printer still
529            // renders a coherent failure rather than silently routing
530            // through a fake empty store.
531            return Ok(vec![VerifyCheck::fail(
532                "trust-root",
533                &format!("trust store unreadable: {e}"),
534            )]);
535        }
536    };
537    verify_package_with_trust(pkg_dir, &trust)
538}
539
540/// Like `verify_package` but takes an explicit `TrustRootStore` so the
541/// caller can verify with a constructed-in-memory trust set (tests) or
542/// a non-default location (CLI `--trust-roots`).
543pub fn verify_package_with_trust(
544    pkg_dir: &Path,
545    trust: &crate::trust::TrustRootStore,
546) -> Result<Vec<VerifyCheck>, PackageError> {
547    verify_package_with_options(pkg_dir, trust, false)
548}
549
550/// Structural checks only: the receipt, the Merkle tree, the approvals
551/// evidence. A package that carries no artifact envelopes (every package
552/// built before 0.31.2) cannot be signature-verified from its own bytes,
553/// and the default verifier fails it for that reason. This entry point
554/// downgrades that failure to a warning for callers who know they are
555/// looking at structure, not evidence.
556pub fn verify_package_structural(pkg_dir: &Path) -> Result<Vec<VerifyCheck>, PackageError> {
557    let trust = crate::trust::TrustRootStore::open_default_or_empty()
558        .unwrap_or_else(|_| crate::trust::TrustRootStore::empty());
559    verify_package_with_options(pkg_dir, &trust, true)
560}
561
562pub fn verify_package_with_options(
563    pkg_dir: &Path,
564    trust: &crate::trust::TrustRootStore,
565    structural_only: bool,
566) -> Result<Vec<VerifyCheck>, PackageError> {
567    let mut checks = Vec::new();
568
569    // 1. receipt.json exists and parses
570    let receipt = match read_package(pkg_dir) {
571        Ok(r) => {
572            checks.push(VerifyCheck::pass(
573                "receipt.json",
574                "Parses as valid Session Receipt",
575            ));
576            r
577        }
578        Err(e) => {
579            checks.push(VerifyCheck::fail(
580                "receipt.json",
581                &format!("Failed to parse: {e}"),
582            ));
583            return Ok(checks);
584        }
585    };
586
587    // 2. Type field
588    if receipt.type_ == RECEIPT_TYPE {
589        checks.push(VerifyCheck::pass("type", "Correct receipt type"));
590    } else {
591        checks.push(VerifyCheck::fail(
592            "type",
593            &format!("Expected {RECEIPT_TYPE}, got {}", receipt.type_),
594        ));
595    }
596
597    // 3. Determinism: re-serialize and check digest matches.
598    //
599    // IMPORTANT SCOPE NOTE (do not read this row as integrity): this only
600    // confirms the receipt struct round-trips to the same bytes. It is NOT a
601    // signature check. The Merkle root below covers ONLY the artifact IDs;
602    // the receipt's timeline, side_effects, tool_usage, and narrative are
603    // composed from the (unsigned) event log and are NOT cryptographically
604    // bound by anything in this package. An attacker who edits those fields
605    // and re-serializes will pass determinism and pass the Merkle check.
606    // The authenticated anchor over the whole receipt is the actor-signed
607    // `session.v1` record (which binds receipt_digest) in the agent's chain;
608    // embedding + requiring it here is tracked as a follow-up. Until then,
609    // `package verify` authenticates the ARTIFACTS, not the narrative, and
610    // says so via the explicit scope check below.
611    let receipt_path = pkg_dir.join(RECEIPT_FILE);
612    let on_disk = std::fs::read(&receipt_path)?;
613    let re_serialized = serde_json::to_vec_pretty(&receipt)?;
614    if on_disk == re_serialized {
615        checks.push(VerifyCheck::pass(
616            "determinism",
617            "receipt.json round-trips identically (structural, NOT a signature)",
618        ));
619    } else {
620        // Not a hard failure -- pretty-print whitespace may differ
621        checks.push(VerifyCheck::warn(
622            "determinism",
623            "receipt.json does not byte-match after re-serialization",
624        ));
625    }
626
627    // 3b. Scope of what this package authenticates for the receipt body
628    // (timeline / side_effects / tool_usage / narrative). It is composed from
629    // the event log, not signed per entry. Since 0.31.4 the close record in
630    // record.json signs the digest of the whole receipt.json, so editing any
631    // of it fails `receipt_binding`; the row below reports that, or the
632    // absence of it. It used to warn unconditionally, next to a PASS that
633    // said the opposite (film findings 2026-09-22, gate report).
634    // Pushed after the record binding check runs, see 3c.
635
636    // 3c. Coverage: does the sealed set say what the harness could observe?
637    // A `coverage.v1` receipt minted at close carries the declared capture
638    // level, the connection modes and the counted events; without it a
639    // reader has no denominator for the timeline. Reported, never a fail:
640    // packages sealed before 0.31.6 carry none.
641    checks.push(coverage_check(pkg_dir, &receipt));
642
643    // 3d. Network scope: when the session declared one, say whether every
644    // recorded destination fell inside it. No scope declared, no row: the
645    // connections stand in side_effects as recorded, unjudged.
646    if let Some(tu) = receipt.tool_usage.as_ref() {
647        if !tu.network_declared.is_empty() {
648            let total = receipt.side_effects.network_connections.len();
649            if tu.network_off_scope.is_empty() {
650                checks.push(VerifyCheck::pass(
651                    "network_scope",
652                    &format!(
653                        "{total} recorded connection(s), all within the declared scope [{}]",
654                        tu.network_declared.join(", ")
655                    ),
656                ));
657            } else {
658                checks.push(VerifyCheck::warn(
659                    "network_scope",
660                    &format!(
661                        "{} destination(s) outside the declared scope [{}]: {}",
662                        tu.network_off_scope.len(),
663                        tu.network_declared.join(", "),
664                        tu.network_off_scope.join(", ")
665                    ),
666                ));
667            }
668        }
669    }
670
671    // 3e. Retries: actions that name an earlier attempt. Reported only when
672    // the sealed set carries any. Checks the chain is consistent (same
673    // action and actor, attempts count up, same idempotency key when both
674    // sides carry one, the retried attempt is in this package) and that two
675    // attempts do not both claim a distinct effect, which is the shape of a
676    // duplicated ticket rather than a recovered one.
677    if let Some(row) = retries_check(pkg_dir, &receipt) {
678        checks.push(row);
679    }
680
681    // 3f. Judgements: model answers the producer acted on. Reported only
682    // when the sealed set carries any; flags one acted on below its own
683    // declared bar, or with no bar at all. The row does not re-run a judge.
684    if let Some(row) = judgements_check(pkg_dir, &receipt) {
685        checks.push(row);
686    }
687
688    // 4. Merkle root re-computation
689    if !receipt.artifacts.is_empty() {
690        // Recompute under the receipt's declared merkle version so
691        // legacy (v0.10.2 and earlier, version=1, no domain separation)
692        // receipts continue to verify. New receipts always emit v2.
693        // Construct through the validating `with_version` so an unknown
694        // version surfaces as a hard fail rather than silently falling
695        // back to v1.
696        let version = receipt.merkle.merkle_version;
697        let mut tree = match crate::merkle::MerkleTree::with_version(version) {
698            Ok(t) => t,
699            Err(e) => {
700                checks.push(VerifyCheck::fail(
701                    "merkle_root",
702                    &format!("receipt declared unknown merkle_version: {e}"),
703                ));
704                // Skip the remaining merkle/inclusion work; emit the
705                // leaf_count + timeline tail and return.
706                return Ok(finish_package_checks(checks, &receipt));
707            }
708        };
709        for art in &receipt.artifacts {
710            tree.append(&art.artifact_id);
711        }
712        let root_bytes = tree.root();
713        let recomputed_root = root_bytes.map(|r| format!("mroot_{}", hex::encode(r)));
714        let root_hex = root_bytes.map(hex::encode).unwrap_or_default();
715
716        if recomputed_root == receipt.merkle.root {
717            checks.push(VerifyCheck::pass(
718                "merkle_root",
719                "Merkle root matches recomputed value",
720            ));
721        } else {
722            checks.push(VerifyCheck::fail(
723                "merkle_root",
724                &format!(
725                    "Mismatch: on-disk {:?} vs recomputed {:?}",
726                    receipt.merkle.root, recomputed_root
727                ),
728            ));
729        }
730
731        // 5. Verify each inclusion proof. Per-proof merkle_version must
732        // match the receipt section's declared version — drift is a
733        // hard fail (smuggled v1 proof inside a v2 receipt would
734        // otherwise dispatch through the weaker hashing path).
735        for proof_entry in &receipt.merkle.inclusion_proofs {
736            if proof_entry.proof.merkle_version != version {
737                checks.push(VerifyCheck::fail(
738                    &format!("inclusion:{}", proof_entry.artifact_id),
739                    &format!(
740                        "proof merkle_version {} != receipt section v{}",
741                        proof_entry.proof.merkle_version, version,
742                    ),
743                ));
744                continue;
745            }
746            let verified = crate::merkle::MerkleTree::verify_proof(
747                version,
748                &root_hex,
749                &proof_entry.artifact_id,
750                &proof_entry.proof,
751            );
752            if verified {
753                checks.push(VerifyCheck::pass(
754                    &format!("inclusion:{}", proof_entry.artifact_id),
755                    "Inclusion proof valid",
756                ));
757            } else {
758                checks.push(VerifyCheck::fail(
759                    &format!("inclusion:{}", proof_entry.artifact_id),
760                    "Inclusion proof failed verification",
761                ));
762            }
763        }
764    } else {
765        checks.push(VerifyCheck::warn("merkle_root", "No artifacts to verify"));
766    }
767
768    // Signatures and chain linkage, from the package's own envelopes
769    // (audit 2026-09, AUD-31 / AUD-32; QA TS-002b).
770    verify_sealed_envelopes(pkg_dir, &receipt, trust, structural_only, &mut checks);
771    let body_bound = verify_receipt_binding(pkg_dir, &receipt, structural_only, &mut checks);
772    if body_bound {
773        checks.push(VerifyCheck::pass(
774            "receipt_body_binding",
775            "timeline/side-effects/narrative are bound: the close record (record.json) signs the digest of the whole receipt.json, so editing any of them fails receipt_binding. They remain the producer's own account of the session, composed from its event log and signed by its key; the artifacts are the evidence",
776        ));
777    } else {
778        checks.push(VerifyCheck::warn(
779            "receipt_body_binding",
780            "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).",
781        ));
782    }
783    verify_session_window(pkg_dir, &receipt, &mut checks);
784
785    // 6. Leaf count matches artifacts
786    if receipt.merkle.leaf_count == receipt.artifacts.len() {
787        checks.push(VerifyCheck::pass(
788            "leaf_count",
789            "Leaf count matches artifact count",
790        ));
791    } else {
792        checks.push(VerifyCheck::fail(
793            "leaf_count",
794            &format!(
795                "leaf_count {} != artifact count {}",
796                receipt.merkle.leaf_count,
797                receipt.artifacts.len()
798            ),
799        ));
800    }
801
802    // 7. Timeline ordering (determinism rule: timestamp, sequence_no, event_id)
803    let ordered = receipt.timeline.windows(2).all(|w| {
804        (&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
805            <= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
806    });
807    if ordered {
808        checks.push(VerifyCheck::pass(
809            "timeline_order",
810            "Timeline is correctly ordered",
811        ));
812    } else {
813        checks.push(VerifyCheck::fail(
814            "timeline_order",
815            "Timeline entries are not in deterministic order",
816        ));
817    }
818
819    // event_log completeness: when session::close skipped malformed
820    // event log lines, the count is recorded on receipt.proofs.event_log_skipped.
821    // Surface as WARN (not FAIL) because the receipt is still
822    // cryptographically valid -- we just want a downstream verifier to
823    // know that some evidence was dropped before the receipt was sealed.
824    // A future --strict flag can promote this to FAIL.
825    // Codex adversarial review finding #8.
826    if receipt.proofs.event_log_skipped > 0 {
827        checks.push(VerifyCheck::warn(
828            "event_log_completeness",
829            &format!(
830                "{} event(s) skipped during close (malformed lines in events.jsonl). \
831                 Receipt is cryptographically valid but does not represent the full event stream. \
832                 Inspect close-time stderr or the events.jsonl directly to investigate.",
833                receipt.proofs.event_log_skipped,
834            ),
835        ));
836    }
837
838    if receipt.proofs.reconcile_untracked_truncated > 0 {
839        checks.push(VerifyCheck::warn(
840            "reconcile_completeness",
841            &format!(
842                "untracked git reconcile exceeded cap {} (saw at least {}). \
843                 Per-file synthetic events were skipped and the receipt is bounded, not complete for untracked files.",
844                receipt.proofs.reconcile_untracked_cap,
845                receipt.proofs.reconcile_untracked_truncated,
846            ),
847        ));
848    }
849
850    // AUD-07: the git-diff backstop was disabled between session start and
851    // close (git worked at start — a HEAD was captured — but not at close).
852    // A file changed via a non-AgentWroteFile channel could be missing from
853    // the "Files changed" ledger with no other signal, so this must not read
854    // as a clean, complete audit trail.
855    if receipt.proofs.reconcile_degraded {
856        checks.push(VerifyCheck::warn(
857            "reconcile_degraded",
858            "the git reconcile backstop was UNAVAILABLE at session close although git worked at start \
859             (.git removed, corrupt index, or git not on PATH). Files changed outside a captured \
860             AgentWroteFile event may be MISSING from this receipt's file ledger — treat the \
861             \"Files changed\" list as incomplete.",
862        ));
863    }
864
865    // 8. Approval evidence -- v0.9.9 PR 4. Three independent replay
866    // checks, each emitted as its own VerifyCheck row so the printer
867    // (and downstream tooling) can render them separately.
868    //
869    //   replay-package-local      duplicate uses INSIDE this package
870    //   replay-included-checkpoint  embedded JournalCheckpoints verify standalone
871    //
872    // The local-journal level requires access to the workspace journal,
873    // which the package alone doesn't carry; that check runs in the CLI
874    // verify_package wrapper that has Ctx access. The hub-org level is
875    // reserved for PR 6 -- not claimed without a real Hub checkpoint.
876    let bundle = read_approvals_bundle(pkg_dir).unwrap_or_default();
877    add_approval_evidence_checks(&mut checks, &bundle, trust);
878
879    Ok(checks)
880}
881
882/// Tail of `verify_package`: emit leaf_count and timeline-order checks.
883/// Used by the early-return path when an unknown merkle version aborts
884/// Merkle recomputation — those two checks are independent of the tree
885/// version and still meaningful to surface.
886/// Which trust-root kinds mean "I accept receipts signed by this key".
887const SIGNER_KINDS: &[crate::trust::TrustRootKind] = &[
888    crate::trust::TrustRootKind::CertIssuer,
889    crate::trust::TrustRootKind::AgentCert,
890    crate::trust::TrustRootKind::SessionHost,
891];
892
893fn read_package_keys(pkg_dir: &Path) -> Option<PackageKeys> {
894    let raw = std::fs::read(pkg_dir.join(KEYS_FILE)).ok()?;
895    serde_json::from_slice(&raw).ok()
896}
897
898/// Decode the keys the package names, by key id. Empty when `keys.json` is
899/// missing or unreadable; the callers report that themselves.
900fn package_verifying_keys(
901    pkg_dir: &Path,
902) -> std::collections::BTreeMap<String, ed25519_dalek::VerifyingKey> {
903    let mut keys = std::collections::BTreeMap::new();
904    if let Some(pk) = read_package_keys(pkg_dir) {
905        for (id, encoded) in pk.keys {
906            if let Ok(vk) = crate::trust::decode_ed25519_pubkey(&encoded) {
907                keys.insert(id, vk);
908            }
909        }
910    }
911    keys
912}
913
914/// The close record binds the sealed set: `session close` signs the SHA-256
915/// of `receipt.json` into a `session.v1` record after the package is built,
916/// and the package carries that envelope as `record.json`. Rewriting the
917/// artifact list and recomputing the tree leaves every per-artifact row green
918/// (the auditor's AUD-34 splice: an artifact from another session, same key,
919/// dropped into an `unchained` slot). This row catches it: the receipt's
920/// digest no longer matches what the producer signed at close. A package
921/// built before 0.31.4 carries no record and gets a WARN, FAIL under
922/// `--strict`; the producer's own key still says nothing about a producer
923/// who re-signs, which is what anchoring is for.
924fn verify_receipt_binding(
925    pkg_dir: &Path,
926    receipt: &SessionReceipt,
927    structural_only: bool,
928    checks: &mut Vec<VerifyCheck>,
929) -> bool {
930    use sha2::{Digest, Sha256};
931    let path = pkg_dir.join(RECORD_FILE);
932    let raw = match std::fs::read(&path) {
933        Ok(b) => b,
934        Err(_) => {
935            checks.push(VerifyCheck::warn(
936                "receipt_binding",
937                "the package carries no close record (built before 0.31.4), so the sealed set is not under a signature: an artifact could be added to the list and the tree recomputed without any per-artifact row failing",
938            ));
939            return false;
940        }
941    };
942    if structural_only {
943        checks.push(VerifyCheck::warn(
944            "receipt_binding",
945            "close record present but not checked under --structural",
946        ));
947        return false;
948    }
949    let envelope = match crate::attestation::Envelope::from_json(&raw) {
950        Ok(e) => e,
951        Err(e) => {
952            checks.push(VerifyCheck::fail(
953                "receipt_binding",
954                &format!("record.json does not parse as a DSSE envelope: {e}"),
955            ));
956            return false;
957        }
958    };
959    let Some(sig) = envelope.signatures.first() else {
960        checks.push(VerifyCheck::fail(
961            "receipt_binding",
962            "record.json carries no signature",
963        ));
964        return false;
965    };
966    let keys = package_verifying_keys(pkg_dir);
967    let Some(vk) = keys.get(&sig.keyid) else {
968        checks.push(VerifyCheck::fail(
969            "receipt_binding",
970            &format!(
971                "record.json is signed by {}, a key the package does not carry",
972                sig.keyid
973            ),
974        ));
975        return false;
976    };
977    if let Err(e) = crate::attestation::verify_with_key(&envelope, &sig.keyid, *vk) {
978        checks.push(VerifyCheck::fail(
979            "receipt_binding",
980            &format!("record.json signature invalid for key {}: {e}", sig.keyid),
981        ));
982        return false;
983    }
984    let payload: serde_json::Value = match envelope
985        .payload_bytes()
986        .ok()
987        .and_then(|b| serde_json::from_slice(&b).ok())
988    {
989        Some(v) => v,
990        None => {
991            checks.push(VerifyCheck::fail(
992                "receipt_binding",
993                "record.json payload is not JSON",
994            ));
995            return false;
996        }
997    };
998    let signed_digest = payload
999        .get("payload")
1000        .and_then(|p| p.get("receipt_digest"))
1001        .and_then(|d| d.as_str())
1002        .unwrap_or("");
1003    let signed_session = payload
1004        .get("payload")
1005        .and_then(|p| p.get("session_id"))
1006        .and_then(|d| d.as_str())
1007        .unwrap_or("");
1008    let receipt_bytes = std::fs::read(pkg_dir.join(RECEIPT_FILE)).unwrap_or_default();
1009    let actual = format!("sha256:{}", hex::encode(Sha256::digest(&receipt_bytes)));
1010    if signed_session != receipt.session.id {
1011        checks.push(VerifyCheck::fail(
1012            "receipt_binding",
1013            &format!(
1014                "the close record names session {} but this receipt is {}",
1015                signed_session, receipt.session.id
1016            ),
1017        ));
1018    } else if signed_digest != actual {
1019        checks.push(VerifyCheck::fail(
1020            "receipt_binding",
1021            &format!(
1022                "the producer signed receipt digest {} at close but receipt.json now digests to {}: the sealed set was rewritten after it was signed",
1023                signed_digest, actual
1024            ),
1025        ));
1026    } else {
1027        checks.push(VerifyCheck::pass(
1028            "receipt_binding",
1029            &format!(
1030                "close record signed by {} binds receipt.json ({}) and names this session",
1031                sig.keyid, actual
1032            ),
1033        ));
1034        return true;
1035    }
1036    false
1037}
1038
1039/// Every sealed artifact's signed timestamp should fall inside the session's
1040/// own window. Clocks skew and a producer controls its own clock, so this is
1041/// a warning that names the artifacts, not a proof; an artifact minutes after
1042/// `ended_at` is the shape a spliced one has.
1043fn verify_session_window(pkg_dir: &Path, receipt: &SessionReceipt, checks: &mut Vec<VerifyCheck>) {
1044    use crate::statements::invitation::parse_rfc3339_to_unix;
1045    const SKEW: u64 = 120;
1046    let Some(started) = parse_rfc3339_to_unix(&receipt.session.started_at) else {
1047        return;
1048    };
1049    let ended = receipt
1050        .session
1051        .ended_at
1052        .as_deref()
1053        .and_then(parse_rfc3339_to_unix);
1054    let art_dir = pkg_dir.join(ARTIFACTS_DIR);
1055    let mut outside: Vec<String> = Vec::new();
1056    let mut seen = 0usize;
1057    for entry in &receipt.artifacts {
1058        let path = art_dir.join(format!("{}.json", sanitize_filename(&entry.artifact_id)));
1059        let Ok(raw) = std::fs::read(&path) else {
1060            continue;
1061        };
1062        let Ok(env) = crate::attestation::Envelope::from_json(&raw) else {
1063            continue;
1064        };
1065        let Some(ts) = env
1066            .payload_bytes()
1067            .ok()
1068            .and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
1069            .and_then(|v| {
1070                v.get("timestamp")
1071                    .and_then(|t| t.as_str())
1072                    .map(str::to_string)
1073            })
1074        else {
1075            continue;
1076        };
1077        let Some(t) = parse_rfc3339_to_unix(&ts) else {
1078            continue;
1079        };
1080        seen += 1;
1081        let before = t + SKEW < started;
1082        let after = ended.map(|e| t > e + SKEW).unwrap_or(false);
1083        if before || after {
1084            outside.push(format!("{} ({})", entry.artifact_id, ts));
1085        }
1086    }
1087    if seen == 0 {
1088        return;
1089    }
1090    if outside.is_empty() {
1091        checks.push(VerifyCheck::pass(
1092            "session_window",
1093            &format!("{seen} sealed artifact(s) were signed inside the session's window"),
1094        ));
1095    } else {
1096        checks.push(VerifyCheck::warn(
1097            "session_window",
1098            &format!(
1099                "{} sealed artifact(s) were signed outside the session's window ({} to {}): {}",
1100                outside.len(),
1101                receipt.session.started_at,
1102                receipt
1103                    .session
1104                    .ended_at
1105                    .clone()
1106                    .unwrap_or_else(|| "open".into()),
1107                outside.join(", ")
1108            ),
1109        ));
1110    }
1111}
1112
1113/// For every sealed artifact: the envelope is in the package, its id
1114/// re-derives from the signed bytes, its Ed25519 signature verifies against
1115/// the key the package names, and each chained entry names the previous
1116/// sealed entry as its parent. Then, separately, whether the signing keys
1117/// are pinned trust roots.
1118///
1119/// A package with no envelopes at all (pre-0.31.2 layout) gets one `envelopes`
1120/// FAIL, or a WARN under `structural_only`: structure without signatures is
1121/// not verification, and a forged sealed set is indistinguishable from an
1122/// honest legacy one from the package's bytes alone.
1123fn verify_sealed_envelopes(
1124    pkg_dir: &Path,
1125    receipt: &SessionReceipt,
1126    trust: &crate::trust::TrustRootStore,
1127    structural_only: bool,
1128    checks: &mut Vec<VerifyCheck>,
1129) {
1130    use std::collections::{BTreeMap, BTreeSet};
1131
1132    if receipt.artifacts.is_empty() {
1133        return;
1134    }
1135    let art_dir = pkg_dir.join(ARTIFACTS_DIR);
1136    let any_envelope = receipt.artifacts.iter().any(|a| {
1137        art_dir
1138            .join(format!("{}.json", sanitize_filename(&a.artifact_id)))
1139            .exists()
1140    });
1141    if !any_envelope {
1142        let detail = "the package carries no artifact envelopes (built before 0.31.2), so nothing here is signature-checked: the sealed set is structurally consistent and nothing more. Verify the artifacts from the producer's store, a bundle, or the hub with `treeship verify <id>`, or read structure only with --structural (verdict: structural-pass)";
1143        checks.push(if structural_only {
1144            VerifyCheck::warn("envelopes", detail)
1145        } else {
1146            VerifyCheck::fail("envelopes", detail)
1147        });
1148        return;
1149    }
1150
1151    // Keys the package names. A key missing here fails the signature check
1152    // for its artifacts; the package cannot vouch for a key it does not carry.
1153    let mut keys: BTreeMap<String, ed25519_dalek::VerifyingKey> = BTreeMap::new();
1154    match read_package_keys(pkg_dir) {
1155        Some(pk) => {
1156            for (id, encoded) in pk.keys {
1157                match crate::trust::decode_ed25519_pubkey(&encoded) {
1158                    Ok(vk) => {
1159                        keys.insert(id, vk);
1160                    }
1161                    Err(e) => checks.push(VerifyCheck::fail(
1162                        "keys.json",
1163                        &format!("key {id} is not a valid ed25519 public key: {e}"),
1164                    )),
1165                }
1166            }
1167        }
1168        None => checks.push(VerifyCheck::fail(
1169            "keys.json",
1170            "package has artifact envelopes but no keys.json naming the signing keys",
1171        )),
1172    }
1173
1174    let mut parents: Vec<(String, Option<String>)> = Vec::new();
1175    let mut signers: BTreeSet<String> = BTreeSet::new();
1176    let mut ok_count = 0usize;
1177    for entry in &receipt.artifacts {
1178        let id = &entry.artifact_id;
1179        let name = format!("signature:{id}");
1180        let path = art_dir.join(format!("{}.json", sanitize_filename(id)));
1181        let raw = match std::fs::read(&path) {
1182            Ok(b) => b,
1183            Err(_) => {
1184                checks.push(VerifyCheck::fail(
1185                    &name,
1186                    "sealed in the Merkle tree but its signed envelope is not in the package",
1187                ));
1188                parents.push((id.clone(), None));
1189                continue;
1190            }
1191        };
1192        let envelope = match crate::attestation::Envelope::from_json(&raw) {
1193            Ok(e) => e,
1194            Err(e) => {
1195                checks.push(VerifyCheck::fail(
1196                    &name,
1197                    &format!("envelope does not parse: {e}"),
1198                ));
1199                parents.push((id.clone(), None));
1200                continue;
1201            }
1202        };
1203        let Some(sig) = envelope.signatures.first() else {
1204            checks.push(VerifyCheck::fail(&name, "envelope carries no signature"));
1205            parents.push((id.clone(), None));
1206            continue;
1207        };
1208        let Some(vk) = keys.get(&sig.keyid) else {
1209            checks.push(VerifyCheck::fail(
1210                &name,
1211                &format!("signed by {}, a key the package does not carry", sig.keyid),
1212            ));
1213            parents.push((id.clone(), None));
1214            continue;
1215        };
1216        match crate::attestation::verify_with_key(&envelope, &sig.keyid, *vk) {
1217            Ok(res) => {
1218                if res.artifact_id != *id {
1219                    checks.push(VerifyCheck::fail(
1220                        &name,
1221                        &format!(
1222                            "the signed bytes re-derive to {}, not the sealed id",
1223                            res.artifact_id
1224                        ),
1225                    ));
1226                } else if entry
1227                    .digest
1228                    .as_deref()
1229                    .map(|d| d != res.digest)
1230                    .unwrap_or(false)
1231                {
1232                    checks.push(VerifyCheck::fail(
1233                        &name,
1234                        &format!(
1235                            "receipt lists digest {} but the signed bytes digest to {}",
1236                            entry.digest.clone().unwrap_or_default(),
1237                            res.digest
1238                        ),
1239                    ));
1240                } else {
1241                    ok_count += 1;
1242                    signers.insert(sig.keyid.clone());
1243                    checks.push(VerifyCheck::pass(&name, &format!("Ed25519 signature by {} verifies; id and digest re-derived from the signed bytes", sig.keyid)));
1244                }
1245            }
1246            Err(e) => checks.push(VerifyCheck::fail(
1247                &name,
1248                &format!("invalid signature for key {}: {e}", sig.keyid),
1249            )),
1250        }
1251        let parent = envelope
1252            .payload_bytes()
1253            .ok()
1254            .and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
1255            .and_then(|v| {
1256                v.get("parentId")
1257                    .and_then(|p| p.as_str())
1258                    .map(str::to_string)
1259            });
1260        parents.push((id.clone(), parent));
1261    }
1262
1263    // Chain linkage: each chained entry's signed parentId is the previous
1264    // sealed entry. The first entry's parent may lie outside the package
1265    // (a previous session), so it is reported, not judged.
1266    let chained: Vec<(usize, &ArtifactEntry)> = receipt
1267        .artifacts
1268        .iter()
1269        .enumerate()
1270        .filter(|(_, a)| !a.unchained)
1271        .collect();
1272    let mut broken: Vec<String> = Vec::new();
1273    for w in chained.windows(2) {
1274        let (i_prev, prev) = w[0];
1275        let (i_cur, cur) = w[1];
1276        let _ = (i_prev, i_cur);
1277        let signed_parent = parents
1278            .iter()
1279            .find(|(id, _)| *id == cur.artifact_id)
1280            .and_then(|(_, p)| p.clone());
1281        match signed_parent {
1282            Some(p) if p == prev.artifact_id => {}
1283            Some(p) => broken.push(format!(
1284                "{} names parent {} but follows {}",
1285                cur.artifact_id, p, prev.artifact_id
1286            )),
1287            None => broken.push(format!("{} has no readable parentId", cur.artifact_id)),
1288        }
1289    }
1290    if chained.len() >= 2 {
1291        if broken.is_empty() {
1292            checks.push(VerifyCheck::pass("chain_linkage", &format!("{} chained artifacts each name the previous one as parent, inside the signature", chained.len())));
1293        } else {
1294            checks.push(VerifyCheck::fail("chain_linkage", &broken.join("; ")));
1295        }
1296    }
1297    let unchained: Vec<&str> = receipt
1298        .artifacts
1299        .iter()
1300        .filter(|a| a.unchained)
1301        .map(|a| a.artifact_id.as_str())
1302        .collect();
1303    if !unchained.is_empty() {
1304        checks.push(VerifyCheck::warn("chain_completeness", &format!("{} sealed artifact(s) were signed during the session but never chained onto it ({}); signed and sealed, but their order relative to the chain is the signer's claim only", unchained.len(), unchained.join(", "))));
1305    }
1306
1307    // Trust: valid signatures by keys the package names; are those keys yours?
1308    if ok_count > 0 {
1309        let unpinned: Vec<String> = signers
1310            .iter()
1311            .filter(|k| {
1312                let vk = keys.get(*k).expect("signer seen in keys");
1313                !SIGNER_KINDS.iter().any(|kind| trust.contains(vk, *kind))
1314            })
1315            .cloned()
1316            .collect();
1317        if unpinned.is_empty() {
1318            // The CLI adds this ship's own keys as roots so a package
1319            // verifies where it was produced; say so, because "pinned"
1320            // reads as a third party's decision and this is not one.
1321            let own: Vec<&str> = signers
1322                .iter()
1323                .filter(|k| {
1324                    trust
1325                        .roots()
1326                        .iter()
1327                        .any(|r| &r.key_id == *k && r.label == OWN_KEY_LABEL)
1328                })
1329                .map(|k| k.as_str())
1330                .collect();
1331            let detail = if own.len() == signers.len() {
1332                format!(
1333                    "all {} signing key(s) are this ship's own ({}); a stranger pins them before this row passes on their machine",
1334                    signers.len(),
1335                    own.join(", ")
1336                )
1337            } else if own.is_empty() {
1338                format!(
1339                    "all {} signing key(s) are pinned trust roots",
1340                    signers.len()
1341                )
1342            } else {
1343                format!(
1344                    "{} signing key(s): {} pinned trust root(s), {} this ship's own ({})",
1345                    signers.len(),
1346                    signers.len() - own.len(),
1347                    own.len(),
1348                    own.join(", ")
1349                )
1350            };
1351            checks.push(VerifyCheck::pass("signer_trust", &detail));
1352        } else {
1353            let pins: Vec<String> = unpinned
1354                .iter()
1355                .map(|k| {
1356                    let vk = keys.get(k).expect("key");
1357                    // `--yes`: the printed command is what gets pasted, and
1358                    // without it trust add refuses to run non-interactively.
1359                    format!(
1360                        "treeship trust add {k} {} --kind cert_issuer --yes",
1361                        crate::trust::encode_ed25519_pubkey(vk)
1362                    )
1363                })
1364                .collect();
1365            checks.push(VerifyCheck::warn("signer_trust", &format!("signature(s) verify for the key(s) the package names, but {} of them are not pinned trust roots here: {}. Pin what you have decided to trust: {}", unpinned.len(), unpinned.join(", "), pins.join("; "))));
1366        }
1367    }
1368}
1369
1370fn finish_package_checks(
1371    mut checks: Vec<VerifyCheck>,
1372    receipt: &SessionReceipt,
1373) -> Vec<VerifyCheck> {
1374    if receipt.merkle.leaf_count == receipt.artifacts.len() {
1375        checks.push(VerifyCheck::pass(
1376            "leaf_count",
1377            "Leaf count matches artifact count",
1378        ));
1379    } else {
1380        checks.push(VerifyCheck::fail(
1381            "leaf_count",
1382            &format!(
1383                "leaf_count {} != artifact count {}",
1384                receipt.merkle.leaf_count,
1385                receipt.artifacts.len(),
1386            ),
1387        ));
1388    }
1389
1390    let ordered = receipt.timeline.windows(2).all(|w| {
1391        (&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
1392            <= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
1393    });
1394    if ordered {
1395        checks.push(VerifyCheck::pass(
1396            "timeline_order",
1397            "Timeline is correctly ordered",
1398        ));
1399    } else {
1400        checks.push(VerifyCheck::fail(
1401            "timeline_order",
1402            "Timeline entries are not in deterministic order",
1403        ));
1404    }
1405
1406    checks
1407}
1408
1409/// Emit the package-local + included-checkpoint replay checks. Both are
1410/// fully offline: package-local scans the embedded uses for duplicates;
1411/// included-checkpoint walks the embedded checkpoint records and
1412/// re-derives each `record_digest` against its stored value.
1413///
1414/// The local-journal check is NOT here -- it requires workspace access
1415/// and is added by the CLI wrapper in `commands/package.rs` that has the
1416/// resolved config_path. Keeping these two pure means an offline tool
1417/// (Hub-side validator, third-party verifier) can run the same checks
1418/// without needing a Treeship workspace.
1419pub(crate) fn add_approval_evidence_checks(
1420    checks: &mut Vec<VerifyCheck>,
1421    bundle: &ApprovalsBundle,
1422    trust: &crate::trust::TrustRootStore,
1423) {
1424    if bundle.uses.is_empty() && bundle.checkpoints.is_empty() {
1425        // Nothing to assert. Stay quiet rather than emit a "skipped"
1426        // row -- session packages without approvals shouldn't drag in
1427        // approval rows by accident.
1428        return;
1429    }
1430
1431    // -- replay-package-local --
1432    // Two distinct violation cases inside the package:
1433    //   (a) uses sharing (grant_id, nonce_digest) EXCEED max_uses on
1434    //       that grant. Two uses of a max_uses=2 grant is fine; three
1435    //       is the violation. max_uses is read from the use record's
1436    //       own `max_uses` field (a snapshot from consume time).
1437    //   (b) two ApprovalUse records with the same use_id -- a copy
1438    //       artifact from a corrupt build, never legitimate.
1439    use std::collections::HashMap;
1440    let mut by_nonce: HashMap<(String, String), Vec<&ApprovalUse>> = HashMap::new();
1441    let mut by_use_id: HashMap<&str, Vec<&ApprovalUse>> = HashMap::new();
1442    for u in &bundle.uses {
1443        by_nonce
1444            .entry((u.grant_id.clone(), u.nonce_digest.clone()))
1445            .or_default()
1446            .push(u);
1447        by_use_id.entry(&u.use_id).or_default().push(u);
1448    }
1449    let over_max: Vec<((String, String), Vec<&ApprovalUse>, u32)> = by_nonce
1450        .iter()
1451        .filter_map(|(key, uses)| {
1452            let max = uses.iter().filter_map(|u| u.max_uses).next()?;
1453            if (uses.len() as u32) > max {
1454                Some((key.clone(), uses.to_vec(), max))
1455            } else {
1456                None
1457            }
1458        })
1459        .collect();
1460    let dup_use_ids: Vec<(&&str, &Vec<&ApprovalUse>)> =
1461        by_use_id.iter().filter(|(_, v)| v.len() > 1).collect();
1462
1463    if over_max.is_empty() && dup_use_ids.is_empty() {
1464        checks.push(VerifyCheck::pass(
1465            "replay-package-local",
1466            &format!(
1467                "no duplicate approval use inside package ({} uses scanned)",
1468                bundle.uses.len()
1469            ),
1470        ));
1471    } else {
1472        let mut detail = String::from("package-local replay violation:");
1473        for ((grant_id, _nd), uses, max) in &over_max {
1474            detail.push_str(&format!(
1475                " grant {grant_id} consumed {} times in this package (max_uses={max});",
1476                uses.len(),
1477            ));
1478        }
1479        for (uid, uses) in &dup_use_ids {
1480            detail.push_str(&format!(" use_id {uid} appears {} times;", uses.len()));
1481        }
1482        checks.push(VerifyCheck::fail("replay-package-local", &detail));
1483    }
1484
1485    // -- replay-included-checkpoint --
1486    // For each checkpoint, recompute its record_digest from canonical
1487    // form. If the stored digest doesn't match, the checkpoint was
1488    // tampered after sealing.
1489    if !bundle.checkpoints.is_empty() {
1490        let mut tampered = Vec::new();
1491        for cp in &bundle.checkpoints {
1492            let recomputed = journal_checkpoint_record_digest(cp);
1493            if recomputed != cp.record_digest {
1494                tampered.push((
1495                    cp.checkpoint_id.clone(),
1496                    cp.record_digest.clone(),
1497                    recomputed,
1498                ));
1499            }
1500        }
1501        if tampered.is_empty() {
1502            checks.push(VerifyCheck::pass(
1503                "replay-included-checkpoint",
1504                &format!(
1505                    "{} included journal checkpoint(s) verify offline",
1506                    bundle.checkpoints.len()
1507                ),
1508            ));
1509        } else {
1510            let detail = tampered
1511                .iter()
1512                .map(|(id, expected, actual)| {
1513                    format!("checkpoint {id} tampered (stored {expected}, recomputed {actual})")
1514                })
1515                .collect::<Vec<_>>()
1516                .join("; ");
1517            checks.push(VerifyCheck::fail("replay-included-checkpoint", &detail));
1518        }
1519    }
1520
1521    // -- approval-use-record-digest --
1522    // Each ApprovalUse carries its own record_digest computed over the
1523    // canonical form of the record (minus the digest itself). Tampering
1524    // any field changes the digest. v0.9.10 PR A renames this from the
1525    // older `approval-use-integrity` because the prior label suggested
1526    // it covered nonce/action binding -- it didn't, and Codex's v0.9.9
1527    // adversarial review flagged the over-claim. The honest scope of
1528    // this row is "each use's stored digest matches its canonical
1529    // recompute"; the binding checks are now separate rows below.
1530    let mut tampered_uses = Vec::new();
1531    for u in &bundle.uses {
1532        let recomputed = approval_use_record_digest(u);
1533        if recomputed != u.record_digest {
1534            tampered_uses.push((u.use_id.clone(), u.record_digest.clone(), recomputed));
1535        }
1536    }
1537    if !bundle.uses.is_empty() {
1538        if tampered_uses.is_empty() {
1539            checks.push(VerifyCheck::pass(
1540                "approval-use-record-digest",
1541                &format!("{} use record(s) recompute identically", bundle.uses.len()),
1542            ));
1543        } else {
1544            let detail = tampered_uses
1545                .iter()
1546                .map(|(id, expected, actual)| {
1547                    format!("use {id} tampered (stored {expected}, recomputed {actual})")
1548                })
1549                .collect::<Vec<_>>()
1550                .join("; ");
1551            checks.push(VerifyCheck::fail("approval-use-record-digest", &detail));
1552        }
1553    }
1554
1555    // -- approval-use-nonce-binding --
1556    // Cross-check each use's `nonce_digest` against the corresponding
1557    // grant's *signed* nonce. v0.9.9 trusted the use's nonce_digest
1558    // verbatim, which let an attacker who controls the package mutate
1559    // it (and recompute record_digest) to claim consumption of a grant
1560    // whose nonce was never actually used. This row closes that gap.
1561    //
1562    // Discipline: the grant envelope is the source of truth. Before
1563    // pulling the raw `nonce` from the grant's payload we verify the
1564    // envelope's *content addressing* -- recompute the artifact_id
1565    // from the envelope's PAE bytes and confirm it equals the grant_id
1566    // the package claims. v0.9.10 PR A round 1 only parsed the
1567    // envelope without this check; that left a forgery window where
1568    // an attacker could ship an arbitrary unsigned envelope under any
1569    // grant_id filename. v0.9.10 PR A round 2 closes the window: only
1570    // a bytes-identical envelope produces the same artifact_id under
1571    // SHA-256.
1572    if !bundle.uses.is_empty() {
1573        use crate::attestation::envelope::Envelope;
1574        use crate::attestation::{artifact_id_from_pae, pae};
1575        use crate::statements::{nonce_digest, ApprovalStatement};
1576        let mut grant_nonce_digest: std::collections::HashMap<String, String> =
1577            std::collections::HashMap::new();
1578        let mut tampered_grants: Vec<String> = Vec::new();
1579        for (grant_id, env_bytes) in &bundle.grants {
1580            let env = match Envelope::from_json(env_bytes) {
1581                Ok(e) => e,
1582                Err(_) => {
1583                    tampered_grants.push(format!("grant {grant_id} envelope unparseable"));
1584                    continue;
1585                }
1586            };
1587            // Content-addressing check: derive the artifact_id from
1588            // the envelope's PAE bytes and confirm it matches the
1589            // claimed grant_id. If they differ the envelope was
1590            // substituted or its bytes were tampered post-sign.
1591            let derived = match env.payload_bytes() {
1592                Ok(p) => artifact_id_from_pae(&pae(&env.payload_type, &p)),
1593                Err(_) => {
1594                    tampered_grants.push(format!("grant {grant_id} envelope payload undecodable"));
1595                    continue;
1596                }
1597            };
1598            if &derived != grant_id {
1599                tampered_grants.push(format!(
1600                    "grant {grant_id} envelope content derives to {derived} -- envelope substituted or tampered",
1601                ));
1602                continue;
1603            }
1604            let approval: ApprovalStatement = match env.unmarshal_statement() {
1605                Ok(a) => a,
1606                Err(_) => {
1607                    tampered_grants
1608                        .push(format!("grant {grant_id} payload not an ApprovalStatement"));
1609                    continue;
1610                }
1611            };
1612            grant_nonce_digest.insert(grant_id.clone(), nonce_digest(&approval.nonce));
1613        }
1614        let mut mismatches: Vec<String> = Vec::new();
1615        let mut missing_grants: Vec<String> = Vec::new();
1616        for u in &bundle.uses {
1617            match grant_nonce_digest.get(&u.grant_id) {
1618                Some(expected) => {
1619                    if expected != &u.nonce_digest {
1620                        mismatches.push(format!(
1621                            "use {} claims nonce_digest {} but grant {} signed nonce hashes to {}",
1622                            u.use_id, u.nonce_digest, u.grant_id, expected,
1623                        ));
1624                    }
1625                }
1626                None => {
1627                    missing_grants.push(format!(
1628                        "use {} references grant {} but no usable grant envelope is in the package",
1629                        u.use_id, u.grant_id,
1630                    ));
1631                }
1632            }
1633        }
1634        if mismatches.is_empty() && missing_grants.is_empty() && tampered_grants.is_empty() {
1635            checks.push(VerifyCheck::pass(
1636                "approval-use-nonce-binding",
1637                &format!(
1638                    "{} use record(s) bind to content-addressed grant signed nonces",
1639                    bundle.uses.len(),
1640                ),
1641            ));
1642        } else {
1643            let mut parts: Vec<String> = Vec::new();
1644            if !tampered_grants.is_empty() {
1645                parts.push(tampered_grants.join("; "));
1646            }
1647            if !mismatches.is_empty() {
1648                parts.push(mismatches.join("; "));
1649            }
1650            if !missing_grants.is_empty() {
1651                parts.push(missing_grants.join("; "));
1652            }
1653            checks.push(VerifyCheck::fail(
1654                "approval-use-nonce-binding",
1655                &parts.join("; "),
1656            ));
1657        }
1658    }
1659
1660    // -- approval-use-action-binding --
1661    // Cross-check each consuming action's `meta.approval_use_id`
1662    // against the package's use records. v0.9.9 ignored this pointer
1663    // entirely; the package didn't even ship action envelopes, so the
1664    // verifier could not see the field. v0.9.10 PR A: action envelopes
1665    // ride along in `artifacts/`, and this row pins that every action
1666    // declaring it consumed an approval has a use record for that
1667    // exact use_id, with matching grant_id and matching
1668    // `nonce_digest(approval_nonce)`.
1669    //
1670    // Honesty rule: when bundle.action_envelopes is empty (pre-v0.9.10
1671    // packages, or a v0.9.10 package with no consuming actions
1672    // recorded), this row reports `not asserted by package` rather
1673    // than silent PASS.
1674    if !bundle.uses.is_empty() {
1675        use crate::attestation::envelope::Envelope;
1676        use crate::attestation::{artifact_id_from_pae, pae};
1677        use crate::statements::{nonce_digest, ActionStatement};
1678        if bundle.action_envelopes.is_empty() {
1679            checks.push(VerifyCheck::warn(
1680                "approval-use-action-binding",
1681                "no action envelopes embedded -- action↔use binding not asserted by package (pre-v0.9.10)",
1682            ));
1683        } else {
1684            let use_ids: std::collections::HashSet<&str> =
1685                bundle.uses.iter().map(|u| u.use_id.as_str()).collect();
1686            let mut violations: Vec<String> = Vec::new();
1687            let mut bound_count = 0usize;
1688            for (artifact_id, env_bytes) in &bundle.action_envelopes {
1689                let env = match Envelope::from_json(env_bytes) {
1690                    Ok(e) => e,
1691                    Err(_) => {
1692                        violations.push(format!("action {artifact_id} envelope unparseable"));
1693                        continue;
1694                    }
1695                };
1696                // Content-addressing gate: derive the artifact_id
1697                // from the envelope's PAE bytes and require it to
1698                // match the filename stem the package shipped this
1699                // envelope under. Without this gate an attacker
1700                // controlling the package can write any forged
1701                // unsigned action JSON to artifacts/<id>.json and the
1702                // binding rows would trust it.
1703                let derived = match env.payload_bytes() {
1704                    Ok(p) => artifact_id_from_pae(&pae(&env.payload_type, &p)),
1705                    Err(_) => {
1706                        violations
1707                            .push(format!("action {artifact_id} envelope payload undecodable"));
1708                        continue;
1709                    }
1710                };
1711                if &derived != artifact_id {
1712                    violations.push(format!(
1713                        "action {artifact_id} envelope content derives to {derived} -- envelope substituted or tampered",
1714                    ));
1715                    continue;
1716                }
1717                let action: ActionStatement = match env.unmarshal_statement() {
1718                    Ok(a) => a,
1719                    Err(_) => {
1720                        violations.push(format!("action {artifact_id} not an ActionStatement"));
1721                        continue;
1722                    }
1723                };
1724                let raw_nonce = match action.approval_nonce.as_deref() {
1725                    Some(n) => n,
1726                    None => continue,
1727                };
1728                let claimed_use_id = action
1729                    .meta
1730                    .as_ref()
1731                    .and_then(|m| m.get("approval_use_id"))
1732                    .and_then(|v| v.as_str());
1733                let Some(claimed_use_id) = claimed_use_id else {
1734                    violations.push(format!(
1735                        "action {artifact_id} consumed an approval but its meta has no approval_use_id"
1736                    ));
1737                    continue;
1738                };
1739                if !use_ids.contains(claimed_use_id) {
1740                    violations.push(format!(
1741                        "action {artifact_id} claims approval_use_id={} but no such use is embedded",
1742                        claimed_use_id,
1743                    ));
1744                    continue;
1745                }
1746                let expected = nonce_digest(raw_nonce);
1747                let matched_use = bundle.uses.iter().find(|u| u.use_id == claimed_use_id);
1748                if let Some(u) = matched_use {
1749                    if u.nonce_digest != expected {
1750                        violations.push(format!(
1751                            "action {artifact_id} approval_nonce hashes to {} but use {} stores nonce_digest {}",
1752                            expected, claimed_use_id, u.nonce_digest,
1753                        ));
1754                        continue;
1755                    }
1756                }
1757                bound_count += 1;
1758            }
1759            if violations.is_empty() {
1760                checks.push(VerifyCheck::pass(
1761                    "approval-use-action-binding",
1762                    &format!(
1763                        "{bound_count} consuming action(s) bind cleanly to content-addressed envelope(s)",
1764                    ),
1765                ));
1766            } else {
1767                checks.push(VerifyCheck::fail(
1768                    "approval-use-action-binding",
1769                    &violations.join("; "),
1770                ));
1771            }
1772        }
1773    }
1774
1775    // -- approval-use-chain-continuity --
1776    // v0.9.9 verified each use's individual record_digest but never
1777    // walked the `previous_record_digest` chain across the embedded
1778    // records. An attacker could rewrite an entire chain consistently
1779    // (recomputing each digest along the way) and the per-record
1780    // checks all passed.
1781    //
1782    // Algorithm (v0.9.10 PR A round 2): build a graph of embedded
1783    // records keyed by record_digest, then require the embedded
1784    // records to form a SINGLE linked list with exactly one genesis
1785    // (previous_record_digest == "") and no cycles, forks, or
1786    // disconnected subchains.
1787    //
1788    //   - Dangling prev pointer (not in `owned`) -> fail.
1789    //   - More than one record with prev == ""    -> fail (mid-chain
1790    //     genesis is a forgery primitive).
1791    //   - Two records sharing the same prev       -> fail (fork).
1792    //   - Cycle reached during the walk           -> fail.
1793    //   - Walk doesn't reach every record         -> fail (disconnected
1794    //     subchain).
1795    //
1796    // We can only check *internal* consistency offline -- the package
1797    // doesn't ship the workspace journal's full history, so the chain
1798    // we see may be a contiguous prefix or window. Anchoring against
1799    // a Hub-signed checkpoint is replay-hub-org's job; here we report
1800    // structural consistency only.
1801    if !bundle.uses.is_empty() || !bundle.checkpoints.is_empty() {
1802        use std::collections::{HashMap, HashSet};
1803        // Each record carries a label for diagnostics + its own
1804        // record_digest + previous_record_digest.
1805        struct Node<'a> {
1806            label: String,
1807            digest: &'a str,
1808            prev: &'a str,
1809        }
1810        let mut nodes: Vec<Node> = Vec::new();
1811        for u in &bundle.uses {
1812            nodes.push(Node {
1813                label: format!("use {}", u.use_id),
1814                digest: u.record_digest.as_str(),
1815                prev: u.previous_record_digest.as_str(),
1816            });
1817        }
1818        for cp in &bundle.checkpoints {
1819            nodes.push(Node {
1820                label: format!("checkpoint {}", cp.checkpoint_id),
1821                digest: cp.record_digest.as_str(),
1822                prev: cp.previous_record_digest.as_str(),
1823            });
1824        }
1825
1826        let owned: HashSet<&str> = std::iter::once("")
1827            .chain(nodes.iter().map(|n| n.digest))
1828            .collect();
1829
1830        let mut violations: Vec<String> = Vec::new();
1831        // Dangling prev: pointer not in owned set.
1832        for n in &nodes {
1833            if !owned.contains(n.prev) {
1834                violations.push(format!(
1835                    "{} previous_record_digest {} not anchored in package",
1836                    n.label, n.prev,
1837                ));
1838            }
1839        }
1840        // Genesis count: only one record allowed to have prev == "".
1841        let genesis: Vec<&Node> = nodes.iter().filter(|n| n.prev.is_empty()).collect();
1842        if genesis.len() > 1 {
1843            violations.push(format!(
1844                "{} records claim previous_record_digest='' (genesis): {}",
1845                genesis.len(),
1846                genesis
1847                    .iter()
1848                    .map(|n| n.label.clone())
1849                    .collect::<Vec<_>>()
1850                    .join(", "),
1851            ));
1852        }
1853        // Forks: two records sharing the same non-empty prev.
1854        let mut by_prev: HashMap<&str, Vec<&Node>> = HashMap::new();
1855        for n in &nodes {
1856            by_prev.entry(n.prev).or_default().push(n);
1857        }
1858        for (prev, group) in &by_prev {
1859            if group.len() > 1 && !prev.is_empty() {
1860                violations.push(format!(
1861                    "fork: {} records share previous_record_digest {}: {}",
1862                    group.len(),
1863                    prev,
1864                    group
1865                        .iter()
1866                        .map(|n| n.label.clone())
1867                        .collect::<Vec<_>>()
1868                        .join(", "),
1869                ));
1870            }
1871        }
1872
1873        // Walk from genesis (if exactly one) following digest-as-prev
1874        // links. Detect cycles and unreachable records.
1875        if violations.is_empty() {
1876            let by_digest: HashMap<&str, &Node> = nodes.iter().map(|n| (n.digest, n)).collect();
1877            let next_of: HashMap<&str, &Node> = nodes
1878                .iter()
1879                .filter(|n| !n.prev.is_empty())
1880                .map(|n| (n.prev, n))
1881                .collect();
1882            let start = genesis.first().copied();
1883            let mut visited: HashSet<&str> = HashSet::new();
1884            let mut current = start;
1885            while let Some(node) = current {
1886                if !visited.insert(node.digest) {
1887                    violations.push(format!(
1888                        "cycle detected at {} (record_digest {})",
1889                        node.label, node.digest,
1890                    ));
1891                    break;
1892                }
1893                current = next_of.get(node.digest).copied();
1894            }
1895            // Disconnected: walk didn't include every node.
1896            if violations.is_empty() && visited.len() != nodes.len() {
1897                let unreached: Vec<String> = nodes
1898                    .iter()
1899                    .filter(|n| !visited.contains(n.digest))
1900                    .map(|n| n.label.clone())
1901                    .collect();
1902                if !unreached.is_empty() {
1903                    violations.push(format!(
1904                        "disconnected subchain: {} record(s) not reachable from genesis: {}",
1905                        unreached.len(),
1906                        unreached.join(", "),
1907                    ));
1908                }
1909            }
1910            let _ = by_digest; // reserved for future cross-checks
1911        }
1912
1913        if violations.is_empty() {
1914            checks.push(VerifyCheck::pass(
1915                "approval-use-chain-continuity",
1916                &format!(
1917                    "{} record(s) form a single connected linked list from one genesis with no cycles or forks",
1918                    nodes.len(),
1919                ),
1920            ));
1921        } else {
1922            checks.push(VerifyCheck::fail(
1923                "approval-use-chain-continuity",
1924                &violations.join("; "),
1925            ));
1926        }
1927    }
1928
1929    // -- replay-hub-org -- v0.9.9 PR 6.
1930    // The strongest level Treeship can speak to today. The release
1931    // rule is non-negotiable: PASS only when (1) at least one embedded
1932    // checkpoint declares kind=HubOrg, (2) every required Hub field is
1933    // populated, (3) the signature verifies against the embedded
1934    // public key, AND (4) the checkpoint covers every embedded
1935    // ApprovalUse via covered_use_ids. Anything short of that means
1936    // "no row" or "fail" -- never silent pass.
1937    //
1938    // No row at all when the package has no Hub-kind checkpoint:
1939    // matches the v0.9.9 PR 4-5 behavior where the panel renders
1940    // "- hub-org   not checked (no Hub checkpoint in package)" so a
1941    // reader doesn't misread an absent row as a failure.
1942    let hub_checkpoints: Vec<&JournalCheckpoint> = bundle
1943        .checkpoints
1944        .iter()
1945        .filter(|cp| cp.checkpoint_kind == crate::statements::CheckpointKind::HubOrg)
1946        .collect();
1947    if !hub_checkpoints.is_empty() {
1948        let mut all_ok = true;
1949        let mut details: Vec<String> = Vec::new();
1950        let mut have_valid_signature = false;
1951        // Security-critical failures (untrusted-issuer / tampered /
1952        // not-hub-kind) must FAIL unconditionally, not warn. Audit
1953        // lane J fix-up: previously these emitted WARN and the CLI
1954        // wrapper's --strict promoted to FAIL, which meant the
1955        // headline audit case (self-signed hub-org forgery) passed
1956        // green-but-yellow in default mode. The release rule is
1957        // "trust pinning is on by default"; expressed in this row
1958        // as "any signature/issuer failure is a hard fail."
1959        let mut security_fatal = false;
1960
1961        for cp in &hub_checkpoints {
1962            match crate::statements::verify_hub_checkpoint_signature(cp, trust) {
1963                crate::statements::HubCheckpointVerification::Valid => {
1964                    have_valid_signature = true;
1965                    // Coverage: every embedded use_id MUST appear in
1966                    // this checkpoint's covered_use_ids. A checkpoint
1967                    // that doesn't cover the package's uses cannot
1968                    // promote replay-hub-org for those uses.
1969                    let covered: std::collections::HashSet<&String> =
1970                        cp.covered_use_ids.iter().collect();
1971                    let missing: Vec<String> = bundle
1972                        .uses
1973                        .iter()
1974                        .filter(|u| !covered.contains(&u.use_id))
1975                        .map(|u| u.use_id.clone())
1976                        .collect();
1977                    if missing.is_empty() {
1978                        details.push(format!(
1979                            "{} signed by {} verifies; covers {} use(s)",
1980                            cp.checkpoint_id,
1981                            cp.hub_id,
1982                            cp.covered_use_ids.len(),
1983                        ));
1984                    } else {
1985                        all_ok = false;
1986                        details.push(format!(
1987                            "{} verifies but does not cover {} use(s): {}",
1988                            cp.checkpoint_id,
1989                            missing.len(),
1990                            missing.join(", "),
1991                        ));
1992                    }
1993                }
1994                crate::statements::HubCheckpointVerification::MissingFields(field) => {
1995                    all_ok = false;
1996                    details.push(format!(
1997                        "{} declares kind=hub-org but field `{}` is missing",
1998                        cp.checkpoint_id, field,
1999                    ));
2000                }
2001                crate::statements::HubCheckpointVerification::Tampered => {
2002                    all_ok = false;
2003                    security_fatal = true;
2004                    details.push(format!(
2005                        "{} hub signature failed verification (tampered or wrong key)",
2006                        cp.checkpoint_id,
2007                    ));
2008                }
2009                crate::statements::HubCheckpointVerification::NotHubKind => {
2010                    // Filter ensures this is unreachable; keep the
2011                    // arm so a future filter relaxation doesn't go
2012                    // silent.
2013                    all_ok = false;
2014                    security_fatal = true;
2015                    details.push(format!(
2016                        "{} kind toggled out of hub-org during verify",
2017                        cp.checkpoint_id,
2018                    ));
2019                }
2020                crate::statements::HubCheckpointVerification::UntrustedIssuer => {
2021                    all_ok = false;
2022                    security_fatal = true;
2023                    details.push(format!(
2024                        "{} hub_public_key is not a trusted root (configure via `treeship trust add`)",
2025                        cp.checkpoint_id,
2026                    ));
2027                }
2028            }
2029        }
2030        if all_ok && have_valid_signature {
2031            checks.push(VerifyCheck::pass("replay-hub-org", &details.join("; ")));
2032        } else if security_fatal {
2033            // Untrusted issuer or tampered signature: fail-by-default
2034            // regardless of --strict. Self-signed forgeries must not
2035            // pass yellow.
2036            checks.push(VerifyCheck::fail("replay-hub-org", &details.join("; ")));
2037        } else {
2038            // Hub checkpoint is present but does not satisfy every
2039            // non-security gate (missing-field, coverage gap).
2040            // Default mode warns; the CLI verify wrapper's --strict
2041            // promotes to fail.
2042            checks.push(VerifyCheck::warn("replay-hub-org", &details.join("; ")));
2043        }
2044    }
2045    // No hub-org checkpoints embedded -> no row. The Approval
2046    // Authority panel still renders "- hub-org   not checked".
2047
2048    let _ = ReplayCheckLevel::HubOrg;
2049    let _ = approval_revocation_record_digest as fn(&ApprovalRevocation) -> String;
2050    let _ = ReplayCheck::not_performed;
2051}
2052
2053/// A single verification check result.
2054#[derive(Debug, Clone)]
2055pub struct VerifyCheck {
2056    pub name: String,
2057    pub status: VerifyStatus,
2058    pub detail: String,
2059}
2060
2061/// Status of a verification check.
2062#[derive(Debug, Clone, PartialEq, Eq)]
2063pub enum VerifyStatus {
2064    Pass,
2065    Fail,
2066    Warn,
2067}
2068
2069impl VerifyCheck {
2070    pub fn pass(name: &str, detail: &str) -> Self {
2071        Self {
2072            name: name.into(),
2073            status: VerifyStatus::Pass,
2074            detail: detail.into(),
2075        }
2076    }
2077    pub fn fail(name: &str, detail: &str) -> Self {
2078        Self {
2079            name: name.into(),
2080            status: VerifyStatus::Fail,
2081            detail: detail.into(),
2082        }
2083    }
2084    pub fn warn(name: &str, detail: &str) -> Self {
2085        Self {
2086            name: name.into(),
2087            status: VerifyStatus::Warn,
2088            detail: detail.into(),
2089        }
2090    }
2091}
2092
2093impl VerifyCheck {
2094    pub fn passed(&self) -> bool {
2095        self.status == VerifyStatus::Pass
2096    }
2097}
2098
2099/// HTML template for the self-contained verifier preview.
2100/// Loaded at compile time so the binary carries no runtime file dependencies.
2101const PREVIEW_TEMPLATE: &str = include_str!("preview_template.html");
2102
2103/// Brand display serif (Fraunces, SIL OFL 1.1) — latin variable slice, weights
2104/// 300..500. Embedded as base64 into the self-contained preview so the document
2105/// renders with the brand type offline, no CDN. Body and mono use the system
2106/// stack. See design/fonts/.
2107// Vendored inside the crate, not referenced out of the workspace. `cargo
2108// package` only tarballs files under the crate root, so an `include_bytes!`
2109// reaching up to `design/fonts/` builds fine here and fails to compile once
2110// published -- which is exactly how treeship-core missed crates.io in v0.22.0
2111// while npm and PyPI shipped. Kept in sync with `design/fonts/` by
2112// `scripts/check-vendored-fonts.py`.
2113const FRAUNCES_WOFF2: &[u8] = include_bytes!("../../assets/fonts/fraunces-latin-var.woff2");
2114
2115/// The `data:` URI for the embedded Fraunces woff2, substituted into the
2116/// template's `@font-face`. Standard (not URL-safe) base64: it sits in a CSS
2117/// `url(...)`, not a URL path.
2118fn fraunces_data_uri() -> String {
2119    use base64::engine::general_purpose::STANDARD;
2120    use base64::Engine;
2121    format!("data:font/woff2;base64,{}", STANDARD.encode(FRAUNCES_WOFF2))
2122}
2123
2124/// Generate a self-contained preview.html that embeds the receipt JSON
2125/// and runs Merkle verification client-side using Web Crypto API.
2126///
2127/// The HTML works fully air-gapped: no network calls, no CDN, no server.
2128/// Open it in any modern browser and it automatically verifies the receipt
2129/// and shows pass/fail for each check.
2130pub fn render_preview_html(receipt: &SessionReceipt) -> String {
2131    render_preview_html_with_approvals(receipt, None)
2132}
2133
2134/// What the preview shows under "Approval gates": every grant the package
2135/// embeds under `approvals/grants` and every use under `approvals/uses`.
2136///
2137/// The preview used to read approvals only from the chained artifacts, so a
2138/// session whose approvals were consumed (and therefore exported into the
2139/// `approvals/` directory, where the verifier checks them) rendered "No
2140/// approval gates recorded" while `package verify` printed `PASS
2141/// replay-local-journal`. This is the same evidence the verifier reads,
2142/// summarised for a reader. A grant envelope that does not parse is listed
2143/// by id with `parsed: false` rather than dropped: the reader should see
2144/// that evidence exists even when this page cannot describe it.
2145pub fn preview_approvals_json(bundle: Option<&ApprovalsBundle>) -> serde_json::Value {
2146    let Some(b) = bundle else {
2147        return serde_json::Value::Null;
2148    };
2149    if b.grants.is_empty() && b.uses.is_empty() {
2150        return serde_json::Value::Null;
2151    }
2152    let grants: Vec<serde_json::Value> = b
2153        .grants
2154        .iter()
2155        .map(|(grant_id, bytes)| {
2156            let parsed = crate::attestation::Envelope::from_json(bytes)
2157                .ok()
2158                .and_then(|env| env.unmarshal_statement::<ApprovalStatement>().ok());
2159            match parsed {
2160                Some(st) => serde_json::json!({
2161                    "grant_id": grant_id,
2162                    "parsed": true,
2163                    "approver": st.approver,
2164                    "description": st.description,
2165                    "timestamp": st.timestamp,
2166                    "expires_at": st.expires_at,
2167                    "scope": st.scope.as_ref().map(|sc| serde_json::json!({
2168                        "allowed_actors": sc.allowed_actors,
2169                        "allowed_actions": sc.allowed_actions,
2170                        "allowed_subjects": sc.allowed_subjects,
2171                        "max_uses": sc.max_actions,
2172                        "valid_until": sc.valid_until,
2173                    })),
2174                }),
2175                None => serde_json::json!({ "grant_id": grant_id, "parsed": false }),
2176            }
2177        })
2178        .collect();
2179    let uses: Vec<serde_json::Value> = b
2180        .uses
2181        .iter()
2182        .map(|u| serde_json::to_value(u).unwrap_or(serde_json::Value::Null))
2183        .collect();
2184    serde_json::json!({ "grants": grants, "uses": uses })
2185}
2186
2187/// `render_preview_html`, plus the approval evidence the package embeds.
2188pub fn render_preview_html_with_approvals(
2189    receipt: &SessionReceipt,
2190    bundle: Option<&ApprovalsBundle>,
2191) -> String {
2192    let approvals_json = preview_approvals_json(bundle).to_string();
2193    let safe_approvals = approvals_json.replace('<', r"\u003c");
2194    let receipt_json = serde_json::to_string_pretty(receipt).unwrap_or_else(|_| "{}".to_string());
2195    // Defense-in-depth: escape </script sequences so a malicious receipt
2196    // field cannot break out of the JSON data block. The primary defense
2197    // is type="application/json" which the HTML parser does not execute,
2198    // but this escaping adds a second layer.
2199    // Escape ALL '<' as '\u003c' in the JSON string to prevent any
2200    // case-variant of </script> from breaking out of the data block.
2201    // This is bulletproof: no HTML parser can see a tag open inside the JSON.
2202    let safe_json = receipt_json.replace('<', r"\u003c");
2203
2204    // The only placeholder that must take the receipt JSON is the data
2205    // block. replacen(.., 1) substitutes exactly that first occurrence, so
2206    // even if the token is ever reused elsewhere in the template (e.g. a JS
2207    // placeholder check) the receipt body is never injected into it. The
2208    // template's own placeholder check uses a split sentinel for the same
2209    // reason. The page title is set at runtime from the parsed JSON.
2210    PREVIEW_TEMPLATE
2211        .replacen("__RECEIPT_JSON__", &safe_json, 1)
2212        .replacen("__APPROVALS_JSON__", &safe_approvals, 1)
2213        .replace("__FONT_FRAUNCES__", &fraunces_data_uri())
2214}
2215
2216/// Find the `coverage.v1` receipt among the sealed envelopes and summarise
2217/// it; warn when the package carries none.
2218fn coverage_check(pkg_dir: &Path, receipt: &SessionReceipt) -> VerifyCheck {
2219    let art_dir = pkg_dir.join(ARTIFACTS_DIR);
2220    for entry in &receipt.artifacts {
2221        let path = art_dir.join(format!("{}.json", sanitize_filename(&entry.artifact_id)));
2222        let Ok(raw) = std::fs::read(&path) else {
2223            continue;
2224        };
2225        let Ok(env) = crate::attestation::Envelope::from_json(&raw) else {
2226            continue;
2227        };
2228        let Some(stmt) = env
2229            .payload_bytes()
2230            .ok()
2231            .and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
2232        else {
2233            continue;
2234        };
2235        if stmt.get("kind").and_then(|k| k.as_str()) != Some("coverage.v1") {
2236            continue;
2237        }
2238        let Some(p) = stmt.get("payload") else {
2239            continue;
2240        };
2241        let level = p
2242            .get("declared_level")
2243            .and_then(|v| v.as_str())
2244            .unwrap_or("?");
2245        let harnesses: Vec<String> = p
2246            .get("harnesses")
2247            .and_then(|h| h.as_array())
2248            .map(|arr| {
2249                arr.iter()
2250                    .map(|h| {
2251                        let id = h.get("harness_id").and_then(|v| v.as_str()).unwrap_or("?");
2252                        let modes: Vec<&str> = h
2253                            .get("connection_modes")
2254                            .and_then(|m| m.as_array())
2255                            .map(|m| m.iter().filter_map(|x| x.as_str()).collect())
2256                            .unwrap_or_default();
2257                        if modes.is_empty() {
2258                            id.to_string()
2259                        } else {
2260                            format!("{id} via {}", modes.join("+"))
2261                        }
2262                    })
2263                    .collect()
2264            })
2265            .unwrap_or_default();
2266        let events = p
2267            .get("observed")
2268            .and_then(|o| o.get("events"))
2269            .and_then(|v| v.as_u64())
2270            .unwrap_or(0);
2271        let types = p
2272            .get("observed")
2273            .and_then(|o| o.get("event_types"))
2274            .and_then(|t| t.as_object())
2275            .map(|m| m.len())
2276            .unwrap_or(0);
2277        let gaps = p
2278            .get("gaps")
2279            .and_then(|g| g.as_array())
2280            .map(|g| g.len())
2281            .unwrap_or(0);
2282        let via = if harnesses.is_empty() {
2283            "no harness state".to_string()
2284        } else {
2285            harnesses.join(", ")
2286        };
2287        return VerifyCheck::pass(
2288            "coverage",
2289            &format!(
2290                "{}: declared {level} ({via}); {events} events observed across {types} types; {gaps} stated gap(s). A declared level is the harness's potential, not proof of what happened outside it",
2291                entry.artifact_id
2292            ),
2293        );
2294    }
2295    VerifyCheck::warn(
2296        "coverage",
2297        "no coverage receipt in the sealed set: the package does not say what the harness could observe (sealed before 0.31.6, or minted without one)",
2298    )
2299}
2300
2301/// One sealed action, as much of it as the retries row needs.
2302struct SealedAction {
2303    action: String,
2304    actor: String,
2305    retry: Option<serde_json::Value>,
2306    /// The signed idempotency key of this attempt (v1 `idempotencyKey`, v2
2307    /// `idempotency_key`), or the one inside its retry block.
2308    idempotency_key: Option<String>,
2309    /// What the attempt says it changed: v2 `effect.readback`, else
2310    /// `effect.output_hash`, else v1 `meta.output_digest`.
2311    effect_signature: Option<String>,
2312    effect_verified: bool,
2313}
2314
2315/// Walk the sealed envelopes for action statements and check every retry
2316/// chain. `None` when no action names an earlier attempt.
2317fn retries_check(pkg_dir: &Path, receipt: &SessionReceipt) -> Option<VerifyCheck> {
2318    use std::collections::{BTreeMap, BTreeSet};
2319    let art_dir = pkg_dir.join(ARTIFACTS_DIR);
2320    let mut actions: BTreeMap<String, SealedAction> = BTreeMap::new();
2321    for entry in &receipt.artifacts {
2322        let path = art_dir.join(format!("{}.json", sanitize_filename(&entry.artifact_id)));
2323        let Ok(raw) = std::fs::read(&path) else {
2324            continue;
2325        };
2326        let Ok(env) = crate::attestation::Envelope::from_json(&raw) else {
2327            continue;
2328        };
2329        let Some(stmt) = env
2330            .payload_bytes()
2331            .ok()
2332            .and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
2333        else {
2334            continue;
2335        };
2336        let ty = stmt.get("type").and_then(|t| t.as_str()).unwrap_or("");
2337        if !ty.contains("/action/") {
2338            continue;
2339        }
2340        let effect = stmt.get("effect");
2341        let effect_signature = effect
2342            .and_then(|e| e.get("readback"))
2343            .and_then(|v| v.as_str())
2344            .or_else(|| {
2345                effect
2346                    .and_then(|e| e.get("output_hash"))
2347                    .and_then(|v| v.as_str())
2348            })
2349            .or_else(|| {
2350                stmt.get("meta")
2351                    .and_then(|m| m.get("output_digest"))
2352                    .and_then(|v| v.as_str())
2353            })
2354            .map(str::to_string);
2355        let effect_verified = matches!(
2356            effect
2357                .and_then(|e| e.get("effect_confidence"))
2358                .and_then(|v| v.as_str()),
2359            Some("verified") | Some("partial")
2360        );
2361        actions.insert(
2362            entry.artifact_id.clone(),
2363            SealedAction {
2364                action: stmt
2365                    .get("action")
2366                    .and_then(|v| v.as_str())
2367                    .unwrap_or("")
2368                    .to_string(),
2369                actor: stmt
2370                    .get("actor")
2371                    .and_then(|v| v.as_str())
2372                    .unwrap_or("")
2373                    .to_string(),
2374                idempotency_key: stmt
2375                    .get("idempotencyKey")
2376                    .or_else(|| stmt.get("idempotency_key"))
2377                    .or_else(|| stmt.get("retry").and_then(|r| r.get("idempotency_key")))
2378                    .and_then(|v| v.as_str())
2379                    .map(str::to_string),
2380                retry: stmt.get("retry").cloned(),
2381                effect_signature,
2382                effect_verified,
2383            },
2384        );
2385    }
2386    let retries: Vec<(&String, &SealedAction)> =
2387        actions.iter().filter(|(_, a)| a.retry.is_some()).collect();
2388    if retries.is_empty() {
2389        return None;
2390    }
2391    let mut problems: Vec<String> = Vec::new();
2392    let mut chains: BTreeSet<String> = BTreeSet::new();
2393    for (id, a) in &retries {
2394        let r = a.retry.as_ref().unwrap();
2395        let of = r.get("of").and_then(|v| v.as_str()).unwrap_or("");
2396        let attempt = r.get("attempt").and_then(|v| v.as_u64()).unwrap_or(0);
2397        let cause = r.get("cause").and_then(|v| v.as_str()).unwrap_or("unknown");
2398        let key = r.get("idempotency_key").and_then(|v| v.as_str());
2399        let Some(prev) = actions.get(of) else {
2400            problems.push(format!(
2401                "{id} (attempt {attempt}, {cause}) retries {of}, which is not in this package"
2402            ));
2403            chains.insert(of.to_string());
2404            continue;
2405        };
2406        // The chain root is the attempt with no retry block.
2407        let mut root = of.to_string();
2408        let mut hops = 0;
2409        while let Some(p) = actions.get(&root) {
2410            match p
2411                .retry
2412                .as_ref()
2413                .and_then(|x| x.get("of"))
2414                .and_then(|v| v.as_str())
2415            {
2416                Some(next) if hops < 64 => {
2417                    root = next.to_string();
2418                    hops += 1;
2419                }
2420                _ => break,
2421            }
2422        }
2423        chains.insert(root);
2424        if prev.action != a.action || prev.actor != a.actor {
2425            problems.push(format!(
2426                "{id} retries {of} but is a different action or actor ({} by {} vs {} by {})",
2427                a.action, a.actor, prev.action, prev.actor
2428            ));
2429        }
2430        let prev_attempt = prev
2431            .retry
2432            .as_ref()
2433            .and_then(|x| x.get("attempt"))
2434            .and_then(|v| v.as_u64())
2435            .unwrap_or(1);
2436        if attempt != prev_attempt + 1 {
2437            problems.push(format!(
2438                "{id} is attempt {attempt} but retries attempt {prev_attempt}"
2439            ));
2440        }
2441        let prev_key = prev.idempotency_key.as_deref();
2442        if let (Some(k), Some(pk)) = (key, prev_key) {
2443            if k != pk {
2444                problems.push(format!(
2445                    "{id} retries {of} with a different idempotency key: the second attempt is not idempotent with the first"
2446                ));
2447            }
2448        }
2449        if let (Some(cur), Some(before)) = (&a.effect_signature, &prev.effect_signature) {
2450            if cur != before {
2451                problems.push(format!(
2452                    "{id} and {of} both report an effect and they differ ({} vs {}): two mutations, not one recovery",
2453                    &cur[..cur.len().min(24)],
2454                    &before[..before.len().min(24)]
2455                ));
2456            }
2457        }
2458        if cause == "timeout" && prev.effect_verified {
2459            problems.push(format!(
2460                "{id} retried {of} for a timeout, but {of} reports a verified effect: the first attempt landed"
2461            ));
2462        }
2463    }
2464    let n = retries.len();
2465    let c = chains.len();
2466    if problems.is_empty() {
2467        Some(VerifyCheck::pass(
2468            "retries",
2469            &format!(
2470                "{n} retry attempt(s) across {c} chain(s): same action and actor, attempts count up, idempotency keys agree, and no two attempts report a distinct effect"
2471            ),
2472        ))
2473    } else {
2474        Some(VerifyCheck::warn(
2475            "retries",
2476            &format!(
2477                "{n} retry attempt(s) across {c} chain(s); {}: {}",
2478                problems.len(),
2479                problems.join("; ")
2480            ),
2481        ))
2482    }
2483}
2484
2485/// Summarise the `judgement.v1` receipts in the sealed set: how many, which
2486/// judges, and whether any was acted on below its own threshold. `None`
2487/// when the package carries no judgement.
2488fn judgements_check(pkg_dir: &Path, receipt: &SessionReceipt) -> Option<VerifyCheck> {
2489    let art_dir = pkg_dir.join(ARTIFACTS_DIR);
2490    let mut total = 0usize;
2491    let mut judges: BTreeSet<String> = BTreeSet::new();
2492    let mut flagged: Vec<String> = Vec::new();
2493    for entry in &receipt.artifacts {
2494        let path = art_dir.join(format!("{}.json", sanitize_filename(&entry.artifact_id)));
2495        let Ok(raw) = std::fs::read(&path) else {
2496            continue;
2497        };
2498        let Ok(env) = crate::attestation::Envelope::from_json(&raw) else {
2499            continue;
2500        };
2501        let Some(stmt) = env
2502            .payload_bytes()
2503            .ok()
2504            .and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
2505        else {
2506            continue;
2507        };
2508        if stmt.get("kind").and_then(|k| k.as_str()) != Some("judgement.v1") {
2509            continue;
2510        }
2511        let Some(p) = stmt.get("payload") else {
2512            continue;
2513        };
2514        total += 1;
2515        if let Some(m) = p
2516            .get("judge")
2517            .and_then(|j| j.get("model"))
2518            .and_then(|v| v.as_str())
2519        {
2520            judges.insert(m.to_string());
2521        }
2522        let outcome = p.get("outcome").and_then(|v| v.as_str()).unwrap_or("");
2523        if outcome != "acted" {
2524            continue;
2525        }
2526        let threshold = p
2527            .get("threshold")
2528            .and_then(|t| t.get("value"))
2529            .and_then(|v| v.as_f64());
2530        let applies_to = p
2531            .get("threshold")
2532            .and_then(|t| t.get("applies_to"))
2533            .and_then(|v| v.as_str())
2534            .unwrap_or("confidence");
2535        let answer = p.get("answer");
2536        let measured = match applies_to {
2537            "noul" => answer.and_then(|a| a.get("noul")).and_then(|v| v.as_f64()),
2538            _ => answer
2539                .and_then(|a| a.get("confidence"))
2540                .and_then(|v| v.as_f64())
2541                .or_else(|| answer.and_then(|a| a.get("noul")).and_then(|v| v.as_f64())),
2542        };
2543        // What "acted" means depends on the question. On a confidence, the
2544        // caller acted on the answer, so the confidence must have met the
2545        // bar. On a yes/no probability the bar cuts both ways: at or above
2546        // it the answer is "yes" and the caller's effect should be the
2547        // refusing one (deny or ask); below it the answer is "no" and the
2548        // effect should be allow or warn. A judgement is outside its bar
2549        // when the effect contradicts the side of the threshold the answer
2550        // fell on. A rules judge answering 0.0 to "unsafe?" and the caller
2551        // proceeding is exactly what the bar asked for, not a violation.
2552        let effect = p.get("effect").and_then(|v| v.as_str());
2553        match (threshold, measured) {
2554            (None, _) => flagged.push(format!(
2555                "{} acted with no threshold declared",
2556                entry.artifact_id
2557            )),
2558            (Some(t), Some(m)) if applies_to == "noul" => {
2559                let yes = m >= t;
2560                let refusing = matches!(effect, Some("deny") | Some("ask"));
2561                let allowing = matches!(effect, Some("allow") | Some("warn"));
2562                if yes && allowing {
2563                    flagged.push(format!(
2564                        "{} acted to {} at noul {m:.3}, at or above its threshold {t:.3} (the answer was yes)",
2565                        entry.artifact_id,
2566                        effect.unwrap_or("")
2567                    ));
2568                } else if !yes && refusing {
2569                    flagged.push(format!(
2570                        "{} acted to {} at noul {m:.3}, below its threshold {t:.3} (the answer was no)",
2571                        entry.artifact_id,
2572                        effect.unwrap_or("")
2573                    ));
2574                } else if effect.is_none() && !yes {
2575                    flagged.push(format!(
2576                        "{} acted at noul {m:.3} below its threshold {t:.3} with no effect recorded",
2577                        entry.artifact_id
2578                    ));
2579                }
2580            }
2581            (Some(t), Some(m)) if m < t => flagged.push(format!(
2582                "{} acted at {applies_to} {m:.3} below its threshold {t:.3}",
2583                entry.artifact_id
2584            )),
2585            (Some(t), None) => flagged.push(format!(
2586                "{} acted against a threshold of {t:.3} on {applies_to} but the answer carries no {applies_to}",
2587                entry.artifact_id
2588            )),
2589            _ => {}
2590        }
2591    }
2592    if total == 0 {
2593        return None;
2594    }
2595    let who = judges.into_iter().collect::<Vec<_>>().join(", ");
2596    if flagged.is_empty() {
2597        Some(VerifyCheck::pass(
2598            "judgements",
2599            &format!(
2600                "{total} judgement(s) by {who}; every one acted on met its declared threshold. The row reads the caller's record; it does not re-run a judge"
2601            ),
2602        ))
2603    } else {
2604        Some(VerifyCheck::warn(
2605            "judgements",
2606            &format!(
2607                "{total} judgement(s) by {who}; {} acted on outside its own bar: {}",
2608                flagged.len(),
2609                flagged.join("; ")
2610            ),
2611        ))
2612    }
2613}
2614
2615#[cfg(test)]
2616mod tests {
2617    use super::*;
2618    use crate::session::event::*;
2619    use crate::session::manifest::SessionManifest;
2620    use crate::session::receipt::{ArtifactEntry, ReceiptComposer};
2621
2622    fn make_receipt() -> SessionReceipt {
2623        let manifest = SessionManifest::new(
2624            "ssn_pkg_test".into(),
2625            "agent://test".into(),
2626            "2026-04-05T08:00:00Z".into(),
2627            1743843600000,
2628        );
2629
2630        let mk = |seq: u64, inst: &str, et: EventType| -> SessionEvent {
2631            SessionEvent {
2632                session_id: "ssn_pkg_test".into(),
2633                event_id: format!("evt_{:016x}", seq),
2634                timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
2635                sequence_no: seq,
2636                trace_id: "trace_1".into(),
2637                span_id: format!("span_{seq}"),
2638                parent_span_id: None,
2639                agent_id: format!("agent://{inst}"),
2640                agent_instance_id: inst.into(),
2641                agent_name: inst.into(),
2642                agent_role: None,
2643                host_id: "host_1".into(),
2644                tool_runtime_id: None,
2645                event_type: et,
2646                artifact_ref: None,
2647                meta: None,
2648            }
2649        };
2650
2651        let events = vec![
2652            mk(0, "root", EventType::SessionStarted),
2653            mk(
2654                1,
2655                "root",
2656                EventType::AgentStarted {
2657                    parent_agent_instance_id: None,
2658                },
2659            ),
2660            mk(
2661                2,
2662                "root",
2663                EventType::AgentCalledTool {
2664                    tool_name: "read_file".into(),
2665                    tool_input_digest: None,
2666                    tool_output_digest: None,
2667                    duration_ms: Some(10),
2668                },
2669            ),
2670            mk(
2671                3,
2672                "root",
2673                EventType::AgentCompleted {
2674                    termination_reason: None,
2675                },
2676            ),
2677            mk(
2678                4,
2679                "root",
2680                EventType::SessionClosed {
2681                    summary: Some("Done".into()),
2682                    duration_ms: Some(60000),
2683                },
2684            ),
2685        ];
2686
2687        let artifacts = vec![ArtifactEntry {
2688            artifact_id: "art_001".into(),
2689            payload_type: "action".into(),
2690            digest: None,
2691            signed_at: None,
2692            unchained: false,
2693        }];
2694
2695        ReceiptComposer::compose(&manifest, &events, artifacts)
2696    }
2697
2698    #[test]
2699    fn build_and_read_package() {
2700        let receipt = make_receipt();
2701        let tmp = std::env::temp_dir().join(format!("treeship-pkg-test-{}", rand::random::<u32>()));
2702
2703        let output = build_package(&receipt, &tmp).unwrap();
2704        assert!(output.path.exists());
2705        assert!(output.path.join("receipt.json").exists());
2706        assert!(output.path.join("merkle.json").exists());
2707        assert!(output.path.join("render.json").exists());
2708        assert!(output.path.join("preview.html").exists());
2709        assert!(output.receipt_digest.starts_with("sha256:"));
2710        assert!(output.file_count >= 4);
2711
2712        // Read back
2713        let read_back = read_package(&output.path).unwrap();
2714        assert_eq!(read_back.session.id, "ssn_pkg_test");
2715        assert_eq!(read_back.type_, RECEIPT_TYPE);
2716
2717        let _ = std::fs::remove_dir_all(&tmp);
2718    }
2719
2720    #[test]
2721    fn verify_valid_package() {
2722        let receipt = make_receipt();
2723        let tmp =
2724            std::env::temp_dir().join(format!("treeship-pkg-verify-{}", rand::random::<u32>()));
2725
2726        let output = build_package(&receipt, &tmp).unwrap();
2727        let checks = verify_package_structural(&output.path).unwrap();
2728
2729        let fails: Vec<_> = checks
2730            .iter()
2731            .filter(|c| c.status == VerifyStatus::Fail)
2732            .collect();
2733        assert!(fails.is_empty(), "unexpected failures: {fails:?}");
2734
2735        let passes: Vec<_> = checks
2736            .iter()
2737            .filter(|c| c.status == VerifyStatus::Pass)
2738            .collect();
2739        assert!(
2740            passes.len() >= 5,
2741            "expected at least 5 pass checks, got {}",
2742            passes.len()
2743        );
2744
2745        let _ = std::fs::remove_dir_all(&tmp);
2746    }
2747
2748    // AUD-07: a receipt stamped reconcile_degraded must surface a WARN on
2749    // verify, so a consumer is told the file ledger may be incomplete rather
2750    // than reading the package as a clean, complete audit trail.
2751    #[test]
2752    fn verify_warns_when_reconcile_degraded() {
2753        let mut receipt = make_receipt();
2754        receipt.proofs.reconcile_degraded = true;
2755        let tmp =
2756            std::env::temp_dir().join(format!("treeship-pkg-degraded-{}", rand::random::<u32>()));
2757
2758        let output = build_package(&receipt, &tmp).unwrap();
2759        let checks = verify_package_structural(&output.path).unwrap();
2760
2761        let warned = checks
2762            .iter()
2763            .any(|c| c.name == "reconcile_degraded" && c.status == VerifyStatus::Warn);
2764        assert!(warned, "expected a reconcile_degraded WARN, got {checks:?}");
2765        // It is a WARN, not a hard fail (the signatures/Merkle are still valid).
2766        let fails: Vec<_> = checks
2767            .iter()
2768            .filter(|c| c.status == VerifyStatus::Fail)
2769            .collect();
2770        assert!(fails.is_empty(), "must not hard-fail: {fails:?}");
2771
2772        let _ = std::fs::remove_dir_all(&tmp);
2773    }
2774
2775    #[test]
2776    fn verify_no_degraded_warn_when_clean() {
2777        // The default receipt has reconcile_degraded=false: no such WARN.
2778        let receipt = make_receipt();
2779        let tmp =
2780            std::env::temp_dir().join(format!("treeship-pkg-clean-{}", rand::random::<u32>()));
2781        let output = build_package(&receipt, &tmp).unwrap();
2782        let checks = verify_package_structural(&output.path).unwrap();
2783        assert!(
2784            !checks.iter().any(|c| c.name == "reconcile_degraded"),
2785            "clean receipt must not emit a reconcile_degraded check"
2786        );
2787        let _ = std::fs::remove_dir_all(&tmp);
2788    }
2789
2790    #[test]
2791    fn verify_detects_missing_receipt() {
2792        let tmp =
2793            std::env::temp_dir().join(format!("treeship-pkg-empty-{}", rand::random::<u32>()));
2794        std::fs::create_dir_all(&tmp).unwrap();
2795
2796        let err = read_package(&tmp);
2797        assert!(err.is_err());
2798
2799        let _ = std::fs::remove_dir_all(&tmp);
2800    }
2801
2802    #[test]
2803    fn preview_html_renders_approval_evidence_from_the_bundle() {
2804        // The package embeds consumed approvals under approvals/ (that is what
2805        // `package verify` checks as replay-local-journal). The preview must
2806        // show them too: a reader saw "No approval gates recorded" on a
2807        // session whose approval was minted, spent once, and verified.
2808        use crate::attestation::sign::sign;
2809        use crate::attestation::Ed25519Signer;
2810        use crate::statements::ApprovalScope;
2811        use crate::statements::TYPE_APPROVAL_USE;
2812
2813        let receipt = make_receipt();
2814        assert_eq!(preview_approvals_json(None), serde_json::Value::Null);
2815        assert_eq!(
2816            preview_approvals_json(Some(&ApprovalsBundle::default())),
2817            serde_json::Value::Null,
2818            "an empty bundle is the same as none"
2819        );
2820
2821        let signer = Ed25519Signer::generate("key_test_preview").unwrap();
2822        let mut grant = ApprovalStatement::new("human://operator", "nonce-preview-0001");
2823        grant.description = Some("apply change chg-0001: 3% clearance".into());
2824        grant.scope = Some(ApprovalScope {
2825            max_actions: Some(1),
2826            valid_until: None,
2827            allowed_actors: vec!["agent://merchant".into()],
2828            allowed_actions: vec!["commerce.tool.apply_change.intent".into()],
2829            allowed_subjects: vec!["change://chg-0001".into()],
2830            extra: None,
2831        });
2832        let signed = sign("application/vnd.treeship.approval.v1+json", &grant, &signer).unwrap();
2833        let grant_id = signed.artifact_id.to_string();
2834        let grant_bytes = serde_json::to_vec(&signed.envelope).unwrap();
2835
2836        let use_record = ApprovalUse {
2837            type_: TYPE_APPROVAL_USE.into(),
2838            use_id: "use_preview_0001".into(),
2839            grant_id: grant_id.clone(),
2840            grant_digest: signed.digest.clone(),
2841            nonce_digest: "sha256:00".into(),
2842            actor: "agent://merchant".into(),
2843            action: "commerce.tool.apply_change.intent".into(),
2844            subject: "change://chg-0001".into(),
2845            session_id: Some("ssn_pkg_test".into()),
2846            action_artifact_id: Some("art_apply_intent".into()),
2847            receipt_digest: None,
2848            use_number: 1,
2849            max_uses: Some(1),
2850            idempotency_key: None,
2851            created_at: "2026-09-07T10:45:49Z".into(),
2852            expires_at: None,
2853            previous_record_digest: String::new(),
2854            record_digest: String::new(),
2855            signature: None,
2856            signature_alg: None,
2857            signing_key_id: None,
2858        };
2859        let bundle = ApprovalsBundle {
2860            grants: vec![
2861                (grant_id.clone(), grant_bytes),
2862                ("art_garbage".into(), b"not json".to_vec()),
2863            ],
2864            uses: vec![use_record],
2865            ..Default::default()
2866        };
2867
2868        let summary = preview_approvals_json(Some(&bundle));
2869        let grants = summary["grants"].as_array().unwrap();
2870        assert_eq!(grants.len(), 2);
2871        assert_eq!(grants[0]["parsed"], true);
2872        assert_eq!(grants[0]["approver"], "human://operator");
2873        assert_eq!(grants[0]["scope"]["max_uses"], 1);
2874        assert_eq!(
2875            grants[0]["scope"]["allowed_subjects"][0],
2876            "change://chg-0001"
2877        );
2878        // An unparsable grant is listed, not dropped, and says so.
2879        assert_eq!(grants[1]["parsed"], false);
2880        assert_eq!(grants[1]["grant_id"], "art_garbage");
2881        let uses = summary["uses"].as_array().unwrap();
2882        assert_eq!(uses[0]["use_number"], 1);
2883        assert_eq!(uses[0]["action_artifact_id"], "art_apply_intent");
2884
2885        let html = render_preview_html_with_approvals(&receipt, Some(&bundle));
2886        assert!(html.contains("id=\"approvals-data\""));
2887        assert!(html.contains("\"approver\":\"human://operator\""));
2888        assert!(html.contains("\"subject\":\"change://chg-0001\""));
2889        assert!(
2890            !html.contains("__APPROVALS_JSON__"),
2891            "placeholder must be substituted"
2892        );
2893        // Without a bundle the data block is a JSON null, never an empty
2894        // string that would throw in JSON.parse and hide the whole page.
2895        let plain = render_preview_html(&receipt);
2896        assert!(plain.contains("type=\"application/json\">null</script>"));
2897    }
2898
2899    #[test]
2900    fn preview_html_contains_session_info() {
2901        let receipt = make_receipt();
2902        let html = render_preview_html(&receipt);
2903        assert!(html.contains("ssn_pkg_test"));
2904        assert!(html.contains("treeship.dev"));
2905        assert!(html.contains("Timeline"));
2906
2907        // Regression: the receipt JSON must land ONLY in the data block,
2908        // never in the inline JS. A prior bug used replace() (all matches)
2909        // against a template that carried the placeholder token twice (data
2910        // slot + a JS placeholder check), injecting the receipt body into a
2911        // JS string literal. That produced an uncaught SyntaxError, so the
2912        // whole script never ran and the preview hung on "Verifying
2913        // receipt...". The JS check now uses a split sentinel that must
2914        // survive substitution verbatim, and replacen(.., 1) fills only the
2915        // first occurrence.
2916        assert!(
2917            html.contains("'__RECEIPT'+'_JSON__'"),
2918            "JS placeholder check was clobbered by the receipt substitution",
2919        );
2920        assert!(
2921            !html.contains("application/json\">__RECEIPT_JSON__</script>"),
2922            "data slot was not substituted with the receipt JSON",
2923        );
2924        // The session id (a receipt value) must appear inside the data block,
2925        // not leak into executable JS, so a quick structural sanity check:
2926        // there is exactly one unsubstituted token left at most (none here).
2927        assert_eq!(
2928            html.matches("__RECEIPT_JSON__").count(),
2929            0,
2930            "no raw placeholder token should remain after substitution",
2931        );
2932    }
2933}