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