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