Skip to main content

kranz_engine/
provenance.rs

1//! Provenance replay (ticket `.kranz/tickets/provenance-replay.md`, KRZ-325 —
2//! the governance evidence layer's audit story): reconstruct WHY a mission's
3//! unit passed from its event log ALONE — which gates in which order, which
4//! artefacts, which model/backend, which prompt identity, which human
5//! decisions, and the terminal outcome — as one typed, ordered
6//! [`ProvenanceChain`] that the CLI renders as a text summary or `--json`.
7//!
8//! The fold's discipline, in the substrate's own rules:
9//!
10//! - **Log alone, plus the mission dir.** Everything here is folded from one
11//!   mission's `events.jsonl`; the only other read is classifying artefact
12//!   references against the mission dir via
13//!   [`crate::gate_results::resolve_artefact`] (the total classifier — a
14//!   reference whose bytes are gone reads [`ArtefactStatus::Unresolved`],
15//!   never an error). No network, no git, no other missions, no persisted
16//!   state: a pruned mission (a cleaned `runs/`) still replays end to end.
17//! - **Deterministic machine form.** Same log → byte-identical `--json`:
18//!   the chain keeps log order (Vec, never a hashed map), consults no clock,
19//!   and carries NO host paths — artefact resolution is recorded as the
20//!   classification alone; the absolute path the resolver probed never
21//!   leaves [`crate::gate_results`]. (A resolved path would also leak the
22//!   host layout into the audit record, the exact failure KRZ-312's
23//!   mission-relative discipline exists to prevent.)
24//! - **Pure-fold idiom.** Same shape as [`crate::escalation_metrics`]:
25//!   [`provenance_chain`] is a pure function over an event slice (plus the
26//!   artefact classification), [`compute_provenance`] the thin read wrapper.
27//!   The replay writes nothing to the mission dir.
28//!
29//! WHY the gate ladder keeps LOG order rather than sorting on
30//! (surface, kind, index): `gate.result` emission is already pipeline order
31//! per surface batch ([`crate::gate_results::gate_result_events`]), so seq
32//! order IS the ladder order — while a re-approval emits a SECOND approval
33//! batch whose (kind, index) positions repeat, and sorting the whole surface
34//! set would interleave the two evaluations. The chain therefore preserves
35//! seq and carries the ladder position fields verbatim for any reader that
36//! wants to re-derive pipeline structure.
37//!
38//! `backend` comes from the resolved `worker.spawned` identity when present,
39//! so fallback and pool dispatch remain visible. Older logs omitted it: for
40//! those alone the fold tracks the config the log itself records
41//! (`mission.created`'s [`crate::types::MissionConfig`], evolved by each
42//! `config.changed` patch through the reducer's OWN deep-merge, so the
43//! replay can never drift from the state fold) and derives each spawn's
44//! backend from the config in force AT THAT SEQ. That legacy derivation cannot
45//! establish the actual backend after fallback. An invalid patch fails the replay
46//! exactly as it fails the reducer's fold — a log the reducer would reject
47//! is corruption, not a provenance gap.
48//!
49//! WHY the decision set is what it is: it mirrors the flight-surgeon
50//! intervention set ([`crate::escalation_metrics`]) so the two folds can
51//! never disagree about what counts as a human acting — grant
52//! approvals/denials, plan-revision decisions, operator milestone unblocks
53//! (excluding the engine-owned workspace-gate lift via the SAME
54//! [`crate::escalation_metrics::is_engine_lift`] classification), and
55//! `user.message` split by SEQUENCE at `plan.approved` (at/after = steer,
56//! before = drafting conversation that shaped the approved plan — both are
57//! human acts a chain must name). Two additions the metrics fold can take
58//! for granted but a chain cannot: `plan.approved` itself (the foundational
59//! approval the whole mission rests on) and `mission.abandoned` (the
60//! operator's terminal decision).
61
62use crate::error::{EngineError, Result};
63use crate::events::{Event, EventKind};
64use crate::gate::{GateKind, GateSurface, GateVerdict};
65use crate::gate_results::{file_artefact_ref, resolve_artefact, ArtefactResolution};
66use crate::types::{MissionConfig, Role};
67use serde::{Deserialize, Serialize};
68use std::path::Path;
69
70/// The artefact-resolution classification carried by the chain: the verdict
71/// of [`crate::gate_results::resolve_artefact`] WITHOUT the probed path, so
72/// the machine form stays host-layout free and byte-stable across machines.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "lowercase")]
75pub enum ArtefactStatus {
76    /// A `file:` reference whose mission-relative bytes are present.
77    Resolved,
78    /// A `file:` reference whose bytes are gone (or whose path could never
79    /// resolve honestly inside a mission dir) — a classification, never an
80    /// error; the replay continues.
81    Unresolved,
82    /// No `file:` scheme: the evidence is textual and travels in the event
83    /// payload itself — there is nothing on disk that could go missing.
84    Inline,
85}
86
87impl ArtefactStatus {
88    /// Classify a resolution, dropping the probed path (see the type docs).
89    fn classify(resolution: &ArtefactResolution) -> Self {
90        match resolution {
91            ArtefactResolution::Resolved { .. } => Self::Resolved,
92            ArtefactResolution::Unresolved { .. } => Self::Unresolved,
93            ArtefactResolution::Inline => Self::Inline,
94        }
95    }
96
97    /// The wire/serde form (`resolved`/`unresolved`/`inline`) for text surfaces.
98    pub fn as_str(&self) -> &'static str {
99        match self {
100            Self::Resolved => "resolved",
101            Self::Unresolved => "unresolved",
102            Self::Inline => "inline",
103        }
104    }
105}
106
107/// One `gate.result` event, replayed: identity, ladder position, verdict,
108/// the artefact handle verbatim, and its resolution against the mission dir.
109/// The `seq` pins the evaluation into the chain's ordering.
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111#[serde(rename_all = "camelCase")]
112pub struct GateLink {
113    pub seq: u64,
114    pub gate: String,
115    pub surface: GateSurface,
116    /// The gate's kind — doubling as the ladder section (events.rs).
117    pub kind: GateKind,
118    /// Zero-based position within the section, verbatim from the event.
119    pub index: u32,
120    pub verdict: GateVerdict,
121    /// The artefact handle exactly as the gate stated it.
122    pub artefact_ref: String,
123    /// Evidence captured verbatim by the gate; absent when the reference
124    /// alone is the evidence.
125    pub artefact_detail: Option<String>,
126    /// Gate-supplied confidence pair (KRZ-315), purely evidentiary.
127    pub score: Option<f64>,
128    pub threshold: Option<f64>,
129    /// Resolution of `artefact_ref` against the mission dir.
130    pub artefact: ArtefactStatus,
131    /// The standards rule ids the evaluation joined (KRZ-343, design D-H),
132    /// verbatim from the event. Additive: absent (never `[]`) on pre-field
133    /// chains and for gates with no standards linkage, so those chains stay
134    /// byte-identical.
135    #[serde(default, skip_serializing_if = "Vec::is_empty")]
136    pub rule_ids: Vec<String>,
137}
138
139/// One `worker.spawned`, replayed: who ran, with what, under which prompt
140/// identity. Carries whatever the log records — the prompt hash is a
141/// required field on the event, so any log that parses surfaces it.
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct SessionLink {
145    pub seq: u64,
146    pub run_id: String,
147    pub role: Role,
148    /// Recorded resolved backend, or config-derived for legacy spawns only.
149    /// `None` when neither a dispatch identity nor preceding config exists.
150    pub backend: Option<String>,
151    pub model: String,
152    pub quant: String,
153    pub weight_hash: Option<String>,
154    /// The prompt identity as recorded (first 12 hex chars of the prompt
155    /// text's SHA-256 — [`crate::prompts::hash_text`]), verbatim from the log.
156    pub prompt_hash: String,
157    pub feature_id: Option<String>,
158    pub milestone_id: Option<String>,
159    /// The mission-relative transcript path recorded on the event.
160    pub transcript_ref: String,
161    /// Its resolution against the mission dir — transcripts live under the
162    /// prunable `runs/`, so this degrades to unresolved exactly like a gate
163    /// artefact.
164    pub transcript: ArtefactStatus,
165}
166
167/// What kind of human act one chain entry records (module docs for the set).
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
169#[serde(rename_all = "kebab-case")]
170pub enum DecisionKind {
171    PlanApproval,
172    PlanRevision,
173    PlanRevisionRejection,
174    GrantApproval,
175    GrantDenial,
176    MilestoneUnblock,
177    /// A `user.message` at/after `plan.approved` (by seq — the
178    /// escalation-metrics steer rule).
179    Steer,
180    /// A `user.message` before plan approval: drafting, not a steer.
181    OperatorMessage,
182    MissionAbandoned,
183}
184
185impl DecisionKind {
186    /// The wire/serde form for text surfaces (the `as_str` idiom of
187    /// [`crate::escalation_metrics::LedgerKind`]).
188    pub fn as_str(&self) -> &'static str {
189        match self {
190            Self::PlanApproval => "plan-approval",
191            Self::PlanRevision => "plan-revision",
192            Self::PlanRevisionRejection => "plan-revision-rejection",
193            Self::GrantApproval => "grant-approval",
194            Self::GrantDenial => "grant-denial",
195            Self::MilestoneUnblock => "milestone-unblock",
196            Self::Steer => "steer",
197            Self::OperatorMessage => "operator-message",
198            Self::MissionAbandoned => "mission-abandoned",
199        }
200    }
201}
202
203/// One human decision, in log order, pinned to its event `seq`.
204#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
205#[serde(rename_all = "camelCase")]
206pub struct DecisionLink {
207    pub seq: u64,
208    pub kind: DecisionKind,
209    /// One line stating what was decided ("approved command: cargo test",
210    /// the steer's text, "unblocked ms-1: user skipped findings").
211    pub summary: String,
212}
213
214/// One divergence-ledger entry, replayed (ticket
215/// `divergence-first-class-event`, KRZ-304): the comparison the pool
216/// parked on, or the resolution that later landed — pinned to its `seq`
217/// so the record and its resolution interleave with the rest of the chain
218/// in log order. The candidate refs (run id, branch, backend, tree hash)
219/// ride verbatim, so the resolution's `selected` index resolves against
220/// the SAME replayed record without git.
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
222#[serde(tag = "kind", rename_all = "kebab-case")]
223pub enum DivergenceLink {
224    /// A `divergence.noted` — the comparison record. `diverged: false` is
225    /// the agreement record: logged, never trusted.
226    Noted {
227        seq: u64,
228        unit: String,
229        candidates: Vec<crate::types::DivergenceCandidate>,
230        diverged: bool,
231    },
232    /// A `divergence.resolved` — which candidate (or none), why, decided
233    /// by whom.
234    Resolved {
235        seq: u64,
236        unit: String,
237        selected: Option<u32>,
238        reason: String,
239        decided_by: String,
240    },
241}
242
243/// How the mission ended, when it did.
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(rename_all = "lowercase")]
246pub enum TerminalStatus {
247    Completed,
248    Failed,
249    Abandoned,
250}
251
252impl TerminalStatus {
253    /// The wire/serde form for text surfaces.
254    pub fn as_str(&self) -> &'static str {
255        match self {
256            Self::Completed => "completed",
257            Self::Failed => "failed",
258            Self::Abandoned => "abandoned",
259        }
260    }
261}
262
263/// The terminal event, pinned to its `seq`. `reason` rides along for
264/// failed/abandoned; `None` on completion.
265#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
266#[serde(rename_all = "camelCase")]
267pub struct TerminalLink {
268    pub seq: u64,
269    pub status: TerminalStatus,
270    pub reason: Option<String>,
271}
272
273/// The replayed chain: mission identity, the gate ladder, the sessions, the
274/// human decisions, and the terminal outcome — every vec in log (seq) order.
275/// `None` identity fields mean the log lacked the event that records them
276/// (a hand-cut log); the chain still reconstructs around the gap.
277#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
278#[serde(rename_all = "camelCase")]
279pub struct ProvenanceChain {
280    pub mission_id: String,
281    pub goal: Option<String>,
282    pub base_branch: Option<String>,
283    pub mission_branch: Option<String>,
284    /// Base-branch commit SHA pinned at approval; `None` on pre-`baseSha`
285    /// logs.
286    pub base_sha: Option<String>,
287    pub gates: Vec<GateLink>,
288    #[serde(default, skip_serializing_if = "Vec::is_empty")]
289    pub gate_evaluations: Vec<crate::gate_evaluation::lifecycle::Record>,
290    pub sessions: Vec<SessionLink>,
291    pub decisions: Vec<DecisionLink>,
292    /// The divergence ledger (KRZ-304): comparison records and their
293    /// resolutions, each pinned to its seq. Empty on pre-pool logs
294    /// (`#[serde(default)]` keeps a pre-field chain.json readable).
295    #[serde(default)]
296    pub divergences: Vec<DivergenceLink>,
297    /// The Flight Rules rule coverage matrix (KRZ-343, design D-H), folded
298    /// from the same log by [`crate::standards_coverage`]: every applicable
299    /// pinned rule's disposition with its mechanism and evidence joins, the
300    /// resolution provenance, and any drift refusals. `None` — and absent
301    /// from the machine form — on missions with no approved standards pin
302    /// (every pre-Flight-Rules log), so those chains stay byte-identical.
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub standards: Option<crate::standards_coverage::StandardsCoverage>,
305    /// The FIRST terminal event (a well-formed log has exactly one); `None`
306    /// while the mission is still in flight.
307    pub outcome: Option<TerminalLink>,
308}
309
310/// Fold one mission's provenance chain from its event slice, classifying
311/// artefact references against `mission_dir`. `events` may contain other
312/// missions' events (filtered out, the [`crate::escalation_metrics`]
313/// discipline) but must be in ascending `seq` order — the chain's ordering
314/// IS the log's. Pure: no clock, no network, no git; the only I/O is the
315/// resolver's metadata probes under `mission_dir`.
316///
317/// Invalid configuration patches fail as they do in the reducer. Logs with
318/// external gate lifecycle events also use the reducer to reject corrupt
319/// authority transitions. Legacy partial logs keep their existing replay
320/// behavior. Artefact resolution is total and never contributes an error.
321pub fn provenance_chain(
322    mission_dir: &Path,
323    mission_id: &str,
324    events: &[Event],
325) -> Result<ProvenanceChain> {
326    let mut chain = ProvenanceChain {
327        mission_id: mission_id.to_string(),
328        goal: None,
329        base_branch: None,
330        mission_branch: None,
331        base_sha: None,
332        gates: Vec::new(),
333        gate_evaluations: Vec::new(),
334        sessions: Vec::new(),
335        decisions: Vec::new(),
336        divergences: Vec::new(),
337        standards: None,
338        outcome: None,
339    };
340    if events.iter().any(|event| {
341        event.mission_id == mission_id
342            && matches!(event.kind, EventKind::GateEvaluationRequested { .. })
343    }) {
344        let mission_events: Vec<_> = events
345            .iter()
346            .filter(|event| event.mission_id == mission_id)
347            .cloned()
348            .collect();
349        chain.gate_evaluations = crate::reducer::fold(&mission_events)?
350            .gate_evaluations
351            .into_values()
352            .collect();
353        chain
354            .gate_evaluations
355            .sort_by_key(|record| record.requested_seq);
356    }
357    // The config in force at the current seq (backend derivation); set by
358    // mission.created, evolved by config.changed through the reducer's merge.
359    let mut config: Option<MissionConfig> = None;
360    let mut plan_approved_seq: Option<u64> = None;
361
362    for event in events.iter().filter(|e| e.mission_id == mission_id) {
363        match &event.kind {
364            EventKind::MissionCreated {
365                goal,
366                base_branch,
367                mission_branch,
368                config: created,
369            } => {
370                chain.goal = Some(goal.clone());
371                chain.base_branch = Some(base_branch.clone());
372                chain.mission_branch = Some(mission_branch.clone());
373                config = Some(created.clone());
374            }
375            EventKind::PlanApproved { base_sha, .. } => {
376                chain.base_sha = base_sha.clone();
377                plan_approved_seq = Some(event.seq);
378                chain.decisions.push(DecisionLink {
379                    seq: event.seq,
380                    kind: DecisionKind::PlanApproval,
381                    summary: "plan approved".to_string(),
382                });
383            }
384            EventKind::PlanRevised { revision, .. } => chain.decisions.push(DecisionLink {
385                seq: event.seq,
386                kind: DecisionKind::PlanRevision,
387                summary: format!("plan revision {revision} approved"),
388            }),
389            EventKind::PlanRevisionRejected { revision, reason } => {
390                chain.decisions.push(DecisionLink {
391                    seq: event.seq,
392                    kind: DecisionKind::PlanRevisionRejection,
393                    summary: format!("plan revision {revision} rejected: {reason}"),
394                });
395            }
396            EventKind::GrantApproved { kind, command } => chain.decisions.push(DecisionLink {
397                seq: event.seq,
398                kind: DecisionKind::GrantApproval,
399                summary: format!(
400                    "approved {}: {command}",
401                    crate::escalation_metrics::grant_kind_str(kind)
402                ),
403            }),
404            EventKind::GrantDenied {
405                kind,
406                command,
407                reason,
408            } => chain.decisions.push(DecisionLink {
409                seq: event.seq,
410                kind: DecisionKind::GrantDenial,
411                summary: format!(
412                    "denied {}: {command} ({reason})",
413                    crate::escalation_metrics::grant_kind_str(kind)
414                ),
415            }),
416            EventKind::MilestoneUnblocked {
417                milestone_id,
418                reason,
419                block_context,
420                ..
421            } if !crate::escalation_metrics::is_engine_lift(reason, block_context.as_ref()) => {
422                chain.decisions.push(DecisionLink {
423                    seq: event.seq,
424                    kind: DecisionKind::MilestoneUnblock,
425                    summary: format!("unblocked {milestone_id}: {reason}"),
426                });
427            }
428            EventKind::UserMessage { text, .. } => {
429                // Classify by SEQUENCE, not wall clock (the escalation-metrics
430                // steer rule): the event log's seq is the order of truth.
431                let kind = match plan_approved_seq {
432                    Some(approved) if event.seq >= approved => DecisionKind::Steer,
433                    _ => DecisionKind::OperatorMessage,
434                };
435                chain.decisions.push(DecisionLink {
436                    seq: event.seq,
437                    kind,
438                    summary: text.clone(),
439                });
440            }
441            EventKind::MissionAbandoned { reason } => {
442                chain.decisions.push(DecisionLink {
443                    seq: event.seq,
444                    kind: DecisionKind::MissionAbandoned,
445                    summary: format!("abandoned: {reason}"),
446                });
447                if chain.outcome.is_none() {
448                    chain.outcome = Some(TerminalLink {
449                        seq: event.seq,
450                        status: TerminalStatus::Abandoned,
451                        reason: Some(reason.clone()),
452                    });
453                }
454            }
455            EventKind::MissionCompleted {} => {
456                if chain.outcome.is_none() {
457                    chain.outcome = Some(TerminalLink {
458                        seq: event.seq,
459                        status: TerminalStatus::Completed,
460                        reason: None,
461                    });
462                }
463            }
464            EventKind::MissionFailed { reason } => {
465                if chain.outcome.is_none() {
466                    chain.outcome = Some(TerminalLink {
467                        seq: event.seq,
468                        status: TerminalStatus::Failed,
469                        reason: Some(reason.clone()),
470                    });
471                }
472            }
473            EventKind::GateResult {
474                gate,
475                surface,
476                kind,
477                index,
478                verdict,
479                artefact_ref,
480                artefact_detail,
481                score,
482                threshold,
483                rule_ids,
484            } => chain.gates.push(GateLink {
485                seq: event.seq,
486                gate: gate.clone(),
487                surface: *surface,
488                kind: *kind,
489                index: *index,
490                verdict: *verdict,
491                artefact_ref: artefact_ref.clone(),
492                artefact_detail: artefact_detail.clone(),
493                score: *score,
494                threshold: *threshold,
495                artefact: ArtefactStatus::classify(&resolve_artefact(mission_dir, artefact_ref)),
496                rule_ids: rule_ids.clone(),
497            }),
498            EventKind::DivergenceNoted {
499                unit,
500                candidates,
501                diverged,
502            } => chain.divergences.push(DivergenceLink::Noted {
503                seq: event.seq,
504                unit: unit.clone(),
505                candidates: candidates.clone(),
506                diverged: *diverged,
507            }),
508            EventKind::DivergenceResolved {
509                unit,
510                selected,
511                reason,
512                decided_by,
513            } => chain.divergences.push(DivergenceLink::Resolved {
514                seq: event.seq,
515                unit: unit.clone(),
516                selected: *selected,
517                reason: reason.clone(),
518                decided_by: decided_by.clone(),
519            }),
520            EventKind::WorkerSpawned {
521                run_id,
522                role,
523                feature_id,
524                milestone_id,
525                model,
526                quant,
527                weight_hash,
528                prompt_hash,
529                transcript_path,
530                backend,
531                ..
532            } => chain.sessions.push(SessionLink {
533                seq: event.seq,
534                run_id: run_id.clone(),
535                role: *role,
536                backend: (backend.is_some() || config.is_some()).then(|| {
537                    crate::cost::resolved_run_backend(*backend, *role, config.as_ref())
538                        .as_str()
539                        .to_string()
540                }),
541                model: model.clone(),
542                quant: quant.clone(),
543                weight_hash: weight_hash.clone(),
544                prompt_hash: prompt_hash.clone(),
545                feature_id: feature_id.clone(),
546                milestone_id: milestone_id.clone(),
547                transcript_ref: transcript_path.clone(),
548                transcript: ArtefactStatus::classify(&resolve_artefact(
549                    mission_dir,
550                    &file_artefact_ref(transcript_path),
551                )),
552            }),
553            EventKind::ConfigChanged { patch } => {
554                if let Some(current) = &mut config {
555                    // The reducer's own merge + error, verbatim: the replay's
556                    // tracked config cannot drift from the state fold's.
557                    let mut value = serde_json::to_value(&*current)?;
558                    crate::reducer::deep_merge(&mut value, patch);
559                    *current = serde_json::from_value(value).map_err(|e| {
560                        EngineError::Config(format!(
561                            "config.changed patch produced invalid config: {e}"
562                        ))
563                    })?;
564                }
565            }
566            _ => {}
567        }
568    }
569    // The standards coverage matrix (KRZ-343, D-H): one fold over the same
570    // slice, attached to the chain so the replay and the evidence bundle
571    // render rule dispositions without a second pass. `None` on
572    // pre-Flight-Rules logs — their chains stay byte-identical.
573    chain.standards = crate::standards_coverage::standards_coverage(mission_id, events);
574    Ok(chain)
575}
576
577/// Locate one mission under `repo_root` and replay its log into a
578/// [`ProvenanceChain`]. Read-only, no lock (§4.3 read-only observers): opens
579/// the log no-follow, refuses a symlinked mission path component (P1 — the
580/// artefact resolver's probe anchor must be the real mission dir), and
581/// writes nothing. A missing/unreadable/corrupt log surfaces as the error,
582/// mirroring `load_state`'s posture for single-mission reads.
583pub fn compute_provenance(repo_root: &Path, mission_id: &str) -> anyhow::Result<ProvenanceChain> {
584    let paths = crate::paths::MissionPaths::new(repo_root, mission_id);
585    paths.require_no_follow()?;
586    let events = crate::event_log::EventLog::read_events(&paths.events_file())?;
587    Ok(provenance_chain(&paths.mission_dir(), mission_id, &events)?)
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593    use crate::event_log::{EventLog, LockForce};
594    use crate::paths::MissionPaths;
595    use crate::types::{GrantKind, Plan};
596    use std::time::Duration;
597    use tempfile::TempDir;
598
599    /// Seed a mission's `events.jsonl` with the given kinds, in order (the
600    /// escalation_metrics fixture idiom); the log handle drops — and flushes
601    /// — before any replay reads.
602    fn seed_mission(repo_root: &Path, id: &str, kinds: Vec<EventKind>) -> MissionPaths {
603        let paths = MissionPaths::new(repo_root, id);
604        let mut log = EventLog::acquire(&paths, id, Duration::ZERO, LockForce::No).unwrap();
605        for kind in kinds {
606            log.append(kind).unwrap();
607        }
608        paths
609    }
610
611    fn sample_plan() -> Plan {
612        Plan {
613            goal: "ship the thing".into(),
614            validation_contract: vec![],
615            milestones: vec![],
616            considered_alternatives: None,
617            command_grants: vec![],
618            touch_set: vec![],
619            standards_manifest: None,
620            reviewer_independence: None,
621        }
622    }
623
624    /// A config whose Worker runs on codex — the backend the replay must
625    /// derive for worker spawns (until a config.changed flips it).
626    fn created_config() -> MissionConfig {
627        let mut config = MissionConfig::default();
628        config.worker.backend = Some("codex".to_string());
629        config
630    }
631
632    fn created() -> EventKind {
633        EventKind::MissionCreated {
634            goal: "ship the thing".into(),
635            base_branch: "main".into(),
636            mission_branch: "kranz/mission-x".into(),
637            config: created_config(),
638        }
639    }
640
641    fn gate_result(
642        gate: &str,
643        surface: GateSurface,
644        kind: GateKind,
645        index: u32,
646        verdict: GateVerdict,
647        artefact_ref: &str,
648    ) -> EventKind {
649        EventKind::GateResult {
650            gate: gate.to_string(),
651            surface,
652            kind,
653            index,
654            verdict,
655            artefact_ref: artefact_ref.to_string(),
656            artefact_detail: None,
657            score: None,
658            threshold: None,
659            rule_ids: Vec::new(),
660        }
661    }
662
663    fn worker_spawned(run_id: &str, role: Role, model: &str, prompt_hash: &str) -> EventKind {
664        EventKind::WorkerSpawned {
665            backend: None,
666            run_id: run_id.to_string(),
667            role,
668            feature_id: None,
669            milestone_id: None,
670            candidate: None,
671            executor_route: None,
672            sdk_session_id: format!("sess-{run_id}"),
673            model: model.to_string(),
674            quant: "n/a".to_string(),
675            weight_hash: None,
676            prompt_hash: prompt_hash.to_string(),
677            transcript_path: MissionPaths::transcript_rel(run_id),
678        }
679    }
680
681    /// The anti-vacuity fixture (ticket acceptance hint 1): a mission with
682    /// the full gate ladder (both surfaces; an inline ref, a resolved file
683    /// ref, a file ref whose bytes were never written), three sessions
684    /// straddling a mid-mission backend flip, and the decision set — a plan
685    /// approval, a grant park (request is NOT a decision) + approval, an
686    /// operator unblock plus the engine-owned lift (NOT a decision), and a
687    /// steer — ending COMPLETED.
688    fn seed_full_mission(root: &Path) -> MissionPaths {
689        let mut judged = gate_result(
690            "plan-review",
691            GateSurface::Approval,
692            GateKind::ModelJudged,
693            0,
694            GateVerdict::Pass,
695            "file:runs/gone.jsonl",
696        );
697        if let EventKind::GateResult {
698            artefact_detail,
699            score,
700            threshold,
701            ..
702        } = &mut judged
703        {
704            *artefact_detail = Some("looks sound".to_string());
705            *score = Some(0.9);
706            *threshold = Some(0.5);
707        }
708        let paths = seed_mission(
709            root,
710            "m-1",
711            vec![
712                created(),
713                EventKind::PlanApproved {
714                    plan: sample_plan(),
715                    base_sha: Some("deadbeef".to_string()),
716                },
717                gate_result(
718                    "vacuous-filter",
719                    GateSurface::Approval,
720                    GateKind::Deterministic,
721                    0,
722                    GateVerdict::Pass,
723                    "contract gate vacuous-filter",
724                ),
725                gate_result(
726                    "merge-gate-suite",
727                    GateSurface::Approval,
728                    GateKind::Deterministic,
729                    1,
730                    GateVerdict::Pass,
731                    "file:runs/gate-base.jsonl",
732                ),
733                judged,
734                {
735                    let mut spawn = worker_spawned("r-1", Role::Worker, "gpt-5", "aaaabbbbcccc");
736                    if let EventKind::WorkerSpawned {
737                        feature_id,
738                        milestone_id,
739                        ..
740                    } = &mut spawn
741                    {
742                        *feature_id = Some("f-1-1".to_string());
743                        *milestone_id = Some("ms-1".to_string());
744                    }
745                    spawn
746                },
747                EventKind::GrantRequested {
748                    milestone_id: "ms-1".into(),
749                    kind: GrantKind::Command,
750                    command: "cargo test".into(),
751                },
752                EventKind::GrantApproved {
753                    kind: GrantKind::Command,
754                    command: "cargo test".into(),
755                },
756                EventKind::ConfigChanged {
757                    patch: serde_json::json!({"worker": {"backend": "local"}}),
758                },
759                worker_spawned("r-2", Role::Worker, "my-local-model", "dddd11112222"),
760                worker_spawned("r-3", Role::ValidatorScrutiny, "sonnet", "ffff33334444"),
761                EventKind::MilestoneBlocked {
762                    block_context: None,
763                    milestone_id: "ms-1".into(),
764                    reason: "fix-cycle cap".into(),
765                },
766                EventKind::MilestoneUnblocked {
767                    block_context: None,
768                    milestone_id: "ms-1".into(),
769                    reason: "user skipped findings".into(),
770                    validator_guidance: None,
771                },
772                EventKind::MilestoneUnblocked {
773                    block_context: None,
774                    milestone_id: "ms-1".into(),
775                    reason: crate::workspace_gate::GATE_LIFT_REASON.to_string(),
776                    validator_guidance: None,
777                },
778                EventKind::UserMessage {
779                    text: "skip the flaky test".into(),
780                    interrupt: false,
781                },
782                gate_result(
783                    "merge-gate-suite",
784                    GateSurface::FinalGate,
785                    GateKind::Deterministic,
786                    0,
787                    GateVerdict::Pass,
788                    ".kranz/merge-gates.json",
789                ),
790                EventKind::MissionCompleted {},
791            ],
792        );
793        // Bytes for the resolved refs: one gate artefact and one transcript.
794        std::fs::write(paths.runs_dir().join("gate-base.jsonl"), b"{}").unwrap();
795        std::fs::write(paths.runs_dir().join("r-1.jsonl"), b"{}").unwrap();
796        paths
797    }
798
799    /// Ticket acceptance hint 1, in one fold: every gate verdict in order,
800    /// every artefact ref with its resolution, the backend/model per session
801    /// (including the derived backend across a mid-mission flip), the prompt
802    /// identity, and each human decision with its event seq — then the
803    /// terminal outcome.
804    #[test]
805    fn provenance_replay_names_ladder_sessions_decisions_and_outcome_in_order() {
806        let tmp = TempDir::new().unwrap();
807        let paths = seed_full_mission(tmp.path());
808        let events = EventLog::read_events(&paths.events_file()).unwrap();
809        let chain = provenance_chain(&paths.mission_dir(), "m-1", &events).unwrap();
810
811        // Mission identity from mission.created + plan.approved.
812        assert_eq!(chain.mission_id, "m-1");
813        assert_eq!(chain.goal.as_deref(), Some("ship the thing"));
814        assert_eq!(chain.base_branch.as_deref(), Some("main"));
815        assert_eq!(chain.mission_branch.as_deref(), Some("kranz/mission-x"));
816        assert_eq!(chain.base_sha.as_deref(), Some("deadbeef"));
817
818        // The ladder, in log order, with resolutions classified.
819        let ladder: Vec<(
820            u64,
821            &str,
822            GateSurface,
823            GateKind,
824            u32,
825            GateVerdict,
826            ArtefactStatus,
827        )> = chain
828            .gates
829            .iter()
830            .map(|gate| {
831                (
832                    gate.seq,
833                    gate.gate.as_str(),
834                    gate.surface,
835                    gate.kind,
836                    gate.index,
837                    gate.verdict,
838                    gate.artefact,
839                )
840            })
841            .collect();
842        assert_eq!(
843            ladder,
844            vec![
845                (
846                    3,
847                    "vacuous-filter",
848                    GateSurface::Approval,
849                    GateKind::Deterministic,
850                    0,
851                    GateVerdict::Pass,
852                    ArtefactStatus::Inline
853                ),
854                (
855                    4,
856                    "merge-gate-suite",
857                    GateSurface::Approval,
858                    GateKind::Deterministic,
859                    1,
860                    GateVerdict::Pass,
861                    ArtefactStatus::Resolved
862                ),
863                (
864                    5,
865                    "plan-review",
866                    GateSurface::Approval,
867                    GateKind::ModelJudged,
868                    0,
869                    GateVerdict::Pass,
870                    ArtefactStatus::Unresolved
871                ),
872                (
873                    16,
874                    "merge-gate-suite",
875                    GateSurface::FinalGate,
876                    GateKind::Deterministic,
877                    0,
878                    GateVerdict::Pass,
879                    ArtefactStatus::Inline
880                ),
881            ]
882        );
883        // Refs and captured evidence arrive verbatim.
884        assert_eq!(chain.gates[0].artefact_ref, "contract gate vacuous-filter");
885        assert_eq!(chain.gates[1].artefact_ref, "file:runs/gate-base.jsonl");
886        assert_eq!(chain.gates[2].artefact_ref, "file:runs/gone.jsonl");
887        assert_eq!(
888            chain.gates[2].artefact_detail.as_deref(),
889            Some("looks sound")
890        );
891        assert_eq!(chain.gates[2].score, Some(0.9));
892        assert_eq!(chain.gates[2].threshold, Some(0.5));
893
894        // Sessions: model + prompt identity verbatim; backend DERIVED from the
895        // recorded config at each seq (codex → local across config.changed;
896        // the validator untouched by the worker patch).
897        assert_eq!(chain.sessions.len(), 3);
898        let r1 = &chain.sessions[0];
899        assert_eq!(r1.seq, 6);
900        assert_eq!(r1.role, Role::Worker);
901        assert_eq!(r1.backend.as_deref(), Some("codex"));
902        assert_eq!(r1.model, "gpt-5");
903        assert_eq!(r1.prompt_hash, "aaaabbbbcccc");
904        assert_eq!(r1.feature_id.as_deref(), Some("f-1-1"));
905        assert_eq!(r1.milestone_id.as_deref(), Some("ms-1"));
906        assert_eq!(r1.transcript_ref, "runs/r-1.jsonl");
907        assert_eq!(r1.transcript, ArtefactStatus::Resolved);
908        let r2 = &chain.sessions[1];
909        assert_eq!(r2.backend.as_deref(), Some("local"));
910        assert_eq!(r2.model, "my-local-model");
911        assert_eq!(r2.prompt_hash, "dddd11112222");
912        // runs/r-2.jsonl was never written: unresolved, never an error.
913        assert_eq!(r2.transcript, ArtefactStatus::Unresolved);
914        let r3 = &chain.sessions[2];
915        assert_eq!(r3.role, Role::ValidatorScrutiny);
916        assert_eq!(r3.backend.as_deref(), Some("claude"));
917
918        // Decisions in seq order: the grant REQUEST (seq 7) and the
919        // engine-owned lift (seq 14) are absent by construction.
920        let decisions: Vec<(u64, DecisionKind, &str)> = chain
921            .decisions
922            .iter()
923            .map(|d| (d.seq, d.kind, d.summary.as_str()))
924            .collect();
925        assert_eq!(
926            decisions,
927            vec![
928                (2, DecisionKind::PlanApproval, "plan approved"),
929                (
930                    8,
931                    DecisionKind::GrantApproval,
932                    "approved command: cargo test"
933                ),
934                (
935                    13,
936                    DecisionKind::MilestoneUnblock,
937                    "unblocked ms-1: user skipped findings"
938                ),
939                (15, DecisionKind::Steer, "skip the flaky test"),
940            ]
941        );
942
943        assert_eq!(
944            chain.outcome,
945            Some(TerminalLink {
946                seq: 17,
947                status: TerminalStatus::Completed,
948                reason: None,
949            })
950        );
951    }
952
953    /// Ticket acceptance hint 2: with `runs/` removed the chain still
954    /// reconstructs end to end — file-backed refs (gate artefact AND session
955    /// transcript) read unresolved, never an error.
956    #[test]
957    fn provenance_replay_without_runs_dir_reconstructs_with_unresolved_refs() {
958        let tmp = TempDir::new().unwrap();
959        let paths = seed_full_mission(tmp.path());
960        std::fs::remove_dir_all(paths.runs_dir()).unwrap();
961
962        let chain = compute_provenance(tmp.path(), "m-1").unwrap();
963        assert_eq!(chain.gates.len(), 4);
964        assert_eq!(chain.gates[1].artefact, ArtefactStatus::Unresolved);
965        // Textual refs are inline no matter what the filesystem holds.
966        assert_eq!(chain.gates[0].artefact, ArtefactStatus::Inline);
967        assert_eq!(chain.gates[3].artefact, ArtefactStatus::Inline);
968        assert_eq!(chain.sessions.len(), 3);
969        for session in &chain.sessions {
970            assert_eq!(
971                session.transcript,
972                ArtefactStatus::Unresolved,
973                "{} must read unresolved with runs/ gone",
974                session.run_id
975            );
976        }
977        assert_eq!(chain.decisions.len(), 4);
978        assert_eq!(
979            chain.outcome.map(|o| o.status),
980            Some(TerminalStatus::Completed)
981        );
982    }
983
984    /// Ticket acceptance hint 3: same log → byte-identical machine output,
985    /// across two independent compute passes (read + fold + resolve each).
986    #[test]
987    fn provenance_replay_machine_form_is_byte_identical_across_replays() {
988        let tmp = TempDir::new().unwrap();
989        seed_full_mission(tmp.path());
990        let first = compute_provenance(tmp.path(), "m-1").unwrap();
991        let second = compute_provenance(tmp.path(), "m-1").unwrap();
992        assert_eq!(first, second);
993        let first_json = serde_json::to_string_pretty(&first).unwrap();
994        let second_json = serde_json::to_string_pretty(&second).unwrap();
995        assert_eq!(first_json, second_json);
996        // The machine form carries no host layout: the temp dir's absolute
997        // path appears nowhere in the serialization.
998        assert!(
999            !first_json.contains(&tmp.path().to_string_lossy().to_string()),
1000            "host path leaked into the machine form: {first_json}"
1001        );
1002    }
1003
1004    /// Old logs (pre-`gate.result`, pre-`baseSha`) still fold: the ladder is
1005    /// empty, the identity falls back to what the log carries, and the
1006    /// failure outcome surfaces with its reason.
1007    #[test]
1008    fn provenance_replay_pre_gate_logs_still_fold() {
1009        let tmp = TempDir::new().unwrap();
1010        let paths = seed_mission(
1011            tmp.path(),
1012            "m-old",
1013            vec![
1014                EventKind::MissionCreated {
1015                    goal: "legacy goal".into(),
1016                    base_branch: "main".into(),
1017                    mission_branch: "kranz/mission-old".into(),
1018                    config: MissionConfig::default(),
1019                },
1020                EventKind::PlanApproved {
1021                    plan: sample_plan(),
1022                    base_sha: None,
1023                },
1024                worker_spawned("r-1", Role::Worker, "sonnet", "9999aaaabbbb"),
1025                EventKind::MissionFailed {
1026                    reason: "honest failure".into(),
1027                },
1028            ],
1029        );
1030        let events = EventLog::read_events(&paths.events_file()).unwrap();
1031        let chain = provenance_chain(&paths.mission_dir(), "m-old", &events).unwrap();
1032        assert!(chain.gates.is_empty());
1033        assert_eq!(chain.base_sha, None);
1034        assert_eq!(chain.sessions.len(), 1);
1035        assert_eq!(chain.sessions[0].backend.as_deref(), Some("claude"));
1036        assert_eq!(chain.sessions[0].prompt_hash, "9999aaaabbbb");
1037        assert_eq!(
1038            chain.outcome,
1039            Some(TerminalLink {
1040                seq: 4,
1041                status: TerminalStatus::Failed,
1042                reason: Some("honest failure".to_string()),
1043            })
1044        );
1045        // A pre-approval message is drafting, not a steer — but still a
1046        // named human act in the chain.
1047        let paths = seed_mission(
1048            tmp.path(),
1049            "m-draft",
1050            vec![
1051                EventKind::UserMessage {
1052                    text: "make it smaller".into(),
1053                    interrupt: false,
1054                },
1055                EventKind::PlanApproved {
1056                    plan: sample_plan(),
1057                    base_sha: None,
1058                },
1059            ],
1060        );
1061        let events = EventLog::read_events(&paths.events_file()).unwrap();
1062        let chain = provenance_chain(&paths.mission_dir(), "m-draft", &events).unwrap();
1063        assert_eq!(
1064            chain
1065                .decisions
1066                .iter()
1067                .map(|d| (d.seq, d.kind))
1068                .collect::<Vec<_>>(),
1069            vec![
1070                (1, DecisionKind::OperatorMessage),
1071                (2, DecisionKind::PlanApproval)
1072            ]
1073        );
1074        // No mission.created: identity fields stay None and the chain still
1075        // reconstructs; in flight, so no outcome.
1076        assert_eq!(chain.goal, None);
1077        assert_eq!(chain.outcome, None);
1078    }
1079
1080    /// A legacy fallible path: a config.changed patch that cannot
1081    /// merge into a valid MissionConfig fails the replay with the reducer's
1082    /// own error — corruption, not a provenance gap.
1083    #[test]
1084    fn provenance_replay_invalid_config_patch_fails_like_the_reducer() {
1085        let tmp = TempDir::new().unwrap();
1086        let paths = seed_mission(
1087            tmp.path(),
1088            "m-1",
1089            vec![
1090                created(),
1091                EventKind::ConfigChanged {
1092                    patch: serde_json::json!({"maxFixCyclesPerMilestone": "not-a-number"}),
1093                },
1094            ],
1095        );
1096        let events = EventLog::read_events(&paths.events_file()).unwrap();
1097        let result = provenance_chain(&paths.mission_dir(), "m-1", &events);
1098        assert!(
1099            matches!(result, Err(EngineError::Config(_))),
1100            "expected the reducer's Config error, got {result:?}"
1101        );
1102    }
1103
1104    /// The divergence record and its resolution survive provenance replay
1105    /// (ticket divergence-first-class-event, KRZ-304): both appear in the
1106    /// chain pinned to their seqs, the candidate refs verbatim, and a
1107    /// pre-pool log folds with an empty ledger (`#[serde(default)]` keeps a
1108    /// pre-field chain.json readable too).
1109    #[test]
1110    fn divergence_event_provenance_chain_carries_record_and_resolution() {
1111        let tmp = TempDir::new().unwrap();
1112        let candidate = |run_id: &str, tree: &str| crate::types::DivergenceCandidate {
1113            run_id: run_id.into(),
1114            branch: format!("kranz/pool/m-1/f-1-1-{run_id}"),
1115            backend: "claude".into(),
1116            tree: tree.into(),
1117        };
1118        let paths = seed_mission(
1119            tmp.path(),
1120            "m-1",
1121            vec![
1122                created(),
1123                EventKind::DivergenceNoted {
1124                    unit: "f-1-1".into(),
1125                    candidates: vec![candidate("r-c0", "aaa"), candidate("r-c1", "bbb")],
1126                    diverged: true,
1127                },
1128                EventKind::DivergenceResolved {
1129                    unit: "f-1-1".into(),
1130                    selected: Some(1),
1131                    reason: "codex kept it total".into(),
1132                    decided_by: "operator".into(),
1133                },
1134            ],
1135        );
1136        let events = EventLog::read_events(&paths.events_file()).unwrap();
1137        let chain = provenance_chain(&paths.mission_dir(), "m-1", &events).unwrap();
1138        assert_eq!(chain.divergences.len(), 2);
1139        match &chain.divergences[0] {
1140            DivergenceLink::Noted {
1141                seq,
1142                unit,
1143                candidates,
1144                diverged,
1145            } => {
1146                assert_eq!(*seq, 2);
1147                assert_eq!(unit, "f-1-1");
1148                assert!(diverged);
1149                assert_eq!(candidates.len(), 2);
1150                assert_eq!(candidates[1].tree, "bbb");
1151            }
1152            other => panic!("expected the noted link first: {other:?}"),
1153        }
1154        match &chain.divergences[1] {
1155            DivergenceLink::Resolved {
1156                seq,
1157                unit,
1158                selected,
1159                reason,
1160                decided_by,
1161            } => {
1162                assert_eq!(*seq, 3);
1163                assert_eq!(unit, "f-1-1");
1164                assert_eq!(*selected, Some(1));
1165                assert_eq!(reason, "codex kept it total");
1166                assert_eq!(decided_by, "operator");
1167            }
1168            other => panic!("expected the resolution link: {other:?}"),
1169        }
1170
1171        // A pre-pool log folds with an empty ledger, and a chain.json
1172        // predating the field still deserializes (serde default).
1173        let quiet = provenance_chain(&paths.mission_dir(), "m-1", &[]).unwrap();
1174        assert!(quiet.divergences.is_empty());
1175        let json = serde_json::to_value(&chain).unwrap();
1176        let mut stripped = json.clone();
1177        stripped.as_object_mut().unwrap().remove("divergences");
1178        let back: ProvenanceChain = serde_json::from_value(stripped).unwrap();
1179        assert!(back.divergences.is_empty());
1180    }
1181
1182    // ---- KRZ-343: the standards coverage matrix rides the chain -----------
1183
1184    /// A plan carrying a two-rule standards pin (KRZ-342's consent shape):
1185    /// one enforced must, one approved should.
1186    fn pinned_plan() -> Plan {
1187        let rule = |id: &str, revision: u64, status: &str, level: &str| crate::types::PinnedRule {
1188            id: id.to_string(),
1189            revision,
1190            rfc: "RFC-001".to_string(),
1191            level: level.to_string(),
1192            effective_status: status.to_string(),
1193            statement: format!("statement for {id}"),
1194            domains: Vec::new(),
1195            stages: vec!["validation".to_string()],
1196            when_paths: Vec::new(),
1197            task_classes: Vec::new(),
1198            checker: Some("gate:zz-gate".to_string()),
1199            waivable: false,
1200        };
1201        Plan {
1202            standards_manifest: Some(Box::new(crate::types::StandardsPin {
1203                pack_name: "zz-pack".to_string(),
1204                pack_dir: "vendor/pack".to_string(),
1205                standards_root: "standards".to_string(),
1206                digest: "ab".repeat(32),
1207                source: crate::types::StandardsPinSource::RepoTracked,
1208                task_class: None,
1209                touch_set: vec!["crates/**".to_string()],
1210                context_paths: Vec::new(),
1211                gates: Vec::new(),
1212                rules: vec![
1213                    rule("ZZ-FAIL-001", 2, "enforced", "must"),
1214                    rule("ZZ-QUIET-001", 1, "approved", "should"),
1215                ],
1216            })),
1217            ..sample_plan()
1218        }
1219    }
1220
1221    /// KRZ-343 (D-H): the replay folds the coverage matrix from the same
1222    /// log — a finding's rule citation joins its pinned row, the untouched
1223    /// rule reads not-evaluated, and the machine form carries the section.
1224    #[test]
1225    fn flight_rules_provenance_replay_folds_the_coverage_matrix() {
1226        let tmp = TempDir::new().unwrap();
1227        seed_mission(
1228            tmp.path(),
1229            "m-1",
1230            vec![
1231                created(),
1232                EventKind::PlanApproved {
1233                    plan: pinned_plan(),
1234                    base_sha: Some("deadbeef".to_string()),
1235                },
1236                EventKind::StandardsResolved {
1237                    source: "repo-tracked".to_string(),
1238                    pack_name: "zz-pack".to_string(),
1239                    standards_root: "standards".to_string(),
1240                    digest: "ab".repeat(32),
1241                    stage: "approval".to_string(),
1242                    task_class: None,
1243                    touch_set: vec!["crates/**".to_string()],
1244                    context_paths: Vec::new(),
1245                    rules: vec![
1246                        crate::types::StandardsRuleRef {
1247                            id: "ZZ-FAIL-001".to_string(),
1248                            revision: 2,
1249                            effective_status: "enforced".to_string(),
1250                        },
1251                        crate::types::StandardsRuleRef {
1252                            id: "ZZ-QUIET-001".to_string(),
1253                            revision: 1,
1254                            effective_status: "approved".to_string(),
1255                        },
1256                    ],
1257                    approval_seq: 2,
1258                },
1259                EventKind::ValidationFinding {
1260                    milestone_id: "ms-1".into(),
1261                    run_id: "v-1".into(),
1262                    finding: crate::types::Finding {
1263                        subject: "a-1".into(),
1264                        severity: "major".into(),
1265                        evidence: "broke the rule".into(),
1266                        suggested_fix: String::new(),
1267                        class: String::new(),
1268                        rule: Some(crate::types::RuleCitation {
1269                            id: "ZZ-FAIL-001".to_string(),
1270                            revision: 2,
1271                            source: "zz-pack standards".to_string(),
1272                            digest: "ab".repeat(32),
1273                            lifecycle: "enforced".to_string(),
1274                            level: "must".to_string(),
1275                            checker: Some("gate:zz-gate".to_string()),
1276                        }),
1277                    },
1278                },
1279                EventKind::MissionCompleted {},
1280            ],
1281        );
1282        let chain = compute_provenance(tmp.path(), "m-1").unwrap();
1283        let coverage = chain
1284            .standards
1285            .as_ref()
1286            .expect("the matrix rides the chain");
1287        assert_eq!(coverage.pack_name, "zz-pack");
1288        assert_eq!(coverage.approval_seq, 2);
1289        assert_eq!(coverage.resolution_seq, Some(3));
1290        assert_eq!(coverage.rules.len(), 2);
1291        let failed = &coverage.rules[0];
1292        assert_eq!(failed.id, "ZZ-FAIL-001");
1293        assert_eq!(
1294            failed.disposition,
1295            crate::standards_coverage::RuleDisposition::Failed
1296        );
1297        assert_eq!(failed.evidence.len(), 1);
1298        assert_eq!(failed.evidence[0].seq, 4);
1299        assert_eq!(failed.evidence[0].mechanism, "v-1");
1300        let quiet = &coverage.rules[1];
1301        assert_eq!(quiet.id, "ZZ-QUIET-001");
1302        assert_eq!(
1303            quiet.disposition,
1304            crate::standards_coverage::RuleDisposition::NotEvaluated
1305        );
1306
1307        // The machine form carries the section; a chain.json predating the
1308        // field still deserializes (serde default).
1309        let json = serde_json::to_value(&chain).unwrap();
1310        assert_eq!(json["standards"]["digest"], "ab".repeat(32));
1311        assert_eq!(json["standards"]["rules"][0]["disposition"], "failed");
1312        let mut stripped = json.clone();
1313        stripped.as_object_mut().unwrap().remove("standards");
1314        let back: ProvenanceChain = serde_json::from_value(stripped).unwrap();
1315        assert!(back.standards.is_none());
1316        // …and the pinned manifest stays inspectable through the machine
1317        // form: id, revision, lifecycle, level, checker, statement all ride
1318        // the row.
1319        let row = &json["standards"]["rules"][1];
1320        assert_eq!(row["id"], "ZZ-QUIET-001");
1321        assert_eq!(row["revision"], 1);
1322        assert_eq!(row["lifecycle"], "approved");
1323        assert_eq!(row["level"], "should");
1324        assert_eq!(row["checker"], "gate:zz-gate");
1325        assert_eq!(row["statement"], "statement for ZZ-QUIET-001");
1326    }
1327
1328    /// The byte-compat regression contract: a pre-Flight-Rules log (the
1329    /// full KRZ-325 fixture — no pin, no standards events) folds with NO
1330    /// standards section, so its chain JSON is byte-identical to what the
1331    /// replay produced before this field existed.
1332    #[test]
1333    fn flight_rules_provenance_pre_flight_rules_chain_is_unchanged() {
1334        let tmp = TempDir::new().unwrap();
1335        seed_full_mission(tmp.path());
1336        let chain = compute_provenance(tmp.path(), "m-1").unwrap();
1337        assert!(chain.standards.is_none());
1338        let json = serde_json::to_string_pretty(&chain).unwrap();
1339        assert!(
1340            !json.contains("\"standards\""),
1341            "a pre-Flight-Rules chain carries no standards key: {json}"
1342        );
1343        // A chain.json written before the field existed still deserializes.
1344        let mut value = serde_json::to_value(&chain).unwrap();
1345        value.as_object_mut().unwrap().remove("divergences");
1346        let back: ProvenanceChain = serde_json::from_value(value).unwrap();
1347        assert!(back.standards.is_none());
1348    }
1349}