Skip to main content

kranz_engine/
evidence_bundle.rs

1//! Evidence bundle export (ticket `.kranz/tickets/evidence-bundle-export.md`,
2//! KRZ-326 — the governance evidence layer's packaging step): assemble ONE
3//! mission's portable audit package — inputs, gate results, diffs, reviewers,
4//! escalations, cost, and the provenance chain — self-contained and suitable
5//! for handing to an auditor who has no access to the repo.
6//!
7//! The bundle is a plain DIRECTORY, not an archive:
8//!
9//! ```text
10//! <out>/
11//!   manifest.json     — machine index: every entry with its sha256 + source
12//!   summary.md        — the human-readable audit summary
13//!   chain.json        — the provenance chain (provenance-replay's machine form)
14//!   escalations.json  — the mission's escalation-ledger rows
15//!   cost.json         — the mission's cost fold
16//!   events.jsonl      — the raw (already-scrubbed) event log, verbatim
17//!   artefacts/…       — bytes for every resolvable `file:` artefact ref,
18//!                       plus the well-known mission documents (plan, report)
19//! ```
20//!
21//! WHY a directory and not a `.tar`: neither `tar` nor `zip` is anywhere in
22//! the dependency tree, and the ticket blesses the directory form — it is
23//! also the MORE auditable container: every entry greps, diffs, and opens in
24//! any tool with no extraction step, and there is no archive metadata
25//! (mtimes, uid/gid, ordering) whose normalization would be a second
26//! determinism surface. Determinism is therefore ENTRY identity: the same
27//! log yields the same (relative-path → bytes) set, byte for byte. Nothing in
28//! assembly consults a clock, a hash map, or a host path — the log's own
29//! event timestamps travel as DATA (escalation rows), which is exactly what
30//! "same log → same bundle" requires.
31//!
32//! The substrate's own rules, kept:
33//!
34//! - **Everything derives from the already-scrubbed log.** The bundle never
35//!   reintroduces scrubbed values: `events.jsonl` crossed the redact-at-write
36//!   boundary when it was appended, and every derived file folds FROM it.
37//!   A log carrying `secret.redacted` audits yields a bundle with
38//!   fingerprints only (test-pinned). Artefact bytes are the one half that
39//!   did NOT cross a write boundary the engine controls — they are ordinary
40//!   files in a worker-writable tree — so [`read_artefact`] scrubs them here,
41//!   as text, and the manifest digests the redacted form (audit H5).
42//! - **Missing evidence is named, never omitted and never an error.** A
43//!   `file:` reference whose bytes are gone (a cleaned `runs/`, a pruned
44//!   mission) becomes a manifest entry marked `unresolved` carrying the
45//!   original reference — the same total-classifier discipline as
46//!   [`crate::gate_results::resolve_artefact`].
47//! - **No host paths.** References stay mission-relative; the absolute path
48//!   the resolver probed never crosses into the bundle (the same reason the
49//!   provenance chain records only the classification — a host path would
50//!   leak the machine layout into the audit record). Bundle-relative paths
51//!   are always `/`-joined so the package is host-platform neutral.
52//! - **Read-only against the mission dir; the write target is outside it.**
53//!   No lock (§4.3 read-only observers); [`export_evidence_bundle`] refuses
54//!   an `--out` inside the mission dir before writing anything.
55//!
56//! WHY the raw log ships beside the folds: the chain, escalations, and cost
57//! are all pure folds of `events.jsonl`; an auditor with no repo access can
58//! only RE-CHECK that claim if the primary record is in the package. The log
59//! is the one entry that is never unresolved — a mission without its log is
60//! not a mission (the CLI's `require_mission` rule), so a missing/unreadable
61//! log fails the export outright.
62
63use crate::error::EngineError;
64use crate::gate_results::{file_artefact_ref, resolve_artefact, ArtefactResolution};
65use crate::outcomes::MissionOutcomes;
66use crate::paths::MissionPaths;
67use crate::provenance::{ArtefactStatus, ProvenanceChain};
68use cap_fs_ext::{FollowSymlinks, OpenOptionsFollowExt as _};
69use cap_std::ambient_authority;
70use cap_std::fs::{Dir, OpenOptions};
71use serde::{Deserialize, Serialize};
72use sha2::{Digest, Sha256};
73use std::io::{ErrorKind, Read as _, Write as _};
74use std::path::{Component, Path, PathBuf};
75
76/// `manifest.json`'s `version` field: the bundle format version. Bump on any
77/// layout/schema change so a reader can tell what it is holding.
78pub const BUNDLE_FORMAT_VERSION: u32 = 1;
79
80pub const MANIFEST_FILE: &str = "manifest.json";
81pub const SUMMARY_FILE: &str = "summary.md";
82pub const CHAIN_FILE: &str = "chain.json";
83pub const ESCALATIONS_FILE: &str = "escalations.json";
84pub const COST_FILE: &str = "cost.json";
85pub const LOG_FILE: &str = "events.jsonl";
86pub const ARTEFACTS_DIR: &str = "artefacts";
87
88/// The well-known mission documents shipped as artefacts — the mission's
89/// recorded inputs (plan, machine plan, research evidence, approval-time
90/// estimate) and its completion report — in fixed bundle order. Absent ones
91/// (a pre-approval mission, an in-flight mission with no report yet) appear
92/// as unresolved entries exactly like any other missing evidence: named,
93/// never silently omitted.
94const MISSION_DOCUMENTS: [&str; 5] = [
95    "plan.md",
96    "plan.json",
97    "research.md",
98    "estimate.json",
99    "report.md",
100];
101
102/// What one manifest entry is. Serde lowercase (the `ArtefactStatus` idiom).
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "lowercase")]
105pub enum EntryKind {
106    /// `summary.md` — the human surface, folded from the log.
107    Summary,
108    /// `chain.json` — the provenance chain, folded from the log.
109    Chain,
110    /// `escalations.json` — the escalation-ledger rows, folded from the log.
111    Escalations,
112    /// `cost.json` — the cost fold.
113    Cost,
114    /// `events.jsonl` — the raw scrubbed log bytes (the primary record).
115    Log,
116    /// Bytes (or an unresolved placeholder) for one `file:` artefact
117    /// reference or well-known mission document.
118    Artefact,
119}
120
121/// One row of the machine index. `path`/`sha256` are absent exactly when the
122/// entry is an unresolved artefact — there are no bytes to point at, and a
123/// fabricated path would be a lie.
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125#[serde(rename_all = "camelCase")]
126pub struct ManifestEntry {
127    /// Bundle-relative path (`/`-joined) of the entry's bytes.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub path: Option<String>,
130    /// Full lowercase-hex SHA-256 of the bytes at `path`.
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub sha256: Option<String>,
133    /// Where the entry came from: the artefact reference verbatim
134    /// (`file:runs/r-1.jsonl`) for artefacts, or the fold that produced a
135    /// generated file (`derived:provenance-chain`, …).
136    pub source: String,
137    pub kind: EntryKind,
138    /// The resolver's classification — artefact entries only. Generated
139    /// files and the log are present by construction, so they carry no
140    /// classification field at all.
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub status: Option<ArtefactStatus>,
143}
144
145/// The machine index (`manifest.json`): every bundle entry with its sha256
146/// and source reference, in bundle order — generated files first (fixed
147/// order), then artefacts in first-appearance order across the chain (gates,
148/// then sessions, then the well-known documents), each unique reference
149/// appearing exactly once.
150#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
151#[serde(rename_all = "camelCase")]
152pub struct EvidenceManifest {
153    pub version: u32,
154    pub mission_id: String,
155    pub entries: Vec<ManifestEntry>,
156}
157
158/// One bundle payload: a `/`-joined bundle-relative path and its bytes.
159/// Logical paths (never host paths), so the in-memory form is already
160/// platform-neutral.
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct BundleFile {
163    pub path: String,
164    pub bytes: Vec<u8>,
165}
166
167/// The assembled bundle: the manifest plus every NON-manifest file's bytes,
168/// in write order. `manifest.json` itself is serialized at write time (it
169/// cannot list its own hash). Held in memory so two assemblies can be
170/// compared for byte identity before anything touches disk.
171#[derive(Debug, Clone, PartialEq)]
172pub struct EvidenceBundle {
173    pub manifest: EvidenceManifest,
174    pub files: Vec<BundleFile>,
175}
176
177/// The mission's cost fold, bundled (`cost.json`). All fields come from
178/// [`crate::outcomes::mission_outcomes`] — the same fold the flight-surgeon
179/// surfaces use, so the bundle can never disagree with them.
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase")]
182pub struct MissionCostSummary {
183    /// Σ worker cost (recorded costUsd, token-priced fallback).
184    pub total_cost_usd: f64,
185    /// Commits on `feature.completed` whose subject is not an engine/meta
186    /// template.
187    pub non_meta_commits: u64,
188    /// total_cost_usd / non_meta_commits — None when there are no non-meta
189    /// commits (the ratio is meaningless, not zero).
190    pub usd_per_commit: Option<f64>,
191    /// created → terminal minus paused spans; None while the mission is in
192    /// flight.
193    pub cycle_time_ms: Option<u64>,
194    /// Whether a terminal event has been recorded.
195    pub closed: bool,
196    /// Operator interventions (the outcomes fold's definition).
197    pub interventions: u64,
198}
199
200/// What [`export_evidence_bundle`] wrote, for the CLI's one-line report.
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct ExportOutcome {
203    pub out_dir: PathBuf,
204    /// Files written, including `manifest.json`.
205    pub files_written: usize,
206    pub resolved_artefacts: usize,
207    pub unresolved_artefacts: usize,
208}
209
210/// Lowercase hex SHA-256 of `bytes` — the manifest's integrity digest. Full
211/// 32-byte digest (unlike [`crate::prompts::hash_text`]'s 12-char identity
212/// hash): the manifest is an audit surface, so collisions must be
213/// cryptographic, not merely unlikely.
214fn sha256_hex(bytes: &[u8]) -> String {
215    let digest = Sha256::digest(bytes);
216    digest.iter().map(|b| format!("{b:02x}")).collect()
217}
218
219/// Serialize with a trailing newline so generated files are POSIX-clean text.
220/// Deterministic: serde_json's struct order is declaration order and its
221/// pretty printer has no environment input.
222fn to_json_bytes<T: Serialize>(value: &T) -> anyhow::Result<Vec<u8>> {
223    let mut text = serde_json::to_string_pretty(value)?;
224    text.push('\n');
225    Ok(text.into_bytes())
226}
227
228/// Map a resolved `file:` reference to its `/`-joined bundle path under
229/// `artefacts/`, keeping only `Normal` components (`CurDir` is dropped).
230/// Returns None when the reference names nothing — impossible for a
231/// RESOLVED reference (the resolver only resolves honest mission-relative
232/// paths), so callers treat None as unresolved rather than erroring.
233fn artefact_bundle_path(reference: &str) -> Option<String> {
234    let relative = reference.strip_prefix(crate::gate_results::FILE_REF_SCHEME)?;
235    let mut parts = Vec::new();
236    for component in Path::new(relative).components() {
237        match component {
238            Component::Normal(part) => parts.push(part.to_str()?),
239            // `./runs/x` and `runs/x` name the same bytes; the bundle path
240            // must be one canonical spelling or the same file could ship
241            // twice under two names.
242            Component::CurDir => {}
243            // Escape shapes never resolve; belt-and-braces, the bundle
244            // never builds a path from one.
245            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
246        }
247    }
248    if parts.is_empty() {
249        return None;
250    }
251    Some(format!("{ARTEFACTS_DIR}/{}", parts.join("/")))
252}
253
254/// Resolve one `file:` reference against the mission dir and read its bytes
255/// no-follow. Total, mirroring [`resolve_artefact`]: a reference that
256/// classifies unresolved, or whose read fails between classification and
257/// open (a racing prune), yields `(Unresolved, None)` — the bundle names
258/// the gap instead of failing.
259///
260/// The bytes cross [`crate::scrub`] on the way in (audit H5). `events.jsonl`
261/// was redacted at append time; artefacts are ordinary files in a tree a
262/// worker can write, so the bundle applies the boundary itself rather than
263/// inheriting a guarantee only the engine's own writers keep. Without this,
264/// planting a secret in a finished transcript put it in the package the
265/// module contract promises is scrubbed.
266///
267/// Artefacts are handled as TEXT: bytes are decoded lossily
268/// (`from_utf8_lossy`), scrubbed, and the scrubbed text is what ships and
269/// what the manifest digests. A binary artefact therefore travels with
270/// U+FFFD in place of its invalid bytes. That is deliberate: the alternative
271/// — passing non-UTF-8 through verbatim — makes one stray byte an opt-out of
272/// redaction, and every artefact the engine produces (JSONL transcripts,
273/// plan/report markdown, JSON) is text.
274fn read_artefact(mission_dir: &Path, reference: &str) -> (ArtefactStatus, Option<Vec<u8>>) {
275    let ArtefactResolution::Resolved { path } = resolve_artefact(mission_dir, reference) else {
276        return (ArtefactStatus::Unresolved, None);
277    };
278    let read = crate::paths::open_read_nofollow(&path).and_then(|mut file| {
279        let mut bytes = Vec::new();
280        file.read_to_end(&mut bytes)?;
281        Ok(bytes)
282    });
283    match read {
284        Ok(bytes) => {
285            let scrubbed = crate::scrub::scrub(&String::from_utf8_lossy(&bytes));
286            (ArtefactStatus::Resolved, Some(scrubbed.into_bytes()))
287        }
288        Err(_) => (ArtefactStatus::Unresolved, None),
289    }
290}
291
292/// The serde wire name of a fieldless enum value ("approval", "pass",
293/// "validator-scrutiny", …). Deriving from serde — rather than hand-writing
294/// a parallel spelling — means the summary can never drift from the
295/// spellings the log itself records.
296fn wire_name<T: Serialize>(value: &T) -> String {
297    serde_json::to_value(value)
298        .ok()
299        .and_then(|v| v.as_str().map(str::to_string))
300        .expect("gate/role enums always serialize to a string")
301}
302
303/// Collapse all whitespace runs to single spaces (goal text, decision
304/// summaries) so one logical line of the summary stays one physical line.
305fn one_line(text: &str) -> String {
306    text.split_whitespace().collect::<Vec<_>>().join(" ")
307}
308
309/// Escape a markdown table cell: pipes would break the column structure,
310/// newlines the row structure.
311fn md_cell(text: &str) -> String {
312    one_line(text).replace('|', "\\|")
313}
314
315/// Milliseconds as a compact duration ("12s", "47m", "2.3h", "3.1d") — the
316/// CLI's `format_duration_ms` shape, kept local so the engine surface stays
317/// renderer-free.
318fn format_duration_ms(ms: u64) -> String {
319    const S: u64 = 1_000;
320    const M: u64 = 60 * S;
321    const H: u64 = 60 * M;
322    const D: u64 = 24 * H;
323    if ms >= D {
324        format!("{:.1}d", ms as f64 / D as f64)
325    } else if ms >= H {
326        format!("{:.1}h", ms as f64 / H as f64)
327    } else if ms >= M {
328        format!("{}m", ms / M)
329    } else {
330        format!("{}s", ms / S)
331    }
332}
333
334/// Render `summary.md` from the chain, the cost fold, the escalation rows,
335/// and the artefact manifest entries. Pure: no clock, no host paths — the
336/// same inputs always render the same bytes.
337fn render_summary(
338    chain: &ProvenanceChain,
339    cost: &MissionCostSummary,
340    escalations: &[crate::outcomes::EscalationRow],
341    artefact_entries: &[ManifestEntry],
342) -> String {
343    let mut out = String::new();
344    out.push_str(&format!(
345        "# Evidence bundle — mission {}\n\n",
346        chain.mission_id
347    ));
348    out.push_str(
349        "Portable audit package (KRZ-326). Everything below derives from the mission's\n\
350         append-only event log (`events.jsonl`, included verbatim — every line crossed\n\
351         the redact-at-write boundary when appended) plus the mission-relative artefact\n\
352         bytes under `artefacts/`. Artefacts ship as scrubbed text: they are redacted\n\
353         at export (not at write), and any byte that is not valid UTF-8 travels as the\n\
354         replacement character. References whose bytes were no longer on disk at\n\
355         export time are listed as `unresolved` in `manifest.json` — named, never\n\
356         silently omitted.\n\n",
357    );
358
359    out.push_str("## Mission\n\n");
360    match &chain.goal {
361        Some(goal) => out.push_str(&format!("- Goal: {}\n", one_line(goal))),
362        None => out.push_str("- Goal: (not recorded in the log)\n"),
363    }
364    if let (Some(mission_branch), Some(base_branch)) = (&chain.mission_branch, &chain.base_branch) {
365        let pinned = chain
366            .base_sha
367            .as_deref()
368            .map(|sha| format!(" @ {sha}"))
369            .unwrap_or_default();
370        out.push_str(&format!(
371            "- Branch: {mission_branch} (base {base_branch}{pinned})\n"
372        ));
373    }
374    match &chain.outcome {
375        Some(terminal) => {
376            let reason = terminal
377                .reason
378                .as_deref()
379                .map(|reason| format!(" — {}", one_line(reason)))
380                .unwrap_or_default();
381            out.push_str(&format!(
382                "- Outcome: {} at seq {}{}\n",
383                terminal.status.as_str(),
384                terminal.seq,
385                reason
386            ));
387        }
388        None => out.push_str("- Outcome: in flight (no terminal event recorded)\n"),
389    }
390    let usd_per_commit = cost
391        .usd_per_commit
392        .map(|usd| format!("${usd:.4}/commit"))
393        .unwrap_or_else(|| "n/a (no non-meta commits)".to_string());
394    out.push_str(&format!(
395        "- Cost: ${:.4} across {} non-meta commits ({})\n",
396        cost.total_cost_usd, cost.non_meta_commits, usd_per_commit
397    ));
398    let cycle = cost
399        .cycle_time_ms
400        .map(format_duration_ms)
401        .unwrap_or_else(|| "n/a (in flight)".to_string());
402    out.push_str(&format!(
403        "- Cycle time: {cycle} | Interventions: {} | Closed: {}\n\n",
404        cost.interventions,
405        if cost.closed { "yes" } else { "no" }
406    ));
407
408    out.push_str("## Gate ladder (log order)\n\n");
409    if chain.gates.is_empty() {
410        out.push_str("(no gate.result events recorded)\n\n");
411    } else {
412        out.push_str(
413            "| seq | surface | kind | # | gate | verdict | score | artefact | resolution |\n\
414             |----:|---------|------|--:|------|---------|-------|----------|------------|\n",
415        );
416        for gate in &chain.gates {
417            let score = match (gate.score, gate.threshold) {
418                (Some(score), Some(threshold)) => format!("{score}/{threshold}"),
419                _ => "—".to_string(),
420            };
421            out.push_str(&format!(
422                "| {} | {} | {} | {} | {} | {} | {} | `{}` | {} |\n",
423                gate.seq,
424                wire_name(&gate.surface),
425                wire_name(&gate.kind),
426                gate.index,
427                md_cell(&gate.gate),
428                wire_name(&gate.verdict),
429                score,
430                md_cell(&gate.artefact_ref),
431                gate.artefact.as_str(),
432            ));
433        }
434        out.push('\n');
435    }
436
437    if !chain.gate_evaluations.is_empty() {
438        out.push_str("## External gate decisions\n\n");
439        for record in &chain.gate_evaluations {
440            let request = &record.requested.request.params;
441            let status = if let Some(reason) = &record.closed {
442                format!("closed: {}", md_cell(reason))
443            } else {
444                match &record.resolution {
445                    Some(resolution) => format!("{:?}", resolution.disposition),
446                    None if record.finished.is_some() => "awaiting engine resolution".into(),
447                    None => "interrupted or pending evaluation".into(),
448                }
449            };
450            out.push_str(&format!(
451                "- `{}` / {:?} / `{}`: {}; consumed={} (effect completion is separate).\n",
452                request.gate_id.as_str(),
453                request.stage,
454                request.attempt_id.as_str(),
455                status,
456                record.consumed.is_some()
457            ));
458        }
459        out.push('\n');
460    }
461
462    // Flight Rules coverage (KRZ-343, design D-H): the rule coverage matrix
463    // rides the chain, so the bundle renders the SAME fold the replay
464    // computed — no second derivation to drift. It follows the gate ladder
465    // it joins against. `None` (no approved standards pin — every
466    // pre-Flight-Rules mission) renders nothing, so those summaries stay
467    // byte-identical.
468    if let Some(coverage) = &chain.standards {
469        out.push_str(&crate::standards_coverage::render_coverage_markdown(
470            coverage,
471        ));
472        out.push('\n');
473    }
474
475    out.push_str("## Sessions (workers and reviewers)\n\n");
476    if chain.sessions.is_empty() {
477        out.push_str("(no sessions recorded)\n\n");
478    } else {
479        out.push_str(
480            "| seq | run | role | backend | model | prompt hash | transcript | resolution |\n\
481             |----:|-----|------|---------|-------|-------------|------------|------------|\n",
482        );
483        for session in &chain.sessions {
484            out.push_str(&format!(
485                "| {} | {} | {} | {} | {} | `{}` | `{}` | {} |\n",
486                session.seq,
487                md_cell(&session.run_id),
488                wire_name(&session.role),
489                session.backend.as_deref().unwrap_or("?"),
490                md_cell(&session.model),
491                session.prompt_hash,
492                md_cell(&session.transcript_ref),
493                session.transcript.as_str(),
494            ));
495        }
496        out.push('\n');
497    }
498
499    out.push_str("## Human decisions\n\n");
500    if chain.decisions.is_empty() {
501        out.push_str("(no human decisions recorded)\n\n");
502    } else {
503        for decision in &chain.decisions {
504            out.push_str(&format!(
505                "- [seq {}] {} — {}\n",
506                decision.seq,
507                decision.kind.as_str(),
508                one_line(&decision.summary)
509            ));
510        }
511        out.push('\n');
512    }
513
514    out.push_str("## Escalations\n\n");
515    if escalations.is_empty() {
516        out.push_str("(no escalations recorded)\n\n");
517    } else {
518        for row in escalations {
519            let latency = row
520                .latency_ms
521                .map(|ms| format!(" (latency {ms} ms)"))
522                .unwrap_or_default();
523            out.push_str(&format!(
524                "- [{}] {}: {} → {}{}\n",
525                row.ts.to_rfc3339(),
526                row.kind.as_str(),
527                one_line(&row.summary),
528                one_line(&row.decision),
529                latency,
530            ));
531        }
532        out.push('\n');
533    }
534
535    out.push_str("## Artefacts\n\n");
536    out.push_str(
537        "| bundle path | source | sha256 | status |\n\
538         |-------------|--------|--------|--------|\n",
539    );
540    for entry in artefact_entries {
541        let status = entry.status.map(|status| status.as_str()).unwrap_or("—");
542        out.push_str(&format!(
543            "| {} | `{}` | {} | {} |\n",
544            entry
545                .path
546                .as_deref()
547                .map(|path| format!("`{path}`"))
548                .unwrap_or_else(|| "—".to_string()),
549            md_cell(&entry.source),
550            entry.sha256.as_deref().unwrap_or("—"),
551            status,
552        ));
553    }
554    out.push('\n');
555    out.push_str(&format!(
556        "Regenerate with `kranz evidence-bundle {}`; the same event log always yields\n\
557         the same bundle bytes.\n",
558        chain.mission_id
559    ));
560    out
561}
562
563/// Assemble one mission's evidence bundle in memory. Read-only against the
564/// mission dir (no lock — §4.3 read-only observers), no clock, no network,
565/// no git: the same log and artefact bytes always assemble the same bundle.
566///
567/// Fallible where honesty demands it: a mission whose log is missing or
568/// corrupt fails (the log is the primary record — there is no bundle without
569/// it), and a `config.changed` patch the reducer would reject fails the
570/// provenance fold exactly as it fails the replay. Artefact gaps NEVER fail:
571/// they are manifest entries.
572pub fn assemble_evidence_bundle(
573    repo_root: &Path,
574    mission_id: &str,
575) -> anyhow::Result<EvidenceBundle> {
576    let paths = MissionPaths::new(repo_root, mission_id);
577    paths.require_no_follow()?;
578    let mission_dir = paths.mission_dir();
579
580    // The primary record, read ONCE: the same buffer is parsed+validated
581    // for the folds AND shipped verbatim as the bundle's log copy
582    // (12th-pass review). Two separate opens — parse here, reread raw bytes
583    // there — would let a concurrent append (or a torn final line the
584    // parser dropped) desync the shipped `events.jsonl` from the
585    // chain/cost/escalations folded from it; the auditor's re-fold of the
586    // shipped bytes must reproduce the bundle exactly. The torn-tail rule
587    // (`read_events_and_log_bytes`): a torn final line is excluded from
588    // BOTH the events and the shipped bytes — bytes-shipped == bytes-parsed.
589    let (events, log_bytes) =
590        crate::event_log::EventLog::read_events_and_log_bytes(&paths.events_file())?;
591
592    let chain = crate::provenance::provenance_chain(&mission_dir, mission_id, &events)?;
593    let outcomes: MissionOutcomes = crate::outcomes::mission_outcomes(mission_id, &events);
594    let cost = MissionCostSummary {
595        total_cost_usd: outcomes.cost_usd,
596        non_meta_commits: outcomes.non_meta_commits,
597        usd_per_commit: (outcomes.non_meta_commits > 0)
598            .then(|| outcomes.cost_usd / outcomes.non_meta_commits as f64),
599        cycle_time_ms: outcomes.cycle_time_ms,
600        closed: outcomes.is_closed,
601        interventions: outcomes.interventions,
602    };
603
604    // Artefact references in first-appearance order — gates (log order),
605    // sessions, then the well-known documents — deduplicated by the verbatim
606    // reference string. First-appearance is a pure function of the log, so
607    // bundle ordering is deterministic without consulting anything else.
608    // Inline references (no `file:` scheme) ship NO manifest entry: their
609    // evidence is textual and already travels verbatim in the chain.
610    let mut references: Vec<String> = Vec::new();
611    let mut push_reference = |reference: String| {
612        if reference.starts_with(crate::gate_results::FILE_REF_SCHEME)
613            && !references.contains(&reference)
614        {
615            references.push(reference);
616        }
617    };
618    for gate in &chain.gates {
619        push_reference(gate.artefact_ref.clone());
620    }
621    let mut gate_expected = std::collections::BTreeMap::new();
622    for record in &chain.gate_evaluations {
623        for artifact in record.requested.retained_inputs.iter().chain(
624            record
625                .finished
626                .iter()
627                .flat_map(|finished| &finished.artifacts),
628        ) {
629            let reference = file_artefact_ref(artifact.path.as_str());
630            let expected = (artifact.retained_digest.clone(), artifact.retained_bytes);
631            gate_expected
632                .entry(reference.clone())
633                .and_modify(|prior: &mut Option<_>| {
634                    if prior.as_ref() != Some(&expected) {
635                        *prior = None;
636                    }
637                })
638                .or_insert(Some(expected));
639            push_reference(reference);
640        }
641    }
642    for session in &chain.sessions {
643        push_reference(file_artefact_ref(&session.transcript_ref));
644    }
645    for document in MISSION_DOCUMENTS {
646        push_reference(file_artefact_ref(document));
647    }
648
649    let mut artefact_entries: Vec<ManifestEntry> = Vec::new();
650    let mut artefact_files: Vec<BundleFile> = Vec::new();
651    for reference in &references {
652        let (mut status, mut bytes) = read_artefact(&mission_dir, reference);
653        if let Some(expected) = gate_expected.get(reference) {
654            let matches =
655                expected
656                    .as_ref()
657                    .zip(bytes.as_ref())
658                    .is_some_and(|((digest, length), bytes)| {
659                        *length == bytes.len() as u64
660                            && *digest == crate::gate_evaluation::protocol::Digest::of(bytes)
661                    });
662            if !matches {
663                status = ArtefactStatus::Unresolved;
664                bytes = None;
665            }
666        }
667        match artefact_bundle_path(reference).zip(bytes) {
668            Some((path, bytes)) => {
669                artefact_entries.push(ManifestEntry {
670                    path: Some(path.clone()),
671                    sha256: Some(sha256_hex(&bytes)),
672                    source: reference.clone(),
673                    kind: EntryKind::Artefact,
674                    status: Some(status),
675                });
676                artefact_files.push(BundleFile { path, bytes });
677            }
678            None => artefact_entries.push(ManifestEntry {
679                path: None,
680                sha256: None,
681                source: reference.clone(),
682                kind: EntryKind::Artefact,
683                status: Some(ArtefactStatus::Unresolved),
684            }),
685        }
686    }
687
688    // The human summary reads the artefact entries, so it is rendered after
689    // them — but it still SORTS first in the bundle (fixed generated order).
690    let summary = render_summary(&chain, &cost, &outcomes.escalations, &artefact_entries);
691
692    let mut files: Vec<BundleFile> = Vec::new();
693    let mut entries: Vec<ManifestEntry> = Vec::new();
694    let mut push_generated = |path: &str, source: &str, kind: EntryKind, bytes: Vec<u8>| {
695        entries.push(ManifestEntry {
696            path: Some(path.to_string()),
697            sha256: Some(sha256_hex(&bytes)),
698            source: source.to_string(),
699            kind,
700            status: None,
701        });
702        files.push(BundleFile {
703            path: path.to_string(),
704            bytes,
705        });
706    };
707    push_generated(
708        SUMMARY_FILE,
709        "derived:human-summary",
710        EntryKind::Summary,
711        summary.into_bytes(),
712    );
713    push_generated(
714        CHAIN_FILE,
715        "derived:provenance-chain",
716        EntryKind::Chain,
717        to_json_bytes(&chain)?,
718    );
719    push_generated(
720        ESCALATIONS_FILE,
721        "derived:escalations-fold",
722        EntryKind::Escalations,
723        to_json_bytes(&outcomes.escalations)?,
724    );
725    push_generated(
726        COST_FILE,
727        "derived:cost-fold",
728        EntryKind::Cost,
729        to_json_bytes(&cost)?,
730    );
731    push_generated(LOG_FILE, "file:events.jsonl", EntryKind::Log, log_bytes);
732    files.extend(artefact_files);
733    entries.extend(artefact_entries);
734
735    Ok(EvidenceBundle {
736        manifest: EvidenceManifest {
737            version: BUNDLE_FORMAT_VERSION,
738            mission_id: mission_id.to_string(),
739            entries,
740        },
741        files,
742    })
743}
744
745/// Absolutize `path` and fold `.`/`..` LEXICALLY, without touching the
746/// filesystem: `std::path::absolute` PRESERVES `..` on this host, so the
747/// fold is what makes an `outside/../.kranz/...` shape comparable with
748/// `starts_with`. A `..` above the root is inert (`/..` == `/`). Lexical
749/// folding is sound for the containment check only because the write path
750/// below verifies no component it traverses is a symlink — a folded `a/..`
751/// equals `a` only when `a` cannot redirect.
752fn absolute_lexical(path: &Path) -> anyhow::Result<PathBuf> {
753    let absolute = std::path::absolute(path)?;
754    let mut out = PathBuf::new();
755    for component in absolute.components() {
756        match component {
757            Component::CurDir => {}
758            Component::ParentDir => {
759                if out.file_name().is_some() {
760                    out.pop();
761                } else if !out.has_root() {
762                    out.push("..");
763                }
764            }
765            other => out.push(other.as_os_str()),
766        }
767    }
768    Ok(out)
769}
770
771/// The planned bundle output directory: the canonical anchor to open and
772/// the missing components to create beneath it.
773struct OutDirPlan {
774    /// Canonical path of the deepest EXISTING ancestor (the trusted anchor —
775    /// canonicalization resolves system symlinks such as macOS `/var`, the
776    /// same trust basis [`crate::paths::open_parent_nofollow`]'s weaker tier
777    /// uses for out-of-model paths).
778    anchor: PathBuf,
779    /// Missing components below the anchor, created no-follow at pin time.
780    tail: Vec<String>,
781    /// The canonical path the pinned out dir will have (`anchor` + `tail` —
782    /// canonical by construction: the anchor is canonical and the tail is
783    /// created as real directories under it).
784    canonical_out: PathBuf,
785}
786
787/// Plan the out dir WITHOUT creating anything: absolutize + lexically fold,
788/// walk up to the deepest existing ancestor (a SYMLINKED or non-directory
789/// ancestor is a refusal — `symlink_metadata` inspects the component
790/// itself, never its target), canonicalize the anchor, and compute the
791/// canonical out path. The containment check runs on this plan before any
792/// directory is created, so a refusal writes nothing (12th-pass review).
793fn plan_out_dir(out_dir: &Path) -> anyhow::Result<OutDirPlan> {
794    let normalized = absolute_lexical(out_dir)?;
795    let mut anchor = normalized.as_path();
796    loop {
797        match std::fs::symlink_metadata(anchor) {
798            Ok(metadata) => {
799                let file_type = metadata.file_type();
800                if file_type.is_symlink() {
801                    return Err(EngineError::InvalidState(format!(
802                        "bundle output {} resolves through a symlinked component: {}",
803                        out_dir.display(),
804                        anchor.display()
805                    ))
806                    .into());
807                }
808                if !file_type.is_dir() {
809                    return Err(EngineError::InvalidState(format!(
810                        "bundle output {} is blocked by a non-directory component: {}",
811                        out_dir.display(),
812                        anchor.display()
813                    ))
814                    .into());
815                }
816                break;
817            }
818            Err(error) if error.kind() == ErrorKind::NotFound => {
819                anchor = anchor.parent().ok_or_else(|| {
820                    EngineError::InvalidState(format!(
821                        "bundle output {} has no existing ancestor",
822                        out_dir.display()
823                    ))
824                })?;
825            }
826            Err(error) => return Err(error.into()),
827        }
828    }
829    let canonical_anchor = anchor.canonicalize()?;
830    let mut tail = Vec::new();
831    let mut canonical_out = canonical_anchor.clone();
832    // The anchor is a lexical prefix of `normalized` by construction; every
833    // component below it is `Normal` (the fold left nothing else).
834    for component in normalized
835        .strip_prefix(anchor)
836        .map_err(|_| {
837            EngineError::InvalidState(format!(
838                "bundle output {} escaped its anchor",
839                out_dir.display()
840            ))
841        })?
842        .components()
843    {
844        let Component::Normal(name) = component else {
845            return Err(EngineError::InvalidState(format!(
846                "bundle output {} has a non-normal component below its anchor",
847                out_dir.display()
848            ))
849            .into());
850        };
851        let name = name.to_str().ok_or_else(|| {
852            EngineError::InvalidState(format!(
853                "bundle output {} has a non-UTF-8 component",
854                out_dir.display()
855            ))
856        })?;
857        tail.push(name.to_string());
858        canonical_out.push(name);
859    }
860    Ok(OutDirPlan {
861        anchor: canonical_anchor,
862        tail,
863        canonical_out,
864    })
865}
866
867/// Pin the planned out dir as a RETAINED capability: open the canonical
868/// anchor ambient, then create and open every missing tail component
869/// per-component no-follow ([`crate::paths::open_real_subdir`] — a component
870/// planted as a symlink mid-walk is refused, never followed). Every later
871/// write goes through the returned capability, never back through the
872/// display path that was checked — closing the check-then-write window.
873fn pin_out_dir(plan: &OutDirPlan) -> anyhow::Result<Dir> {
874    let mut dir = Dir::open_ambient_dir(&plan.anchor, ambient_authority())?;
875    let mut walked = plan.anchor.clone();
876    for component in &plan.tail {
877        walked.push(component);
878        dir = crate::paths::open_real_subdir(&dir, component, &walked, true)?;
879    }
880    Ok(dir)
881}
882
883/// Write the bundle through the pinned no-follow capability: the emptiness
884/// check, per-entry parent creation, and every file write go through `out`
885/// (never back through the display path), so nothing crosses a symlink
886/// between check and write. Bundle paths are re-validated on the way out
887/// (relative, non-empty `Normal` components only) so a hostile or buggy
888/// assembly cannot write outside the out dir, and each file is
889/// `create_new` + `FollowSymlinks::No` — the out dir was empty, so a
890/// pre-existing name (a planted symlink most of all) fails instead of
891/// being written through.
892fn write_bundle_files(bundle: &EvidenceBundle, out_dir: &Path, out: &Dir) -> anyhow::Result<usize> {
893    let mut entries = out.entries().map_err(|error| {
894        EngineError::InvalidState(format!(
895            "bundle output {} is not an empty directory: {error}",
896            out_dir.display()
897        ))
898    })?;
899    if entries.next().is_some() {
900        return Err(EngineError::InvalidState(format!(
901            "bundle output {} is not empty; choose a fresh --out or remove it",
902            out_dir.display()
903        ))
904        .into());
905    }
906
907    let manifest_bytes = to_json_bytes(&bundle.manifest)?;
908    let mut written = 0usize;
909    // The manifest writes last: it indexes the other entries, and a partial
910    // write then leaves a tree whose index is absent rather than wrong.
911    for (relative, bytes) in bundle
912        .files
913        .iter()
914        .map(|file| (file.path.as_str(), file.bytes.as_slice()))
915        .chain([(MANIFEST_FILE, manifest_bytes.as_slice())])
916    {
917        let mut names = Vec::new();
918        for component in relative.split('/') {
919            if component.is_empty() || component == "." || component == ".." {
920                return Err(
921                    EngineError::InvalidState(format!("unsafe bundle path {relative:?}")).into(),
922                );
923            }
924            names.push(component);
925        }
926        let (leaf, parents) = names.split_last().expect("validated non-empty");
927        let mut dir = None;
928        let mut display = out_dir.to_path_buf();
929        for parent in parents {
930            display.push(parent);
931            dir = Some(crate::paths::open_real_subdir(
932                dir.as_ref().unwrap_or(out),
933                parent,
934                &display,
935                true,
936            )?);
937        }
938        let mut options = OpenOptions::new();
939        options
940            .write(true)
941            .create_new(true)
942            .follow(FollowSymlinks::No);
943        let mut file = dir.as_ref().unwrap_or(out).open_with(leaf, &options)?;
944        file.write_all(bytes)?;
945        written += 1;
946    }
947    Ok(written)
948}
949
950/// Write an assembled bundle to `out_dir`, returning the number of files
951/// written (including `manifest.json`). The directory must not already hold
952/// anything: silently mixing two exports would leave stale artefacts no
953/// manifest entry names — the same honesty discipline as unresolved entries.
954/// The out dir is created and written through a pinned no-follow capability
955/// ([`plan_out_dir`] / [`pin_out_dir`]): a symlinked existing component is
956/// refused, and nothing written ever crosses a symlink.
957pub fn write_evidence_bundle(bundle: &EvidenceBundle, out_dir: &Path) -> anyhow::Result<usize> {
958    let plan = plan_out_dir(out_dir)?;
959    let out = pin_out_dir(&plan)?;
960    write_bundle_files(bundle, out_dir, &out)
961}
962
963/// Assemble + write the bundle, with the one placement rule enforced: the
964/// write target must be OUTSIDE the mission dir (a bundle written into the
965/// tree it audits would both mutate the read-only surface and risk shipping
966/// itself as evidence).
967///
968/// The rule is enforced in two tiers, both BEFORE anything is written
969/// (12th-pass review): a lexical tier (absolutize + fold `..`, then
970/// `starts_with`) that catches the direct and `..`-shaped in-mission paths
971/// without touching the filesystem, and a canonical tier — `absolute`
972/// preserves `..` on this host and a symlinked component makes a lexical
973/// `starts_with` lie — that canonicalizes the out dir's deepest existing
974/// ancestor and compares the canonical out path against the canonical
975/// mission dir. The write itself then goes through the pinned no-follow
976/// capability from [`plan_out_dir`] / [`pin_out_dir`].
977pub fn export_evidence_bundle(
978    repo_root: &Path,
979    mission_id: &str,
980    out_dir: &Path,
981) -> anyhow::Result<ExportOutcome> {
982    let paths = MissionPaths::new(repo_root, mission_id);
983    paths.require_no_follow()?;
984    let refusal = || {
985        EngineError::InvalidState(format!(
986            "bundle output {} must be outside the mission dir {}",
987            out_dir.display(),
988            paths.mission_dir().display()
989        ))
990    };
991    // Lexical tier: refuses the direct and `..`-shaped placements before
992    // any filesystem write (a refusal leaves nothing behind).
993    let out_lexical = absolute_lexical(out_dir)?;
994    let mission_lexical = absolute_lexical(&paths.mission_dir())?;
995    if out_lexical.starts_with(&mission_lexical) {
996        return Err(refusal().into());
997    }
998    // Canonical tier: the lexical fold cannot see symlinks, so compare the
999    // canonical out path against the canonical mission dir. A symlinked
1000    // existing component of the out path is refused by the plan itself.
1001    // (A not-yet-existing mission dir skips this tier — there is no audited
1002    // tree to contaminate, and the assembly below fails the unknown mission
1003    // honestly.)
1004    let plan = plan_out_dir(out_dir)?;
1005    match std::fs::symlink_metadata(paths.mission_dir()) {
1006        Ok(_) => {
1007            if plan
1008                .canonical_out
1009                .starts_with(paths.mission_dir().canonicalize()?)
1010            {
1011                return Err(refusal().into());
1012            }
1013        }
1014        Err(error) if error.kind() == ErrorKind::NotFound => {}
1015        Err(error) => return Err(error.into()),
1016    }
1017
1018    let bundle = assemble_evidence_bundle(repo_root, mission_id)?;
1019    let out = pin_out_dir(&plan)?;
1020    let files_written = write_bundle_files(&bundle, out_dir, &out)?;
1021    let resolved_artefacts = bundle
1022        .manifest
1023        .entries
1024        .iter()
1025        .filter(|entry| entry.status == Some(ArtefactStatus::Resolved))
1026        .count();
1027    let unresolved_artefacts = bundle
1028        .manifest
1029        .entries
1030        .iter()
1031        .filter(|entry| entry.status == Some(ArtefactStatus::Unresolved))
1032        .count();
1033    Ok(ExportOutcome {
1034        out_dir: out_dir.to_path_buf(),
1035        files_written,
1036        resolved_artefacts,
1037        unresolved_artefacts,
1038    })
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043    use super::*;
1044    use crate::event_log::{EventLog, LockForce};
1045    use crate::events::EventKind;
1046    use crate::gate::{GateKind, GateSurface, GateVerdict};
1047    use crate::types::{GrantKind, MissionConfig, Plan, Role, RunResult, TokenUsage};
1048    use std::collections::BTreeMap;
1049    use std::time::Duration;
1050    use tempfile::TempDir;
1051
1052    /// Seed a mission's `events.jsonl` with the given kinds, in order (the
1053    /// provenance fixture idiom); the log handle drops — and flushes — before
1054    /// any assembly reads.
1055    fn seed_mission(repo_root: &Path, id: &str, kinds: Vec<EventKind>) -> MissionPaths {
1056        let paths = MissionPaths::new(repo_root, id);
1057        let mut log = EventLog::acquire(&paths, id, Duration::ZERO, LockForce::No).unwrap();
1058        for kind in kinds {
1059            log.append(kind).unwrap();
1060        }
1061        paths
1062    }
1063
1064    fn sample_plan() -> Plan {
1065        Plan {
1066            goal: "ship the thing".into(),
1067            validation_contract: vec![],
1068            milestones: vec![],
1069            considered_alternatives: None,
1070            command_grants: vec![],
1071            touch_set: vec![],
1072            standards_manifest: None,
1073            reviewer_independence: None,
1074        }
1075    }
1076
1077    fn created() -> EventKind {
1078        EventKind::MissionCreated {
1079            goal: "ship the thing".into(),
1080            base_branch: "main".into(),
1081            mission_branch: "kranz/mission-x".into(),
1082            config: MissionConfig::default(),
1083        }
1084    }
1085
1086    fn gate_result(
1087        gate: &str,
1088        surface: GateSurface,
1089        kind: GateKind,
1090        index: u32,
1091        artefact_ref: &str,
1092    ) -> EventKind {
1093        EventKind::GateResult {
1094            gate: gate.to_string(),
1095            surface,
1096            kind,
1097            index,
1098            verdict: GateVerdict::Pass,
1099            artefact_ref: artefact_ref.to_string(),
1100            artefact_detail: None,
1101            score: None,
1102            threshold: None,
1103            rule_ids: Vec::new(),
1104        }
1105    }
1106
1107    fn worker_spawned(run_id: &str, role: Role, model: &str) -> EventKind {
1108        EventKind::WorkerSpawned {
1109            backend: None,
1110            run_id: run_id.to_string(),
1111            role,
1112            feature_id: None,
1113            milestone_id: None,
1114            candidate: None,
1115            executor_route: None,
1116            sdk_session_id: format!("sess-{run_id}"),
1117            model: model.to_string(),
1118            quant: "n/a".to_string(),
1119            weight_hash: None,
1120            prompt_hash: "aaaabbbbcccc".to_string(),
1121            transcript_path: MissionPaths::transcript_rel(run_id),
1122        }
1123    }
1124
1125    /// The full fixture: both gate surfaces; an inline ref, a resolved file
1126    /// ref, a file ref whose bytes were never written, and a DUPLICATE file
1127    /// ref (the manifest-dedup pin); a completed worker run with cost and a
1128    /// non-meta commit; a grant park + approval; a blocked→unblocked pair; a
1129    /// steer — ending COMPLETED. Documents: plan.md/plan.json/report.md are
1130    /// written, research.md/estimate.json deliberately absent (the unresolved
1131    /// arm for well-known documents).
1132    fn seed_full_mission(root: &Path) -> MissionPaths {
1133        let paths = seed_mission(
1134            root,
1135            "m-1",
1136            vec![
1137                created(),
1138                EventKind::PlanApproved {
1139                    plan: sample_plan(),
1140                    base_sha: Some("deadbeef".to_string()),
1141                },
1142                gate_result(
1143                    "vacuous-filter",
1144                    GateSurface::Approval,
1145                    GateKind::Deterministic,
1146                    0,
1147                    "contract gate vacuous-filter",
1148                ),
1149                gate_result(
1150                    "merge-gate-suite",
1151                    GateSurface::Approval,
1152                    GateKind::Deterministic,
1153                    1,
1154                    "file:runs/gate-base.jsonl",
1155                ),
1156                gate_result(
1157                    "merge-gate-suite-recheck",
1158                    GateSurface::Approval,
1159                    GateKind::Deterministic,
1160                    2,
1161                    // The same reference as the previous gate: the manifest
1162                    // must list it exactly once.
1163                    "file:runs/gate-base.jsonl",
1164                ),
1165                gate_result(
1166                    "plan-review",
1167                    GateSurface::Approval,
1168                    GateKind::ModelJudged,
1169                    0,
1170                    "file:runs/gone.jsonl",
1171                ),
1172                worker_spawned("r-1", Role::Worker, "gpt-5"),
1173                EventKind::WorkerCompleted {
1174                    run_id: "r-1".into(),
1175                    result: RunResult::Pass,
1176                    tokens: TokenUsage {
1177                        input: 100,
1178                        output: 50,
1179                        cache_read: 0,
1180                        cache_write: 0,
1181                    },
1182                    cost_usd: Some(0.42),
1183                    report: None,
1184                },
1185                EventKind::FeatureCompleted {
1186                    feature_id: "f-1-1".into(),
1187                    commits: vec!["abc1234 implement the widget".into()],
1188                },
1189                EventKind::GrantRequested {
1190                    milestone_id: "ms-1".into(),
1191                    kind: GrantKind::Command,
1192                    command: "cargo test".into(),
1193                },
1194                EventKind::GrantApproved {
1195                    kind: GrantKind::Command,
1196                    command: "cargo test".into(),
1197                },
1198                worker_spawned("r-2", Role::Worker, "my-local-model"),
1199                worker_spawned("r-3", Role::ValidatorScrutiny, "sonnet"),
1200                EventKind::MilestoneBlocked {
1201                    block_context: None,
1202                    milestone_id: "ms-1".into(),
1203                    reason: "fix-cycle cap".into(),
1204                },
1205                EventKind::MilestoneUnblocked {
1206                    block_context: None,
1207                    milestone_id: "ms-1".into(),
1208                    reason: "user skipped findings".into(),
1209                    validator_guidance: None,
1210                },
1211                EventKind::UserMessage {
1212                    text: "skip the flaky test".into(),
1213                    interrupt: false,
1214                },
1215                gate_result(
1216                    "merge-gate-suite",
1217                    GateSurface::FinalGate,
1218                    GateKind::Deterministic,
1219                    0,
1220                    ".kranz/merge-gates.json",
1221                ),
1222                EventKind::MissionCompleted {},
1223            ],
1224        );
1225        // Bytes for the resolvable refs and the shipped documents.
1226        std::fs::write(paths.runs_dir().join("gate-base.jsonl"), b"{}").unwrap();
1227        std::fs::write(paths.runs_dir().join("r-1.jsonl"), b"{}").unwrap();
1228        std::fs::write(paths.plan_md_file(), b"# plan\n").unwrap();
1229        std::fs::write(paths.plan_file(), b"{}").unwrap();
1230        std::fs::write(paths.report_file(), b"# report\n").unwrap();
1231        paths
1232    }
1233
1234    /// Recursively collect a written bundle tree as (relative `/`-joined
1235    /// path → bytes), sorted — the entry-identity comparison the directory
1236    /// container's determinism is defined over.
1237    fn collect_files(dir: &Path) -> BTreeMap<String, Vec<u8>> {
1238        let mut out = BTreeMap::new();
1239        let mut stack = vec![dir.to_path_buf()];
1240        while let Some(current) = stack.pop() {
1241            for entry in std::fs::read_dir(&current).unwrap() {
1242                let path = entry.unwrap().path();
1243                if path.is_dir() {
1244                    stack.push(path);
1245                } else {
1246                    let relative = path
1247                        .strip_prefix(dir)
1248                        .unwrap()
1249                        .components()
1250                        .map(|c| c.as_os_str().to_str().unwrap().to_string())
1251                        .collect::<Vec<_>>()
1252                        .join("/");
1253                    out.insert(relative, std::fs::read(&path).unwrap());
1254                }
1255            }
1256        }
1257        out
1258    }
1259
1260    fn manifest_entry<'m>(manifest: &'m EvidenceManifest, source: &str) -> &'m ManifestEntry {
1261        manifest
1262            .entries
1263            .iter()
1264            .find(|entry| entry.source == source)
1265            .unwrap_or_else(|| panic!("manifest entry {source} missing"))
1266    }
1267
1268    /// Ticket acceptance hint 1: the bundle opens standalone — manifest,
1269    /// human summary, chain, escalations, cost, the raw log, and the
1270    /// artefact bytes — with NO reference into the source machine's paths
1271    /// anywhere in any file. Every resolved manifest entry's sha256 matches
1272    /// the bytes it names; the missing ref is an unresolved entry; the
1273    /// duplicated ref appears exactly once.
1274    #[test]
1275    fn evidence_bundle_opens_standalone_with_no_host_paths() {
1276        let tmp = TempDir::new().unwrap();
1277        seed_full_mission(tmp.path());
1278        let out = tmp.path().join("bundle-out");
1279        let outcome = export_evidence_bundle(tmp.path(), "m-1", &out).unwrap();
1280
1281        for name in [
1282            MANIFEST_FILE,
1283            SUMMARY_FILE,
1284            CHAIN_FILE,
1285            ESCALATIONS_FILE,
1286            COST_FILE,
1287            LOG_FILE,
1288        ] {
1289            assert!(out.join(name).is_file(), "{name} missing from the bundle");
1290        }
1291        for shipped in [
1292            "artefacts/runs/gate-base.jsonl",
1293            "artefacts/runs/r-1.jsonl",
1294            "artefacts/plan.md",
1295            "artefacts/plan.json",
1296            "artefacts/report.md",
1297        ] {
1298            assert!(
1299                out.join(shipped).is_file(),
1300                "{shipped} missing from artefacts/"
1301            );
1302        }
1303        // 5 generated + manifest + 5 resolved artefacts; 5 unresolved
1304        // (gone.jsonl, r-2, r-3 transcripts, research.md, estimate.json).
1305        assert_eq!(outcome.files_written, 11);
1306        assert_eq!(outcome.resolved_artefacts, 5);
1307        assert_eq!(outcome.unresolved_artefacts, 5);
1308
1309        // The host temp path appears in NO bundle file (the test greps the
1310        // whole tree for it).
1311        let host = tmp.path().to_string_lossy().to_string();
1312        let files = collect_files(&out);
1313        for (relative, bytes) in &files {
1314            let text = String::from_utf8_lossy(bytes);
1315            assert!(
1316                !text.contains(&host),
1317                "host path leaked into bundle file {relative}"
1318            );
1319        }
1320
1321        // The manifest round-trips and every resolved entry's sha256 matches
1322        // the shipped bytes.
1323        let manifest: EvidenceManifest =
1324            serde_json::from_str(&std::fs::read_to_string(out.join(MANIFEST_FILE)).unwrap())
1325                .unwrap();
1326        assert_eq!(manifest.version, BUNDLE_FORMAT_VERSION);
1327        assert_eq!(manifest.mission_id, "m-1");
1328        for entry in &manifest.entries {
1329            if let (Some(path), Some(sha256)) = (&entry.path, &entry.sha256) {
1330                let bytes = std::fs::read(out.join(path)).unwrap();
1331                assert_eq!(&sha256_hex(&bytes), sha256, "sha256 mismatch for {path}");
1332            }
1333        }
1334        // The duplicated gate ref produced exactly ONE artefact entry.
1335        assert_eq!(
1336            manifest
1337                .entries
1338                .iter()
1339                .filter(|entry| entry.source == "file:runs/gate-base.jsonl")
1340                .count(),
1341            1
1342        );
1343        // Inline refs carry no manifest entry (their evidence is in the chain).
1344        assert!(manifest
1345            .entries
1346            .iter()
1347            .all(|entry| entry.source != "contract gate vacuous-filter"));
1348        // The never-written ref is an unresolved entry with the original
1349        // reference and no path/sha — named, never omitted.
1350        let gone = manifest_entry(&manifest, "file:runs/gone.jsonl");
1351        assert_eq!(gone.status, Some(ArtefactStatus::Unresolved));
1352        assert!(gone.path.is_none() && gone.sha256.is_none());
1353        // The chain parses and carries the ladder.
1354        let chain: ProvenanceChain =
1355            serde_json::from_str(&std::fs::read_to_string(out.join(CHAIN_FILE)).unwrap()).unwrap();
1356        assert_eq!(chain.gates.len(), 5);
1357        // The cost fold crossed: one $0.42 run, one non-meta commit.
1358        let cost: MissionCostSummary =
1359            serde_json::from_str(&std::fs::read_to_string(out.join(COST_FILE)).unwrap()).unwrap();
1360        assert_eq!(cost.total_cost_usd, 0.42);
1361        assert_eq!(cost.non_meta_commits, 1);
1362        assert_eq!(cost.usd_per_commit, Some(0.42));
1363        assert!(cost.closed);
1364    }
1365
1366    /// Ticket acceptance hint 2: same log → identical bundle. Two assemblies
1367    /// are byte-identical in memory, and two written trees are
1368    /// entry-identical (the directory container's determinism definition).
1369    #[test]
1370    fn evidence_bundle_is_byte_identical_across_exports() {
1371        let tmp = TempDir::new().unwrap();
1372        seed_full_mission(tmp.path());
1373
1374        let first = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1375        let second = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1376        assert_eq!(first, second);
1377        assert_eq!(
1378            serde_json::to_string_pretty(&first.manifest).unwrap(),
1379            serde_json::to_string_pretty(&second.manifest).unwrap()
1380        );
1381
1382        let out_a = tmp.path().join("out-a");
1383        let out_b = tmp.path().join("out-b");
1384        export_evidence_bundle(tmp.path(), "m-1", &out_a).unwrap();
1385        export_evidence_bundle(tmp.path(), "m-1", &out_b).unwrap();
1386        assert_eq!(collect_files(&out_a), collect_files(&out_b));
1387    }
1388
1389    /// Ticket acceptance hint 3: a log carrying redaction audits yields a
1390    /// bundle with FINGERPRINTS only — the secret value planted pre-redaction
1391    /// appears in no bundle file, while the audit fingerprint crosses in the
1392    /// raw log.
1393    #[test]
1394    fn evidence_bundle_redacted_secret_leaves_fingerprints_only() {
1395        let tmp = TempDir::new().unwrap();
1396        let secret = "sk-ant-F00barBazQuux9_7";
1397        let text = format!("the key is {secret} ok");
1398        // The fingerprint the write boundary will record for this value.
1399        let findings = crate::scrub::scan_text(&text);
1400        assert_eq!(findings.len(), 1, "fixture must trip exactly one rule");
1401        let fingerprint = findings[0].fingerprint.clone();
1402
1403        seed_mission(
1404            tmp.path(),
1405            "m-sec",
1406            vec![
1407                created(),
1408                EventKind::UserMessage {
1409                    text,
1410                    interrupt: false,
1411                },
1412                EventKind::MissionCompleted {},
1413            ],
1414        );
1415
1416        let out = tmp.path().join("bundle-sec");
1417        export_evidence_bundle(tmp.path(), "m-sec", &out).unwrap();
1418        let files = collect_files(&out);
1419        assert!(!files.is_empty());
1420        for (relative, bytes) in &files {
1421            let text = String::from_utf8_lossy(bytes);
1422            assert!(
1423                !text.contains(secret),
1424                "secret value leaked into bundle file {relative}"
1425            );
1426        }
1427        // The fingerprint crosses in the verbatim log (the secret.redacted
1428        // audit line), and the redaction marker replaced the value.
1429        let log = String::from_utf8_lossy(&files[LOG_FILE]).to_string();
1430        assert!(log.contains(&fingerprint), "audit fingerprint missing");
1431        assert!(log.contains("[REDACTED]"));
1432    }
1433
1434    /// Audit H5: artefact BYTES cross the same redact boundary the log
1435    /// crossed at append time. A hostile writer who plants a secret straight
1436    /// into a finished transcript (never through `append_redacting`) must not
1437    /// get it into the package the operator hands an auditor, and the
1438    /// manifest sha256 must be the digest of the REDACTED bytes so the
1439    /// package still verifies against itself.
1440    #[test]
1441    fn evidence_bundle_scrubs_artefact_bytes_and_hashes_the_redacted_form() {
1442        let tmp = TempDir::new().unwrap();
1443        let secret = "sk-ant-F00barBazQuux9_7";
1444        let paths = seed_full_mission(tmp.path());
1445        // Overwrite a finished transcript the way a worker with write access
1446        // to the mission dir would: raw bytes, no scrub on the way in.
1447        let planted = format!("{{\"text\":\"the key is {secret} ok\"}}\n");
1448        std::fs::write(paths.runs_dir().join("r-1.jsonl"), planted.as_bytes()).unwrap();
1449
1450        let out = tmp.path().join("bundle-artefact-secret");
1451        export_evidence_bundle(tmp.path(), "m-1", &out).unwrap();
1452        let files = collect_files(&out);
1453        for (relative, bytes) in &files {
1454            let text = String::from_utf8_lossy(bytes);
1455            assert!(
1456                !text.contains(secret),
1457                "secret value leaked into bundle file {relative}"
1458            );
1459        }
1460        let shipped = &files["artefacts/runs/r-1.jsonl"];
1461        assert!(String::from_utf8_lossy(shipped).contains("[REDACTED]"));
1462
1463        // The manifest digest is over the bytes the bundle actually ships.
1464        let manifest: EvidenceManifest =
1465            serde_json::from_str(&std::fs::read_to_string(out.join(MANIFEST_FILE)).unwrap())
1466                .unwrap();
1467        let entry = manifest_entry(&manifest, "file:runs/r-1.jsonl");
1468        assert_eq!(entry.sha256.as_deref(), Some(sha256_hex(shipped).as_str()));
1469    }
1470
1471    /// A non-UTF-8 artefact still ships, as lossy-decoded scrubbed text: the
1472    /// bundle has ONE rule for artefact bytes and an invalid byte must not be
1473    /// a way to opt out of it.
1474    #[test]
1475    fn evidence_bundle_scrubs_non_utf8_artefact_bytes_lossily() {
1476        let tmp = TempDir::new().unwrap();
1477        let secret = "sk-ant-F00barBazQuux9_7";
1478        let paths = seed_full_mission(tmp.path());
1479        let mut planted = format!("the key is {secret} ok").into_bytes();
1480        planted.push(0xff);
1481        std::fs::write(paths.runs_dir().join("r-1.jsonl"), &planted).unwrap();
1482
1483        let bundle = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1484        let shipped = bundle
1485            .files
1486            .iter()
1487            .find(|file| file.path == "artefacts/runs/r-1.jsonl")
1488            .expect("artefact shipped");
1489        let text = String::from_utf8(shipped.bytes.clone()).expect("lossy decode yields UTF-8");
1490        assert!(!text.contains(secret));
1491        assert!(text.contains("[REDACTED]"));
1492        assert!(
1493            text.contains('\u{fffd}'),
1494            "invalid byte became a replacement"
1495        );
1496    }
1497
1498    /// Ticket acceptance hint 4: with `runs/` pruned, every file-backed gate
1499    /// artefact and every transcript degrades to an unresolved manifest entry
1500    /// — and the export still completes.
1501    #[test]
1502    fn evidence_bundle_missing_artefact_bytes_become_unresolved_manifest_entries() {
1503        let tmp = TempDir::new().unwrap();
1504        let paths = seed_full_mission(tmp.path());
1505        std::fs::remove_dir_all(paths.runs_dir()).unwrap();
1506
1507        let bundle = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1508        for source in [
1509            "file:runs/gate-base.jsonl",
1510            "file:runs/gone.jsonl",
1511            "file:runs/r-1.jsonl",
1512            "file:runs/r-2.jsonl",
1513            "file:runs/r-3.jsonl",
1514            "file:research.md",
1515            "file:estimate.json",
1516        ] {
1517            let entry = manifest_entry(&bundle.manifest, source);
1518            assert_eq!(
1519                entry.status,
1520                Some(ArtefactStatus::Unresolved),
1521                "{source} must be unresolved with its bytes gone"
1522            );
1523            assert!(entry.path.is_none() && entry.sha256.is_none());
1524        }
1525        // The documents outside runs/ still resolve.
1526        for source in ["file:plan.md", "file:plan.json", "file:report.md"] {
1527            assert_eq!(
1528                manifest_entry(&bundle.manifest, source).status,
1529                Some(ArtefactStatus::Resolved),
1530                "{source} must still resolve"
1531            );
1532        }
1533        // No artefact bytes shipped under runs/.
1534        assert!(bundle
1535            .files
1536            .iter()
1537            .all(|file| !file.path.starts_with("artefacts/runs/")));
1538    }
1539
1540    /// The placement rule: the write target must be outside the mission dir
1541    /// (a bundle inside the tree it audits would mutate the read-only
1542    /// surface). Refused before anything is written.
1543    #[test]
1544    fn evidence_bundle_refuses_out_dir_inside_the_mission_dir() {
1545        let tmp = TempDir::new().unwrap();
1546        let paths = seed_full_mission(tmp.path());
1547        let inside = paths.mission_dir().join("bundle");
1548        let result = export_evidence_bundle(tmp.path(), "m-1", &inside);
1549        assert!(result.is_err(), "an in-mission --out must be refused");
1550        assert!(!inside.exists(), "nothing must be written on refusal");
1551    }
1552
1553    /// A non-empty output directory is refused: silently mixing two exports
1554    /// would leave stale files no manifest entry names.
1555    #[test]
1556    fn evidence_bundle_refuses_a_non_empty_out_dir() {
1557        let tmp = TempDir::new().unwrap();
1558        seed_full_mission(tmp.path());
1559        let out = tmp.path().join("bundle-used");
1560        std::fs::create_dir_all(&out).unwrap();
1561        std::fs::write(out.join("stale.txt"), b"stale").unwrap();
1562        let result = export_evidence_bundle(tmp.path(), "m-1", &out);
1563        assert!(result.is_err(), "a non-empty --out must be refused");
1564        assert_eq!(
1565            std::fs::read_to_string(out.join("stale.txt")).unwrap(),
1566            "stale"
1567        );
1568    }
1569
1570    /// 12th-pass review: the bundle's log copy is the SAME buffer the folds
1571    /// were derived from — the log is read once, never re-opened for the raw
1572    /// bytes. A torn final line (a crash write the parser drops) is excluded
1573    /// from BOTH the parsed events and the shipped bytes, so the shipped log
1574    /// always re-folds to the shipped chain/cost/escalations.
1575    /// bytes-shipped == bytes-parsed.
1576    #[test]
1577    fn evidence_single_snapshot_torn_tail_is_excluded_from_parse_and_bytes() {
1578        use std::io::Write as _;
1579        let tmp = TempDir::new().unwrap();
1580        let paths = seed_full_mission(tmp.path());
1581        let pristine = std::fs::read(paths.events_file()).unwrap();
1582        // A crash-torn append the writer never finished: partial JSON, no
1583        // newline — the parser drops it (with a warning).
1584        let mut file = std::fs::OpenOptions::new()
1585            .append(true)
1586            .open(paths.events_file())
1587            .unwrap();
1588        file.write_all(b"{\"seq\":999,\"ts\":\"torn").unwrap();
1589        drop(file);
1590
1591        let bundle = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1592        let shipped = bundle
1593            .files
1594            .iter()
1595            .find(|file| file.path == LOG_FILE)
1596            .expect("the raw log ships");
1597        assert_eq!(
1598            shipped.bytes, pristine,
1599            "the torn tail is in NEITHER the events nor the shipped bytes"
1600        );
1601        // The folds are unaffected (the same gate ladder as the clean log).
1602        assert_eq!(bundle.manifest.mission_id, "m-1");
1603        // And the shipped bytes alone reproduce the fold: a re-parse of the
1604        // bundle's log copy yields exactly the events the mission log's
1605        // valid prefix yields.
1606        let replay = paths.runs_dir().join("replay.jsonl");
1607        std::fs::write(&replay, &shipped.bytes).unwrap();
1608        let folded = crate::event_log::EventLog::read_events(&paths.events_file()).unwrap();
1609        let refolded = crate::event_log::EventLog::read_events(&replay).unwrap();
1610        assert_eq!(refolded.len(), folded.len());
1611        assert_eq!(
1612            refolded.last().map(|event| event.seq),
1613            folded.last().map(|event| event.seq)
1614        );
1615    }
1616
1617    // ---- out-dir containment (12th-pass review) --------------------------
1618
1619    /// `std::path::absolute` preserves `..` on this host, so containment
1620    /// must fold `..` lexically AND compare canonical paths: the
1621    /// `outside/../.kranz/missions/<id>/bundle` shape must be refused
1622    /// exactly like the direct in-mission path — before anything is written.
1623    #[test]
1624    fn evidence_outdir_containment_refuses_dotdot_escape_into_the_mission() {
1625        let tmp = TempDir::new().unwrap();
1626        let paths = seed_full_mission(tmp.path());
1627        let escape = tmp
1628            .path()
1629            .join("outside")
1630            .join("..")
1631            .join(".kranz")
1632            .join("missions")
1633            .join("m-1")
1634            .join("bundle");
1635        let result = export_evidence_bundle(tmp.path(), "m-1", &escape);
1636        assert!(result.is_err(), "the `..` shape must be refused");
1637        assert!(
1638            !paths.mission_dir().join("bundle").exists(),
1639            "nothing must be written on refusal"
1640        );
1641    }
1642
1643    /// A symlinked out-dir component pointing into the audited mission:
1644    /// refused (the plan's symlink screen, with canonical containment behind
1645    /// it) — the bundle must never write through a link into the tree it
1646    /// audits. Unix-only, like every symlink-creating test in the repo.
1647    #[cfg(unix)]
1648    #[test]
1649    fn evidence_outdir_containment_refuses_a_symlinked_component() {
1650        use std::os::unix::fs::symlink;
1651        let tmp = TempDir::new().unwrap();
1652        let paths = seed_full_mission(tmp.path());
1653        let link = tmp.path().join("linked-out");
1654        symlink(paths.mission_dir(), &link).unwrap();
1655        let result = export_evidence_bundle(tmp.path(), "m-1", &link.join("bundle"));
1656        let err = result.expect_err("a symlinked out-dir component must be refused");
1657        assert!(err.to_string().contains("symlinked"), "{err}");
1658        assert!(
1659            !paths.mission_dir().join("bundle").exists(),
1660            "nothing must be written through the link"
1661        );
1662    }
1663
1664    /// The honest path: a normal external out dir still exports, with
1665    /// multi-level missing components created through the no-follow pin.
1666    #[test]
1667    fn evidence_outdir_containment_normal_external_dir_works() {
1668        let tmp = TempDir::new().unwrap();
1669        seed_full_mission(tmp.path());
1670        let out = tmp.path().join("fresh").join("bundle-out");
1671        let outcome = export_evidence_bundle(tmp.path(), "m-1", &out).unwrap();
1672        assert!(outcome.files_written > 0);
1673        assert!(out.join(MANIFEST_FILE).is_file());
1674        assert!(out.join(LOG_FILE).is_file());
1675    }
1676
1677    // ---- KRZ-343: the standards coverage matrix rides the bundle ----------
1678
1679    /// A plan carrying a three-rule standards pin (KRZ-342's consent
1680    /// shape): one failed by a citing finding, one passed by a naming gate,
1681    /// one never evaluated.
1682    fn pinned_plan() -> Plan {
1683        let rule = |id: &str, revision: u64, status: &str| crate::types::PinnedRule {
1684            id: id.to_string(),
1685            revision,
1686            rfc: "RFC-001".to_string(),
1687            level: "must".to_string(),
1688            effective_status: status.to_string(),
1689            statement: format!("statement for {id}"),
1690            domains: Vec::new(),
1691            stages: vec!["validation".to_string()],
1692            when_paths: Vec::new(),
1693            task_classes: Vec::new(),
1694            checker: Some("gate:zz-gate".to_string()),
1695            waivable: false,
1696        };
1697        Plan {
1698            standards_manifest: Some(Box::new(crate::types::StandardsPin {
1699                pack_name: "zz-pack".to_string(),
1700                pack_dir: "vendor/pack".to_string(),
1701                standards_root: "standards".to_string(),
1702                digest: "ab".repeat(32),
1703                source: crate::types::StandardsPinSource::RepoTracked,
1704                task_class: None,
1705                touch_set: vec!["crates/**".to_string()],
1706                context_paths: Vec::new(),
1707                gates: Vec::new(),
1708                rules: vec![
1709                    rule("ZZ-FAIL-001", 2, "enforced"),
1710                    rule("ZZ-PASS-001", 1, "enforced"),
1711                    rule("ZZ-QUIET-001", 1, "enforced"),
1712                ],
1713            })),
1714            ..sample_plan()
1715        }
1716    }
1717
1718    /// A pinned mission: approval + the resolution record, a gate pass
1719    /// naming ZZ-PASS-001 with a file artefact whose bytes were NEVER
1720    /// written (the unresolved-artefact arm), a finding citing ZZ-FAIL-001,
1721    /// and ZZ-QUIET-001 evaluated by nothing — ending COMPLETED.
1722    fn seed_pinned_mission(root: &Path) -> MissionPaths {
1723        let mut gate = gate_result(
1724            "zz-gate",
1725            GateSurface::FinalGate,
1726            GateKind::Deterministic,
1727            0,
1728            "file:runs/gone.jsonl",
1729        );
1730        if let EventKind::GateResult { rule_ids, .. } = &mut gate {
1731            *rule_ids = vec!["ZZ-PASS-001".to_string()];
1732        }
1733        seed_mission(
1734            root,
1735            "m-1",
1736            vec![
1737                created(),
1738                EventKind::PlanApproved {
1739                    plan: pinned_plan(),
1740                    base_sha: Some("deadbeef".to_string()),
1741                },
1742                EventKind::StandardsResolved {
1743                    source: "repo-tracked".to_string(),
1744                    pack_name: "zz-pack".to_string(),
1745                    standards_root: "standards".to_string(),
1746                    digest: "ab".repeat(32),
1747                    stage: "approval".to_string(),
1748                    task_class: None,
1749                    touch_set: vec!["crates/**".to_string()],
1750                    context_paths: Vec::new(),
1751                    rules: Vec::new(),
1752                    approval_seq: 2,
1753                },
1754                gate,
1755                EventKind::ValidationFinding {
1756                    milestone_id: "ms-1".into(),
1757                    run_id: "v-1".into(),
1758                    finding: crate::types::Finding {
1759                        subject: "a-1".into(),
1760                        severity: "major".into(),
1761                        evidence: "the rule failed".into(),
1762                        suggested_fix: String::new(),
1763                        class: String::new(),
1764                        rule: Some(crate::types::RuleCitation {
1765                            id: "ZZ-FAIL-001".to_string(),
1766                            revision: 2,
1767                            source: "zz-pack standards".to_string(),
1768                            digest: "ab".repeat(32),
1769                            lifecycle: "enforced".to_string(),
1770                            level: "must".to_string(),
1771                            checker: Some("gate:zz-gate".to_string()),
1772                        }),
1773                    },
1774                },
1775                EventKind::MissionCompleted {},
1776            ],
1777        )
1778    }
1779
1780    /// KRZ-343 (D-H): the bundle renders the coverage matrix from the SAME
1781    /// fold the replay computed — summary.md carries the dispositions with
1782    /// mechanism and artefact references, chain.json carries the machine
1783    /// form — and the assembly stays byte-identical across runs.
1784    #[test]
1785    fn flight_rules_provenance_bundle_renders_coverage_byte_identically() {
1786        let tmp = TempDir::new().unwrap();
1787        seed_pinned_mission(tmp.path());
1788        let first = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1789        let second = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1790        assert_eq!(first, second, "same log → byte-identical bundle");
1791
1792        let summary = first
1793            .files
1794            .iter()
1795            .find(|file| file.path == SUMMARY_FILE)
1796            .expect("summary ships");
1797        let summary = String::from_utf8(summary.bytes.clone()).unwrap();
1798        assert!(
1799            summary.contains("## Flight Rules standards coverage"),
1800            "{summary}"
1801        );
1802        assert!(
1803            summary.contains("| ZZ-FAIL-001 | r2 | enforced | must | gate:zz-gate | failed |"),
1804            "{summary}"
1805        );
1806        assert!(
1807            summary.contains("| ZZ-PASS-001 | r1 | enforced | must | gate:zz-gate | passed |"),
1808            "{summary}"
1809        );
1810        assert!(
1811            summary
1812                .contains("| ZZ-QUIET-001 | r1 | enforced | must | gate:zz-gate | not-evaluated |"),
1813            "{summary}"
1814        );
1815        // The evidence cell names the artefact reference verbatim…
1816        assert!(
1817            summary.contains("gate.result seq 4 zz-gate pass `file:runs/gone.jsonl`"),
1818            "{summary}"
1819        );
1820
1821        let chain = first
1822            .files
1823            .iter()
1824            .find(|file| file.path == CHAIN_FILE)
1825            .expect("the chain ships");
1826        let chain = String::from_utf8(chain.bytes.clone()).unwrap();
1827        assert!(chain.contains("\"standards\""), "{chain}");
1828        assert!(
1829            chain.contains("\"disposition\": \"not-evaluated\""),
1830            "{chain}"
1831        );
1832    }
1833
1834    /// The replay contract survives the matrix (KRZ-343): a referenced
1835    /// artefact whose bytes are gone stays `unresolved` in the manifest —
1836    /// the coverage row still names the reference, and nothing about the
1837    /// missing bytes becomes an error or a pass.
1838    #[test]
1839    fn flight_rules_provenance_bundle_removed_artefacts_stay_unresolved() {
1840        let tmp = TempDir::new().unwrap();
1841        seed_pinned_mission(tmp.path());
1842        // runs/gone.jsonl was never written: the gate's file ref is gone.
1843        let bundle = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1844        let entry = manifest_entry(&bundle.manifest, "file:runs/gone.jsonl");
1845        assert_eq!(entry.status, Some(ArtefactStatus::Unresolved));
1846        assert_eq!(entry.path, None, "an unresolved entry has no bytes path");
1847        // …and the matrix still renders the reference, marked passed ONLY
1848        // because the gate stated a pass verdict — never because evidence
1849        // was absent.
1850        let summary = bundle
1851            .files
1852            .iter()
1853            .find(|file| file.path == SUMMARY_FILE)
1854            .expect("summary ships");
1855        let summary = String::from_utf8(summary.bytes.clone()).unwrap();
1856        assert!(summary.contains("`file:runs/gone.jsonl`"), "{summary}");
1857        assert!(
1858            summary.contains("Absence of evidence is never rendered as pass"),
1859            "{summary}"
1860        );
1861    }
1862
1863    /// The byte-compat regression contract: the pre-Flight-Rules fixture
1864    /// (no pin, no standards events) bundles with NO coverage section and
1865    /// NO standards key in chain.json — byte-identical to what the export
1866    /// produced before KRZ-343.
1867    #[test]
1868    fn flight_rules_provenance_bundle_pre_flight_rules_mission_is_unchanged() {
1869        let tmp = TempDir::new().unwrap();
1870        seed_full_mission(tmp.path());
1871        let bundle = assemble_evidence_bundle(tmp.path(), "m-1").unwrap();
1872        let summary = bundle
1873            .files
1874            .iter()
1875            .find(|file| file.path == SUMMARY_FILE)
1876            .expect("summary ships");
1877        let summary = String::from_utf8(summary.bytes.clone()).unwrap();
1878        assert!(
1879            !summary.contains("Flight Rules standards coverage"),
1880            "no pin, no matrix: {summary}"
1881        );
1882        let chain = bundle
1883            .files
1884            .iter()
1885            .find(|file| file.path == CHAIN_FILE)
1886            .expect("the chain ships");
1887        let chain = String::from_utf8(chain.bytes.clone()).unwrap();
1888        assert!(
1889            !chain.contains("\"standards\""),
1890            "a pre-Flight-Rules chain carries no standards key: {chain}"
1891        );
1892    }
1893}