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    pub sessions: Vec<SessionLink>,
289    pub decisions: Vec<DecisionLink>,
290    /// The divergence ledger (KRZ-304): comparison records and their
291    /// resolutions, each pinned to its seq. Empty on pre-pool logs
292    /// (`#[serde(default)]` keeps a pre-field chain.json readable).
293    #[serde(default)]
294    pub divergences: Vec<DivergenceLink>,
295    /// The Flight Rules rule coverage matrix (KRZ-343, design D-H), folded
296    /// from the same log by [`crate::standards_coverage`]: every applicable
297    /// pinned rule's disposition with its mechanism and evidence joins, the
298    /// resolution provenance, and any drift refusals. `None` — and absent
299    /// from the machine form — on missions with no approved standards pin
300    /// (every pre-Flight-Rules log), so those chains stay byte-identical.
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub standards: Option<crate::standards_coverage::StandardsCoverage>,
303    /// The FIRST terminal event (a well-formed log has exactly one); `None`
304    /// while the mission is still in flight.
305    pub outcome: Option<TerminalLink>,
306}
307
308/// Fold one mission's provenance chain from its event slice, classifying
309/// artefact references against `mission_dir`. `events` may contain other
310/// missions' events (filtered out, the [`crate::escalation_metrics`]
311/// discipline) but must be in ascending `seq` order — the chain's ordering
312/// IS the log's. Pure: no clock, no network, no git; the only I/O is the
313/// resolver's metadata probes under `mission_dir`.
314///
315/// Fallible in exactly one place, by design: a `config.changed` patch that
316/// does not merge into a valid [`MissionConfig`] fails here exactly as it
317/// fails the reducer's fold (corruption, not a provenance gap). Artefact
318/// resolution is total and never contributes an error.
319pub fn provenance_chain(
320    mission_dir: &Path,
321    mission_id: &str,
322    events: &[Event],
323) -> Result<ProvenanceChain> {
324    let mut chain = ProvenanceChain {
325        mission_id: mission_id.to_string(),
326        goal: None,
327        base_branch: None,
328        mission_branch: None,
329        base_sha: None,
330        gates: Vec::new(),
331        sessions: Vec::new(),
332        decisions: Vec::new(),
333        divergences: Vec::new(),
334        standards: None,
335        outcome: None,
336    };
337    // The config in force at the current seq (backend derivation); set by
338    // mission.created, evolved by config.changed through the reducer's merge.
339    let mut config: Option<MissionConfig> = None;
340    let mut plan_approved_seq: Option<u64> = None;
341
342    for event in events.iter().filter(|e| e.mission_id == mission_id) {
343        match &event.kind {
344            EventKind::MissionCreated {
345                goal,
346                base_branch,
347                mission_branch,
348                config: created,
349            } => {
350                chain.goal = Some(goal.clone());
351                chain.base_branch = Some(base_branch.clone());
352                chain.mission_branch = Some(mission_branch.clone());
353                config = Some(created.clone());
354            }
355            EventKind::PlanApproved { base_sha, .. } => {
356                chain.base_sha = base_sha.clone();
357                plan_approved_seq = Some(event.seq);
358                chain.decisions.push(DecisionLink {
359                    seq: event.seq,
360                    kind: DecisionKind::PlanApproval,
361                    summary: "plan approved".to_string(),
362                });
363            }
364            EventKind::PlanRevised { revision, .. } => chain.decisions.push(DecisionLink {
365                seq: event.seq,
366                kind: DecisionKind::PlanRevision,
367                summary: format!("plan revision {revision} approved"),
368            }),
369            EventKind::PlanRevisionRejected { revision, reason } => {
370                chain.decisions.push(DecisionLink {
371                    seq: event.seq,
372                    kind: DecisionKind::PlanRevisionRejection,
373                    summary: format!("plan revision {revision} rejected: {reason}"),
374                });
375            }
376            EventKind::GrantApproved { kind, command } => chain.decisions.push(DecisionLink {
377                seq: event.seq,
378                kind: DecisionKind::GrantApproval,
379                summary: format!(
380                    "approved {}: {command}",
381                    crate::escalation_metrics::grant_kind_str(kind)
382                ),
383            }),
384            EventKind::GrantDenied {
385                kind,
386                command,
387                reason,
388            } => chain.decisions.push(DecisionLink {
389                seq: event.seq,
390                kind: DecisionKind::GrantDenial,
391                summary: format!(
392                    "denied {}: {command} ({reason})",
393                    crate::escalation_metrics::grant_kind_str(kind)
394                ),
395            }),
396            EventKind::MilestoneUnblocked {
397                milestone_id,
398                reason,
399                block_context,
400                ..
401            } if !crate::escalation_metrics::is_engine_lift(reason, block_context.as_ref()) => {
402                chain.decisions.push(DecisionLink {
403                    seq: event.seq,
404                    kind: DecisionKind::MilestoneUnblock,
405                    summary: format!("unblocked {milestone_id}: {reason}"),
406                });
407            }
408            EventKind::UserMessage { text, .. } => {
409                // Classify by SEQUENCE, not wall clock (the escalation-metrics
410                // steer rule): the event log's seq is the order of truth.
411                let kind = match plan_approved_seq {
412                    Some(approved) if event.seq >= approved => DecisionKind::Steer,
413                    _ => DecisionKind::OperatorMessage,
414                };
415                chain.decisions.push(DecisionLink {
416                    seq: event.seq,
417                    kind,
418                    summary: text.clone(),
419                });
420            }
421            EventKind::MissionAbandoned { reason } => {
422                chain.decisions.push(DecisionLink {
423                    seq: event.seq,
424                    kind: DecisionKind::MissionAbandoned,
425                    summary: format!("abandoned: {reason}"),
426                });
427                if chain.outcome.is_none() {
428                    chain.outcome = Some(TerminalLink {
429                        seq: event.seq,
430                        status: TerminalStatus::Abandoned,
431                        reason: Some(reason.clone()),
432                    });
433                }
434            }
435            EventKind::MissionCompleted {} => {
436                if chain.outcome.is_none() {
437                    chain.outcome = Some(TerminalLink {
438                        seq: event.seq,
439                        status: TerminalStatus::Completed,
440                        reason: None,
441                    });
442                }
443            }
444            EventKind::MissionFailed { reason } => {
445                if chain.outcome.is_none() {
446                    chain.outcome = Some(TerminalLink {
447                        seq: event.seq,
448                        status: TerminalStatus::Failed,
449                        reason: Some(reason.clone()),
450                    });
451                }
452            }
453            EventKind::GateResult {
454                gate,
455                surface,
456                kind,
457                index,
458                verdict,
459                artefact_ref,
460                artefact_detail,
461                score,
462                threshold,
463                rule_ids,
464            } => chain.gates.push(GateLink {
465                seq: event.seq,
466                gate: gate.clone(),
467                surface: *surface,
468                kind: *kind,
469                index: *index,
470                verdict: *verdict,
471                artefact_ref: artefact_ref.clone(),
472                artefact_detail: artefact_detail.clone(),
473                score: *score,
474                threshold: *threshold,
475                artefact: ArtefactStatus::classify(&resolve_artefact(mission_dir, artefact_ref)),
476                rule_ids: rule_ids.clone(),
477            }),
478            EventKind::DivergenceNoted {
479                unit,
480                candidates,
481                diverged,
482            } => chain.divergences.push(DivergenceLink::Noted {
483                seq: event.seq,
484                unit: unit.clone(),
485                candidates: candidates.clone(),
486                diverged: *diverged,
487            }),
488            EventKind::DivergenceResolved {
489                unit,
490                selected,
491                reason,
492                decided_by,
493            } => chain.divergences.push(DivergenceLink::Resolved {
494                seq: event.seq,
495                unit: unit.clone(),
496                selected: *selected,
497                reason: reason.clone(),
498                decided_by: decided_by.clone(),
499            }),
500            EventKind::WorkerSpawned {
501                run_id,
502                role,
503                feature_id,
504                milestone_id,
505                model,
506                quant,
507                weight_hash,
508                prompt_hash,
509                transcript_path,
510                backend,
511                ..
512            } => chain.sessions.push(SessionLink {
513                seq: event.seq,
514                run_id: run_id.clone(),
515                role: *role,
516                backend: (backend.is_some() || config.is_some()).then(|| {
517                    crate::cost::resolved_run_backend(*backend, *role, config.as_ref())
518                        .as_str()
519                        .to_string()
520                }),
521                model: model.clone(),
522                quant: quant.clone(),
523                weight_hash: weight_hash.clone(),
524                prompt_hash: prompt_hash.clone(),
525                feature_id: feature_id.clone(),
526                milestone_id: milestone_id.clone(),
527                transcript_ref: transcript_path.clone(),
528                transcript: ArtefactStatus::classify(&resolve_artefact(
529                    mission_dir,
530                    &file_artefact_ref(transcript_path),
531                )),
532            }),
533            EventKind::ConfigChanged { patch } => {
534                if let Some(current) = &mut config {
535                    // The reducer's own merge + error, verbatim: the replay's
536                    // tracked config cannot drift from the state fold's.
537                    let mut value = serde_json::to_value(&*current)?;
538                    crate::reducer::deep_merge(&mut value, patch);
539                    *current = serde_json::from_value(value).map_err(|e| {
540                        EngineError::Config(format!(
541                            "config.changed patch produced invalid config: {e}"
542                        ))
543                    })?;
544                }
545            }
546            _ => {}
547        }
548    }
549    // The standards coverage matrix (KRZ-343, D-H): one fold over the same
550    // slice, attached to the chain so the replay and the evidence bundle
551    // render rule dispositions without a second pass. `None` on
552    // pre-Flight-Rules logs — their chains stay byte-identical.
553    chain.standards = crate::standards_coverage::standards_coverage(mission_id, events);
554    Ok(chain)
555}
556
557/// Locate one mission under `repo_root` and replay its log into a
558/// [`ProvenanceChain`]. Read-only, no lock (§4.3 read-only observers): opens
559/// the log no-follow, refuses a symlinked mission path component (P1 — the
560/// artefact resolver's probe anchor must be the real mission dir), and
561/// writes nothing. A missing/unreadable/corrupt log surfaces as the error,
562/// mirroring `load_state`'s posture for single-mission reads.
563pub fn compute_provenance(repo_root: &Path, mission_id: &str) -> anyhow::Result<ProvenanceChain> {
564    let paths = crate::paths::MissionPaths::new(repo_root, mission_id);
565    paths.require_no_follow()?;
566    let events = crate::event_log::EventLog::read_events(&paths.events_file())?;
567    Ok(provenance_chain(&paths.mission_dir(), mission_id, &events)?)
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573    use crate::event_log::{EventLog, LockForce};
574    use crate::paths::MissionPaths;
575    use crate::types::{GrantKind, Plan};
576    use std::time::Duration;
577    use tempfile::TempDir;
578
579    /// Seed a mission's `events.jsonl` with the given kinds, in order (the
580    /// escalation_metrics fixture idiom); the log handle drops — and flushes
581    /// — before any replay reads.
582    fn seed_mission(repo_root: &Path, id: &str, kinds: Vec<EventKind>) -> MissionPaths {
583        let paths = MissionPaths::new(repo_root, id);
584        let mut log = EventLog::acquire(&paths, id, Duration::ZERO, LockForce::No).unwrap();
585        for kind in kinds {
586            log.append(kind).unwrap();
587        }
588        paths
589    }
590
591    fn sample_plan() -> Plan {
592        Plan {
593            goal: "ship the thing".into(),
594            validation_contract: vec![],
595            milestones: vec![],
596            considered_alternatives: None,
597            command_grants: vec![],
598            touch_set: vec![],
599            standards_manifest: None,
600            reviewer_independence: None,
601        }
602    }
603
604    /// A config whose Worker runs on codex — the backend the replay must
605    /// derive for worker spawns (until a config.changed flips it).
606    fn created_config() -> MissionConfig {
607        let mut config = MissionConfig::default();
608        config.worker.backend = Some("codex".to_string());
609        config
610    }
611
612    fn created() -> EventKind {
613        EventKind::MissionCreated {
614            goal: "ship the thing".into(),
615            base_branch: "main".into(),
616            mission_branch: "kranz/mission-x".into(),
617            config: created_config(),
618        }
619    }
620
621    fn gate_result(
622        gate: &str,
623        surface: GateSurface,
624        kind: GateKind,
625        index: u32,
626        verdict: GateVerdict,
627        artefact_ref: &str,
628    ) -> EventKind {
629        EventKind::GateResult {
630            gate: gate.to_string(),
631            surface,
632            kind,
633            index,
634            verdict,
635            artefact_ref: artefact_ref.to_string(),
636            artefact_detail: None,
637            score: None,
638            threshold: None,
639            rule_ids: Vec::new(),
640        }
641    }
642
643    fn worker_spawned(run_id: &str, role: Role, model: &str, prompt_hash: &str) -> EventKind {
644        EventKind::WorkerSpawned {
645            backend: None,
646            run_id: run_id.to_string(),
647            role,
648            feature_id: None,
649            milestone_id: None,
650            candidate: None,
651            executor_route: None,
652            sdk_session_id: format!("sess-{run_id}"),
653            model: model.to_string(),
654            quant: "n/a".to_string(),
655            weight_hash: None,
656            prompt_hash: prompt_hash.to_string(),
657            transcript_path: MissionPaths::transcript_rel(run_id),
658        }
659    }
660
661    /// The anti-vacuity fixture (ticket acceptance hint 1): a mission with
662    /// the full gate ladder (both surfaces; an inline ref, a resolved file
663    /// ref, a file ref whose bytes were never written), three sessions
664    /// straddling a mid-mission backend flip, and the decision set — a plan
665    /// approval, a grant park (request is NOT a decision) + approval, an
666    /// operator unblock plus the engine-owned lift (NOT a decision), and a
667    /// steer — ending COMPLETED.
668    fn seed_full_mission(root: &Path) -> MissionPaths {
669        let mut judged = gate_result(
670            "plan-review",
671            GateSurface::Approval,
672            GateKind::ModelJudged,
673            0,
674            GateVerdict::Pass,
675            "file:runs/gone.jsonl",
676        );
677        if let EventKind::GateResult {
678            artefact_detail,
679            score,
680            threshold,
681            ..
682        } = &mut judged
683        {
684            *artefact_detail = Some("looks sound".to_string());
685            *score = Some(0.9);
686            *threshold = Some(0.5);
687        }
688        let paths = seed_mission(
689            root,
690            "m-1",
691            vec![
692                created(),
693                EventKind::PlanApproved {
694                    plan: sample_plan(),
695                    base_sha: Some("deadbeef".to_string()),
696                },
697                gate_result(
698                    "vacuous-filter",
699                    GateSurface::Approval,
700                    GateKind::Deterministic,
701                    0,
702                    GateVerdict::Pass,
703                    "contract gate vacuous-filter",
704                ),
705                gate_result(
706                    "merge-gate-suite",
707                    GateSurface::Approval,
708                    GateKind::Deterministic,
709                    1,
710                    GateVerdict::Pass,
711                    "file:runs/gate-base.jsonl",
712                ),
713                judged,
714                {
715                    let mut spawn = worker_spawned("r-1", Role::Worker, "gpt-5", "aaaabbbbcccc");
716                    if let EventKind::WorkerSpawned {
717                        feature_id,
718                        milestone_id,
719                        ..
720                    } = &mut spawn
721                    {
722                        *feature_id = Some("f-1-1".to_string());
723                        *milestone_id = Some("ms-1".to_string());
724                    }
725                    spawn
726                },
727                EventKind::GrantRequested {
728                    milestone_id: "ms-1".into(),
729                    kind: GrantKind::Command,
730                    command: "cargo test".into(),
731                },
732                EventKind::GrantApproved {
733                    kind: GrantKind::Command,
734                    command: "cargo test".into(),
735                },
736                EventKind::ConfigChanged {
737                    patch: serde_json::json!({"worker": {"backend": "local"}}),
738                },
739                worker_spawned("r-2", Role::Worker, "my-local-model", "dddd11112222"),
740                worker_spawned("r-3", Role::ValidatorScrutiny, "sonnet", "ffff33334444"),
741                EventKind::MilestoneBlocked {
742                    block_context: None,
743                    milestone_id: "ms-1".into(),
744                    reason: "fix-cycle cap".into(),
745                },
746                EventKind::MilestoneUnblocked {
747                    block_context: None,
748                    milestone_id: "ms-1".into(),
749                    reason: "user skipped findings".into(),
750                    validator_guidance: None,
751                },
752                EventKind::MilestoneUnblocked {
753                    block_context: None,
754                    milestone_id: "ms-1".into(),
755                    reason: crate::workspace_gate::GATE_LIFT_REASON.to_string(),
756                    validator_guidance: None,
757                },
758                EventKind::UserMessage {
759                    text: "skip the flaky test".into(),
760                    interrupt: false,
761                },
762                gate_result(
763                    "merge-gate-suite",
764                    GateSurface::FinalGate,
765                    GateKind::Deterministic,
766                    0,
767                    GateVerdict::Pass,
768                    ".kranz/merge-gates.json",
769                ),
770                EventKind::MissionCompleted {},
771            ],
772        );
773        // Bytes for the resolved refs: one gate artefact and one transcript.
774        std::fs::write(paths.runs_dir().join("gate-base.jsonl"), b"{}").unwrap();
775        std::fs::write(paths.runs_dir().join("r-1.jsonl"), b"{}").unwrap();
776        paths
777    }
778
779    /// Ticket acceptance hint 1, in one fold: every gate verdict in order,
780    /// every artefact ref with its resolution, the backend/model per session
781    /// (including the derived backend across a mid-mission flip), the prompt
782    /// identity, and each human decision with its event seq — then the
783    /// terminal outcome.
784    #[test]
785    fn provenance_replay_names_ladder_sessions_decisions_and_outcome_in_order() {
786        let tmp = TempDir::new().unwrap();
787        let paths = seed_full_mission(tmp.path());
788        let events = EventLog::read_events(&paths.events_file()).unwrap();
789        let chain = provenance_chain(&paths.mission_dir(), "m-1", &events).unwrap();
790
791        // Mission identity from mission.created + plan.approved.
792        assert_eq!(chain.mission_id, "m-1");
793        assert_eq!(chain.goal.as_deref(), Some("ship the thing"));
794        assert_eq!(chain.base_branch.as_deref(), Some("main"));
795        assert_eq!(chain.mission_branch.as_deref(), Some("kranz/mission-x"));
796        assert_eq!(chain.base_sha.as_deref(), Some("deadbeef"));
797
798        // The ladder, in log order, with resolutions classified.
799        let ladder: Vec<(
800            u64,
801            &str,
802            GateSurface,
803            GateKind,
804            u32,
805            GateVerdict,
806            ArtefactStatus,
807        )> = chain
808            .gates
809            .iter()
810            .map(|gate| {
811                (
812                    gate.seq,
813                    gate.gate.as_str(),
814                    gate.surface,
815                    gate.kind,
816                    gate.index,
817                    gate.verdict,
818                    gate.artefact,
819                )
820            })
821            .collect();
822        assert_eq!(
823            ladder,
824            vec![
825                (
826                    3,
827                    "vacuous-filter",
828                    GateSurface::Approval,
829                    GateKind::Deterministic,
830                    0,
831                    GateVerdict::Pass,
832                    ArtefactStatus::Inline
833                ),
834                (
835                    4,
836                    "merge-gate-suite",
837                    GateSurface::Approval,
838                    GateKind::Deterministic,
839                    1,
840                    GateVerdict::Pass,
841                    ArtefactStatus::Resolved
842                ),
843                (
844                    5,
845                    "plan-review",
846                    GateSurface::Approval,
847                    GateKind::ModelJudged,
848                    0,
849                    GateVerdict::Pass,
850                    ArtefactStatus::Unresolved
851                ),
852                (
853                    16,
854                    "merge-gate-suite",
855                    GateSurface::FinalGate,
856                    GateKind::Deterministic,
857                    0,
858                    GateVerdict::Pass,
859                    ArtefactStatus::Inline
860                ),
861            ]
862        );
863        // Refs and captured evidence arrive verbatim.
864        assert_eq!(chain.gates[0].artefact_ref, "contract gate vacuous-filter");
865        assert_eq!(chain.gates[1].artefact_ref, "file:runs/gate-base.jsonl");
866        assert_eq!(chain.gates[2].artefact_ref, "file:runs/gone.jsonl");
867        assert_eq!(
868            chain.gates[2].artefact_detail.as_deref(),
869            Some("looks sound")
870        );
871        assert_eq!(chain.gates[2].score, Some(0.9));
872        assert_eq!(chain.gates[2].threshold, Some(0.5));
873
874        // Sessions: model + prompt identity verbatim; backend DERIVED from the
875        // recorded config at each seq (codex → local across config.changed;
876        // the validator untouched by the worker patch).
877        assert_eq!(chain.sessions.len(), 3);
878        let r1 = &chain.sessions[0];
879        assert_eq!(r1.seq, 6);
880        assert_eq!(r1.role, Role::Worker);
881        assert_eq!(r1.backend.as_deref(), Some("codex"));
882        assert_eq!(r1.model, "gpt-5");
883        assert_eq!(r1.prompt_hash, "aaaabbbbcccc");
884        assert_eq!(r1.feature_id.as_deref(), Some("f-1-1"));
885        assert_eq!(r1.milestone_id.as_deref(), Some("ms-1"));
886        assert_eq!(r1.transcript_ref, "runs/r-1.jsonl");
887        assert_eq!(r1.transcript, ArtefactStatus::Resolved);
888        let r2 = &chain.sessions[1];
889        assert_eq!(r2.backend.as_deref(), Some("local"));
890        assert_eq!(r2.model, "my-local-model");
891        assert_eq!(r2.prompt_hash, "dddd11112222");
892        // runs/r-2.jsonl was never written: unresolved, never an error.
893        assert_eq!(r2.transcript, ArtefactStatus::Unresolved);
894        let r3 = &chain.sessions[2];
895        assert_eq!(r3.role, Role::ValidatorScrutiny);
896        assert_eq!(r3.backend.as_deref(), Some("claude"));
897
898        // Decisions in seq order: the grant REQUEST (seq 7) and the
899        // engine-owned lift (seq 14) are absent by construction.
900        let decisions: Vec<(u64, DecisionKind, &str)> = chain
901            .decisions
902            .iter()
903            .map(|d| (d.seq, d.kind, d.summary.as_str()))
904            .collect();
905        assert_eq!(
906            decisions,
907            vec![
908                (2, DecisionKind::PlanApproval, "plan approved"),
909                (
910                    8,
911                    DecisionKind::GrantApproval,
912                    "approved command: cargo test"
913                ),
914                (
915                    13,
916                    DecisionKind::MilestoneUnblock,
917                    "unblocked ms-1: user skipped findings"
918                ),
919                (15, DecisionKind::Steer, "skip the flaky test"),
920            ]
921        );
922
923        assert_eq!(
924            chain.outcome,
925            Some(TerminalLink {
926                seq: 17,
927                status: TerminalStatus::Completed,
928                reason: None,
929            })
930        );
931    }
932
933    /// Ticket acceptance hint 2: with `runs/` removed the chain still
934    /// reconstructs end to end — file-backed refs (gate artefact AND session
935    /// transcript) read unresolved, never an error.
936    #[test]
937    fn provenance_replay_without_runs_dir_reconstructs_with_unresolved_refs() {
938        let tmp = TempDir::new().unwrap();
939        let paths = seed_full_mission(tmp.path());
940        std::fs::remove_dir_all(paths.runs_dir()).unwrap();
941
942        let chain = compute_provenance(tmp.path(), "m-1").unwrap();
943        assert_eq!(chain.gates.len(), 4);
944        assert_eq!(chain.gates[1].artefact, ArtefactStatus::Unresolved);
945        // Textual refs are inline no matter what the filesystem holds.
946        assert_eq!(chain.gates[0].artefact, ArtefactStatus::Inline);
947        assert_eq!(chain.gates[3].artefact, ArtefactStatus::Inline);
948        assert_eq!(chain.sessions.len(), 3);
949        for session in &chain.sessions {
950            assert_eq!(
951                session.transcript,
952                ArtefactStatus::Unresolved,
953                "{} must read unresolved with runs/ gone",
954                session.run_id
955            );
956        }
957        assert_eq!(chain.decisions.len(), 4);
958        assert_eq!(
959            chain.outcome.map(|o| o.status),
960            Some(TerminalStatus::Completed)
961        );
962    }
963
964    /// Ticket acceptance hint 3: same log → byte-identical machine output,
965    /// across two independent compute passes (read + fold + resolve each).
966    #[test]
967    fn provenance_replay_machine_form_is_byte_identical_across_replays() {
968        let tmp = TempDir::new().unwrap();
969        seed_full_mission(tmp.path());
970        let first = compute_provenance(tmp.path(), "m-1").unwrap();
971        let second = compute_provenance(tmp.path(), "m-1").unwrap();
972        assert_eq!(first, second);
973        let first_json = serde_json::to_string_pretty(&first).unwrap();
974        let second_json = serde_json::to_string_pretty(&second).unwrap();
975        assert_eq!(first_json, second_json);
976        // The machine form carries no host layout: the temp dir's absolute
977        // path appears nowhere in the serialization.
978        assert!(
979            !first_json.contains(&tmp.path().to_string_lossy().to_string()),
980            "host path leaked into the machine form: {first_json}"
981        );
982    }
983
984    /// Old logs (pre-`gate.result`, pre-`baseSha`) still fold: the ladder is
985    /// empty, the identity falls back to what the log carries, and the
986    /// failure outcome surfaces with its reason.
987    #[test]
988    fn provenance_replay_pre_gate_logs_still_fold() {
989        let tmp = TempDir::new().unwrap();
990        let paths = seed_mission(
991            tmp.path(),
992            "m-old",
993            vec![
994                EventKind::MissionCreated {
995                    goal: "legacy goal".into(),
996                    base_branch: "main".into(),
997                    mission_branch: "kranz/mission-old".into(),
998                    config: MissionConfig::default(),
999                },
1000                EventKind::PlanApproved {
1001                    plan: sample_plan(),
1002                    base_sha: None,
1003                },
1004                worker_spawned("r-1", Role::Worker, "sonnet", "9999aaaabbbb"),
1005                EventKind::MissionFailed {
1006                    reason: "honest failure".into(),
1007                },
1008            ],
1009        );
1010        let events = EventLog::read_events(&paths.events_file()).unwrap();
1011        let chain = provenance_chain(&paths.mission_dir(), "m-old", &events).unwrap();
1012        assert!(chain.gates.is_empty());
1013        assert_eq!(chain.base_sha, None);
1014        assert_eq!(chain.sessions.len(), 1);
1015        assert_eq!(chain.sessions[0].backend.as_deref(), Some("claude"));
1016        assert_eq!(chain.sessions[0].prompt_hash, "9999aaaabbbb");
1017        assert_eq!(
1018            chain.outcome,
1019            Some(TerminalLink {
1020                seq: 4,
1021                status: TerminalStatus::Failed,
1022                reason: Some("honest failure".to_string()),
1023            })
1024        );
1025        // A pre-approval message is drafting, not a steer — but still a
1026        // named human act in the chain.
1027        let paths = seed_mission(
1028            tmp.path(),
1029            "m-draft",
1030            vec![
1031                EventKind::UserMessage {
1032                    text: "make it smaller".into(),
1033                    interrupt: false,
1034                },
1035                EventKind::PlanApproved {
1036                    plan: sample_plan(),
1037                    base_sha: None,
1038                },
1039            ],
1040        );
1041        let events = EventLog::read_events(&paths.events_file()).unwrap();
1042        let chain = provenance_chain(&paths.mission_dir(), "m-draft", &events).unwrap();
1043        assert_eq!(
1044            chain
1045                .decisions
1046                .iter()
1047                .map(|d| (d.seq, d.kind))
1048                .collect::<Vec<_>>(),
1049            vec![
1050                (1, DecisionKind::OperatorMessage),
1051                (2, DecisionKind::PlanApproval)
1052            ]
1053        );
1054        // No mission.created: identity fields stay None and the chain still
1055        // reconstructs; in flight, so no outcome.
1056        assert_eq!(chain.goal, None);
1057        assert_eq!(chain.outcome, None);
1058    }
1059
1060    /// The one fallible path, by design: a config.changed patch that cannot
1061    /// merge into a valid MissionConfig fails the replay with the reducer's
1062    /// own error — corruption, not a provenance gap.
1063    #[test]
1064    fn provenance_replay_invalid_config_patch_fails_like_the_reducer() {
1065        let tmp = TempDir::new().unwrap();
1066        let paths = seed_mission(
1067            tmp.path(),
1068            "m-1",
1069            vec![
1070                created(),
1071                EventKind::ConfigChanged {
1072                    patch: serde_json::json!({"maxFixCyclesPerMilestone": "not-a-number"}),
1073                },
1074            ],
1075        );
1076        let events = EventLog::read_events(&paths.events_file()).unwrap();
1077        let result = provenance_chain(&paths.mission_dir(), "m-1", &events);
1078        assert!(
1079            matches!(result, Err(EngineError::Config(_))),
1080            "expected the reducer's Config error, got {result:?}"
1081        );
1082    }
1083
1084    /// The divergence record and its resolution survive provenance replay
1085    /// (ticket divergence-first-class-event, KRZ-304): both appear in the
1086    /// chain pinned to their seqs, the candidate refs verbatim, and a
1087    /// pre-pool log folds with an empty ledger (`#[serde(default)]` keeps a
1088    /// pre-field chain.json readable too).
1089    #[test]
1090    fn divergence_event_provenance_chain_carries_record_and_resolution() {
1091        let tmp = TempDir::new().unwrap();
1092        let candidate = |run_id: &str, tree: &str| crate::types::DivergenceCandidate {
1093            run_id: run_id.into(),
1094            branch: format!("kranz/pool/m-1/f-1-1-{run_id}"),
1095            backend: "claude".into(),
1096            tree: tree.into(),
1097        };
1098        let paths = seed_mission(
1099            tmp.path(),
1100            "m-1",
1101            vec![
1102                created(),
1103                EventKind::DivergenceNoted {
1104                    unit: "f-1-1".into(),
1105                    candidates: vec![candidate("r-c0", "aaa"), candidate("r-c1", "bbb")],
1106                    diverged: true,
1107                },
1108                EventKind::DivergenceResolved {
1109                    unit: "f-1-1".into(),
1110                    selected: Some(1),
1111                    reason: "codex kept it total".into(),
1112                    decided_by: "operator".into(),
1113                },
1114            ],
1115        );
1116        let events = EventLog::read_events(&paths.events_file()).unwrap();
1117        let chain = provenance_chain(&paths.mission_dir(), "m-1", &events).unwrap();
1118        assert_eq!(chain.divergences.len(), 2);
1119        match &chain.divergences[0] {
1120            DivergenceLink::Noted {
1121                seq,
1122                unit,
1123                candidates,
1124                diverged,
1125            } => {
1126                assert_eq!(*seq, 2);
1127                assert_eq!(unit, "f-1-1");
1128                assert!(diverged);
1129                assert_eq!(candidates.len(), 2);
1130                assert_eq!(candidates[1].tree, "bbb");
1131            }
1132            other => panic!("expected the noted link first: {other:?}"),
1133        }
1134        match &chain.divergences[1] {
1135            DivergenceLink::Resolved {
1136                seq,
1137                unit,
1138                selected,
1139                reason,
1140                decided_by,
1141            } => {
1142                assert_eq!(*seq, 3);
1143                assert_eq!(unit, "f-1-1");
1144                assert_eq!(*selected, Some(1));
1145                assert_eq!(reason, "codex kept it total");
1146                assert_eq!(decided_by, "operator");
1147            }
1148            other => panic!("expected the resolution link: {other:?}"),
1149        }
1150
1151        // A pre-pool log folds with an empty ledger, and a chain.json
1152        // predating the field still deserializes (serde default).
1153        let quiet = provenance_chain(&paths.mission_dir(), "m-1", &[]).unwrap();
1154        assert!(quiet.divergences.is_empty());
1155        let json = serde_json::to_value(&chain).unwrap();
1156        let mut stripped = json.clone();
1157        stripped.as_object_mut().unwrap().remove("divergences");
1158        let back: ProvenanceChain = serde_json::from_value(stripped).unwrap();
1159        assert!(back.divergences.is_empty());
1160    }
1161
1162    // ---- KRZ-343: the standards coverage matrix rides the chain -----------
1163
1164    /// A plan carrying a two-rule standards pin (KRZ-342's consent shape):
1165    /// one enforced must, one approved should.
1166    fn pinned_plan() -> Plan {
1167        let rule = |id: &str, revision: u64, status: &str, level: &str| crate::types::PinnedRule {
1168            id: id.to_string(),
1169            revision,
1170            rfc: "RFC-001".to_string(),
1171            level: level.to_string(),
1172            effective_status: status.to_string(),
1173            statement: format!("statement for {id}"),
1174            domains: Vec::new(),
1175            stages: vec!["validation".to_string()],
1176            when_paths: Vec::new(),
1177            task_classes: Vec::new(),
1178            checker: Some("gate:zz-gate".to_string()),
1179            waivable: false,
1180        };
1181        Plan {
1182            standards_manifest: Some(Box::new(crate::types::StandardsPin {
1183                pack_name: "zz-pack".to_string(),
1184                pack_dir: "vendor/pack".to_string(),
1185                standards_root: "standards".to_string(),
1186                digest: "ab".repeat(32),
1187                source: crate::types::StandardsPinSource::RepoTracked,
1188                task_class: None,
1189                touch_set: vec!["crates/**".to_string()],
1190                context_paths: Vec::new(),
1191                gates: Vec::new(),
1192                rules: vec![
1193                    rule("ZZ-FAIL-001", 2, "enforced", "must"),
1194                    rule("ZZ-QUIET-001", 1, "approved", "should"),
1195                ],
1196            })),
1197            ..sample_plan()
1198        }
1199    }
1200
1201    /// KRZ-343 (D-H): the replay folds the coverage matrix from the same
1202    /// log — a finding's rule citation joins its pinned row, the untouched
1203    /// rule reads not-evaluated, and the machine form carries the section.
1204    #[test]
1205    fn flight_rules_provenance_replay_folds_the_coverage_matrix() {
1206        let tmp = TempDir::new().unwrap();
1207        seed_mission(
1208            tmp.path(),
1209            "m-1",
1210            vec![
1211                created(),
1212                EventKind::PlanApproved {
1213                    plan: pinned_plan(),
1214                    base_sha: Some("deadbeef".to_string()),
1215                },
1216                EventKind::StandardsResolved {
1217                    source: "repo-tracked".to_string(),
1218                    pack_name: "zz-pack".to_string(),
1219                    standards_root: "standards".to_string(),
1220                    digest: "ab".repeat(32),
1221                    stage: "approval".to_string(),
1222                    task_class: None,
1223                    touch_set: vec!["crates/**".to_string()],
1224                    context_paths: Vec::new(),
1225                    rules: vec![
1226                        crate::types::StandardsRuleRef {
1227                            id: "ZZ-FAIL-001".to_string(),
1228                            revision: 2,
1229                            effective_status: "enforced".to_string(),
1230                        },
1231                        crate::types::StandardsRuleRef {
1232                            id: "ZZ-QUIET-001".to_string(),
1233                            revision: 1,
1234                            effective_status: "approved".to_string(),
1235                        },
1236                    ],
1237                    approval_seq: 2,
1238                },
1239                EventKind::ValidationFinding {
1240                    milestone_id: "ms-1".into(),
1241                    run_id: "v-1".into(),
1242                    finding: crate::types::Finding {
1243                        subject: "a-1".into(),
1244                        severity: "major".into(),
1245                        evidence: "broke the rule".into(),
1246                        suggested_fix: String::new(),
1247                        class: String::new(),
1248                        rule: Some(crate::types::RuleCitation {
1249                            id: "ZZ-FAIL-001".to_string(),
1250                            revision: 2,
1251                            source: "zz-pack standards".to_string(),
1252                            digest: "ab".repeat(32),
1253                            lifecycle: "enforced".to_string(),
1254                            level: "must".to_string(),
1255                            checker: Some("gate:zz-gate".to_string()),
1256                        }),
1257                    },
1258                },
1259                EventKind::MissionCompleted {},
1260            ],
1261        );
1262        let chain = compute_provenance(tmp.path(), "m-1").unwrap();
1263        let coverage = chain
1264            .standards
1265            .as_ref()
1266            .expect("the matrix rides the chain");
1267        assert_eq!(coverage.pack_name, "zz-pack");
1268        assert_eq!(coverage.approval_seq, 2);
1269        assert_eq!(coverage.resolution_seq, Some(3));
1270        assert_eq!(coverage.rules.len(), 2);
1271        let failed = &coverage.rules[0];
1272        assert_eq!(failed.id, "ZZ-FAIL-001");
1273        assert_eq!(
1274            failed.disposition,
1275            crate::standards_coverage::RuleDisposition::Failed
1276        );
1277        assert_eq!(failed.evidence.len(), 1);
1278        assert_eq!(failed.evidence[0].seq, 4);
1279        assert_eq!(failed.evidence[0].mechanism, "v-1");
1280        let quiet = &coverage.rules[1];
1281        assert_eq!(quiet.id, "ZZ-QUIET-001");
1282        assert_eq!(
1283            quiet.disposition,
1284            crate::standards_coverage::RuleDisposition::NotEvaluated
1285        );
1286
1287        // The machine form carries the section; a chain.json predating the
1288        // field still deserializes (serde default).
1289        let json = serde_json::to_value(&chain).unwrap();
1290        assert_eq!(json["standards"]["digest"], "ab".repeat(32));
1291        assert_eq!(json["standards"]["rules"][0]["disposition"], "failed");
1292        let mut stripped = json.clone();
1293        stripped.as_object_mut().unwrap().remove("standards");
1294        let back: ProvenanceChain = serde_json::from_value(stripped).unwrap();
1295        assert!(back.standards.is_none());
1296        // …and the pinned manifest stays inspectable through the machine
1297        // form: id, revision, lifecycle, level, checker, statement all ride
1298        // the row.
1299        let row = &json["standards"]["rules"][1];
1300        assert_eq!(row["id"], "ZZ-QUIET-001");
1301        assert_eq!(row["revision"], 1);
1302        assert_eq!(row["lifecycle"], "approved");
1303        assert_eq!(row["level"], "should");
1304        assert_eq!(row["checker"], "gate:zz-gate");
1305        assert_eq!(row["statement"], "statement for ZZ-QUIET-001");
1306    }
1307
1308    /// The byte-compat regression contract: a pre-Flight-Rules log (the
1309    /// full KRZ-325 fixture — no pin, no standards events) folds with NO
1310    /// standards section, so its chain JSON is byte-identical to what the
1311    /// replay produced before this field existed.
1312    #[test]
1313    fn flight_rules_provenance_pre_flight_rules_chain_is_unchanged() {
1314        let tmp = TempDir::new().unwrap();
1315        seed_full_mission(tmp.path());
1316        let chain = compute_provenance(tmp.path(), "m-1").unwrap();
1317        assert!(chain.standards.is_none());
1318        let json = serde_json::to_string_pretty(&chain).unwrap();
1319        assert!(
1320            !json.contains("\"standards\""),
1321            "a pre-Flight-Rules chain carries no standards key: {json}"
1322        );
1323        // A chain.json written before the field existed still deserializes.
1324        let mut value = serde_json::to_value(&chain).unwrap();
1325        value.as_object_mut().unwrap().remove("divergences");
1326        let back: ProvenanceChain = serde_json::from_value(value).unwrap();
1327        assert!(back.standards.is_none());
1328    }
1329}