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