Skip to main content

kranz_engine/
reducer.rs

1//! Pure, deterministic fold of the event log into [`MissionState`].
2//!
3//! `state.json` is only a cache of `fold(events)`; the log is the source of
4//! truth. `fold` == `fold(first)` + repeated [`apply`] (property-tested), so
5//! the engine can maintain state incrementally while any reader can rebuild
6//! it from scratch and get byte-identical JSON.
7
8use crate::error::{EngineError, Result};
9use crate::events::{Event, EventKind};
10use crate::types::*;
11use std::collections::{BTreeMap, BTreeSet};
12use std::path::Path;
13
14/// Reserved `run_id` for `validation.finding` events produced by the engine
15/// itself (final contract gate command failures) rather than a validator run.
16pub const ENGINE_RUN_ID: &str = "engine";
17
18/// Fold a contiguous event slice into a state. The first event MUST be
19/// `mission.created`.
20pub fn fold(events: &[Event]) -> Result<MissionState> {
21    let first = events
22        .first()
23        .ok_or_else(|| EngineError::InvalidState("cannot fold an empty event log".to_string()))?;
24    let mut state = initial_state(first)?;
25    for event in &events[1..] {
26        apply(&mut state, event)?;
27    }
28    Ok(state)
29}
30
31/// Apply one event on top of an existing state. `event.seq` must be exactly
32/// `state.last_seq + 1` (fold passes contiguous events; anything else is a
33/// caller bug or log corruption).
34pub fn apply(state: &mut MissionState, event: &Event) -> Result<()> {
35    if event.seq != state.last_seq + 1 {
36        return Err(EngineError::InvalidState(format!(
37            "non-contiguous apply: state at seq {}, event seq {}",
38            state.last_seq, event.seq
39        )));
40    }
41
42    match &event.kind {
43        EventKind::MissionCreated { .. } => {
44            return Err(EngineError::InvalidState(format!(
45                "mission.created at seq {} is only valid as the first event",
46                event.seq
47            )));
48        }
49
50        EventKind::PlanApproved { plan, base_sha } => {
51            // Status guard, the sibling of `expect_pending_grant` below
52            // (audit 2026-09-01 H6). `approve_plan` only ever emits this from
53            // `Planning`, so a `plan.approved` folded on top of a running,
54            // blocked, or completed mission did not come from the approval
55            // path: it is a late forgery appended to the log, and applying it
56            // would replace the milestone set, the contract, the touch set,
57            // and the command grants wholesale. Ignored rather than a fold
58            // error, so one bad line cannot make an existing mission
59            // permanently unreadable.
60            if state.mission.status != MissionStatus::Planning {
61                tracing::warn!(
62                    seq = event.seq,
63                    status = ?state.mission.status,
64                    "ignoring plan.approved outside Planning status"
65                );
66                state.last_seq = event.seq;
67                return Ok(());
68            }
69            crate::contract_controls::validate(&plan.validation_contract)?;
70            state.mission.base_sha = base_sha.clone();
71            state.mission.goal = plan.goal.clone();
72            state.mission.validation_contract = plan.validation_contract.clone();
73            state.mission.milestones = plan
74                .milestones
75                .iter()
76                .enumerate()
77                .map(|(mi, pm)| Milestone {
78                    id: format!("ms-{}", mi + 1),
79                    title: pm.title.clone(),
80                    features: pm
81                        .features
82                        .iter()
83                        .enumerate()
84                        .map(|(fi, pf)| Feature {
85                            id: format!("f-{}-{}", mi + 1, fi + 1),
86                            title: pf.title.clone(),
87                            spec: pf.spec.clone(),
88                            validation_criteria: pf.validation_criteria.clone(),
89                            origin: FeatureOrigin::Plan,
90                            status: FeatureStatus::Pending,
91                            worker_runs: Vec::new(),
92                            commits: Vec::new(),
93                            respawns: 0,
94                        })
95                        .collect(),
96                    status: MilestoneStatus::Pending,
97                    fix_cycles: 0,
98                    start_sha: None,
99                    validator_guidance: None,
100                })
101                .collect();
102            state.mission.command_grants = plan.command_grants.clone();
103            state.mission.touch_set = plan.touch_set.clone();
104            // The Flight Rules approval pin (KRZ-342 D-E) folds with the plan
105            // it was approved with — the mission's standards authority from
106            // here on.
107            state.mission.standards_manifest = plan.standards_manifest.as_deref().cloned();
108            state.mission.reviewer_independence = plan.reviewer_independence;
109            state.mission.status = MissionStatus::Approved;
110            state.latest_plan_revision = 0;
111            state.pending_revision = None;
112        }
113
114        EventKind::PlanRevisionProposed {
115            revision,
116            plan,
117            instructions,
118        } => {
119            if *revision == 0 {
120                return Err(EngineError::InvalidState(
121                    "plan.revision.proposed revision must be >= 1".to_string(),
122                ));
123            }
124            state.latest_plan_revision = state.latest_plan_revision.max(*revision);
125            state.pending_revision = Some(PendingRevision {
126                revision: *revision,
127                plan: plan.clone(),
128                instructions: instructions.clone(),
129            });
130        }
131
132        EventKind::PlanRevised { revision, plan } => {
133            if let Some(pending) = &state.pending_revision {
134                if pending.revision != *revision {
135                    return Err(EngineError::InvalidState(format!(
136                        "plan.revised revision {revision} does not match pending revision {}",
137                        pending.revision
138                    )));
139                }
140            }
141            apply_revised_plan(state, plan, *revision)?;
142            state.latest_plan_revision = state.latest_plan_revision.max(*revision);
143            state.pending_revision = None;
144        }
145
146        EventKind::PlanRevisionRejected { revision, .. } => {
147            if let Some(pending) = &state.pending_revision {
148                if pending.revision != *revision {
149                    return Err(EngineError::InvalidState(format!(
150                        "plan.revision.rejected revision {revision} does not match pending revision {}",
151                        pending.revision
152                    )));
153                }
154            }
155            state.latest_plan_revision = state.latest_plan_revision.max(*revision);
156            state.pending_revision = None;
157        }
158
159        EventKind::GrantRequested {
160            milestone_id,
161            kind,
162            command,
163        } => {
164            milestone_mut(state, milestone_id)?; // existence check
165            if command.trim().is_empty() {
166                return Err(EngineError::InvalidState(
167                    "grant.requested command must not be empty".to_string(),
168                ));
169            }
170            state.pending_grant_request = Some(PendingGrantRequest {
171                milestone_id: milestone_id.clone(),
172                kind: *kind,
173                command: command.clone(),
174            });
175        }
176
177        EventKind::GrantApproved { kind, command } => {
178            // Cross-check against the parked request (mirrors PlanRevised): a
179            // forged or replayed grant.approved with no matching pending
180            // request — or one naming a different kind/target than was
181            // requested — must never silently widen an allow-list.
182            expect_pending_grant(state, *kind, command, "grant.approved")?;
183            // Extend-only, deduped: the approved target joins the list `kind`
184            // selects so the retried run clears the boundary.
185            let list = match kind {
186                GrantKind::Command => &mut state.mission.command_grants,
187                GrantKind::TouchPath => &mut state.mission.touch_set,
188                GrantKind::WorkerDeny => &mut state.mission.deny_exceptions,
189                GrantKind::Egress => &mut state.mission.egress_grants,
190            };
191            if !list.iter().any(|c| c == command) {
192                list.push(command.clone());
193            }
194            state.pending_grant_request = None;
195        }
196
197        EventKind::GrantDenied { kind, command, .. } => {
198            expect_pending_grant(state, *kind, command, "grant.denied")?;
199            state.pending_grant_request = None;
200        }
201
202        EventKind::MilestoneStarted {
203            milestone_id,
204            start_sha,
205        } => {
206            if state.executor_tier() == ExecutorTier::Local {
207                state.local_executor_milestones += 1;
208            }
209            let ms = milestone_mut(state, milestone_id)?;
210            ms.status = MilestoneStatus::Active;
211            ms.start_sha = Some(start_sha.clone());
212            if state.mission.status == MissionStatus::Approved {
213                state.mission.status = MissionStatus::Running;
214            }
215        }
216
217        EventKind::FeatureStarted { feature_id } => {
218            feature_mut(state, feature_id)?.status = FeatureStatus::Active;
219        }
220
221        EventKind::FeatureProgress {
222            feature_id,
223            base_sha,
224            commits,
225        } => {
226            if feature_mut(state, feature_id)?.status != FeatureStatus::Active {
227                return Err(EngineError::InvalidState(format!(
228                    "feature.progress for inactive feature '{feature_id}'"
229                )));
230            }
231            if state
232                .feature_base_shas
233                .get(feature_id)
234                .is_some_and(|existing| existing != base_sha)
235            {
236                return Err(EngineError::InvalidState(format!(
237                    "feature.progress changed the baseline for '{feature_id}'"
238                )));
239            }
240            state
241                .feature_base_shas
242                .insert(feature_id.clone(), base_sha.clone());
243            record_feature_commits(feature_mut(state, feature_id)?, commits);
244        }
245
246        EventKind::WorkerSpawned {
247            run_id,
248            role,
249            feature_id,
250            milestone_id,
251            candidate,
252            executor_route: _,
253            sdk_session_id,
254            model,
255            backend,
256            quant,
257            weight_hash,
258            prompt_hash,
259            transcript_path,
260        } => {
261            if state.runs.contains_key(run_id) {
262                return Err(EngineError::InvalidState(format!(
263                    "duplicate worker.spawned for run '{run_id}'"
264                )));
265            }
266            if let Some(mid) = milestone_id {
267                milestone_mut(state, mid)?; // existence check
268            }
269            if let Some(fid) = feature_id {
270                let feature = feature_mut(state, fid)?;
271                feature.worker_runs.push(run_id.clone());
272                // A 2nd+ run on the same feature is a respawn — EXCEPT a
273                // dispatch-pool candidate (KRZ-303): the N sibling streams
274                // are ONE logical dispatch of the unit, not N-1 retries, and
275                // the pool path has no judgement-driven respawn loop at all,
276                // so counting them would silently deplete `max_respawns`.
277                if feature.worker_runs.len() > 1 && candidate.is_none() {
278                    feature.respawns += 1;
279                }
280            }
281            state.runs.insert(
282                run_id.clone(),
283                WorkerRun {
284                    backend: *backend,
285                    id: run_id.clone(),
286                    role: *role,
287                    feature_id: feature_id.clone(),
288                    milestone_id: milestone_id.clone(),
289                    candidate: candidate.clone(),
290                    sdk_session_id: sdk_session_id.clone(),
291                    model: model.clone(),
292                    quant: quant.clone(),
293                    weight_hash: weight_hash.clone(),
294                    started_at: event.ts,
295                    ended_at: None,
296                    tokens: TokenUsage::default(),
297                    cost_usd: None,
298                    transcript_path: transcript_path.clone(),
299                    result: None,
300                    report: None,
301                    prompt_hash: prompt_hash.clone(),
302                },
303            );
304            if state.mission.status == MissionStatus::Approved {
305                state.mission.status = MissionStatus::Running;
306            }
307        }
308
309        EventKind::WorkerMessage { run_id, .. } => {
310            run_mut(state, run_id)?; // stream delta: existence check only
311        }
312
313        EventKind::WorkerEgressDenied { run_id, .. } => {
314            // Audit-only runtime evidence. The durable event deliberately
315            // adds no mutable state shape; consumers select the relevant run
316            // ids directly from the validated log. Still validate the run
317            // reference so a hand-edited event cannot cite a nonexistent
318            // session.
319            run_mut(state, run_id)?;
320        }
321
322        EventKind::WorkerCompleted {
323            run_id,
324            result,
325            tokens,
326            cost_usd,
327            report,
328        } => {
329            let run = run_mut(state, run_id)?;
330            run.result = Some(*result);
331            run.tokens = tokens.clone();
332            run.cost_usd = *cost_usd;
333            run.report = report.clone();
334            run.ended_at = Some(event.ts);
335            state.totals.add(tokens);
336            state.total_cost_usd += cost_usd.unwrap_or(0.0);
337        }
338
339        EventKind::FeatureCompleted {
340            feature_id,
341            commits,
342        } => {
343            let cumulative = state.feature_base_shas.contains_key(feature_id);
344            let feature = feature_mut(state, feature_id)?;
345            feature.status = FeatureStatus::Complete;
346            if cumulative {
347                record_feature_commits(feature, commits);
348            } else {
349                feature.commits.extend(commits.iter().cloned());
350            }
351        }
352
353        EventKind::FeatureFailed {
354            feature_id,
355            commits,
356            ..
357        } => {
358            let cumulative = state.feature_base_shas.contains_key(feature_id);
359            let feature = feature_mut(state, feature_id)?;
360            feature.status = FeatureStatus::Failed;
361            // Record any commits the failure landed on the mission branch:
362            // the fix-feature supersession guard reads `commits.is_empty()`
363            // to tell a failed-COMMITLESS feature (re-proposable — the
364            // m-eee81f auth-death wedge) from failed-with-real-work (started;
365            // a duplicate fixfeature.created must reject).
366            if cumulative {
367                record_feature_commits(feature, commits);
368            } else {
369                feature.commits.extend(commits.iter().cloned());
370            }
371        }
372
373        EventKind::FeatureSkipped { feature_id, .. } => {
374            feature_mut(state, feature_id)?.status = FeatureStatus::Skipped;
375        }
376
377        EventKind::MilestoneValidating { milestone_id } => {
378            milestone_mut(state, milestone_id)?.status = MilestoneStatus::Validating;
379        }
380
381        EventKind::ValidationFinding {
382            milestone_id,
383            run_id,
384            ..
385        } => {
386            // No structural change; validate references as a corruption guard.
387            // run_id "engine" is reserved for findings the engine itself
388            // produces (final contract gate command failures) — no session
389            // exists behind them, so the run lookup is skipped.
390            milestone_mut(state, milestone_id)?;
391            if run_id != ENGINE_RUN_ID {
392                run_mut(state, run_id)?;
393            }
394        }
395
396        EventKind::ValidatorTamper {
397            milestone_id,
398            run_id,
399            ..
400        } => {
401            // Audit record of the failed immutability assertion; the
402            // accompanying milestone.blocked drives status. Validate
403            // references as a corruption guard (mirrors validation.finding).
404            milestone_mut(state, milestone_id)?;
405            if run_id != ENGINE_RUN_ID {
406                run_mut(state, run_id)?;
407            }
408        }
409
410        EventKind::ValidationSnapshot { milestone_id, .. } => {
411            // Audit record of the throwaway checkout a validator session
412            // ran in; no structural state change, and no run id exists at
413            // emit time (the session starts after the snapshot). Validate
414            // the milestone reference as a corruption guard only.
415            milestone_mut(state, milestone_id)?;
416        }
417
418        EventKind::ValidationConfirm {
419            milestone_id,
420            local_run_id,
421            confirm_run_id,
422            ..
423        } => {
424            // Audit-only record (KRZ-206b, the gate.result additive
425            // template): the local-vs-frontier comparison drives no state
426            // transition — a disagreement's finding already flows through
427            // validation.finding, and the miss rate reads this event back
428            // off the log, so state shape does not grow. Validate all
429            // references as a corruption guard (mirrors validation.finding):
430            // both run ids name real sessions (the local primary and the
431            // frontier confirmation), so a hand-edited confirm cannot cite
432            // a run the log never recorded.
433            milestone_mut(state, milestone_id)?;
434            run_mut(state, local_run_id)?;
435            run_mut(state, confirm_run_id)?;
436        }
437
438        EventKind::ValidationPtyTranscript { milestone_id, .. } => {
439            // Audit-only record (ticket pty-functional-validation): the
440            // verdict reaches the round through the functional validator's
441            // evidence block, not through this event, so it drives no state
442            // transition (mirrors validation.snapshot). No run id exists at
443            // emit time — the evidence pass is engine-run — so only the
444            // milestone reference is validated as a corruption guard.
445            milestone_mut(state, milestone_id)?;
446        }
447
448        EventKind::GateResult { .. } => {
449            // Audit-only record (KRZ-312): one gate evaluation — id, ladder
450            // position, verdict, artefact handle. Gate results drive no
451            // state transition (the advisory posture of contract gates is
452            // unchanged: verdicts inform, they never block), and the ladder
453            // a replay reconstructs is read from the events themselves, so
454            // state shape intentionally does not grow. And unlike
455            // validation.finding there is no milestone/run reference on the
456            // payload to validate as a corruption guard — the arm is a pure
457            // no-op, exactly like secret.redacted below.
458        }
459
460        EventKind::HookGateFired { run_id, .. } => {
461            // Record-only (KRZ-302, the gate.result additive template): the
462            // in-process hook verdict already happened inside the session,
463            // and the engine-side sweep remains the authoritative layer —
464            // so this drives no state transition and state shape does not
465            // grow. Validate the run reference as a corruption guard
466            // (mirrors validation.finding); the run id is engine-stamped at
467            // fold time, so this cannot be aimed at a run the log never
468            // recorded.
469            run_mut(state, run_id)?;
470        }
471
472        EventKind::DivergenceNoted {
473            unit, candidates, ..
474        } => {
475            // Audit record of the candidate comparison (KRZ-304); the
476            // accompanying milestone.blocked drives the park, and the
477            // `diverged` verdict is deliberately NEVER folded into any
478            // state a decision could key on — agreement between models is a
479            // signal to log, never a criterion to trust. Validate
480            // references as a corruption guard (mirrors validation.finding):
481            // the unit names a feature, every candidate a recorded run.
482            feature_mut(state, unit)?;
483            for candidate in candidates {
484                run_mut(state, &candidate.run_id)?;
485            }
486        }
487
488        EventKind::DivergenceResolved { unit, .. } => {
489            // The judgement record (KRZ-304). The unit joins the folded
490            // resolution set — the engine's restart-safe memory for "this
491            // unit was already judged" (at most one resolution per unit;
492            // the set insert keeps a duplicated hand-written event benign).
493            // Reference validation as a corruption guard, as above.
494            feature_mut(state, unit)?;
495            state.resolved_divergence_units.insert(unit.clone());
496        }
497
498        EventKind::FixFeatureCreated {
499            milestone_id,
500            feature,
501        } => {
502            let ms = milestone_mut(state, milestone_id)?;
503            if let Some(existing) = ms.features.iter().find(|f| f.id == feature.id) {
504                // A duplicate with an IDENTICAL proposal is an idempotent
505                // replay — a retried emission after a crash between emit
506                // and fold (mission m-83d1ed). Event-sourced recovery must
507                // no-op it, not brick — and it must NOT skip the last_seq
508                // advance at the tail, or the next event fails contiguity
509                // (the m-83d1ed wedge's second form).
510                if existing.title == feature.title
511                    && existing.spec == feature.spec
512                    && existing.validation_criteria == feature.validation_criteria
513                {
514                    // fall through to the tail: seq advances, state unchanged
515                } else {
516                    // A duplicate with a DIFFERENT payload is an implicit
517                    // SUPERSESSION when the prior feature never produced work:
518                    // an unstarted (Pending) or failed-and-commitless feature
519                    // can be re-proposed by a re-plan — this is the normal
520                    // shape after new findings (m-83d1ed re-proposed the same
521                    // id twice). Failed-with-runs-but-no-commits is the same
522                    // class: runs that never committed produced no work (the
523                    // m-eee81f wedge — three infra-failed runs made
524                    // `worker_runs` non-empty and bricked every re-proposal);
525                    // their records stay in the log. A feature whose run is
526                    // in flight is Active, so runs alone are not the "started"
527                    // signal. The successor REPLACES the prior payload in
528                    // place and restarts as Pending; the original payload is
529                    // not lost — it lives in this same event log (the first
530                    // fixfeature.created). Once a feature has started,
531                    // committed, or completed, a revision is genuinely
532                    // shadowing and stays loudly invalid.
533                    let idx = ms
534                        .features
535                        .iter()
536                        .position(|f| f.id == feature.id)
537                        .expect("found above");
538                    let existing = &ms.features[idx];
539                    let prior_started = matches!(
540                        existing.status,
541                        FeatureStatus::Active | FeatureStatus::Complete | FeatureStatus::Skipped
542                    ) || !existing.commits.is_empty();
543                    if prior_started {
544                        return Err(EngineError::InvalidState(format!(
545                        "duplicate fixfeature.created for feature '{}' with a different payload",
546                        feature.id
547                    )));
548                    }
549                    tracing::info!(
550                        feature_id = %feature.id,
551                        "fixfeature re-proposed before any work: folding as implicit supersession"
552                    );
553                    if ms.status == MilestoneStatus::Validating {
554                        ms.fix_cycles += 1;
555                        ms.status = MilestoneStatus::Active;
556                    }
557                    let mut successor = feature.clone();
558                    successor.status = FeatureStatus::Pending;
559                    ms.features[idx] = successor;
560                    state.feature_base_shas.remove(&feature.id);
561                }
562            } else {
563                // One fix-cycle increment per validation round: the first
564                // fixfeature after milestone.validating flips the milestone back
565                // to Active; later fixfeatures in the same round arrive while
566                // Active and do not increment.
567                if ms.status == MilestoneStatus::Validating {
568                    ms.fix_cycles += 1;
569                    ms.status = MilestoneStatus::Active;
570                }
571                ms.features.push(feature.clone());
572            }
573        }
574
575        EventKind::TierEscalated { milestone_id, .. } => {
576            state.config.worker.backend = None;
577            state.config.worker.base_url = None;
578            state.config.worker.context_budget = None;
579            state.config.worker.temperature = None;
580            state.escalated_milestones += 1;
581            let ms = milestone_mut(state, milestone_id)?;
582            ms.status = MilestoneStatus::Active;
583            ms.fix_cycles = 0;
584        }
585
586        EventKind::WorkerEscalated { run_id, .. } => {
587            // Record-only (KRZ-331, the gate.result additive template): the
588            // worker's escalation request is provenance — the judgement turn
589            // (the frontier advisor) acts on the report, and nothing a
590            // decision could key on changes here: the validator route, the
591            // executor tier, the respawn budget, and every milestone status
592            // are deliberately untouched, so a worker escalation can never
593            // bypass the floor's validator requirements (contrast
594            // tier.escalated above, the orchestrator-initiated tier flip,
595            // which DOES rewrite worker config). Validate the run reference
596            // as a corruption guard (mirrors hook.gate.fired): the run id is
597            // engine-stamped at emit time, so this cannot be aimed at a run
598            // the log never recorded.
599            run_mut(state, run_id)?;
600        }
601
602        EventKind::QuestionOpened {
603            question_id,
604            role,
605            text,
606            options,
607            run_id,
608            feature_id,
609            milestone_id,
610        } => {
611            // The pending-decision projection's open edge (ticket
612            // structured-human-question-events). NOT record-only: the open
613            // question IS state a surface renders and an answer cross-checks
614            // against, so it folds onto `pending_questions` — but it gates
615            // NOTHING in the run loop (contrast grant.requested's park).
616            // Validate references as a corruption guard (mirrors
617            // validation.finding), then dedupe like fixfeature.created: a
618            // duplicated open with an IDENTICAL payload is an idempotent
619            // replay (no double-push, no id-counter bump); the same id with
620            // a DIFFERENT payload is shadowing and stays loudly invalid.
621            if question_id.trim().is_empty() {
622                return Err(EngineError::InvalidState(
623                    "question.opened question id must not be empty".to_string(),
624                ));
625            }
626            if text.trim().is_empty() {
627                return Err(EngineError::InvalidState(format!(
628                    "question.opened {question_id} text must not be empty"
629                )));
630            }
631            if let Some(run_id) = run_id {
632                run_mut(state, run_id)?;
633            }
634            if let Some(feature_id) = feature_id {
635                feature_mut(state, feature_id)?;
636            }
637            if let Some(milestone_id) = milestone_id {
638                milestone_mut(state, milestone_id)?;
639            }
640            if let Some(existing) = state
641                .pending_questions
642                .iter()
643                .find(|q| q.question_id == *question_id)
644            {
645                let identical = existing.role == *role
646                    && existing.text == *text
647                    && existing.options == *options
648                    && existing.run_id == *run_id
649                    && existing.feature_id == *feature_id
650                    && existing.milestone_id == *milestone_id;
651                if !identical {
652                    return Err(EngineError::InvalidState(format!(
653                        "duplicate question.opened for '{question_id}' with a different payload"
654                    )));
655                }
656                // fall through to the tail: seq advances, state unchanged
657            } else {
658                state.question_count += 1;
659                state.pending_questions.push(PendingQuestion {
660                    question_id: question_id.clone(),
661                    role: *role,
662                    text: text.clone(),
663                    options: options.clone(),
664                    run_id: run_id.clone(),
665                    feature_id: feature_id.clone(),
666                    milestone_id: milestone_id.clone(),
667                });
668            }
669        }
670
671        EventKind::QuestionAnswered {
672            question_id,
673            answer,
674            ..
675        } => {
676            // The projection's answer edge: cross-check against the parked
677            // question (mirrors expect_pending_grant — a stale or forged
678            // answer for a question that is not open fails the fold), remove
679            // it, then route the answer onto `pending_user_messages` — the
680            // EXISTING consult path (D-X: answers ride the msg machinery,
681            // never a new delivery mechanism), so the orchestrator's next
682            // user-message consult consumes the answer and a restart replays
683            // it from the log alone.
684            let question = take_pending_question(state, question_id, "question.answered")?;
685            state.pending_user_messages.push(format!(
686                "answer to question {} (\"{}\"): {}",
687                question.question_id, question.text, answer
688            ));
689        }
690
691        EventKind::QuestionCleared { question_id, .. } => {
692            // The projection's clear edge: same parked-question cross-check
693            // as the answer (a clear for a question that is not open is
694            // corruption, never a silent no-op).
695            take_pending_question(state, question_id, "question.cleared")?;
696        }
697
698        EventKind::MilestoneBlocked { milestone_id, .. } => {
699            milestone_mut(state, milestone_id)?.status = MilestoneStatus::Blocked;
700            state.mission.status = MissionStatus::Blocked;
701        }
702
703        EventKind::MilestoneUnblocked {
704            milestone_id,
705            validator_guidance,
706            ..
707        } => {
708            let ms = milestone_mut(state, milestone_id)?;
709            ms.status = MilestoneStatus::Active;
710            // Latest unblock wins (including None — a bare unblock clears
711            // guidance left by an earlier one).
712            ms.validator_guidance = validator_guidance.clone();
713            state.mission.status = MissionStatus::Running;
714        }
715
716        EventKind::MilestoneCompleted { milestone_id, .. } => {
717            let ms = milestone_mut(state, milestone_id)?;
718            ms.status = MilestoneStatus::Complete;
719            // Guidance served its purpose; never leak it into a later
720            // milestone's (or a re-run's) validators.
721            ms.validator_guidance = None;
722        }
723
724        EventKind::MissionValidating {} => {
725            state.mission.status = MissionStatus::Validating;
726        }
727
728        EventKind::MissionPaused {} => {
729            state.mission.status = MissionStatus::Paused;
730        }
731
732        EventKind::MissionResumed {} => {
733            state.mission.status = MissionStatus::Running;
734        }
735
736        EventKind::UserMessage { text, .. } => {
737            state.pending_user_messages.push(text.clone());
738        }
739
740        EventKind::OrchestratorDecision { summary, .. } => {
741            state.recent_decisions.push(summary.clone());
742            while state.recent_decisions.len() > MAX_RECENT_DECISIONS {
743                state.recent_decisions.remove(0);
744            }
745            // A decision marks the queued user messages as consumed.
746            state.pending_user_messages.clear();
747        }
748
749        EventKind::SecretRedacted { .. } => {
750            // Audit-only: the write boundary already redacted the event that
751            // preceded this marker. State shape intentionally does not grow.
752        }
753
754        EventKind::ConfigChanged { patch } => {
755            let mut value = serde_json::to_value(&state.config)?;
756            deep_merge(&mut value, patch);
757            state.config = serde_json::from_value(value).map_err(|e| {
758                EngineError::Config(format!("config.changed patch produced invalid config: {e}"))
759            })?;
760        }
761
762        EventKind::MissionCompleted {} => {
763            state.mission.status = MissionStatus::Complete;
764        }
765
766        EventKind::MissionFailed { .. } => {
767            state.mission.status = MissionStatus::Failed;
768        }
769
770        EventKind::MissionAbandoned { .. } => {
771            state.mission.status = MissionStatus::Abandoned;
772        }
773
774        EventKind::WorkspaceProvisioned { provider, .. } => {
775            // The last provisioned provider kind is the durable record (D-E);
776            // a resume re-provisions and supersedes it with the same value.
777            state.workspace_provider = Some(provider.clone());
778        }
779
780        EventKind::WorkspaceReadinessReport { .. } => {
781            // Audit-only artifact (D-E): the readiness outcome lives on the
782            // event trail; state shape intentionally does not grow from it.
783        }
784
785        EventKind::WorkspaceTeardown { state: outcome, .. } => {
786            // The teardown outcome (ticket workspace-idle-hibernate) folds
787            // into the last-known workspace lifecycle — latest transition
788            // wins (append-only order), with the event's own ts as the
789            // workspace-hours anchor for cost tooling. Teardown events
790            // without an outcome (old keep-only logs) leave it untouched.
791            if let Some(outcome) = outcome {
792                state.workspace_lifecycle = Some(WorkspaceLifecycle {
793                    state: outcome.clone(),
794                    ts: event.ts,
795                });
796            }
797        }
798
799        EventKind::WorkspaceProviderPinned {
800            provider,
801            template,
802            version,
803        } => {
804            // The approval-time consent pin (D-B). Emitted once per
805            // approve_plan; a retried approval after a failed attempt re-pins
806            // (last pin wins).
807            state.workspace_pin = Some(WorkspacePin {
808                provider: provider.clone(),
809                template: template.clone(),
810                version: version.clone(),
811            });
812        }
813
814        EventKind::StandardsResolved { .. }
815        | EventKind::StandardsDrifted { .. }
816        | EventKind::StandardsWaiverApproved { .. }
817        | EventKind::StandardsAttestationApproved { .. } => {
818            // Audit-only (KRZ-342 D-H; KRZ-344 D-I): the pin itself folds
819            // with plan.approved; these events are the queryable provenance,
820            // refusal, and waiver evidence. The coverage fold joins waivers
821            // straight from the log — state shape intentionally does not
822            // grow.
823        }
824    }
825
826    state.last_seq = event.seq;
827    Ok(())
828}
829
830/// Newest-last cap on `MissionState::recent_decisions`.
831const MAX_RECENT_DECISIONS: usize = 10;
832
833fn record_feature_commits(feature: &mut Feature, commits: &[String]) {
834    for commit in commits {
835        if !feature
836            .commits
837            .iter()
838            .any(|existing| existing.split_whitespace().next() == commit.split_whitespace().next())
839        {
840            feature.commits.push(commit.clone());
841        }
842    }
843}
844
845fn initial_state(event: &Event) -> Result<MissionState> {
846    let EventKind::MissionCreated {
847        goal,
848        base_branch,
849        mission_branch,
850        config,
851    } = &event.kind
852    else {
853        return Err(EngineError::InvalidState(format!(
854            "first event must be mission.created, found '{}'",
855            event.kind.type_name()
856        )));
857    };
858    Ok(MissionState {
859        feature_base_shas: BTreeMap::new(),
860        mission: Mission {
861            id: event.mission_id.clone(),
862            goal: goal.clone(),
863            validation_contract: Vec::new(),
864            milestones: Vec::new(),
865            status: MissionStatus::Planning,
866            created_at: event.ts,
867            base_branch: base_branch.clone(),
868            base_sha: None,
869            mission_branch: mission_branch.clone(),
870            command_grants: Vec::new(),
871            touch_set: Vec::new(),
872            deny_exceptions: Vec::new(),
873            egress_grants: Vec::new(),
874            standards_manifest: None,
875            reviewer_independence: None,
876            // The seed-time route record (ticket routing-rules-config): the
877            // folded task class exists only on THIS event's goal, so the
878            // decision is derived here, once — deterministically equal to
879            // what create applied (routing::seed_executor_route).
880            executor_route: crate::routing::seed_executor_route(config, goal),
881        },
882        runs: BTreeMap::new(),
883        totals: TokenUsage::default(),
884        total_cost_usd: 0.0,
885        pending_user_messages: Vec::new(),
886        recent_decisions: Vec::new(),
887        config: config.clone(),
888        latest_plan_revision: 0,
889        pending_revision: None,
890        pending_grant_request: None,
891        pending_questions: Vec::new(),
892        question_count: 0,
893        last_seq: event.seq,
894        escalated_milestones: 0,
895        local_executor_milestones: 0,
896        workspace_provider: None,
897        workspace_pin: None,
898        workspace_lifecycle: None,
899        resolved_divergence_units: BTreeSet::new(),
900    })
901}
902
903/// Assert that `kind`+`command` match the parked `pending_grant_request`. Both
904/// `grant.approved` and `grant.denied` gate on this, so a forged or replayed
905/// decision event can neither widen an allow-list (approve) nor clear a request
906/// the operator never saw (deny) — and can't apply to the WRONG list by
907/// swapping the kind. Mirrors the `pending.revision` cross-check that
908/// `PlanRevised`/`PlanRevisionRejected` perform.
909fn expect_pending_grant(
910    state: &MissionState,
911    kind: GrantKind,
912    command: &str,
913    event: &str,
914) -> Result<()> {
915    match &state.pending_grant_request {
916        Some(pending) if pending.kind == kind && pending.command == command => Ok(()),
917        Some(pending) => Err(EngineError::InvalidState(format!(
918            "{event} {kind:?} {command:?} does not match pending grant {:?} {:?}",
919            pending.kind, pending.command
920        ))),
921        None => Err(EngineError::InvalidState(format!(
922            "{event} with no pending grant request"
923        ))),
924    }
925}
926
927/// Remove and return the parked question `question_id` names, or fail the
928/// fold. Both `question.answered` and `question.cleared` gate on this
929/// (mirrors [`expect_pending_grant`]): a stale, replayed, or forged
930/// resolution for a question that is not open — never asked, already
931/// answered, already cleared — is corruption, not a silent no-op.
932fn take_pending_question(
933    state: &mut MissionState,
934    question_id: &str,
935    event: &str,
936) -> Result<PendingQuestion> {
937    let Some(index) = state
938        .pending_questions
939        .iter()
940        .position(|q| q.question_id == question_id)
941    else {
942        return Err(EngineError::InvalidState(format!(
943            "{event} for question '{question_id}' that is not open"
944        )));
945    };
946    Ok(state.pending_questions.remove(index))
947}
948
949fn apply_revised_plan(state: &mut MissionState, plan: &Plan, revision: u32) -> Result<()> {
950    crate::contract_controls::validate(&plan.validation_contract)?;
951    if plan.reviewer_independence != state.mission.reviewer_independence {
952        return Err(EngineError::Config(
953            "plan.revised cannot replace the approved reviewerIndependence policy".into(),
954        ));
955    }
956    ensure_contract_extends(
957        &state.mission.validation_contract,
958        &plan.validation_contract,
959    )?;
960    ensure_strings_extend(
961        "commandGrants",
962        &state.mission.command_grants,
963        &plan.command_grants,
964    )?;
965    ensure_strings_extend("touchSet", &state.mission.touch_set, &plan.touch_set)?;
966
967    let completed_prefix = state
968        .mission
969        .milestones
970        .iter()
971        .position(|m| m.status != MilestoneStatus::Complete)
972        .unwrap_or(state.mission.milestones.len());
973    if plan.milestones.len() < completed_prefix {
974        return Err(EngineError::InvalidState(
975            "plan.revised drops completed milestones".to_string(),
976        ));
977    }
978
979    let mut revised_milestones = Vec::new();
980    for (idx, existing) in state
981        .mission
982        .milestones
983        .iter()
984        .enumerate()
985        .take(completed_prefix)
986    {
987        let Some(plan_milestone) = plan.milestones.get(idx) else {
988            return Err(EngineError::InvalidState(format!(
989                "plan.revised drops completed milestone '{}'",
990                existing.title
991            )));
992        };
993        if !completed_milestone_matches(existing, plan_milestone) {
994            return Err(EngineError::InvalidState(format!(
995                "plan.revised alters completed milestone '{}'",
996                existing.title
997            )));
998        }
999        revised_milestones.push(existing.clone());
1000    }
1001
1002    for (idx, plan_milestone) in plan.milestones.iter().enumerate().skip(completed_prefix) {
1003        if let Some(existing) = state.mission.milestones.get(idx) {
1004            revised_milestones.push(merge_revised_milestone(existing, plan_milestone, revision));
1005        } else {
1006            revised_milestones.push(new_plan_milestone(idx, plan_milestone));
1007        }
1008    }
1009
1010    state.mission.goal = plan.goal.clone();
1011    state.mission.validation_contract = plan.validation_contract.clone();
1012    state.mission.command_grants = plan.command_grants.clone();
1013    state.mission.touch_set = plan.touch_set.clone();
1014    // The Flight Rules pin (KRZ-342 D-E) is NEVER re-read from a revision:
1015    // the planner never authors policy, and no revision flow re-validates a
1016    // carried manifest against the trusted source — folding one would let a
1017    // re-plan substitute weakened policy into the consent artifact. The
1018    // approval-time pin stands for the mission's life; an envelope escape is
1019    // caught by the final-validation check (which re-resolves the pinned
1020    // base snapshot against actual paths) and by the merge drift check.
1021    state.mission.milestones = revised_milestones;
1022    Ok(())
1023}
1024
1025/// Validate that a `PlanRevised { revision, plan }` event would fold cleanly
1026/// onto `state`, WITHOUT mutating it. The orchestrator calls this before it
1027/// durably appends the event — `emit` appends before it folds — so a revision
1028/// the reducer would reject is refused up front instead of poisoning the
1029/// append-only log. A failed fold on replay would otherwise error on every
1030/// subsequent load and permanently brick the mission.
1031pub fn dry_run_revised_plan(state: &MissionState, plan: &Plan, revision: u32) -> Result<()> {
1032    apply_revised_plan(&mut state.clone(), plan, revision)
1033}
1034
1035fn ensure_contract_extends(existing: &[Assertion], revised: &[Assertion]) -> Result<()> {
1036    for old in existing {
1037        let Some(new) = revised.iter().find(|a| a.id == old.id) else {
1038            return Err(EngineError::InvalidState(format!(
1039                "plan.revised removes validation assertion '{}'",
1040                old.id
1041            )));
1042        };
1043        if old.statement != new.statement
1044            || old.check != new.check
1045            || old.command != new.command
1046            || old.negative_control != new.negative_control
1047        {
1048            return Err(EngineError::InvalidState(format!(
1049                "plan.revised weakens or changes validation assertion '{}'",
1050                old.id
1051            )));
1052        }
1053    }
1054    Ok(())
1055}
1056
1057fn ensure_strings_extend(label: &str, existing: &[String], revised: &[String]) -> Result<()> {
1058    for old in existing {
1059        if !revised.iter().any(|new| new == old) {
1060            return Err(EngineError::InvalidState(format!(
1061                "plan.revised removes {label} entry '{old}'"
1062            )));
1063        }
1064    }
1065    Ok(())
1066}
1067
1068fn completed_milestone_matches(existing: &Milestone, revised: &PlanMilestone) -> bool {
1069    existing.title.trim() == revised.title.trim()
1070        && completed_features_match(&existing.features, &revised.features)
1071}
1072
1073/// Whether a completed milestone's features are reproduced UNCHANGED in a
1074/// revised plan: same count, and same title/spec/validation-criteria in order,
1075/// compared TRIMMED. This is the single source of truth for the "completed
1076/// work is frozen" rule — the orchestrator's pre-emit gate
1077/// (`completed_features_unchanged`) delegates here so the gate and the reducer
1078/// can never diverge. The leniency is deliberate: a regenerated plan will not
1079/// echo incidental whitespace back byte-for-byte, and whitespace is not a
1080/// content change. An exact compare here would let the gate accept a revision
1081/// the reducer then rejects, and because `emit` appends before it folds, that
1082/// leaves an unfoldable event in the append-only log and bricks the mission.
1083pub(crate) fn completed_features_match(existing: &[Feature], revised: &[PlanFeature]) -> bool {
1084    existing.len() == revised.len()
1085        && existing.iter().zip(revised).all(|(a, b)| {
1086            a.title.trim() == b.title.trim()
1087                && a.spec.trim() == b.spec.trim()
1088                && a.validation_criteria.len() == b.validation_criteria.len()
1089                && a.validation_criteria
1090                    .iter()
1091                    .zip(&b.validation_criteria)
1092                    .all(|(x, y)| x.trim() == y.trim())
1093        })
1094}
1095
1096fn merge_revised_milestone(
1097    existing: &Milestone,
1098    revised: &PlanMilestone,
1099    revision: u32,
1100) -> Milestone {
1101    let mut features = Vec::new();
1102    let mut new_count = 0usize;
1103    for feature in &existing.features {
1104        let matching = revised
1105            .features
1106            .iter()
1107            .find(|candidate| norm_title(&candidate.title) == norm_title(&feature.title));
1108        match (feature.status, matching) {
1109            (FeatureStatus::Pending, Some(plan_feature)) => {
1110                let mut updated = feature.clone();
1111                updated.title = plan_feature.title.clone();
1112                updated.spec = plan_feature.spec.clone();
1113                updated.validation_criteria = plan_feature.validation_criteria.clone();
1114                features.push(updated);
1115            }
1116            (FeatureStatus::Pending, None) => {
1117                let mut skipped = feature.clone();
1118                skipped.status = FeatureStatus::Skipped;
1119                features.push(skipped);
1120            }
1121            _ => features.push(feature.clone()),
1122        }
1123    }
1124
1125    for plan_feature in &revised.features {
1126        let already_present = existing
1127            .features
1128            .iter()
1129            .any(|feature| norm_title(&feature.title) == norm_title(&plan_feature.title));
1130        if !already_present {
1131            new_count += 1;
1132            features.push(Feature {
1133                id: format!("{}-rev-{revision}-{new_count}", existing.id),
1134                title: plan_feature.title.clone(),
1135                spec: plan_feature.spec.clone(),
1136                validation_criteria: plan_feature.validation_criteria.clone(),
1137                origin: FeatureOrigin::Plan,
1138                status: FeatureStatus::Pending,
1139                worker_runs: Vec::new(),
1140                commits: Vec::new(),
1141                respawns: 0,
1142            });
1143        }
1144    }
1145
1146    Milestone {
1147        id: existing.id.clone(),
1148        title: revised.title.clone(),
1149        features,
1150        status: existing.status,
1151        fix_cycles: existing.fix_cycles,
1152        start_sha: existing.start_sha.clone(),
1153        // A revision rebuilds the milestone but does not unblock it — folded
1154        // operator guidance survives, exactly like fix_cycles and start_sha.
1155        validator_guidance: existing.validator_guidance.clone(),
1156    }
1157}
1158
1159fn new_plan_milestone(idx: usize, plan_milestone: &PlanMilestone) -> Milestone {
1160    Milestone {
1161        id: format!("ms-{}", idx + 1),
1162        title: plan_milestone.title.clone(),
1163        features: plan_milestone
1164            .features
1165            .iter()
1166            .enumerate()
1167            .map(|(fi, feature)| Feature {
1168                id: format!("f-{}-{}", idx + 1, fi + 1),
1169                title: feature.title.clone(),
1170                spec: feature.spec.clone(),
1171                validation_criteria: feature.validation_criteria.clone(),
1172                origin: FeatureOrigin::Plan,
1173                status: FeatureStatus::Pending,
1174                worker_runs: Vec::new(),
1175                commits: Vec::new(),
1176                respawns: 0,
1177            })
1178            .collect(),
1179        status: MilestoneStatus::Pending,
1180        fix_cycles: 0,
1181        start_sha: None,
1182        validator_guidance: None,
1183    }
1184}
1185
1186fn norm_title(title: &str) -> String {
1187    title.trim().to_ascii_lowercase()
1188}
1189
1190fn milestone_mut<'a>(state: &'a mut MissionState, id: &str) -> Result<&'a mut Milestone> {
1191    state
1192        .mission
1193        .milestones
1194        .iter_mut()
1195        .find(|m| m.id == id)
1196        .ok_or_else(|| {
1197            EngineError::InvalidState(format!("event references unknown milestone '{id}'"))
1198        })
1199}
1200
1201fn feature_mut<'a>(state: &'a mut MissionState, id: &str) -> Result<&'a mut Feature> {
1202    state
1203        .mission
1204        .milestones
1205        .iter_mut()
1206        .flat_map(|m| m.features.iter_mut())
1207        .find(|f| f.id == id)
1208        .ok_or_else(|| {
1209            EngineError::InvalidState(format!("event references unknown feature '{id}'"))
1210        })
1211}
1212
1213fn run_mut<'a>(state: &'a mut MissionState, id: &str) -> Result<&'a mut WorkerRun> {
1214    state
1215        .runs
1216        .get_mut(id)
1217        .ok_or_else(|| EngineError::InvalidState(format!("event references unknown run '{id}'")))
1218}
1219
1220/// Recursive JSON merge: objects merge key-by-key, anything else in the patch
1221/// replaces the base value wholesale. `pub(crate)` so the provenance replay
1222/// (provenance.rs) evolves its tracked config by the SAME merge — a second
1223/// spelling of "how a config.changed patch applies" could drift from this one.
1224pub(crate) fn deep_merge(base: &mut serde_json::Value, patch: &serde_json::Value) {
1225    use serde_json::Value;
1226    match (base, patch) {
1227        (Value::Object(base_map), Value::Object(patch_map)) => {
1228            for (key, patch_value) in patch_map {
1229                deep_merge(
1230                    base_map.entry(key.clone()).or_insert(Value::Null),
1231                    patch_value,
1232                );
1233            }
1234        }
1235        (base_slot, patch_value) => *base_slot = patch_value.clone(),
1236    }
1237}
1238
1239// ---------------------------------------------------------------------------
1240// Snapshot cache (state.json)
1241// ---------------------------------------------------------------------------
1242
1243/// Serialize the state pretty-printed to a sibling tmp file, then atomically
1244/// rename over `path` so readers never observe a half-written snapshot.
1245pub fn write_snapshot(state: &MissionState, path: &Path) -> Result<()> {
1246    let file_name = path.file_name().ok_or_else(|| {
1247        EngineError::InvalidState(format!("snapshot path {} has no file name", path.display()))
1248    })?;
1249    let tmp_name = format!("{}.tmp", file_name.to_string_lossy());
1250    let (parent, pinned_name) = crate::paths::open_parent_nofollow(path)?;
1251
1252    // Refuse hostile leaves before opening. The no-follow open below is the
1253    // authoritative race-safe check; this metadata pass gives a useful error
1254    // for directories, fifos, devices, and pre-existing symlinks.
1255    for (name, display) in [
1256        (pinned_name.as_os_str(), path.to_path_buf()),
1257        (
1258            std::ffi::OsStr::new(&tmp_name),
1259            path.with_file_name(&tmp_name),
1260        ),
1261    ] {
1262        match parent.symlink_metadata(name) {
1263            Ok(metadata) if metadata.file_type().is_file() => {}
1264            Ok(_) => {
1265                return Err(EngineError::InvalidState(format!(
1266                    "refusing snapshot write through non-regular path {}",
1267                    display.display()
1268                )))
1269            }
1270            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1271            Err(error) => return Err(error.into()),
1272        }
1273    }
1274
1275    let json = serde_json::to_string_pretty(state)?;
1276    {
1277        use cap_fs_ext::OpenOptionsFollowExt as _;
1278        use cap_primitives::fs::FollowSymlinks;
1279        let mut options = cap_std::fs::OpenOptions::new();
1280        options
1281            .write(true)
1282            .create(true)
1283            .truncate(true)
1284            .follow(FollowSymlinks::No);
1285        let mut file = parent.open_with(&tmp_name, &options)?.into_std();
1286        std::io::Write::write_all(&mut file, json.as_bytes())?;
1287        file.sync_data()?;
1288    }
1289    parent.rename(&tmp_name, &parent, &pinned_name)?;
1290    Ok(())
1291}
1292
1293/// Read a snapshot previously written by [`write_snapshot`]. A symlinked
1294/// `state.json` — or any symlinked component above it — is refused (P1
1295/// mission-path-no-follow), never read through: mission-layout paths are
1296/// pinned capability-relative from the trusted repo-root anchor (7th-pass
1297/// review); out-of-layout paths (test scratch) use the weaker
1298/// canonicalize tier — see [`crate::paths::open_read_nofollow`].
1299pub fn read_snapshot(path: &Path) -> Result<MissionState> {
1300    use std::io::Read;
1301    let mut content = String::new();
1302    crate::paths::open_read_nofollow(path)?.read_to_string(&mut content)?;
1303    Ok(serde_json::from_str(&content)?)
1304}
1305
1306#[cfg(test)]
1307mod negative_control_tests {
1308    use super::*;
1309
1310    fn event(seq: u64, kind: EventKind) -> Event {
1311        Event {
1312            seq,
1313            ts: chrono::Utc::now(),
1314            mission_id: "m-controls".into(),
1315            kind,
1316        }
1317    }
1318
1319    fn plan() -> Plan {
1320        serde_json::from_value(serde_json::json!({
1321            "goal": "control tests",
1322            "validationContract": [{
1323                "id": "a-1", "statement": "reject the defect", "check": "command", "command": "sh check.sh",
1324                "negativeControl": {
1325                    "checkerFiles": [{"path": "check.sh", "content": "check"}],
1326                    "validFiles": [{"path": "value.txt", "content": "valid"}],
1327                    "defectiveFiles": [{"path": "value.txt", "content": "defect"}],
1328                    "expectedFailure": "wrong-value"
1329                }
1330            }],
1331            "milestones": [{"title": "one", "features": [{"title": "change", "spec": "change", "validationCriteria": []}]}]
1332        })).unwrap()
1333    }
1334
1335    fn approved(plan: &Plan) -> Result<MissionState> {
1336        fold(&[
1337            event(
1338                1,
1339                EventKind::MissionCreated {
1340                    goal: plan.goal.clone(),
1341                    base_branch: "main".into(),
1342                    mission_branch: "kranz/mission-m-controls".into(),
1343                    config: MissionConfig::default(),
1344                },
1345            ),
1346            event(
1347                2,
1348                EventKind::PlanApproved {
1349                    plan: plan.clone(),
1350                    base_sha: Some("base".into()),
1351                },
1352            ),
1353        ])
1354    }
1355
1356    #[test]
1357    fn negative_control_replay_and_revision_validation_agree() {
1358        let plan = plan();
1359        let state = approved(&plan).unwrap();
1360        dry_run_revised_plan(&state, &plan, 1).unwrap();
1361        let mut added = plan.clone();
1362        let mut extra = added.validation_contract[0].clone();
1363        extra.id = "a-2".into();
1364        added.validation_contract.push(extra);
1365        crate::planning::validate_revised_plan_for_gate(&state.mission, &added).unwrap();
1366        dry_run_revised_plan(&state, &added, 1).unwrap();
1367        for mutation in ["removed", "changed", "invalid-new"] {
1368            let mut revised = plan.clone();
1369            match mutation {
1370                "removed" => revised.validation_contract[0].negative_control = None,
1371                "changed" => {
1372                    revised.validation_contract[0]
1373                        .negative_control
1374                        .as_mut()
1375                        .unwrap()
1376                        .expected_failure = "other-defect".into()
1377                }
1378                _ => {
1379                    revised = added.clone();
1380                    revised.validation_contract[1]
1381                        .negative_control
1382                        .as_mut()
1383                        .unwrap()
1384                        .timeout_seconds = 0;
1385                }
1386            }
1387            assert!(
1388                crate::planning::validate_revised_plan_for_gate(&state.mission, &revised).is_err(),
1389                "pre-emit {mutation}"
1390            );
1391            assert!(
1392                dry_run_revised_plan(&state, &revised, 1).is_err(),
1393                "fold {mutation}"
1394            );
1395        }
1396        let mut malformed = plan;
1397        malformed.validation_contract[0]
1398            .negative_control
1399            .as_mut()
1400            .unwrap()
1401            .timeout_seconds = 0;
1402        assert!(
1403            approved(&malformed).is_err(),
1404            "new malformed control cannot enter via plan.approved replay"
1405        );
1406    }
1407
1408    #[test]
1409    fn negative_control_checks_preserve_legacy_pty_revision_replay() {
1410        let mut old = plan();
1411        old.validation_contract = serde_json::from_value(serde_json::json!([{
1412            "id": "pty", "statement": "legacy terminal check", "check": "pty-script",
1413            "ptyScript": {"command": "old", "steps": []}
1414        }]))
1415        .unwrap();
1416        let state = approved(&old).unwrap();
1417        assert!(state.mission.validation_contract[0]
1418            .negative_control
1419            .is_none());
1420        let mut revised = old;
1421        revised.validation_contract[0]
1422            .pty_script
1423            .as_mut()
1424            .unwrap()
1425            .command = "new".into();
1426        crate::planning::validate_revised_plan_for_gate(&state.mission, &revised).unwrap();
1427        dry_run_revised_plan(&state, &revised, 1).unwrap();
1428    }
1429}
1430
1431#[cfg(test)]
1432mod executor_tier_tests {
1433    use super::*;
1434    use crate::events::EventKind;
1435
1436    fn created_event(config: MissionConfig) -> Event {
1437        Event {
1438            seq: 1,
1439            ts: chrono::Utc::now(),
1440            mission_id: "m-test".to_string(),
1441            kind: EventKind::MissionCreated {
1442                goal: "ship the thing".to_string(),
1443                base_branch: "main".to_string(),
1444                mission_branch: "kranz/mission-m-test".to_string(),
1445                config,
1446            },
1447        }
1448    }
1449
1450    #[test]
1451    fn executor_routing_applies_local_worker_backend_folds_to_local_tier() {
1452        let mut config = MissionConfig::default();
1453        config.worker.backend = Some("local".to_string());
1454
1455        let state = fold(&[created_event(config)]).unwrap();
1456
1457        assert_eq!(state.executor_tier(), ExecutorTier::Local);
1458    }
1459
1460    #[test]
1461    fn executor_routing_applies_non_local_worker_backend_folds_to_frontier_tier() {
1462        let mut config = MissionConfig::default();
1463        config.worker.backend = Some("codex".to_string());
1464
1465        let state = fold(&[created_event(config)]).unwrap();
1466
1467        assert_eq!(state.executor_tier(), ExecutorTier::Frontier);
1468    }
1469
1470    #[test]
1471    fn validator_stays_frontier_regardless_of_worker_routing() {
1472        let mut config = MissionConfig::default();
1473        config.worker.backend = Some("local".to_string());
1474
1475        let state = fold(&[created_event(config)]).unwrap();
1476
1477        assert_eq!(
1478            state.config.backend_kind(Role::ValidatorScrutiny),
1479            BackendKind::Claude
1480        );
1481        assert_eq!(
1482            state.config.backend_kind(Role::ValidatorFunctional),
1483            BackendKind::Claude
1484        );
1485    }
1486}
1487
1488#[cfg(test)]
1489mod hook_gate_projection_tests {
1490    use super::*;
1491    use crate::events::EventKind;
1492
1493    fn event(seq: u64, kind: EventKind) -> Event {
1494        Event {
1495            seq,
1496            ts: chrono::Utc::now(),
1497            mission_id: "m-test".to_string(),
1498            kind,
1499        }
1500    }
1501
1502    fn spawned(seq: u64, run_id: &str) -> Event {
1503        event(
1504            seq,
1505            EventKind::WorkerSpawned {
1506                backend: None,
1507                run_id: run_id.to_string(),
1508                role: Role::Worker,
1509                feature_id: None,
1510                milestone_id: None,
1511                candidate: None,
1512                executor_route: None,
1513                sdk_session_id: "s-1".to_string(),
1514                model: "m".to_string(),
1515                quant: "n/a".to_string(),
1516                weight_hash: None,
1517                prompt_hash: "h".to_string(),
1518                transcript_path: "runs/r-1.jsonl".to_string(),
1519            },
1520        )
1521    }
1522
1523    /// The hook.gate.fired fold arm is record-only (KRZ-302): the run
1524    /// reference is validated as a corruption guard, and state shape does
1525    /// not grow — the mission's course is unchanged by the in-process
1526    /// verdict (the engine-side sweep remains the authoritative layer).
1527    #[test]
1528    fn hook_gate_projection_event_folds_record_only() {
1529        let created = event(
1530            1,
1531            EventKind::MissionCreated {
1532                goal: "g".to_string(),
1533                base_branch: "main".to_string(),
1534                mission_branch: "kranz/mission-m-test".to_string(),
1535                config: MissionConfig::default(),
1536            },
1537        );
1538        let fired = event(
1539            3,
1540            EventKind::HookGateFired {
1541                run_id: "r-1".to_string(),
1542                gate: "out-of-contract-write".to_string(),
1543                hook_event: "PreToolUse".to_string(),
1544                tool: "Write".to_string(),
1545                subject: "docs/oops.md".to_string(),
1546                verdict: "blocked".to_string(),
1547                detail: Some("outside the touch set".to_string()),
1548            },
1549        );
1550
1551        let with_hook = fold(&[created.clone(), spawned(2, "r-1"), fired]).unwrap();
1552        let without_hook = fold(&[created, spawned(2, "r-1")]).unwrap();
1553
1554        // No state transition: identical mission status, run set, and run
1555        // outcome with and without the hook event.
1556        assert_eq!(with_hook.mission.status, without_hook.mission.status);
1557        assert_eq!(with_hook.runs.len(), 1);
1558        assert!(with_hook.runs["r-1"].result.is_none());
1559        assert_eq!(
1560            with_hook.mission.milestones.len(),
1561            without_hook.mission.milestones.len()
1562        );
1563    }
1564
1565    /// The run reference is a corruption guard: a hook.gate.fired naming a
1566    /// run the log never recorded refuses the fold (the run id is
1567    /// engine-stamped at fold time, so this can only be log corruption).
1568    #[test]
1569    fn hook_gate_projection_event_with_unknown_run_is_refused() {
1570        let created = event(
1571            1,
1572            EventKind::MissionCreated {
1573                goal: "g".to_string(),
1574                base_branch: "main".to_string(),
1575                mission_branch: "kranz/mission-m-test".to_string(),
1576                config: MissionConfig::default(),
1577            },
1578        );
1579        let bogus = event(
1580            2,
1581            EventKind::HookGateFired {
1582                run_id: "no-such-run".to_string(),
1583                gate: "out-of-contract-write".to_string(),
1584                hook_event: "PreToolUse".to_string(),
1585                tool: "Write".to_string(),
1586                subject: "docs/oops.md".to_string(),
1587                verdict: "blocked".to_string(),
1588                detail: None,
1589            },
1590        );
1591        assert!(fold(&[created, bogus]).is_err());
1592    }
1593}
1594
1595#[cfg(test)]
1596mod routing_abstraction_tests {
1597    use super::*;
1598    use crate::events::EventKind;
1599
1600    fn event(seq: u64, kind: EventKind) -> Event {
1601        Event {
1602            seq,
1603            ts: chrono::Utc::now(),
1604            mission_id: "m-test".to_string(),
1605            kind,
1606        }
1607    }
1608
1609    fn created_with_local_worker() -> Event {
1610        let mut config = MissionConfig::default();
1611        config.worker.backend = Some("local".to_string());
1612        event(
1613            1,
1614            EventKind::MissionCreated {
1615                goal: "g".to_string(),
1616                base_branch: "main".to_string(),
1617                mission_branch: "kranz/mission-m-test".to_string(),
1618                config,
1619            },
1620        )
1621    }
1622
1623    fn spawned(seq: u64, run_id: &str) -> Event {
1624        event(
1625            seq,
1626            EventKind::WorkerSpawned {
1627                backend: None,
1628                run_id: run_id.to_string(),
1629                role: Role::Worker,
1630                feature_id: None,
1631                milestone_id: None,
1632                candidate: None,
1633                executor_route: None,
1634                sdk_session_id: "s-1".to_string(),
1635                model: "m".to_string(),
1636                quant: "n/a".to_string(),
1637                weight_hash: None,
1638                prompt_hash: "h".to_string(),
1639                transcript_path: "runs/r-1.jsonl".to_string(),
1640            },
1641        )
1642    }
1643
1644    /// The worker.escalated fold arm is record-only (KRZ-331, the gate.result
1645    /// template): a worker escalation NEVER bypasses the floor's validator
1646    /// requirements — the validator route, the executor tier, and every
1647    /// decision-keyed counter fold exactly as if the event were absent
1648    /// (contrast tier.escalated, the orchestrator-initiated valve, which
1649    /// deliberately rewrites worker config). Only the run reference is
1650    /// validated, as a corruption guard (mirrors hook.gate.fired).
1651    #[test]
1652    fn routing_abstraction_escalation_folds_record_only_leaving_validators_untouched() {
1653        let escalated = event(
1654            3,
1655            EventKind::WorkerEscalated {
1656                run_id: "r-1".to_string(),
1657                feature_id: "f-1-1".to_string(),
1658                from: ExecutorTier::Local,
1659                to: ExecutorTier::Frontier,
1660                reason: "spec ambiguity beyond my confidence".to_string(),
1661            },
1662        );
1663
1664        let with = fold(&[created_with_local_worker(), spawned(2, "r-1"), escalated]).unwrap();
1665        let without = fold(&[created_with_local_worker(), spawned(2, "r-1")]).unwrap();
1666
1667        // The WHOLE config is identical with and without the escalation —
1668        // validator backends are inside it, so this pins "validator route
1669        // unaffected" exactly, not by a sampled field.
1670        assert_eq!(with.config, without.config);
1671        assert_eq!(
1672            with.config.backend_kind(Role::ValidatorScrutiny),
1673            BackendKind::Claude
1674        );
1675        assert_eq!(
1676            with.config.backend_kind(Role::ValidatorFunctional),
1677            BackendKind::Claude
1678        );
1679        // The worker escalation never flips the executor tier (that flip is
1680        // tier.escalated's job, and it is orchestrator-initiated only).
1681        assert_eq!(with.executor_tier(), ExecutorTier::Local);
1682        // No state transition of any kind: same mission status, same run
1683        // set, same escalation counters.
1684        assert_eq!(with.mission.status, without.mission.status);
1685        assert_eq!(with.runs.len(), without.runs.len());
1686        assert_eq!(with.escalated_milestones, without.escalated_milestones);
1687        assert_eq!(
1688            with.local_executor_milestones,
1689            without.local_executor_milestones
1690        );
1691    }
1692
1693    /// The run reference is a corruption guard: a worker.escalated naming a
1694    /// run the log never recorded refuses the fold (the run id is
1695    /// engine-stamped at emit time, so this can only be log corruption).
1696    #[test]
1697    fn routing_abstraction_escalation_with_unknown_run_is_refused() {
1698        let bogus = event(
1699            2,
1700            EventKind::WorkerEscalated {
1701                run_id: "no-such-run".to_string(),
1702                feature_id: "f-1-1".to_string(),
1703                from: ExecutorTier::Local,
1704                to: ExecutorTier::Frontier,
1705                reason: "r".to_string(),
1706            },
1707        );
1708        let mut config = MissionConfig::default();
1709        config.worker.backend = Some("local".to_string());
1710        let created = event(
1711            1,
1712            EventKind::MissionCreated {
1713                goal: "g".to_string(),
1714                base_branch: "main".to_string(),
1715                mission_branch: "kranz/mission-m-test".to_string(),
1716                config,
1717            },
1718        );
1719        assert!(fold(&[created, bogus]).is_err());
1720    }
1721}