Skip to main content

kranz_engine/
corpus_export.rs

1//! Provenance-tagged training-corpus export (ticket
2//! `.kranz/tickets/training-corpus-export.md`, KRZ-332 — the governance
3//! evidence layer's flywheel feed): one JSONL stream joining the three
4//! judged-artefact sources a local model can learn from —
5//!
6//! 1. **Validated worker traces** — [`crate::trace_export`]'s
7//!    instruction pairs (a run qualifies only as validation-PASSED:
8//!    `Role::Worker`, `RunResult::Pass`, feature Complete inside a Complete
9//!    milestone), now carrying the provenance tags. A failed or unvalidated
10//!    session is excluded by CONSTRUCTION — the selection fold is reused,
11//!    not reimplemented, so the corpus can never widen past the trace
12//!    export's gate.
13//! 2. **Divergence records** — each `divergence.noted` comparison (every
14//!    candidate ref verbatim: run id, branch, backend, tree hash) paired
15//!    with its `divergence.resolved` judgement (selected index or none,
16//!    reason, decider). These are the consent/judgement pairs: what the
17//!    pool produced, and which side the human picked and why.
18//! 3. **Escalation-ledger records** — the flight-surgeon fold
19//!    ([`crate::escalation_metrics`]) as labeled human-judgment examples:
20//!    grant parks (ask, decision, latency), steers, and milestone blocks
21//!    the OPERATOR lifted.
22//!
23//! Every record carries provenance refs that RESOLVE via the provenance
24//! replay ([`crate::provenance::provenance_chain`]) — they are taken FROM
25//! the replay, not recomputed beside it: the trace's backend is the
26//! replay's config-at-seq derivation, the gate-chain refs are the replay's
27//! ladder seqs, the divergence seqs are the replay's ledger seqs, and an
28//! escalation's decision seq joins the replay's human-decision chain. A
29//! consumer can therefore walk any record back to the mission, the session,
30//! and the gates that vouched for it without re-deriving anything.
31//!
32//! WHY a new `export-corpus` command rather than extending `export-traces`:
33//! the trace export's line shape IS its consumer contract (one
34//! instruction-pair object per line), and the two new sources are not
35//! instruction pairs — a consent judgement or an escalation decision has no
36//! instruction/response. Widening `export-traces` would either break that
37//! contract or force a tagged union onto a command whose name promises
38//! traces. A separate command keeps `export-traces` byte-stable and gives
39//! the corpus one stream, one ordering rule, one determinism rule.
40//!
41//! Determinism, in the substrate's own discipline ([`crate::trace_export`],
42//! [`crate::provenance`]): the export is a pure function of the event log —
43//! no clock is consulted (the only time-derived value, a grant/block
44//! latency, is a difference of RECORDED timestamps), no hashed map is
45//! iterated (trace selection walks `state.runs`, a BTreeMap; every other
46//! fold keeps log order in Vecs), and serialization is struct-order
47//! stable. Same log → byte-identical JSONL.
48//!
49//! WHY the ordering key is what it is: records group by source in a fixed
50//! order (worker traces, then divergences, then escalations), stable within
51//! each group — run id for traces (the BTreeMap order), the noted event's
52//! seq for divergences, the ask event's seq for escalations. Each
53//! within-group key is already total over one log, while a merged seq
54//! ordering would interleave sources for no gain: a corpus consumer filters
55//! by `source` anyway. Across missions (`kranz export-corpus --all`),
56//! mission ids sort (the [`crate::paths::MissionPaths::list_missions`]
57//! order), then the same grouping applies per mission.
58
59use crate::error::Result;
60use crate::events::{Event, EventKind};
61use crate::gate::{GateSurface, GateVerdict};
62use crate::provenance::{DivergenceLink, ProvenanceChain};
63use crate::types::{DivergenceCandidate, MissionState};
64use serde::{Deserialize, Serialize};
65use std::path::Path;
66
67/// One gate-ladder link referenced by a worker-trace record: identity,
68/// surface, and verdict of one `gate.result`, pinned by its event `seq` —
69/// the join key into the provenance replay's ladder
70/// ([`crate::provenance::GateLink`]).
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct GateChainRef {
74    pub seq: u64,
75    pub gate: String,
76    pub surface: GateSurface,
77    pub verdict: GateVerdict,
78}
79
80/// A validation-PASSED worker trace: the
81/// [`crate::trace_export::InstructionPair`] fields verbatim (so a corpus
82/// line is a strict superset of an `export-traces` line) plus the
83/// provenance tags the ticket demands — the backend the session ran on and
84/// the mission's gate ladder by seq.
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86#[serde(rename_all = "camelCase")]
87pub struct WorkerTraceRecord {
88    pub instruction: String,
89    pub response: String,
90    pub model: String,
91    pub quant: String,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub weight_hash: Option<String>,
94    pub mission_id: String,
95    pub feature_id: String,
96    pub run_id: String,
97    /// DERIVED, never read from the event: the replay's config-at-seq
98    /// backend derivation ([`crate::provenance::SessionLink::backend`]), so
99    /// the corpus and the audit chain can never disagree about which
100    /// backend drove the session. Serialized as `null` (never omitted) on
101    /// hand-cut logs with no `mission.created` — a corpus consumer should
102    /// see the gap, not guess at it.
103    pub backend: Option<String>,
104    /// The mission's gate ladder as replay refs. The ladder is
105    /// mission-level (contract gates evaluate the floor, not an individual
106    /// run), so every trace of one mission carries the same chain; empty on
107    /// pre-`gate.result` logs.
108    pub gate_chain: Vec<GateChainRef>,
109}
110
111/// The judgement half of a divergence record: which candidate was selected
112/// (an index into the record's `candidates`; `null` = judged-and-abandoned,
113/// serialized explicitly so it cannot be confused with a real index), why,
114/// by whom, and at which event seq (the join key into the replay's
115/// divergence ledger).
116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117#[serde(rename_all = "camelCase")]
118pub struct DivergenceResolution {
119    pub selected: Option<u32>,
120    pub reason: String,
121    pub decided_by: String,
122    pub resolved_seq: u64,
123}
124
125/// One `divergence.noted`, paired with its resolution: the comparison
126/// record (every candidate ref verbatim — the run ids and branches the
127/// judgement chose between) plus the judgement that landed, or `null`
128/// while the unit awaits one. `diverged: false` is the agreement record:
129/// logged, never trusted — it exports like any other noted, the consumer
130/// decides what to learn from it.
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132#[serde(rename_all = "camelCase")]
133pub struct DivergenceRecord {
134    pub mission_id: String,
135    pub unit: String,
136    /// The noted event's seq — the record's own position in the log and its
137    /// join key into the replay.
138    pub noted_seq: u64,
139    pub diverged: bool,
140    pub candidates: Vec<DivergenceCandidate>,
141    pub resolution: Option<DivergenceResolution>,
142}
143
144/// Which kind of escalation one record labels (the ledger's grant/steer set
145/// plus blocks — a milestone block the operator lifted is a human judgement
146/// the ledger only counts, and the corpus names it).
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(rename_all = "lowercase")]
149pub enum EscalationKind {
150    Grant,
151    Steer,
152    Block,
153}
154
155/// One escalation as a labeled human-judgment example: what was asked, what
156/// was decided, how long the decision took, and the seq refs that join it
157/// to the replay. Grant/block rows mirror the ledger's vocabulary
158/// (`approved` / `denied: <reason>` / `pending`); a block's decision is
159/// `unblocked: <reason>`. Pending rows export with a `null` decision seq —
160/// the ask is real log data the consumer may want, the missing label is
161/// visible rather than silently dropped.
162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163#[serde(rename_all = "camelCase")]
164pub struct EscalationRecord {
165    pub mission_id: String,
166    pub kind: EscalationKind,
167    /// Grant/block: the milestone that parked; `null` on steers.
168    pub milestone_id: Option<String>,
169    /// Grant: `<kind>: <command>`; steer: the operator's message; block:
170    /// the milestone.blocked reason.
171    pub ask: String,
172    pub decision: String,
173    /// Ask→decision latency from the RECORDED timestamps (no clock is
174    /// consulted); `null` while pending and on steers.
175    pub latency_ms: Option<u64>,
176    /// Seq of the ask event (grant.requested / user.message /
177    /// milestone.blocked) — the record's ordering key.
178    pub ask_seq: u64,
179    /// Seq of the decision event — joins the replay's human-decision chain
180    /// ([`crate::provenance::DecisionLink`]); `null` while pending.
181    pub decision_seq: Option<u64>,
182}
183
184/// One corpus line: a tagged union over the three sources (module docs).
185/// The `source` tag is the consumer's filter key.
186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
187#[serde(tag = "source", rename_all = "kebab-case")]
188pub enum CorpusRecord {
189    WorkerTrace(WorkerTraceRecord),
190    Divergence(DivergenceRecord),
191    Escalation(EscalationRecord),
192}
193
194/// Derive one mission's corpus records from its event log. `events` must be
195/// that mission's log (the [`crate::reducer::fold`] discipline, same as
196/// `export-traces`); `mission_dir` anchors the provenance replay's artefact
197/// probes. Fallible exactly where its two folds are: a log the reducer
198/// rejects, or a `config.changed` patch the replay cannot merge — both are
199/// corruption, never a corpus gap.
200///
201/// Pure apart from the replay's read-only artefact probes: no clock, no
202/// network, no git, nothing persisted — calling this again over the same
203/// log yields the same records.
204pub fn export_corpus(
205    mission_dir: &Path,
206    mission_id: &str,
207    events: &[Event],
208) -> Result<Vec<CorpusRecord>> {
209    let state = crate::reducer::fold(events)?;
210    let chain = crate::provenance::provenance_chain(mission_dir, mission_id, events)?;
211    let mut records = Vec::new();
212    records.extend(
213        worker_trace_records(&state, events, &chain)
214            .into_iter()
215            .map(CorpusRecord::WorkerTrace),
216    );
217    records.extend(
218        divergence_records(mission_id, &chain)
219            .into_iter()
220            .map(CorpusRecord::Divergence),
221    );
222    records.extend(
223        escalation_records(mission_id, events)
224            .into_iter()
225            .map(CorpusRecord::Escalation),
226    );
227    Ok(records)
228}
229
230/// Source 1: the validation-PASSED instruction pairs, tagged. Selection
231/// stays with [`crate::trace_export::export_validated_traces`] — a failed
232/// or unvalidated session never enters the corpus because it never enters
233/// THAT fold; this only decorates what it returns with the replay's
234/// backend derivation and ladder refs.
235fn worker_trace_records(
236    state: &MissionState,
237    events: &[Event],
238    chain: &ProvenanceChain,
239) -> Vec<WorkerTraceRecord> {
240    let gate_chain: Vec<GateChainRef> = chain
241        .gates
242        .iter()
243        .map(|gate| GateChainRef {
244            seq: gate.seq,
245            gate: gate.gate.clone(),
246            surface: gate.surface,
247            verdict: gate.verdict,
248        })
249        .collect();
250    crate::trace_export::export_validated_traces(state, events)
251        .into_iter()
252        .map(|pair| {
253            let backend = chain
254                .sessions
255                .iter()
256                .find(|session| session.run_id == pair.run_id)
257                .and_then(|session| session.backend.clone());
258            WorkerTraceRecord {
259                instruction: pair.instruction,
260                response: pair.response,
261                model: pair.model,
262                quant: pair.quant,
263                weight_hash: pair.weight_hash,
264                mission_id: pair.mission_id,
265                feature_id: pair.feature_id,
266                run_id: pair.run_id,
267                backend,
268                gate_chain: gate_chain.clone(),
269            }
270        })
271        .collect()
272}
273
274/// Source 2: each noted divergence with its resolution. Pairing rule: a
275/// noted pairs with the earliest LATER (by seq) unconsumed resolution for
276/// the same unit — "at most one resolution per unit, the first judgement
277/// stands" (events.rs) means a re-noted unit must NOT inherit the old
278/// resolution, and consuming each resolution once keeps the pairing total
279/// and deterministic. The replay's ledger is already in log order.
280fn divergence_records(mission_id: &str, chain: &ProvenanceChain) -> Vec<DivergenceRecord> {
281    let mut used_resolutions = vec![false; chain.divergences.len()];
282    let mut records = Vec::new();
283    for link in &chain.divergences {
284        let DivergenceLink::Noted {
285            seq,
286            unit,
287            candidates,
288            diverged,
289        } = link
290        else {
291            continue;
292        };
293        let mut resolution = None;
294        for (i, candidate) in chain.divergences.iter().enumerate() {
295            if used_resolutions[i] {
296                continue;
297            }
298            if let DivergenceLink::Resolved {
299                seq: resolved_seq,
300                unit: resolved_unit,
301                selected,
302                reason,
303                decided_by,
304            } = candidate
305            {
306                if resolved_unit == unit && resolved_seq > seq {
307                    used_resolutions[i] = true;
308                    resolution = Some(DivergenceResolution {
309                        selected: *selected,
310                        reason: reason.clone(),
311                        decided_by: decided_by.clone(),
312                        resolved_seq: *resolved_seq,
313                    });
314                    break;
315                }
316            }
317        }
318        records.push(DivergenceRecord {
319            mission_id: mission_id.to_string(),
320            unit: unit.clone(),
321            noted_seq: *seq,
322            diverged: *diverged,
323            candidates: candidates.clone(),
324            resolution,
325        });
326    }
327    records
328}
329
330/// Source 3: the escalation ledger as judgment examples. Grant pairing is
331/// [`crate::escalation_metrics::pair_grant_decisions`] itself and the steer
332/// rule is its SEQUENCE rule (a `user.message` at/after `plan.approved`) —
333/// the corpus and the flight surgeon read the same log identically. Blocks
334/// pair each `milestone.blocked` with the earliest later unconsumed unblock
335/// for the same milestone; a block the ENGINE lifted (the workspace gate,
336/// [`crate::escalation_metrics::is_engine_lift`]) is not a human judgement
337/// and yields no record, consuming its unblock so it cannot pair again.
338fn escalation_records(mission_id: &str, events: &[Event]) -> Vec<EscalationRecord> {
339    let mission_events: Vec<&Event> = events
340        .iter()
341        .filter(|e| e.mission_id == mission_id)
342        .collect();
343    let plan_approved_seq = mission_events
344        .iter()
345        .find(|e| matches!(e.kind, EventKind::PlanApproved { .. }))
346        .map(|e| e.seq);
347    let mut records = Vec::new();
348
349    // Steers: the message is both ask and decision.
350    for e in &mission_events {
351        if let EventKind::UserMessage { text, .. } = &e.kind {
352            if let Some(approved) = plan_approved_seq {
353                if e.seq >= approved {
354                    records.push(EscalationRecord {
355                        mission_id: mission_id.to_string(),
356                        kind: EscalationKind::Steer,
357                        milestone_id: None,
358                        ask: text.clone(),
359                        decision: "steered".to_string(),
360                        latency_ms: None,
361                        ask_seq: e.seq,
362                        decision_seq: Some(e.seq),
363                    });
364                }
365            }
366        }
367    }
368
369    // Grant parks, via the shared pairing rule.
370    for (req_idx, matched) in crate::escalation_metrics::pair_grant_decisions(&mission_events) {
371        let req = mission_events[req_idx];
372        let EventKind::GrantRequested {
373            milestone_id,
374            kind,
375            command,
376        } = &req.kind
377        else {
378            unreachable!("pair_grant_decisions only returns grant.requested indices")
379        };
380        let (decision, latency_ms, decision_seq) = match matched {
381            Some(i) => {
382                let decided = mission_events[i];
383                let latency = (decided.ts - req.ts).num_milliseconds();
384                let latency_ms = if latency >= 0 {
385                    Some(latency as u64)
386                } else {
387                    None
388                };
389                let decision = match &decided.kind {
390                    EventKind::GrantApproved { .. } => "approved".to_string(),
391                    EventKind::GrantDenied { reason, .. } => format!("denied: {reason}"),
392                    _ => unreachable!("pair_grant_decisions only matches grant decisions"),
393                };
394                (decision, latency_ms, Some(decided.seq))
395            }
396            None => ("pending".to_string(), None, None),
397        };
398        records.push(EscalationRecord {
399            mission_id: mission_id.to_string(),
400            kind: EscalationKind::Grant,
401            milestone_id: Some(milestone_id.clone()),
402            ask: format!(
403                "{}: {command}",
404                crate::escalation_metrics::grant_kind_str(kind)
405            ),
406            decision,
407            latency_ms,
408            ask_seq: req.seq,
409            decision_seq,
410        });
411    }
412
413    // Milestone blocks the operator lifted.
414    let mut used_unblocks = vec![false; mission_events.len()];
415    for (block_idx, block) in mission_events.iter().enumerate() {
416        let EventKind::MilestoneBlocked {
417            milestone_id,
418            reason,
419            ..
420        } = &block.kind
421        else {
422            continue;
423        };
424        let mut matched = None;
425        for (i, candidate) in mission_events.iter().enumerate() {
426            if i <= block_idx || used_unblocks[i] {
427                continue;
428            }
429            if let EventKind::MilestoneUnblocked {
430                milestone_id: unblocked,
431                ..
432            } = &candidate.kind
433            {
434                if unblocked == milestone_id {
435                    used_unblocks[i] = true;
436                    matched = Some(i);
437                    break;
438                }
439            }
440        }
441        let (decision, latency_ms, decision_seq) = match matched {
442            Some(i) => {
443                let decided = mission_events[i];
444                let EventKind::MilestoneUnblocked {
445                    reason: unblock_reason,
446                    block_context,
447                    ..
448                } = &decided.kind
449                else {
450                    unreachable!("matched only milestone.unblocked above")
451                };
452                if crate::escalation_metrics::is_engine_lift(unblock_reason, block_context.as_ref())
453                {
454                    continue;
455                }
456                let latency = (decided.ts - block.ts).num_milliseconds();
457                let latency_ms = if latency >= 0 {
458                    Some(latency as u64)
459                } else {
460                    None
461                };
462                (
463                    format!("unblocked: {unblock_reason}"),
464                    latency_ms,
465                    Some(decided.seq),
466                )
467            }
468            None => ("pending".to_string(), None, None),
469        };
470        records.push(EscalationRecord {
471            mission_id: mission_id.to_string(),
472            kind: EscalationKind::Block,
473            milestone_id: Some(milestone_id.clone()),
474            ask: reason.clone(),
475            decision,
476            latency_ms,
477            ask_seq: block.seq,
478            decision_seq,
479        });
480    }
481
482    // Merge the three kinds into one lane by the ask event's seq — unique
483    // per record (event seqs are unique within a log), so the order is
484    // total and stable.
485    records.sort_by_key(|record| record.ask_seq);
486    records
487}
488
489/// Render corpus records as JSONL: one compact JSON object per line, each
490/// terminated by `\n`. Pure function of its input — the
491/// [`crate::trace_export::to_jsonl`] discipline — so it is byte-identical
492/// across repeated calls on the same records.
493pub fn to_jsonl(records: &[CorpusRecord]) -> String {
494    let mut out = String::new();
495    for record in records {
496        out.push_str(&serde_json::to_string(record).expect("CorpusRecord always serializes"));
497        out.push('\n');
498    }
499    out
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505    use crate::events::EventKind;
506    use crate::gate::GateKind;
507    use crate::types::*;
508    use chrono::{DateTime, TimeZone, Utc};
509
510    const MISSION: &str = "m-1";
511
512    fn base_ts() -> DateTime<Utc> {
513        Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap()
514    }
515
516    /// One event per fixture line: ts = base + seq seconds, so a one-seq gap
517    /// between an ask and its decision reads as exactly 1000ms of latency.
518    fn ev(seq: u64, kind: EventKind) -> Event {
519        Event {
520            seq,
521            ts: base_ts() + chrono::Duration::seconds(seq as i64),
522            mission_id: MISSION.to_string(),
523            kind,
524        }
525    }
526
527    fn plan_feature(title: &str) -> PlanFeature {
528        PlanFeature {
529            title: title.to_string(),
530            spec: format!("spec for {title}"),
531            validation_criteria: vec![format!("{title} works")],
532        }
533    }
534
535    /// One milestone, two features: f-1-1 (passes) and f-1-2 (fails).
536    fn plan() -> Plan {
537        Plan {
538            goal: "build the thing".to_string(),
539            validation_contract: vec![],
540            milestones: vec![PlanMilestone {
541                title: "milestone one".to_string(),
542                features: vec![plan_feature("alpha"), plan_feature("beta")],
543            }],
544            considered_alternatives: None,
545            command_grants: vec![],
546            touch_set: vec![],
547            standards_manifest: None,
548            reviewer_independence: None,
549        }
550    }
551
552    fn gate(index: u32) -> EventKind {
553        EventKind::GateResult {
554            gate: "merge-gate-suite".to_string(),
555            surface: GateSurface::Approval,
556            kind: GateKind::Deterministic,
557            index,
558            verdict: GateVerdict::Pass,
559            artefact_ref: format!("contract gate {index}"),
560            artefact_detail: None,
561            score: None,
562            threshold: None,
563            rule_ids: Vec::new(),
564        }
565    }
566
567    fn spawn(run_id: &str, feature_id: &str, model: &str) -> EventKind {
568        EventKind::WorkerSpawned {
569            backend: None,
570            run_id: run_id.to_string(),
571            role: Role::Worker,
572            feature_id: Some(feature_id.to_string()),
573            milestone_id: None,
574            candidate: None,
575            executor_route: None,
576            sdk_session_id: format!("sess-{run_id}"),
577            model: model.to_string(),
578            quant: "n/a".to_string(),
579            weight_hash: None,
580            prompt_hash: "deadbeef".to_string(),
581            transcript_path: format!("runs/{run_id}.jsonl"),
582        }
583    }
584
585    fn completed(run_id: &str, result: RunResult, summary: &str) -> EventKind {
586        EventKind::WorkerCompleted {
587            run_id: run_id.to_string(),
588            result,
589            tokens: TokenUsage::default(),
590            cost_usd: None,
591            report: Some(WorkerReport {
592                result,
593                summary: summary.to_string(),
594                files_touched: vec![],
595                tests_added: vec![],
596                test_evidence: "cargo test: ok".to_string(),
597                dependencies_added: vec![],
598                known_gaps: vec![],
599                commits: vec!["deadbeef commit".to_string()],
600                commands_run: vec![],
601                escalation: None,
602                questions: None,
603            }),
604        }
605    }
606
607    fn candidates() -> Vec<DivergenceCandidate> {
608        vec![
609            DivergenceCandidate {
610                run_id: "r-pass".to_string(),
611                branch: format!("kranz/pool/{MISSION}/f-1-1-c0"),
612                backend: "claude".to_string(),
613                tree: "aaa".to_string(),
614            },
615            DivergenceCandidate {
616                run_id: "r-cand".to_string(),
617                branch: format!("kranz/pool/{MISSION}/f-1-1-c1"),
618                backend: "codex".to_string(),
619                tree: "bbb".to_string(),
620            },
621        ]
622    }
623
624    /// The anti-vacuity fixture: one mission touching all three sources.
625    ///
626    /// - Traces: r-pass qualifies (Pass on a Complete feature); r-fail
627    ///   (failed feature) and r-cand (spawned, never completed — an
628    ///   unvalidated session) must never enter the corpus.
629    /// - Divergences: f-1-1 noted diverged and resolved (selected 0), then
630    ///   noted AGAIN as an agreement record that is never resolved — the
631    ///   second noted must NOT inherit the first's resolution.
632    /// - Escalations: an operator-lifted block, an approved grant, a steer,
633    ///   a pending grant, and an ENGINE-lifted block (no record) — in log
634    ///   order with 1s ask→decision gaps (1000ms latencies).
635    fn fixture_events() -> Vec<Event> {
636        vec![
637            ev(
638                1,
639                EventKind::MissionCreated {
640                    goal: "build the thing".to_string(),
641                    base_branch: "main".to_string(),
642                    mission_branch: format!("kranz/mission-{MISSION}"),
643                    config: MissionConfig::default(),
644                },
645            ),
646            ev(
647                2,
648                EventKind::PlanApproved {
649                    plan: plan(),
650                    base_sha: Some("deadbeef".to_string()),
651                },
652            ),
653            ev(3, gate(0)),
654            ev(4, gate(1)),
655            ev(
656                5,
657                EventKind::MilestoneStarted {
658                    milestone_id: "ms-1".to_string(),
659                    start_sha: "abc123".to_string(),
660                },
661            ),
662            ev(
663                6,
664                EventKind::FeatureStarted {
665                    feature_id: "f-1-1".to_string(),
666                },
667            ),
668            ev(7, spawn("r-pass", "f-1-1", "sonnet")),
669            ev(
670                8,
671                completed("r-pass", RunResult::Pass, "did the alpha thing"),
672            ),
673            ev(
674                9,
675                EventKind::FeatureCompleted {
676                    feature_id: "f-1-1".to_string(),
677                    commits: vec!["deadbeef".to_string()],
678                },
679            ),
680            // The pool's second candidate stream: spawned, never completed.
681            ev(10, spawn("r-cand", "f-1-1", "gpt-5")),
682            ev(
683                11,
684                EventKind::DivergenceNoted {
685                    unit: "f-1-1".to_string(),
686                    candidates: candidates(),
687                    diverged: true,
688                },
689            ),
690            ev(
691                12,
692                EventKind::MilestoneBlocked {
693                    block_context: None,
694                    milestone_id: "ms-1".to_string(),
695                    reason: "divergence on f-1-1: candidates disagree".to_string(),
696                },
697            ),
698            ev(
699                13,
700                EventKind::MilestoneUnblocked {
701                    block_context: None,
702                    milestone_id: "ms-1".to_string(),
703                    reason: "kept candidate 0".to_string(),
704                    validator_guidance: None,
705                },
706            ),
707            ev(
708                14,
709                EventKind::DivergenceResolved {
710                    unit: "f-1-1".to_string(),
711                    selected: Some(0),
712                    reason: "kept candidate 0".to_string(),
713                    decided_by: "operator".to_string(),
714                },
715            ),
716            ev(
717                15,
718                EventKind::GrantRequested {
719                    milestone_id: "ms-1".to_string(),
720                    kind: GrantKind::Command,
721                    command: "cargo test".to_string(),
722                },
723            ),
724            ev(
725                16,
726                EventKind::GrantApproved {
727                    kind: GrantKind::Command,
728                    command: "cargo test".to_string(),
729                },
730            ),
731            ev(
732                17,
733                EventKind::UserMessage {
734                    text: "ship it as-is".to_string(),
735                    interrupt: false,
736                },
737            ),
738            ev(
739                18,
740                EventKind::FeatureStarted {
741                    feature_id: "f-1-2".to_string(),
742                },
743            ),
744            ev(19, spawn("r-fail", "f-1-2", "sonnet")),
745            ev(
746                20,
747                completed("r-fail", RunResult::Fail, "could not do the beta thing"),
748            ),
749            ev(
750                21,
751                EventKind::FeatureFailed {
752                    feature_id: "f-1-2".to_string(),
753                    reason: "gave up".to_string(),
754                    commits: Vec::new(),
755                },
756            ),
757            // Engine-owned workspace-gate lift: NOT a human judgement.
758            ev(
759                22,
760                EventKind::MilestoneBlocked {
761                    block_context: None,
762                    milestone_id: "ms-1".to_string(),
763                    reason: "workspace gate: bootstrap failed".to_string(),
764                },
765            ),
766            ev(
767                23,
768                EventKind::MilestoneUnblocked {
769                    block_context: None,
770                    milestone_id: "ms-1".to_string(),
771                    reason: crate::workspace_gate::GATE_LIFT_REASON.to_string(),
772                    validator_guidance: None,
773                },
774            ),
775            // Never decided: a pending grant.
776            ev(
777                24,
778                EventKind::GrantRequested {
779                    milestone_id: "ms-1".to_string(),
780                    kind: GrantKind::Egress,
781                    command: "example.com:443".to_string(),
782                },
783            ),
784            // The agreement record (diverged: false), never resolved.
785            ev(
786                25,
787                EventKind::DivergenceNoted {
788                    unit: "f-1-1".to_string(),
789                    candidates: candidates(),
790                    diverged: false,
791                },
792            ),
793            ev(
794                26,
795                EventKind::MilestoneCompleted {
796                    milestone_id: "ms-1".to_string(),
797                    tag: None,
798                },
799            ),
800            ev(27, EventKind::MissionCompleted {}),
801        ]
802    }
803
804    fn export(dir: &std::path::Path, events: &[Event]) -> Vec<CorpusRecord> {
805        export_corpus(dir, MISSION, events).unwrap()
806    }
807
808    #[test]
809    fn corpus_export_emits_traces_divergences_and_escalations_with_provenance() {
810        let tmp = tempfile::TempDir::new().unwrap();
811        let events = fixture_events();
812        let records = export(tmp.path(), &events);
813
814        // 1 trace + 2 divergences + 4 escalations, grouped by source.
815        assert_eq!(records.len(), 7, "record count: {records:?}");
816
817        // -- worker-trace: the pair fields plus the provenance tags.
818        let CorpusRecord::WorkerTrace(trace) = &records[0] else {
819            panic!("records[0] must be the worker trace: {records:?}")
820        };
821        assert_eq!(trace.run_id, "r-pass");
822        assert_eq!(trace.mission_id, MISSION);
823        assert_eq!(trace.feature_id, "f-1-1");
824        assert_eq!(trace.model, "sonnet");
825        assert!(trace.instruction.contains("spec for alpha"));
826        assert!(trace.response.contains("did the alpha thing"));
827        // Backend DERIVED exactly as the replay derives it (default config →
828        // claude); the gate chain is the mission's ladder, pinned by seq.
829        assert_eq!(trace.backend.as_deref(), Some("claude"));
830        let ladder: Vec<(u64, GateSurface, GateVerdict)> = trace
831            .gate_chain
832            .iter()
833            .map(|r| (r.seq, r.surface, r.verdict))
834            .collect();
835        assert_eq!(
836            ladder,
837            vec![
838                (3, GateSurface::Approval, GateVerdict::Pass),
839                (4, GateSurface::Approval, GateVerdict::Pass),
840            ]
841        );
842
843        // -- divergences: both candidates AND the resolution; the re-noted
844        // agreement record stays unresolved (no inherited judgement).
845        let CorpusRecord::Divergence(resolved) = &records[1] else {
846            panic!("records[1] must be the resolved divergence: {records:?}")
847        };
848        assert_eq!(resolved.unit, "f-1-1");
849        assert_eq!(resolved.noted_seq, 11);
850        assert!(resolved.diverged);
851        let candidate_refs: Vec<(&str, &str, &str)> = resolved
852            .candidates
853            .iter()
854            .map(|c| (c.run_id.as_str(), c.branch.as_str(), c.backend.as_str()))
855            .collect();
856        assert_eq!(
857            candidate_refs,
858            vec![
859                ("r-pass", "kranz/pool/m-1/f-1-1-c0", "claude"),
860                ("r-cand", "kranz/pool/m-1/f-1-1-c1", "codex"),
861            ]
862        );
863        let resolution = resolved.resolution.as_ref().expect("resolved at 14");
864        assert_eq!(resolution.selected, Some(0));
865        assert_eq!(resolution.reason, "kept candidate 0");
866        assert_eq!(resolution.decided_by, "operator");
867        assert_eq!(resolution.resolved_seq, 14);
868
869        let CorpusRecord::Divergence(pending) = &records[2] else {
870            panic!("records[2] must be the pending divergence: {records:?}")
871        };
872        assert_eq!(pending.noted_seq, 25);
873        assert!(!pending.diverged, "the agreement record exports verbatim");
874        assert_eq!(pending.resolution, None);
875
876        // -- escalations, merged into ask-seq order: block(12), grant(15),
877        // steer(17), pending grant(24). The engine-lifted block (22) is gone.
878        let escalations: Vec<&EscalationRecord> = records[3..]
879            .iter()
880            .map(|r| match r {
881                CorpusRecord::Escalation(e) => e,
882                other => panic!("expected escalation, got {other:?}"),
883            })
884            .collect();
885        let lane: Vec<(u64, EscalationKind, &str, &str)> = escalations
886            .iter()
887            .map(|e| (e.ask_seq, e.kind, e.ask.as_str(), e.decision.as_str()))
888            .collect();
889        assert_eq!(
890            lane,
891            vec![
892                (
893                    12,
894                    EscalationKind::Block,
895                    "divergence on f-1-1: candidates disagree",
896                    "unblocked: kept candidate 0"
897                ),
898                (15, EscalationKind::Grant, "command: cargo test", "approved"),
899                (17, EscalationKind::Steer, "ship it as-is", "steered"),
900                (
901                    24,
902                    EscalationKind::Grant,
903                    "egress: example.com:443",
904                    "pending"
905                ),
906            ]
907        );
908        assert_eq!(escalations[0].latency_ms, Some(1000));
909        assert_eq!(escalations[0].decision_seq, Some(13));
910        assert_eq!(escalations[0].milestone_id.as_deref(), Some("ms-1"));
911        assert_eq!(escalations[1].latency_ms, Some(1000));
912        assert_eq!(escalations[1].decision_seq, Some(16));
913        assert_eq!(escalations[2].milestone_id, None);
914        assert_eq!(escalations[2].decision_seq, Some(17));
915        assert_eq!(escalations[3].latency_ms, None);
916        assert_eq!(escalations[3].decision_seq, None);
917
918        // The wire shape: tagged lines, camelCase keys.
919        let jsonl = to_jsonl(&records);
920        let lines: Vec<&str> = jsonl.lines().collect();
921        assert_eq!(lines.len(), 7);
922        let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
923        assert_eq!(first["source"], "worker-trace");
924        assert!(first["gateChain"].is_array());
925        assert_eq!(first["gateChain"][0]["surface"], "approval");
926        let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
927        assert_eq!(second["source"], "divergence");
928        assert_eq!(second["notedSeq"], 11);
929        assert_eq!(second["resolution"]["decidedBy"], "operator");
930        let fourth: serde_json::Value = serde_json::from_str(lines[3]).unwrap();
931        assert_eq!(fourth["source"], "escalation");
932        assert_eq!(fourth["kind"], "block");
933        assert_eq!(fourth["askSeq"], 12);
934    }
935
936    #[test]
937    fn corpus_export_excludes_failed_and_unvalidated_sessions() {
938        let tmp = tempfile::TempDir::new().unwrap();
939        let events = fixture_events();
940        let records = export(tmp.path(), &events);
941
942        let traces: Vec<&WorkerTraceRecord> = records
943            .iter()
944            .filter_map(|r| match r {
945                CorpusRecord::WorkerTrace(t) => Some(t),
946                _ => None,
947            })
948            .collect();
949        assert_eq!(
950            traces.len(),
951            1,
952            "only the validation-PASSED run qualifies: {traces:?}"
953        );
954        assert_eq!(traces[0].run_id, "r-pass");
955        // The failed run appears nowhere in the corpus at all; the
956        // unvalidated candidate stream appears ONLY as a divergence
957        // candidate ref (a comparison anchor), never as a trace.
958        let jsonl = to_jsonl(&records);
959        assert!(!jsonl.contains("r-fail"), "failed run leaked: {jsonl}");
960        assert!(traces.iter().all(|t| t.run_id != "r-cand"));
961    }
962
963    #[test]
964    fn corpus_export_is_byte_identical_across_regeneration() {
965        let tmp = tempfile::TempDir::new().unwrap();
966        let events = fixture_events();
967
968        let first = to_jsonl(&export(tmp.path(), &events));
969        let second = to_jsonl(&export(tmp.path(), &events));
970        assert_eq!(first, second, "same log must yield byte-identical JSONL");
971        assert!(!first.is_empty());
972        for tag in ["worker-trace", "divergence", "escalation"] {
973            assert!(
974                first.contains(&format!("\"source\":\"{tag}\"")),
975                "missing {tag} records: {first}"
976            );
977        }
978    }
979
980    #[test]
981    fn corpus_export_provenance_refs_resolve_via_the_replay() {
982        let tmp = tempfile::TempDir::new().unwrap();
983        let events = fixture_events();
984        let records = export(tmp.path(), &events);
985        // An INDEPENDENT replay of the same log: every ref a record carries
986        // must join it, by seq, without re-deriving anything.
987        let chain = crate::provenance::provenance_chain(tmp.path(), MISSION, &events).unwrap();
988
989        for record in &records {
990            match record {
991                CorpusRecord::WorkerTrace(trace) => {
992                    let session = chain
993                        .sessions
994                        .iter()
995                        .find(|s| s.run_id == trace.run_id)
996                        .expect("trace run id must resolve to a replayed session");
997                    assert_eq!(session.backend, trace.backend);
998                    assert_eq!(session.model, trace.model);
999                    for gate_ref in &trace.gate_chain {
1000                        let gate = chain
1001                            .gates
1002                            .iter()
1003                            .find(|g| g.seq == gate_ref.seq)
1004                            .expect("gate-chain seq must resolve to a ladder link");
1005                        assert_eq!(gate.gate, gate_ref.gate);
1006                        assert_eq!(gate.surface, gate_ref.surface);
1007                        assert_eq!(gate.verdict, gate_ref.verdict);
1008                    }
1009                }
1010                CorpusRecord::Divergence(divergence) => {
1011                    assert!(chain.divergences.iter().any(|link| matches!(
1012                        link,
1013                        DivergenceLink::Noted { seq, unit, .. }
1014                            if *seq == divergence.noted_seq && *unit == divergence.unit
1015                    )));
1016                    if let Some(resolution) = &divergence.resolution {
1017                        assert!(chain.divergences.iter().any(|link| matches!(
1018                            link,
1019                            DivergenceLink::Resolved { seq, unit, .. }
1020                                if *seq == resolution.resolved_seq && *unit == divergence.unit
1021                        )));
1022                        // The selected index names one of the record's own
1023                        // candidates — the both-candidates reference is real.
1024                        if let Some(selected) = resolution.selected {
1025                            assert!((selected as usize) < divergence.candidates.len());
1026                        }
1027                    }
1028                }
1029                CorpusRecord::Escalation(escalation) => {
1030                    if let Some(decision_seq) = escalation.decision_seq {
1031                        assert!(
1032                            chain.decisions.iter().any(|d| d.seq == decision_seq),
1033                            "decision seq {decision_seq} of {escalation:?} must resolve \
1034                             to a replayed human decision"
1035                        );
1036                    }
1037                }
1038            }
1039        }
1040    }
1041
1042    #[test]
1043    fn corpus_export_grant_and_steer_rows_match_the_ledger_fold() {
1044        let tmp = tempfile::TempDir::new().unwrap();
1045        let events = fixture_events();
1046        let records = export(tmp.path(), &events);
1047
1048        // The flight surgeon's own fold over the same log: its grant/steer
1049        // rows must equal the corpus's (ask, decision, latency) — one rule,
1050        // no drift. Blocks have no ledger row kind and sit this join out.
1051        let ledger =
1052            crate::escalation_metrics::aggregate(&[(MISSION.to_string(), events.clone())], &[])
1053                .ledger;
1054        let mut from_ledger: Vec<(String, String, Option<u64>)> = ledger
1055            .iter()
1056            .map(|row| (row.ask.clone(), row.decision.clone(), row.latency_ms))
1057            .collect();
1058        let mut from_corpus: Vec<(String, String, Option<u64>)> = records
1059            .iter()
1060            .filter_map(|r| match r {
1061                CorpusRecord::Escalation(e)
1062                    if matches!(e.kind, EscalationKind::Grant | EscalationKind::Steer) =>
1063                {
1064                    Some((e.ask.clone(), e.decision.clone(), e.latency_ms))
1065                }
1066                _ => None,
1067            })
1068            .collect();
1069        from_ledger.sort();
1070        from_corpus.sort();
1071        assert_eq!(from_corpus, from_ledger);
1072        assert_eq!(from_corpus.len(), 3, "two grants + one steer");
1073    }
1074}