Skip to main content

kranz_engine/
events.rs

1//! Event envelope and event set (plan §4.3) — the resumability backbone.
2//!
3//! CONTRACT FILE — do not modify in implementation phases. If a change seems
4//! necessary, report it instead of editing.
5//!
6//! Every line of `events.jsonl` is one `Event` serialized as:
7//! `{ "seq": 412, "ts": "...", "missionId": "m-01", "type": "worker.completed", "payload": { ... } }`
8//!
9//! Rules (§4.3):
10//! - `seq` is monotonically increasing, assigned by the single writer (the
11//!   engine). Gaps are a corruption signal; the loader must detect and refuse.
12//! - Every status value in the data model must be reachable through some
13//!   event, or the reducer has a dead state (enforced by test).
14
15use crate::types::*;
16use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase")]
21pub struct Event {
22    pub seq: u64,
23    pub ts: DateTime<Utc>,
24    pub mission_id: String,
25    #[serde(flatten)]
26    pub kind: EventKind,
27}
28
29/// Default `quant` for worker.spawned events predating provenance fields.
30fn default_quant() -> String {
31    "n/a".to_string()
32}
33
34// Missing field means a legacy event; explicit null is not an escape hatch
35// from typed ownership. Deserialize the present value as a context, not Option.
36fn deserialize_block_context<'de, D>(
37    deserializer: D,
38) -> std::result::Result<Option<BlockContext>, D::Error>
39where
40    D: serde::Deserializer<'de>,
41{
42    BlockContext::deserialize(deserializer).map(Some)
43}
44
45/// Serialized as `"type": "<dotted.name>", "payload": { ... }`.
46// large_enum_variant: MissionCreated carries the full MissionConfig (~456B).
47// It occurs once per mission and events are I/O-bound; boxing would ripple
48// through every construction/match site for no measurable win.
49#[allow(clippy::large_enum_variant)]
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(tag = "type", content = "payload")]
52pub enum EventKind {
53    #[serde(rename = "mission.created")]
54    MissionCreated {
55        goal: String,
56        #[serde(rename = "baseBranch")]
57        base_branch: String,
58        #[serde(rename = "missionBranch")]
59        mission_branch: String,
60        config: MissionConfig,
61    },
62
63    #[serde(rename = "plan.approved")]
64    PlanApproved {
65        plan: Plan,
66        /// Base-branch commit SHA pinned at approval time (validation
67        /// contract diffs against this, not the moving base branch).
68        #[serde(rename = "baseSha", default, skip_serializing_if = "Option::is_none")]
69        base_sha: Option<String>,
70    },
71
72    #[serde(rename = "plan.revision.proposed")]
73    PlanRevisionProposed {
74        revision: u32,
75        plan: Plan,
76        instructions: String,
77    },
78
79    #[serde(rename = "plan.revised")]
80    PlanRevised { revision: u32, plan: Plan },
81
82    #[serde(rename = "plan.revision.rejected")]
83    PlanRevisionRejected { revision: u32, reason: String },
84
85    /// A run was stopped by a capability boundary and parks the milestone's
86    /// validation for an operator approve/deny decision — the capability-denial
87    /// analogue of `plan.revision.proposed`. `kind` selects the boundary: a
88    /// `command` (validator command outside its allow-set → `command_grants`),
89    /// a `touch-path` (worker write outside the `touch_set` → `touch_set`), a
90    /// `worker-deny` (worker command blocked by a deny rule → `deny_exceptions`),
91    /// or an `egress` (sandboxed run refused a destination by the egress proxy
92    /// → `egress_grants`). Validators/sweeps are keyed to a milestone (they
93    /// diff its start..HEAD), so this is too. Deny is the default; an
94    /// unanswered request times out to `grant.denied`. `command` holds the
95    /// target (a command, a path glob, a deny rule, or `host:port`).
96    #[serde(rename = "grant.requested")]
97    GrantRequested {
98        #[serde(rename = "milestoneId")]
99        milestone_id: String,
100        #[serde(default)]
101        kind: crate::types::GrantKind,
102        command: String,
103    },
104
105    /// Operator approved the parked grant. The reducer extends the list `kind`
106    /// selects (`command_grants`, `touch_set`, `deny_exceptions`, or
107    /// `egress_grants`), extend-only, so the retried run clears the boundary.
108    #[serde(rename = "grant.approved")]
109    GrantApproved {
110        #[serde(default)]
111        kind: crate::types::GrantKind,
112        command: String,
113    },
114
115    /// Operator denied the parked grant, or it timed out (deny-default). A
116    /// denied command or egress grant blocks the milestone (refusal); a denied
117    /// touch-path grant lets the out-of-contract write flow to the normal
118    /// fix/waive path.
119    #[serde(rename = "grant.denied")]
120    GrantDenied {
121        #[serde(default)]
122        kind: crate::types::GrantKind,
123        command: String,
124        reason: String,
125    },
126
127    #[serde(rename = "milestone.started")]
128    MilestoneStarted {
129        #[serde(rename = "milestoneId")]
130        milestone_id: String,
131        /// SHA at milestone start; validators diff start..HEAD (§4.4).
132        #[serde(rename = "startSha")]
133        start_sha: String,
134    },
135
136    #[serde(rename = "feature.started")]
137    FeatureStarted {
138        #[serde(rename = "featureId")]
139        feature_id: String,
140    },
141
142    /// Engine-observed sequential feature baseline and cumulative commit receipts.
143    /// Recorded before execution and after checkpoints, so retries and resume
144    /// cannot turn retained work into an apparently commitless feature.
145    #[serde(rename = "feature.progress")]
146    FeatureProgress {
147        #[serde(rename = "featureId")]
148        feature_id: String,
149        #[serde(rename = "baseSha")]
150        base_sha: String,
151        commits: Vec<String>,
152    },
153
154    #[serde(rename = "worker.spawned")]
155    WorkerSpawned {
156        #[serde(rename = "runId")]
157        run_id: String,
158        role: Role,
159        #[serde(rename = "featureId", skip_serializing_if = "Option::is_none")]
160        feature_id: Option<String>,
161        #[serde(rename = "milestoneId", skip_serializing_if = "Option::is_none")]
162        milestone_id: Option<String>,
163        /// Sibling-candidate linkage when this run is one stream of a
164        /// heterogeneous dispatch pool (KRZ-303): which unit it belongs to,
165        /// the stream's index, N, and the backend it ran. Additive; absent on
166        /// ordinary runs and in every pre-pool log — `None` never hits the
167        /// wire. Carried on `worker.spawned` (not `worker.completed`) so the
168        /// run is a labelled candidate from the moment it exists.
169        #[serde(default, skip_serializing_if = "Option::is_none")]
170        candidate: Option<CandidateLink>,
171        /// The effective executor route and the rule that decided it (ticket
172        /// `routing-rules-config`): routing is provenance, not a hidden
173        /// implementation detail, so it rides the same event that already
174        /// records the model. Additive; present only on Worker-role spawns of
175        /// missions whose seed carried a task class — absent everywhere else
176        /// and in every pre-provenance log, where it folds to `None` and
177        /// `None` never hits the wire.
178        #[serde(
179            rename = "executorRoute",
180            default,
181            skip_serializing_if = "Option::is_none"
182        )]
183        executor_route: Option<crate::types::ExecutorRoute>,
184        #[serde(rename = "sdkSessionId")]
185        sdk_session_id: String,
186        model: String,
187        /// Actual dispatch backend after resolution/fallback; absent in old logs.
188        #[serde(default, skip_serializing_if = "Option::is_none")]
189        backend: Option<crate::types::BackendKind>,
190        /// Quantization of the model weights used for this run (provenance).
191        #[serde(default = "default_quant")]
192        quant: String,
193        /// Hash of the model weights used for this run, when known (provenance).
194        #[serde(
195            rename = "weightHash",
196            default,
197            skip_serializing_if = "Option::is_none"
198        )]
199        weight_hash: Option<String>,
200        #[serde(rename = "promptHash")]
201        prompt_hash: String,
202        #[serde(rename = "transcriptPath")]
203        transcript_path: String,
204    },
205
206    /// Throttled stream deltas; also carries `denied` guardrail hits (§4.7).
207    #[serde(rename = "worker.message")]
208    WorkerMessage {
209        #[serde(rename = "runId")]
210        run_id: String,
211        /// "text" | "tool-use" | "tool-result" | "denied" | "system"
212        tag: String,
213        /// Scrubbed + truncated human-readable content.
214        content: String,
215    },
216
217    /// Durable, run-attributed audit record for destinations refused by the
218    /// filtering egress proxy. Record-only: grant handling still uses
219    /// the in-memory [`crate::egress_proxy::EgressDenial`] returned by the
220    /// session, while this event survives runtime-artifact cleanup and can
221    /// be projected as bounded validator evidence.
222    #[serde(rename = "worker.egress.denied")]
223    WorkerEgressDenied {
224        #[serde(rename = "runId")]
225        run_id: String,
226        denials: Vec<crate::egress_proxy::EgressDenial>,
227        /// Repeated or over-cap denial records excluded from `denials`.
228        /// Additive default keeps an early/pre-field event readable.
229        #[serde(rename = "omittedCount", default)]
230        omitted_count: u64,
231    },
232
233    #[serde(rename = "worker.completed")]
234    WorkerCompleted {
235        #[serde(rename = "runId")]
236        run_id: String,
237        result: RunResult,
238        tokens: TokenUsage,
239        #[serde(rename = "costUsd", skip_serializing_if = "Option::is_none")]
240        cost_usd: Option<f64>,
241        #[serde(skip_serializing_if = "Option::is_none")]
242        report: Option<WorkerReport>,
243    },
244
245    #[serde(rename = "feature.completed")]
246    FeatureCompleted {
247        #[serde(rename = "featureId")]
248        feature_id: String,
249        commits: Vec<String>,
250    },
251
252    #[serde(rename = "feature.failed")]
253    FeatureFailed {
254        #[serde(rename = "featureId")]
255        feature_id: String,
256        reason: String,
257        /// Commits the failed feature landed on the mission branch before the
258        /// judgement (empty for a run that never committed — the m-eee81f
259        /// auth-death class — and for parallel/dirty-tree paths where nothing
260        /// reached the branch). Recorded so the supersession guard can tell
261        /// "failed with real work" (started; re-proposal rejects) from
262        /// "failed commitless" (re-proposable). Additive; old logs default
263        /// to empty.
264        #[serde(default)]
265        commits: Vec<String>,
266    },
267
268    #[serde(rename = "feature.skipped")]
269    FeatureSkipped {
270        #[serde(rename = "featureId")]
271        feature_id: String,
272        reason: String,
273    },
274
275    #[serde(rename = "milestone.validating")]
276    MilestoneValidating {
277        #[serde(rename = "milestoneId")]
278        milestone_id: String,
279    },
280
281    #[serde(rename = "validation.finding")]
282    ValidationFinding {
283        #[serde(rename = "milestoneId")]
284        milestone_id: String,
285        #[serde(rename = "runId")]
286        run_id: String,
287        finding: Finding,
288    },
289
290    /// A validator session altered its checkout (validator immutability
291    /// proof, ticket `validator-immutability-proof`): the HEAD/index/worktree
292    /// identity assertion around every validator session found drift, so the
293    /// round failed honestly — the milestone blocks, with no retry and no
294    /// waivable finding. The payload records WHAT changed: HEAD before/after
295    /// and the `git status --porcelain` entries gained/lost across the
296    /// session. Additive event; absent in pre-field logs.
297    #[serde(rename = "validator.tamper")]
298    ValidatorTamper {
299        #[serde(rename = "milestoneId")]
300        milestone_id: String,
301        #[serde(rename = "runId")]
302        run_id: String,
303        role: Role,
304        /// HEAD when the session started.
305        #[serde(rename = "headBefore")]
306        head_before: String,
307        /// HEAD when the session ended (== headBefore unless the session
308        /// moved it, e.g. a validator-run `git commit`).
309        #[serde(rename = "headAfter")]
310        head_after: String,
311        /// Porcelain entries present after but not before (the session's
312        /// writes: ` M <path>`, `A  <path>`, `?? <path>`, …).
313        appeared: Vec<String>,
314        /// Porcelain entries present before but not after (the session
315        /// reverted or hid a pre-existing dirty state — equally a mutation).
316        resolved: Vec<String>,
317        /// Whether `.git` metadata (config/hooks/refs) changed across the
318        /// session — the checkout can look identical while the plumbing was
319        /// weaponized (`core.fsmonitor`/`core.hooksPath` execute on the
320        /// ENGINE's own git invocations; a moved ref retargets later merges).
321        #[serde(default, rename = "gitMetadataChanged")]
322        git_metadata_changed: bool,
323        /// WHICH metadata surfaces changed (additive): `config` / `hooks` /
324        /// `refs` / `index-flags` / `info-exclude` — a tripwire fire is
325        /// diagnosable from the event alone.
326        #[serde(default, rename = "gitMetadataFields")]
327        git_metadata_fields: Vec<String>,
328    },
329
330    /// A validator session ran in a throwaway snapshot of the session
331    /// checkout (copy-on-write immutable validator snapshot, the follow-up
332    /// to ticket `validator-immutability-proof`; module
333    /// [`crate::validator_snapshot`]): HEAD plus the worker's uncommitted
334    /// diff and untracked files, a warmed `target/` copy, discarded after
335    /// the session regardless of outcome. The payload records the snapshot
336    /// path, which target-copy tier warmed it, and the creation cost.
337    /// Additive event; absent in pre-field logs.
338    #[serde(rename = "validation.snapshot")]
339    ValidationSnapshot {
340        #[serde(rename = "milestoneId")]
341        milestone_id: String,
342        role: Role,
343        /// Absolute path of the (already discarded) snapshot worktree,
344        /// under the mission's gitignored `runs/` scratch.
345        path: String,
346        /// How the snapshot's `target/` was warmed: "clonefile", "reflink",
347        /// "copy", "fresh" (empty target — the cost is named in `detail`),
348        /// or "absent" (no target/ in the session checkout).
349        #[serde(rename = "targetTier")]
350        target_tier: String,
351        /// Wall-clock cost of building the snapshot (worktree add + diff
352        /// apply + untracked copy + target warm), in milliseconds.
353        #[serde(rename = "creationMs")]
354        creation_ms: u64,
355        /// Extra context — notably the named cost when `targetTier` is
356        /// "fresh".
357        #[serde(default)]
358        detail: Option<String>,
359    },
360
361    /// Local-validator confirm-on-pass (ticket
362    /// `local-inference-validator-guarded`, KRZ-206b; review addendum §4 of
363    /// docs/scoping/local-inference-executor-tier.md): a LOCAL functional
364    /// validator's PASS never greens a gate alone — a frontier functional
365    /// session re-judged the same milestone and engine-captured
366    /// contract-command evidence, and this event records the comparison.
367    /// `confirmed` names the contract-command assertions both tiers pass;
368    /// `disagreements` carries every frontier finding on a subject the local
369    /// report passed (a local PASS vs frontier FAIL — the miss), each of
370    /// which ALSO lands as a `validation.finding` and fails closed into the
371    /// round as the frontier verdict. A local FAIL never triggers this
372    /// event: failures are visible (they cost a fix cycle), misses are the
373    /// danger — the asymmetry is deliberate.
374    ///
375    /// The confirmations ARE the local-vs-frontier miss-rate ground truth
376    /// the ticket's start precondition demands: misses = disagreement
377    /// subjects, opportunities = confirmed + disagreement command
378    /// assertions + `judgmentOpportunity` (0/1), all computable from the
379    /// log alone (join `localRunId` / `confirmRunId` against
380    /// `worker.spawned` for the models). Additive event; absent in
381    /// pre-field logs, which simply have no local-validator confirmations
382    /// to measure.
383    #[serde(rename = "validation.confirm")]
384    ValidationConfirm {
385        #[serde(rename = "milestoneId")]
386        milestone_id: String,
387        /// Run id of the LOCAL functional session whose PASS was confirmed.
388        #[serde(rename = "localRunId")]
389        local_run_id: String,
390        /// Run id of the FRONTIER confirmation session.
391        #[serde(rename = "confirmRunId")]
392        confirm_run_id: String,
393        /// Contract-command assertion ids both the local report and the
394        /// frontier confirmation pass.
395        confirmed: Vec<String>,
396        /// Frontier findings on subjects the local report passed — the
397        /// misses. Failed closed: each stands as the round's verdict.
398        disagreements: Vec<Finding>,
399        /// True when the confirmed PASS was JUDGMENT-only: a contract with
400        /// no command assertions hands the local session pure judgment, and
401        /// its all-clean report is confirmed exactly like a command-
402        /// assertion PASS — but there are no assertion ids to list, so
403        /// `confirmed`/`disagreements` alone would record ZERO opportunities
404        /// for a confirmation that covered one, silently undercounting the
405        /// miss-rate denominator (14th-pass review). Additive; absent
406        /// (= false) in logs predating the field, which simply never
407        /// recorded a judgment-only confirmation.
408        #[serde(default, rename = "judgmentOpportunity")]
409        judgment_opportunity: bool,
410    },
411
412    /// Pty-driven functional validation: one engine-run pty-script contract
413    /// assertion (ticket `pty-functional-validation`, module
414    /// [`crate::pty_harness`]) produced a bounded session transcript under
415    /// the mission's gitignored `runs/pty-transcripts/`; this audit record
416    /// names the milestone, the assertion, the stated verdict, and the
417    /// transcript's `file:`-schemed mission-relative reference (the
418    /// [`crate::gate_results`] ArtefactRef idiom — mission-relative, never
419    /// an absolute host path, resolving to "unresolved" rather than erroring
420    /// once the bytes are pruned). Record-only: the verdict reaches the
421    /// round through the functional validator's evidence block, not through
422    /// this event, so the reducer treats it as an audit record exactly like
423    /// `validation.snapshot`. Additive event; absent in pre-field logs,
424    /// which simply have no pty-driven validations.
425    #[serde(rename = "validation.pty.transcript")]
426    ValidationPtyTranscript {
427        #[serde(rename = "milestoneId")]
428        milestone_id: String,
429        /// The contract assertion the session drove.
430        #[serde(rename = "assertionId")]
431        assertion_id: String,
432        /// The verdict the harness stated — pass (every expect matched) or
433        /// fail (the failing session's transcript is the evidence).
434        verdict: crate::gate::GateVerdict,
435        /// The `file:`-schemed mission-relative transcript path.
436        #[serde(rename = "artefactRef")]
437        artefact_ref: String,
438        /// The per-step summary (contract-authored patterns and timings —
439        /// no raw target output).
440        #[serde(default, skip_serializing_if = "Option::is_none")]
441        detail: Option<String>,
442    },
443
444    /// One gate evaluation, recorded as a first-class event (ticket
445    /// `.kranz/tickets/gate-results-first-class-events`, KRZ-312 — the
446    /// governance evidence layer's last substrate gap before
447    /// provenance-replay). Every [`crate::gate::GatePipeline`] evaluation
448    /// emits one of these per gate, in pipeline order, carrying the gate id,
449    /// its ladder position, the stated verdict, and the artefact handle —
450    /// so the mission's full gate ladder replays from the log alone, with no
451    /// dependency on external state that may have moved. Record-only: the
452    /// reducer treats it as an audit record (like `secret.redacted`), never
453    /// a state transition, so old logs without any gate.result fold
454    /// unchanged.
455    ///
456    /// WHY `surface` is a first-class field: the same gate id is evaluated
457    /// more than once per mission (approval and final gate), so gate id +
458    /// ladder position cannot name ONE evaluation — and reconstructing the
459    /// surface from neighbouring events would couple replay to emission
460    /// order, exactly the external-state fragility this event abolishes.
461    ///
462    /// WHY there is no separate `section` field: [`crate::gate::GateKind`]
463    /// selects the pipeline section one-to-one (gate.rs), so `kind` doubles
464    /// as the section discriminator; `index` is the zero-based evaluation
465    /// position WITHIN that section.
466    ///
467    /// Artefact discipline (ticket text): `artefactRef` is mission-relative
468    /// or content-addressed, NEVER an absolute host path — a `file:`-schemed
469    /// mission-relative path when the evidence is a file
470    /// ([`crate::gate_results`]), or the gate-local handle verbatim (a
471    /// command line, a description) when the evidence is inherently textual.
472    /// A reference whose bytes are gone resolves to "unresolved", never to
473    /// an error that blocks replay.
474    #[serde(rename = "gate.result")]
475    GateResult {
476        /// Gate identity: the registered `Gate::name()` — e.g. a defect-class
477        /// name (`vacuous-filter`), a pack gate name, `merge-gate-suite`.
478        gate: String,
479        /// Which evaluation surface ran the pipeline (see
480        /// [`crate::gate::GateSurface`]).
481        surface: crate::gate::GateSurface,
482        /// The gate's kind — doubling as the ladder section (see the variant
483        /// docs).
484        kind: crate::gate::GateKind,
485        /// Zero-based evaluation position within the section: registration
486        /// order is evaluation order (gate.rs), so index order within
487        /// (surface, kind) IS the pipeline order.
488        index: u32,
489        /// The verdict the gate stated — never derived from `score`.
490        verdict: crate::gate::GateVerdict,
491        /// The artefact handle, verbatim from the outcome's
492        /// [`crate::gate::ArtefactRef::reference`].
493        #[serde(rename = "artefactRef")]
494        artefact_ref: String,
495        /// Evidence captured verbatim by the gate (a failing command's
496        /// output tail, per-assertion findings), from
497        /// [`crate::gate::ArtefactRef::detail`]. Absent when the reference
498        /// alone is the evidence; `None` never hits the wire.
499        #[serde(
500            rename = "artefactDetail",
501            default,
502            skip_serializing_if = "Option::is_none"
503        )]
504        artefact_detail: Option<String>,
505        /// Gate-supplied confidence score (KRZ-315), purely evidentiary —
506        /// absent for boolean-only gates, never consulted to compute
507        /// `verdict`.
508        #[serde(default, skip_serializing_if = "Option::is_none")]
509        score: Option<f64>,
510        /// The threshold the gate judged `score` against; present exactly
511        /// when `score` is (the two travel as a pair from
512        /// [`crate::gate::GateScore`]).
513        #[serde(default, skip_serializing_if = "Option::is_none")]
514        threshold: Option<f64>,
515        /// The stable Flight Rules standards rule ids this evaluation
516        /// joined (KRZ-343, design D-H): the linkage the coverage matrix
517        /// joins on, from [`crate::gate::GateOutcome::rule_ids`]. Additive
518        /// and evidentiary only — a gate with no standards linkage carries
519        /// an empty list, which never hits the wire, so a boolean-only
520        /// gate's payload stays byte-identical.
521        #[serde(rename = "ruleIds", default, skip_serializing_if = "Vec::is_empty")]
522        rule_ids: Vec<String>,
523    },
524
525    /// One deterministic gate projected onto a Claude Code lifecycle hook
526    /// fired IN-PROCESS inside a worker session (ticket
527    /// `.kranz/tickets/claude-code-hook-gate-projection.md`, KRZ-302; module
528    /// [`crate::hook_gates`]). The first projection is the out-of-contract
529    /// write rule: a `PreToolUse` hook on the file-writing tools judges the
530    /// target path against the mission's `touch_set` and blocks an
531    /// out-of-contract write before it happens. The payload carries the
532    /// gate identity, the hook event, the tool, the judged path, and the
533    /// guard's verdict (`blocked` — refused in-process; `error` — the guard
534    /// itself failed open, so only the engine-side sweep can judge it).
535    ///
536    /// Additive, RECORD-ONLY (the `gate.result` template): the engine-side
537    /// gate ladder remains authoritative — hooks are defense-in-depth, never
538    /// a replacement — so this event drives no state transition; it is the
539    /// in-process layer's evidence landing in the log (folded from the
540    /// per-session record file after the session stream closes, BEFORE
541    /// `worker.completed`). `runId` is stamped from the run's metadata at
542    /// fold time, never from the session-writable record file.
543    #[serde(rename = "hook.gate.fired")]
544    HookGateFired {
545        #[serde(rename = "runId")]
546        run_id: String,
547        /// Gate identity (e.g. `out-of-contract-write`) — the same
548        /// defect-class name the engine-side sweep reports, so one gate
549        /// reads at two layers.
550        gate: String,
551        /// The lifecycle event that fired (`PreToolUse`).
552        #[serde(rename = "hookEvent")]
553        hook_event: String,
554        /// The tool whose call was judged (`Write`, `Edit`, ...).
555        tool: String,
556        /// The judged target (repo-relative when it resolved inside the
557        /// checkout, else the raw path).
558        subject: String,
559        /// `blocked` | `error` (see the variant docs).
560        verdict: String,
561        /// The guard's reason / error note, scrubbed and truncated at fold
562        /// time. Absent when the record carried none; `None` never hits the
563        /// wire.
564        #[serde(default, skip_serializing_if = "Option::is_none")]
565        detail: Option<String>,
566    },
567
568    /// The candidate-comparison record of a heterogeneous dispatch pool
569    /// (ticket `divergence-first-class-event`, KRZ-304; the follow-up the
570    /// KRZ-303 pool parks for): when a unit's sibling streams have all
571    /// recorded and the engine parks the milestone for judgement, the
572    /// candidate branch TREES are compared and exactly one of these is
573    /// appended, naming the unit, every compared candidate (run id, branch,
574    /// backend, tree hash — see [`crate::types::DivergenceCandidate`]), and
575    /// the verdict.
576    ///
577    /// **Agreement between models is a signal to log, never a criterion to
578    /// trust.** Identical candidate trees produce THE SAME record kind with
579    /// `diverged: false` — the agreement record: logged, never trusted. A
580    /// unit is done when gates are green and no escalation is open, not
581    /// when streams stop disagreeing; no gate, judgement, or park posture
582    /// anywhere in the engine is keyed on this verdict (the
583    /// agreement-record test pins that).
584    ///
585    /// TWO kinds, not one with a resolution field: the log is append-only,
586    /// so a resolution that arrives later (or never) could only ever be a
587    /// second event — mirroring `grant.requested` → `grant.approved` /
588    /// `grant.denied`. Record-only in the reducer (the `gate.result`
589    /// additive template, with reference validation as a corruption guard):
590    /// the accompanying `milestone.blocked` drives the park, so old logs
591    /// without any divergence.noted fold unchanged.
592    ///
593    /// WHY the tree hash travels on the event: it pins the exact bytes the
594    /// verdict was computed from, so replay (provenance, the training
595    /// corpus) never needs git — the branches stay for the judging human,
596    /// the hash is the audit anchor. Only streams that produced a run
597    /// record are compared (a stream that never started has no candidate
598    /// diff; counting its untouched branch would fabricate agreement out of
599    /// a failure), and with fewer than two recorded candidates NO event is
600    /// appended at all — a one-stream "agreement" would be vacuous.
601    #[serde(rename = "divergence.noted")]
602    DivergenceNoted {
603        /// The dispatch unit — the feature id fanned out to the pool
604        /// ([`crate::types::CandidateLink::unit`] of every compared run).
605        unit: String,
606        /// Every compared candidate stream, in candidate-index order.
607        candidates: Vec<DivergenceCandidate>,
608        /// TRUE when at least two candidate branch trees differ (the
609        /// streams diverged); FALSE = the agreement record (identical
610        /// trees) — logged, never trusted (see the variant docs).
611        diverged: bool,
612    },
613
614    /// The resolution of a unit's divergence record (ticket
615    /// `divergence-first-class-event`, KRZ-304): WHICH candidate was chosen
616    /// (or that none was), WHY, and decided by WHOM — today always the
617    /// operator through the milestone unblock path the pool parks on; the
618    /// string leaves room for a gate decider without a schema change.
619    /// RECORD ONLY: the engine never merges a candidate (the KRZ-303
620    /// freeze), so this changes nothing about the mission's course — it is
621    /// the judgement landing in the log, feeding the escalation ledger and
622    /// the provenance chain. At most one per unit: the first operator
623    /// judgement stands (the reducer folds the unit set the engine dedupes
624    /// against across restarts).
625    #[serde(rename = "divergence.resolved")]
626    DivergenceResolved {
627        /// The dispatch unit whose divergence is resolved (the feature id).
628        unit: String,
629        /// The chosen candidate's zero-based stream index (the `-c<i>`
630        /// branch suffix / [`crate::types::CandidateLink::index`]); `None`
631        /// when no candidate was selected — a judged-and-abandoned unit is
632        /// itself a recorded resolution, distinct from "not yet judged".
633        /// `None` never hits the wire.
634        #[serde(default, skip_serializing_if = "Option::is_none")]
635        selected: Option<u32>,
636        /// WHY, verbatim from the decider (the operator's note, or the
637        /// unblock action when no note was given).
638        reason: String,
639        /// WHO or WHAT decided: `"operator"` for the unblock path; a gate
640        /// identity when a gate ever resolves (none does today).
641        #[serde(rename = "decidedBy")]
642        decided_by: String,
643    },
644
645    /// Orchestrator converted findings into a fix-feature (origin: fix).
646    #[serde(rename = "fixfeature.created")]
647    FixFeatureCreated {
648        #[serde(rename = "milestoneId")]
649        milestone_id: String,
650        feature: Feature,
651    },
652    /// Orchestrator escalated the executor tier after repeated failed local
653    /// validations, rather than blocking the milestone.
654    #[serde(rename = "tier.escalated")]
655    TierEscalated {
656        #[serde(rename = "milestoneId")]
657        milestone_id: String,
658        from: ExecutorTier,
659        to: ExecutorTier,
660        reason: String,
661    },
662
663    /// Worker-initiated escalation to the frontier advisor (ticket
664    /// `backend-routing-abstraction`, KRZ-331): a worker whose report carried
665    /// an `escalation` reason judged its task beyond its route's confidence
666    /// and asked for frontier-tier advice. Distinct from `tier.escalated` —
667    /// that is the ORCHESTRATOR's fix-cycle-cap valve, which flips the
668    /// executor tier and resets the milestone; THIS is the WORKER's request,
669    /// layered on top of the deterministic routing floor and never replacing
670    /// it.
671    ///
672    /// RECORD-ONLY (the `gate.result` additive template): the fold validates
673    /// the run reference as a corruption guard and changes NO state — the
674    /// validator route, the executor tier, the respawn budget, and every
675    /// milestone status are all untouched, so a worker escalation can never
676    /// bypass the floor's validator requirements. The judgement turn that
677    /// already reads the worker's report IS the frontier advisor act
678    /// consuming the request (the orchestrator role's model/endpoint,
679    /// frontier-floor enforced by `config::validate`); this event is the
680    /// provenance that the request was made, feeding the escalation record
681    /// the ticket requires of every escalation. Old logs without any
682    /// worker.escalated fold unchanged.
683    ///
684    /// Routes are capability classes ([`ExecutorTier`]), never model ids —
685    /// the same discipline as the routing table itself.
686    #[serde(rename = "worker.escalated")]
687    WorkerEscalated {
688        #[serde(rename = "runId")]
689        run_id: String,
690        /// The feature whose worker asked (denormalized onto the event so
691        /// the log reads without a join; the run record is the join of
692        /// record).
693        #[serde(rename = "featureId")]
694        feature_id: String,
695        /// Source route: the executor capability class the escalating worker
696        /// session ran on.
697        from: ExecutorTier,
698        /// Target route: the advisor capability class requested — always
699        /// `frontier` in this pass (see the variant docs).
700        to: ExecutorTier,
701        /// WHY the worker asked, verbatim from its report (already
702        /// credential-scrubbed with the report text it was parsed from).
703        reason: String,
704    },
705
706    /// A worker asked the human a structured question (ticket
707    /// `structured-human-question-events`): the report's `questions` payload
708    /// (the "ask the human" tool shape — text plus capped structured choices)
709    /// opened as ONE entry of the pending-decision projection
710    /// ([`crate::types::MissionState::pending_questions`]) that the dashboard
711    /// and Slack render beside grants — the D-X channel-unification ruling:
712    /// permission prompts stay on the grant flow, ticket underspecification
713    /// stays on NeedsContext, and ONLY orchestrator/worker structured asks
714    /// land here, so this is not a third competing human-input inbox.
715    ///
716    /// Unlike `grant.requested`, opening a question parks NOTHING: the
717    /// worker's own run result drives the mission's course exactly as before
718    /// (a prose-only report opens no question at all — the prose fallback),
719    /// and an answer reaches the running mission through the existing
720    /// user-message consult fold (see `question.answered`). The id is
721    /// engine-minted (`q-<n>` from the folded
722    /// [`crate::types::MissionState::question_count`] — restart-safe, never
723    /// reused), never model-supplied. Text and options are credential-
724    /// scrubbed and size-capped at write (orchestrator.rs caps); `role`
725    /// names who asked (`worker` today — an orchestrator ask path can land
726    /// without a schema change). The run/feature/milestone refs are
727    /// denormalized context so surfaces render without a join.
728    #[serde(rename = "question.opened")]
729    QuestionOpened {
730        /// Engine-minted id (`q-<n>`, per-mission monotonic).
731        #[serde(rename = "questionId")]
732        question_id: String,
733        /// Who asked — `worker` in this pass.
734        role: Role,
735        /// The question text (scrubbed, capped at write).
736        text: String,
737        /// Structured choices the asker offered (each scrubbed + capped, the
738        /// list capped at write). EMPTY means a free-text answer is expected.
739        /// Absent in pre-field logs and omitted from the wire when empty.
740        #[serde(default, skip_serializing_if = "Vec::is_empty")]
741        options: Vec<String>,
742        /// The run whose report carried the ask. `None` never hits the wire.
743        #[serde(rename = "runId", default, skip_serializing_if = "Option::is_none")]
744        run_id: Option<String>,
745        /// Feature the asking run worked on (context ref). `None` never hits
746        /// the wire.
747        #[serde(rename = "featureId", default, skip_serializing_if = "Option::is_none")]
748        feature_id: Option<String>,
749        /// Milestone the asking run worked under (context ref; the clear-on-
750        /// complete sweep keys on it). `None` never hits the wire.
751        #[serde(
752            rename = "milestoneId",
753            default,
754            skip_serializing_if = "Option::is_none"
755        )]
756        milestone_id: Option<String>,
757    },
758
759    /// The operator answered an open question (ticket
760    /// `structured-human-question-events`), mirroring
761    /// `grant.requested` → `grant.approved`: the reducer cross-checks the id
762    /// against the parked projection (a stale or forged answer for a question
763    /// that is not open fails the fold), removes it from
764    /// [`crate::types::MissionState::pending_questions`], and folds the answer
765    /// onto `pending_user_messages` — the EXISTING consult path, so the
766    /// answer reaches the running mission (and replays after restart) with no
767    /// new delivery mechanism. `answer` is the chosen option's text verbatim
768    /// or the operator's free text (scrubbed + capped at write — an operator
769    /// can paste a token into an answer box, and the log is corpus-exported);
770    /// `option` records the 0-based index when an offered option was picked,
771    /// `None` for free text. `via` names the control path that delivered it
772    /// (the `answer-question` control kind today; a free-form string so a
773    /// future `msg`-carried answer needs no schema change).
774    #[serde(rename = "question.answered")]
775    QuestionAnswered {
776        #[serde(rename = "questionId")]
777        question_id: String,
778        answer: String,
779        via: String,
780        /// 0-based index into the question's `options` when an offered option
781        /// was picked; absent for free-text answers. `None` never hits the
782        /// wire.
783        #[serde(default, skip_serializing_if = "Option::is_none")]
784        option: Option<u32>,
785    },
786
787    /// An open question stopped being actionable WITHOUT an answer (ticket
788    /// `structured-human-question-events`) — its milestone completed, or the
789    /// mission ended with the ask still open. Third kind rather than a
790    /// resolution field on `question.opened` for the same reason grants are
791    /// two kinds: the log is append-only, so a later resolution can only ever
792    /// be a second event. `why` is the engine's reason verbatim
793    /// ("milestone completed", "mission completed", ...).
794    #[serde(rename = "question.cleared")]
795    QuestionCleared {
796        #[serde(rename = "questionId")]
797        question_id: String,
798        why: String,
799    },
800
801    #[serde(rename = "milestone.blocked")]
802    MilestoneBlocked {
803        #[serde(rename = "milestoneId")]
804        milestone_id: String,
805        reason: String,
806        /// Absent only in legacy logs; present unknown values fail closed.
807        #[serde(
808            rename = "blockContext",
809            default,
810            skip_serializing_if = "Option::is_none",
811            deserialize_with = "deserialize_block_context"
812        )]
813        block_context: Option<BlockContext>,
814    },
815
816    #[serde(rename = "milestone.unblocked")]
817    MilestoneUnblocked {
818        #[serde(rename = "milestoneId")]
819        milestone_id: String,
820        /// e.g. "raised fix-cycle cap", "user skipped findings"
821        reason: String,
822        /// Absent only in legacy logs; present unknown values fail closed.
823        #[serde(
824            rename = "blockContext",
825            default,
826            skip_serializing_if = "Option::is_none",
827            deserialize_with = "deserialize_block_context"
828        )]
829        block_context: Option<BlockContext>,
830        /// Operator guidance carried verbatim into the next validator task
831        /// (and its retry). Folded into milestone state so it survives a
832        /// process restart; replaced by each new unblock, cleared on
833        /// milestone completion. Absent in pre-field logs.
834        #[serde(
835            rename = "validatorGuidance",
836            default,
837            skip_serializing_if = "Option::is_none"
838        )]
839        validator_guidance: Option<String>,
840    },
841
842    #[serde(rename = "milestone.completed")]
843    MilestoneCompleted {
844        #[serde(rename = "milestoneId")]
845        milestone_id: String,
846        #[serde(skip_serializing_if = "Option::is_none")]
847        tag: Option<String>,
848    },
849
850    /// Final contract gate started (plan §4.5).
851    #[serde(rename = "mission.validating")]
852    MissionValidating {},
853
854    #[serde(rename = "mission.paused")]
855    MissionPaused {},
856
857    #[serde(rename = "mission.resumed")]
858    MissionResumed {},
859
860    #[serde(rename = "user.message")]
861    UserMessage { text: String, interrupt: bool },
862
863    #[serde(rename = "orchestrator.decision")]
864    OrchestratorDecision {
865        summary: String,
866        #[serde(skip_serializing_if = "Option::is_none")]
867        detail: Option<String>,
868    },
869
870    #[serde(rename = "secret.redacted")]
871    SecretRedacted {
872        #[serde(rename = "ruleId")]
873        rule_id: String,
874        fingerprint: String,
875        location: String,
876    },
877
878    #[serde(rename = "config.changed")]
879    ConfigChanged { patch: serde_json::Value },
880
881    #[serde(rename = "mission.completed")]
882    MissionCompleted {},
883
884    #[serde(rename = "mission.failed")]
885    MissionFailed { reason: String },
886
887    /// Operator retired the mission (`kranz abandon`) — terminal, not a failure.
888    #[serde(rename = "mission.abandoned")]
889    MissionAbandoned { reason: String },
890
891    /// A [`crate::workspace_provider::WorkspaceProvider`] provisioned the
892    /// mission workspace (design D-B/D-E): records the provider kind and the
893    /// execution cwd so the audit trail names the environment workers ran
894    /// in. Emitted once per `run()` invocation, before readiness.
895    #[serde(rename = "workspace.provisioned")]
896    WorkspaceProvisioned {
897        #[serde(default)]
898        provider: String,
899        #[serde(default)]
900        cwd: String,
901        /// Additive (ticket `local-container-workspace`): provider-specific
902        /// detail — the container provider records its compose project name.
903        /// Absent on old logs and for providers without extra detail.
904        #[serde(default, skip_serializing_if = "Option::is_none")]
905        detail: Option<String>,
906        /// Additive (ticket `workspace-remote-coder-provider`): the
907        /// substrate-reported takeover URL (SSH/web) for remote providers.
908        /// Absent on old logs and for local kinds (their takeover truth is
909        /// the workspace cwd — no SSH/remote fiction).
910        #[serde(default, skip_serializing_if = "Option::is_none")]
911        takeover: Option<String>,
912        /// Additive (ticket `workspace-remote-coder-provider`): previews as
913        /// provisioned — the substrate-reported URLs name-matched to the
914        /// contract's `previews[]`. Absent on old logs and for local kinds
915        /// (their placeholders derive from the contract itself).
916        #[serde(default, skip_serializing_if = "Option::is_none")]
917        previews: Option<Vec<crate::types::ProvisionedPreview>>,
918    },
919
920    /// The provider's readiness outcome (D-E: readiness status is a mission
921    /// artifact). Emitted only when a workspace contract drove a real
922    /// bootstrap/readiness execution — never with `outcome = "ready"` for a
923    /// contract-less run, which would imply a runnable environment that does
924    /// not exist (D-H). `detail` carries the scrubbed block reason on
925    /// failure.
926    #[serde(rename = "workspace.readiness")]
927    WorkspaceReadinessReport {
928        #[serde(default)]
929        outcome: String,
930        #[serde(default, skip_serializing_if = "Option::is_none")]
931        detail: Option<String>,
932    },
933
934    /// Workspace teardown recorded (D-E). The engine drives the configured
935    /// `workspace.teardownMode` when a run reaches a terminal state
936    /// (ticket `workspace-idle-hibernate`) and `keep` otherwise;
937    /// local-worktree is always `keep` — the integration worktree's
938    /// filesystem lifecycle stays with the existing mission-branch/merge
939    /// machinery.
940    #[serde(rename = "workspace.teardown")]
941    WorkspaceTeardown {
942        #[serde(default)]
943        mode: String,
944        /// Additive (ticket `workspace-idle-hibernate`): the teardown
945        /// OUTCOME — `"kept"` (mode keep), `"stopped"` (hibernate),
946        /// `"destroyed"` (destroy), `"failed"` (the provider call failed;
947        /// the run's outcome stands — see the accompanying
948        /// `orchestrator.decision`). Absent on old logs (v1 keep-only
949        /// teardowns recorded no outcome); folds into
950        /// [`crate::types::MissionState::workspace_lifecycle`] with the
951        /// event's own `ts` as the workspace-hours anchor.
952        #[serde(default, skip_serializing_if = "Option::is_none")]
953        state: Option<String>,
954    },
955
956    /// The effective workspace provider identity pinned at plan approval
957    /// (design D-B, ticket `workspace-provider-pin-at-approval`) — the consent
958    /// artifact recording WHAT was approved: provider kind, template
959    /// (isolation mode for local kinds; the configured substrate
960    /// template/image id for `remote`), and version (the workspace contract's
961    /// schemaVersion, `"none"` without a contract, or the remote adapter
962    /// version). Emitted in
963    /// `approve_plan` immediately before `plan.approved`, so the log reads:
964    /// contract validated → provider pinned → plan approved. See
965    /// [`crate::types::WorkspacePin`] for the per-kind field meanings.
966    #[serde(rename = "workspace.provider.pinned")]
967    WorkspaceProviderPinned {
968        #[serde(default)]
969        provider: String,
970        #[serde(default)]
971        template: String,
972        #[serde(default)]
973        version: String,
974    },
975
976    /// The Flight Rules resolution record (KRZ-342, design D-D/D-E/D-H):
977    /// emitted at plan approval, immediately after `plan.approved`, when a
978    /// standards-configured pack governed the approval. Records the source
979    /// identity + digest, the selection inputs (stage, task class, touch
980    /// set), the selected rule revisions, and the `plan.approved` seq the
981    /// pin attaches to — the queryable provenance for the consent artifact
982    /// the plan's `standardsManifest` carries in full.
983    ///
984    /// D-H's record list, verified for KRZ-343: the source identity/digest,
985    /// selection inputs, stage, rule revisions, and approval sequence all
986    /// ride in this payload; the effective-time evaluation instant is the
987    /// event envelope's own `ts` — resolution runs in the same approve_plan
988    /// call as the emission, so the append stamp IS the instant the
989    /// effective statuses were judged (payloads never duplicate the envelope
990    /// clock anywhere in this schema). The RFC `effective_at` absorption
991    /// window itself stays unevaluated in this slice: KRZ-341 parses and
992    /// carries the field, and the stage-projection slice that evaluates it
993    /// (KRZ-345) records its own surfaces.
994    #[serde(rename = "standards.resolved")]
995    StandardsResolved {
996        /// `repo-tracked` or `external-pinned` ([`StandardsPinSource`]).
997        source: String,
998        #[serde(rename = "packName")]
999        pack_name: String,
1000        #[serde(rename = "standardsRoot")]
1001        standards_root: String,
1002        /// sha256 over the pack's normalized canonical manifest text.
1003        digest: String,
1004        /// The resolution surface: `approval` for the pinning resolution
1005        /// (stage-specific projections are KRZ-345's emitters).
1006        stage: String,
1007        #[serde(rename = "taskClass", default, skip_serializing_if = "Option::is_none")]
1008        task_class: Option<String>,
1009        #[serde(rename = "touchSet", default, skip_serializing_if = "Vec::is_empty")]
1010        touch_set: Vec<String>,
1011        #[serde(
1012            rename = "contextPaths",
1013            default,
1014            skip_serializing_if = "Vec::is_empty"
1015        )]
1016        context_paths: Vec<String>,
1017        /// The selected rules, stable-sorted by id.
1018        rules: Vec<StandardsRuleRef>,
1019        /// The seq of the `plan.approved` event this resolution pins.
1020        #[serde(rename = "approvalSeq")]
1021        approval_seq: u64,
1022    },
1023
1024    /// The Flight Rules policy-drift refusal (KRZ-342, design D-E/D-H):
1025    /// emitted when merge re-resolves the LIVE base policy against the exact
1026    /// scratch integration diff and the applicable ENFORCED set differs from
1027    /// the approved pin's — the merge is refused and the mission requires
1028    /// explicit revalidation/reapproval. `currentDigest` is `None` when the
1029    /// live base no longer yields a readable standards manifest at all (a
1030    /// removed or malformed pack — the ultimate drift, failed closed).
1031    /// Audit-only in the reducer: the refusal already happened; the event is
1032    /// the evidence.
1033    #[serde(rename = "standards.drifted")]
1034    StandardsDrifted {
1035        /// The digest pinned at approval.
1036        #[serde(rename = "approvedDigest")]
1037        approved_digest: String,
1038        /// The digest resolved from the live base, when one resolved.
1039        #[serde(
1040            rename = "currentDigest",
1041            default,
1042            skip_serializing_if = "Option::is_none"
1043        )]
1044        current_digest: Option<String>,
1045        /// The surface that detected the drift (`merge` in this slice).
1046        surface: String,
1047        /// Id-level descriptions of the changed applicable enforced rules
1048        /// (added / removed / changed), stable-sorted.
1049        #[serde(rename = "changedRules")]
1050        changed_rules: Vec<String>,
1051    },
1052
1053    /// The Flight Rules human waiver decision (ticket
1054    /// `.kranz/tickets/flight-rules-waiver-decisions.md`, KRZ-344; design
1055    /// D-I — "waivers are narrow human decisions"): the ONE authorized
1056    /// exception path for a standards failure. Only an authenticated human
1057    /// surface records it (`kranz standards waive` in this slice) — a model
1058    /// may request a waiver or propose a fix but can NEVER approve one, so
1059    /// no engine or backend code path emits this event. The binding is
1060    /// deliberately narrow enough that the waiver cannot survive a
1061    /// meaningful rule/finding/scope/diff change: it names the pinned rule
1062    /// id + revision + manifest digest + approval sequence, the fingerprint
1063    /// of the EXACT finding it subtracts, the affected paths, and the
1064    /// sha256 over the affected-path diff (the whole diff for an unscoped
1065    /// rule), plus the reason, the approver, and the expiry. A change to
1066    /// the affected-path diff, the rule revision, the finding fingerprint,
1067    /// or the pin — or the expiry passing — invalidates the waiver and
1068    /// restores the block; unrelated paths receive no authority. It
1069    /// subtracts EXACTLY ONE matching standards failure: it never disables
1070    /// a checker, an RFC, a domain, or a class, and engine floor gates have
1071    /// no waiver slot at all. Audit-only in the reducer: the coverage fold
1072    /// joins it straight from the log.
1073    #[serde(rename = "standards.waiver.approved")]
1074    StandardsWaiverApproved {
1075        /// The pinned rule id the waiver excepts (frontmatter `id:`).
1076        #[serde(rename = "ruleId")]
1077        rule_id: String,
1078        /// The pinned rule revision — a waiver naming any other revision
1079        /// joins nothing.
1080        #[serde(rename = "ruleRevision")]
1081        rule_revision: u64,
1082        /// sha256 of the approved manifest the waiver binds to
1083        /// ([`StandardsPin::digest`]).
1084        #[serde(rename = "manifestDigest")]
1085        manifest_digest: String,
1086        /// The seq of the `plan.approved` event whose pin the waiver binds
1087        /// — a re-approval supersedes every earlier waiver.
1088        #[serde(rename = "approvalSeq")]
1089        approval_seq: u64,
1090        /// sha256 fingerprint of the ONE finding this waiver subtracts
1091        /// ([`crate::standards_waiver::finding_fingerprint`]).
1092        #[serde(rename = "findingFingerprint")]
1093        finding_fingerprint: String,
1094        /// The affected paths the bound diff covers: the rule's
1095        /// `when-paths` intersected with the mission diff, or the whole
1096        /// changed set for an unscoped rule. Recorded so the audit names
1097        /// exactly what the digest covers; empty when a scoped rule
1098        /// matched no changed path (the waiver then binds the empty
1099        /// scoped diff).
1100        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1101        paths: Vec<String>,
1102        /// sha256 over the affected-path diff bytes at approval time — a
1103        /// later change to any affected path digests differently and
1104        /// invalidates the waiver.
1105        #[serde(rename = "diffDigest")]
1106        diff_digest: String,
1107        /// The human's reason, verbatim (scrubbed at write like every
1108        /// payload string).
1109        reason: String,
1110        /// The approver principal: the authenticated identity where the
1111        /// local authority model can name one, else honestly
1112        /// `local-operator` (D-I — never invent a real-world identity).
1113        approver: String,
1114        /// The authenticated invocation surface (`cli` in this slice).
1115        /// The coverage fold honors only recognized human surfaces — a
1116        /// hand-cut event claiming a model surface carries no authority.
1117        surface: String,
1118        /// The expiry instant. The fold judges it against the log's own
1119        /// frontier (the latest event instant — never a wall clock, so
1120        /// replays stay byte-identical); an enforcement decision re-judges
1121        /// it against its own clock.
1122        #[serde(rename = "expiresAt")]
1123        expires_at: DateTime<Utc>,
1124    },
1125
1126    /// Positive human verdict for a rule whose typed checker is
1127    /// `manual-attestation` (KRZ-346 D-F). Like a waiver, authority is narrow:
1128    /// exact mission pin, rule revision, affected paths, and current diff.
1129    /// Unlike a waiver it does not except a failing checker; it IS the
1130    /// checker and therefore carries no finding fingerprint or expiry.
1131    #[serde(rename = "standards.attestation.approved")]
1132    StandardsAttestationApproved {
1133        #[serde(rename = "ruleId")]
1134        rule_id: String,
1135        #[serde(rename = "ruleRevision")]
1136        rule_revision: u64,
1137        #[serde(rename = "manifestDigest")]
1138        manifest_digest: String,
1139        #[serde(rename = "approvalSeq")]
1140        approval_seq: u64,
1141        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1142        paths: Vec<String>,
1143        #[serde(rename = "diffDigest")]
1144        diff_digest: String,
1145        reason: String,
1146        approver: String,
1147        surface: String,
1148    },
1149}
1150
1151impl EventKind {
1152    /// The dotted wire name of this event (matches the serde rename).
1153    pub fn type_name(&self) -> &'static str {
1154        match self {
1155            EventKind::MissionCreated { .. } => "mission.created",
1156            EventKind::PlanApproved { .. } => "plan.approved",
1157            EventKind::PlanRevisionProposed { .. } => "plan.revision.proposed",
1158            EventKind::PlanRevised { .. } => "plan.revised",
1159            EventKind::PlanRevisionRejected { .. } => "plan.revision.rejected",
1160            EventKind::GrantRequested { .. } => "grant.requested",
1161            EventKind::GrantApproved { .. } => "grant.approved",
1162            EventKind::GrantDenied { .. } => "grant.denied",
1163            EventKind::MilestoneStarted { .. } => "milestone.started",
1164            EventKind::FeatureStarted { .. } => "feature.started",
1165            EventKind::FeatureProgress { .. } => "feature.progress",
1166            EventKind::WorkerSpawned { .. } => "worker.spawned",
1167            EventKind::WorkerMessage { .. } => "worker.message",
1168            EventKind::WorkerEgressDenied { .. } => "worker.egress.denied",
1169            EventKind::WorkerCompleted { .. } => "worker.completed",
1170            EventKind::FeatureCompleted { .. } => "feature.completed",
1171            EventKind::FeatureFailed { .. } => "feature.failed",
1172            EventKind::FeatureSkipped { .. } => "feature.skipped",
1173            EventKind::MilestoneValidating { .. } => "milestone.validating",
1174            EventKind::ValidationFinding { .. } => "validation.finding",
1175            EventKind::ValidatorTamper { .. } => "validator.tamper",
1176            EventKind::ValidationSnapshot { .. } => "validation.snapshot",
1177            EventKind::ValidationConfirm { .. } => "validation.confirm",
1178            EventKind::ValidationPtyTranscript { .. } => "validation.pty.transcript",
1179            EventKind::GateResult { .. } => "gate.result",
1180            EventKind::HookGateFired { .. } => "hook.gate.fired",
1181            EventKind::DivergenceNoted { .. } => "divergence.noted",
1182            EventKind::DivergenceResolved { .. } => "divergence.resolved",
1183            EventKind::FixFeatureCreated { .. } => "fixfeature.created",
1184            EventKind::TierEscalated { .. } => "tier.escalated",
1185            EventKind::WorkerEscalated { .. } => "worker.escalated",
1186            EventKind::QuestionOpened { .. } => "question.opened",
1187            EventKind::QuestionAnswered { .. } => "question.answered",
1188            EventKind::QuestionCleared { .. } => "question.cleared",
1189            EventKind::MilestoneBlocked { .. } => "milestone.blocked",
1190            EventKind::MilestoneUnblocked { .. } => "milestone.unblocked",
1191            EventKind::MilestoneCompleted { .. } => "milestone.completed",
1192            EventKind::MissionValidating { .. } => "mission.validating",
1193            EventKind::MissionPaused {} => "mission.paused",
1194            EventKind::MissionResumed {} => "mission.resumed",
1195            EventKind::UserMessage { .. } => "user.message",
1196            EventKind::OrchestratorDecision { .. } => "orchestrator.decision",
1197            EventKind::SecretRedacted { .. } => "secret.redacted",
1198            EventKind::ConfigChanged { .. } => "config.changed",
1199            EventKind::MissionCompleted {} => "mission.completed",
1200            EventKind::MissionFailed { .. } => "mission.failed",
1201            EventKind::MissionAbandoned { .. } => "mission.abandoned",
1202            EventKind::WorkspaceProvisioned { .. } => "workspace.provisioned",
1203            EventKind::WorkspaceReadinessReport { .. } => "workspace.readiness",
1204            EventKind::WorkspaceTeardown { .. } => "workspace.teardown",
1205            EventKind::WorkspaceProviderPinned { .. } => "workspace.provider.pinned",
1206            EventKind::StandardsResolved { .. } => "standards.resolved",
1207            EventKind::StandardsDrifted { .. } => "standards.drifted",
1208            EventKind::StandardsWaiverApproved { .. } => "standards.waiver.approved",
1209            EventKind::StandardsAttestationApproved { .. } => "standards.attestation.approved",
1210        }
1211    }
1212
1213    /// Lifecycle events are fsynced per append; stream deltas (worker.message)
1214    /// may be batched (§4.3).
1215    pub fn is_stream_delta(&self) -> bool {
1216        matches!(self, EventKind::WorkerMessage { .. })
1217    }
1218}
1219
1220#[cfg(test)]
1221mod tests {
1222    use super::*;
1223    use crate::types::Plan;
1224
1225    fn sample_plan() -> Plan {
1226        Plan {
1227            goal: "g".into(),
1228            validation_contract: vec![],
1229            milestones: vec![],
1230            considered_alternatives: None,
1231            command_grants: vec![],
1232            touch_set: vec![],
1233            standards_manifest: None,
1234            reviewer_independence: None,
1235        }
1236    }
1237
1238    /// Runtime egress evidence is an additive event, not a new required field
1239    /// on worker.completed: legacy logs remain byte-compatible simply by not
1240    /// containing this record, while new records preserve exact run
1241    /// attribution and destination data.
1242    #[test]
1243    fn worker_egress_denied_round_trips() {
1244        let event = EventKind::WorkerEgressDenied {
1245            run_id: "run-1".to_string(),
1246            denials: vec![crate::egress_proxy::EgressDenial {
1247                host: "example.com".to_string(),
1248                port: 443,
1249            }],
1250            omitted_count: 3,
1251        };
1252        let json = serde_json::to_value(&event).unwrap();
1253        assert_eq!(json["type"], "worker.egress.denied");
1254        assert_eq!(json["payload"]["runId"], "run-1");
1255        assert_eq!(json["payload"]["denials"][0]["host"], "example.com");
1256        assert_eq!(json["payload"]["denials"][0]["port"], 443);
1257        assert_eq!(json["payload"]["omittedCount"], 3);
1258        assert_eq!(event.type_name(), "worker.egress.denied");
1259
1260        let mut legacy = json.clone();
1261        legacy["payload"]
1262            .as_object_mut()
1263            .unwrap()
1264            .remove("omittedCount");
1265        match serde_json::from_value::<EventKind>(legacy).unwrap() {
1266            EventKind::WorkerEgressDenied { omitted_count, .. } => assert_eq!(omitted_count, 0),
1267            _ => panic!("wrong variant"),
1268        }
1269
1270        let back: EventKind = serde_json::from_value(json).unwrap();
1271        match back {
1272            EventKind::WorkerEgressDenied {
1273                run_id,
1274                denials,
1275                omitted_count,
1276            } => {
1277                assert_eq!(run_id, "run-1");
1278                assert_eq!(denials.len(), 1);
1279                assert_eq!(denials[0].host, "example.com");
1280                assert_eq!(denials[0].port, 443);
1281                assert_eq!(omitted_count, 3);
1282            }
1283            _ => panic!("wrong variant"),
1284        }
1285    }
1286
1287    #[test]
1288    fn workspace_lifecycle_events_wire_names_and_payloads_round_trip() {
1289        let provisioned = EventKind::WorkspaceProvisioned {
1290            provider: "local-worktree".into(),
1291            cwd: "/tmp/m-1_integration".into(),
1292            detail: None,
1293            takeover: None,
1294            previews: None,
1295        };
1296        let json = serde_json::to_value(&provisioned).unwrap();
1297        assert_eq!(json["type"], "workspace.provisioned");
1298        assert_eq!(json["payload"]["provider"], "local-worktree");
1299        assert_eq!(json["payload"]["cwd"], "/tmp/m-1_integration");
1300        assert_eq!(provisioned.type_name(), "workspace.provisioned");
1301        let back: EventKind = serde_json::from_value(json).unwrap();
1302        assert!(matches!(back, EventKind::WorkspaceProvisioned { .. }));
1303
1304        let readiness = EventKind::WorkspaceReadinessReport {
1305            outcome: "failed".into(),
1306            detail: Some("workspace gate: readiness check 1/1 failed".into()),
1307        };
1308        let json = serde_json::to_value(&readiness).unwrap();
1309        assert_eq!(json["type"], "workspace.readiness");
1310        assert_eq!(json["payload"]["outcome"], "failed");
1311        assert_eq!(readiness.type_name(), "workspace.readiness");
1312        // detail is omitted from the wire when None.
1313        let no_detail = EventKind::WorkspaceReadinessReport {
1314            outcome: "ready".into(),
1315            detail: None,
1316        };
1317        let json = serde_json::to_value(&no_detail).unwrap();
1318        assert!(
1319            !json["payload"].as_object().unwrap().contains_key("detail"),
1320            "payload must not contain detail when None: {json}"
1321        );
1322
1323        let teardown = EventKind::WorkspaceTeardown {
1324            mode: "keep".into(),
1325            state: None,
1326        };
1327        let json = serde_json::to_value(&teardown).unwrap();
1328        assert_eq!(json["type"], "workspace.teardown");
1329        assert_eq!(json["payload"]["mode"], "keep");
1330        assert_eq!(teardown.type_name(), "workspace.teardown");
1331
1332        let pinned = EventKind::WorkspaceProviderPinned {
1333            provider: "local-worktree".into(),
1334            template: "worktree".into(),
1335            version: "1".into(),
1336        };
1337        let json = serde_json::to_value(&pinned).unwrap();
1338        assert_eq!(json["type"], "workspace.provider.pinned");
1339        assert_eq!(json["payload"]["provider"], "local-worktree");
1340        assert_eq!(json["payload"]["template"], "worktree");
1341        assert_eq!(json["payload"]["version"], "1");
1342        assert_eq!(pinned.type_name(), "workspace.provider.pinned");
1343        let back: EventKind = serde_json::from_value(json).unwrap();
1344        assert!(matches!(back, EventKind::WorkspaceProviderPinned { .. }));
1345
1346        // Backcompat: a payload missing fields (or the whole payload, as a
1347        // hand-written or future-trimmed log line might) folds with serde
1348        // defaults instead of failing the log read.
1349        let sparse: EventKind = serde_json::from_str(
1350            r#"{"type":"workspace.provider.pinned","payload":{"provider":"local-worktree"}}"#,
1351        )
1352        .unwrap();
1353        match sparse {
1354            EventKind::WorkspaceProviderPinned {
1355                provider,
1356                template,
1357                version,
1358            } => {
1359                assert_eq!(provider, "local-worktree");
1360                assert_eq!(template, "");
1361                assert_eq!(version, "");
1362            }
1363            _ => panic!("wrong variant"),
1364        }
1365    }
1366
1367    /// The additive `detail` on `workspace.provisioned` (ticket
1368    /// `local-container-workspace`): the container provider records its
1369    /// compose project name there; old log lines without it still fold with
1370    /// `detail = None`, and `None` never hits the wire.
1371    #[test]
1372    fn workspace_provisioned_detail_is_additive_and_old_logs_still_fold() {
1373        let with_detail = EventKind::WorkspaceProvisioned {
1374            provider: "container".into(),
1375            cwd: "/tmp/m-1_integration".into(),
1376            detail: Some("compose project kranz-ws-m-1".into()),
1377            takeover: None,
1378            previews: None,
1379        };
1380        let json = serde_json::to_value(&with_detail).unwrap();
1381        assert_eq!(json["payload"]["provider"], "container");
1382        assert_eq!(json["payload"]["detail"], "compose project kranz-ws-m-1");
1383        let back: EventKind = serde_json::from_value(json).unwrap();
1384        match back {
1385            EventKind::WorkspaceProvisioned {
1386                provider, detail, ..
1387            } => {
1388                assert_eq!(provider, "container");
1389                assert_eq!(detail.as_deref(), Some("compose project kranz-ws-m-1"));
1390            }
1391            _ => panic!("wrong variant"),
1392        }
1393
1394        // Old log line (pre-detail): folds with detail = None.
1395        let old: EventKind = serde_json::from_str(
1396            r#"{"type":"workspace.provisioned","payload":{"provider":"local-worktree","cwd":"/tmp/wt"}}"#,
1397        )
1398        .unwrap();
1399        match old {
1400            EventKind::WorkspaceProvisioned { detail, .. } => assert_eq!(detail, None),
1401            _ => panic!("wrong variant"),
1402        }
1403
1404        // detail = None is omitted from the wire (additive, never breaks old
1405        // readers comparing payloads).
1406        let no_detail = EventKind::WorkspaceProvisioned {
1407            provider: "local-worktree".into(),
1408            cwd: "/tmp/wt".into(),
1409            detail: None,
1410            takeover: None,
1411            previews: None,
1412        };
1413        let json = serde_json::to_value(&no_detail).unwrap();
1414        assert!(
1415            !json["payload"].as_object().unwrap().contains_key("detail"),
1416            "payload must not contain detail when None: {json}"
1417        );
1418    }
1419
1420    /// The additive remote-kind fields on `workspace.provisioned` (ticket
1421    /// `workspace-remote-coder-provider`): takeover + name-matched previews
1422    /// (with the substrate's auth report) round-trip, old log lines without
1423    /// them fold to None, and None never hits the wire.
1424    #[test]
1425    fn remote_workspace_provisioned_fields_are_additive_and_old_logs_still_fold() {
1426        let remote = EventKind::WorkspaceProvisioned {
1427            provider: "remote".into(),
1428            cwd: "/tmp/m-1_integration".into(),
1429            detail: Some("substrate workspace kranz-remote-m-1 (id ws-1)".into()),
1430            takeover: Some("https://coder.example.com/@me/ws-1".into()),
1431            previews: Some(vec![crate::types::ProvisionedPreview {
1432                name: "app".into(),
1433                url: "https://app.example.com".into(),
1434                auth: Some(true),
1435            }]),
1436        };
1437        let json = serde_json::to_value(&remote).unwrap();
1438        assert_eq!(
1439            json["payload"]["takeover"],
1440            "https://coder.example.com/@me/ws-1"
1441        );
1442        assert_eq!(
1443            json["payload"]["previews"],
1444            serde_json::json!([{"name": "app", "url": "https://app.example.com", "auth": true}])
1445        );
1446        let back: EventKind = serde_json::from_value(json).unwrap();
1447        match back {
1448            EventKind::WorkspaceProvisioned {
1449                takeover, previews, ..
1450            } => {
1451                assert_eq!(
1452                    takeover.as_deref(),
1453                    Some("https://coder.example.com/@me/ws-1")
1454                );
1455                assert_eq!(
1456                    previews,
1457                    Some(vec![crate::types::ProvisionedPreview {
1458                        name: "app".into(),
1459                        url: "https://app.example.com".into(),
1460                        auth: Some(true),
1461                    }])
1462                );
1463            }
1464            _ => panic!("wrong variant"),
1465        }
1466
1467        // Old log line (pre-remote): folds with takeover/previews = None…
1468        let old: EventKind = serde_json::from_str(
1469            r#"{"type":"workspace.provisioned","payload":{"provider":"local-worktree","cwd":"/tmp/wt"}}"#,
1470        )
1471        .unwrap();
1472        match old {
1473            EventKind::WorkspaceProvisioned {
1474                takeover, previews, ..
1475            } => {
1476                assert_eq!(takeover, None);
1477                assert_eq!(previews, None);
1478            }
1479            _ => panic!("wrong variant"),
1480        }
1481
1482        // …and None stays off the wire (additive, never breaks old readers).
1483        let local = EventKind::WorkspaceProvisioned {
1484            provider: "local-worktree".into(),
1485            cwd: "/tmp/wt".into(),
1486            detail: None,
1487            takeover: None,
1488            previews: None,
1489        };
1490        let json = serde_json::to_value(&local).unwrap();
1491        let payload = json["payload"].as_object().unwrap();
1492        assert!(
1493            !payload.contains_key("takeover") && !payload.contains_key("previews"),
1494            "local kinds must not carry the remote fields: {json}"
1495        );
1496
1497        // A preview whose substrate did not report auth omits the key (never
1498        // read as "no auth").
1499        let preview = serde_json::to_value(crate::types::ProvisionedPreview {
1500            name: "app".into(),
1501            url: "https://app.example.com".into(),
1502            auth: None,
1503        })
1504        .unwrap();
1505        assert!(
1506            !preview.as_object().unwrap().contains_key("auth"),
1507            "auth absent from the wire when the substrate did not say: {preview}"
1508        );
1509    }
1510
1511    /// The additive `state` on `workspace.teardown` (ticket
1512    /// `workspace-idle-hibernate`): the outcome round-trips, old log lines
1513    /// without it fold to None, and None never hits the wire.
1514    #[test]
1515    fn workspace_teardown_state_is_additive_and_old_logs_still_fold() {
1516        let stopped = EventKind::WorkspaceTeardown {
1517            mode: "hibernate".into(),
1518            state: Some("stopped".into()),
1519        };
1520        let json = serde_json::to_value(&stopped).unwrap();
1521        assert_eq!(json["payload"]["mode"], "hibernate");
1522        assert_eq!(json["payload"]["state"], "stopped");
1523        let back: EventKind = serde_json::from_value(json).unwrap();
1524        match back {
1525            EventKind::WorkspaceTeardown { mode, state } => {
1526                assert_eq!(mode, "hibernate");
1527                assert_eq!(state.as_deref(), Some("stopped"));
1528            }
1529            _ => panic!("wrong variant"),
1530        }
1531
1532        // Old log line (v1 keep-only, no outcome): folds with state = None.
1533        let old: EventKind =
1534            serde_json::from_str(r#"{"type":"workspace.teardown","payload":{"mode":"keep"}}"#)
1535                .unwrap();
1536        match old {
1537            EventKind::WorkspaceTeardown { mode, state } => {
1538                assert_eq!(mode, "keep");
1539                assert_eq!(state, None);
1540            }
1541            _ => panic!("wrong variant"),
1542        }
1543
1544        // state = None is omitted from the wire (additive, never breaks old
1545        // readers comparing payloads).
1546        let no_state = EventKind::WorkspaceTeardown {
1547            mode: "keep".into(),
1548            state: None,
1549        };
1550        let json = serde_json::to_value(&no_state).unwrap();
1551        assert!(
1552            !json["payload"].as_object().unwrap().contains_key("state"),
1553            "payload must not contain state when None: {json}"
1554        );
1555    }
1556
1557    #[test]
1558    fn command_grants_backcompat_defaults_empty() {
1559        // A Plan JSON that omits commandGrants deserializes to an empty vec.
1560        let plan_json = r#"{
1561            "goal": "g",
1562            "validationContract": [],
1563            "milestones": []
1564        }"#;
1565        let plan: Plan = serde_json::from_str(plan_json).unwrap();
1566        assert!(plan.command_grants.is_empty());
1567
1568        // A plan.approved event payload omitting commandGrants folds to an
1569        // empty vec on the nested plan too.
1570        let event_json = r#"{
1571            "seq": 1,
1572            "ts": "2026-01-02T03:04:05Z",
1573            "missionId": "m-1",
1574            "type": "plan.approved",
1575            "payload": {
1576                "plan": {
1577                    "goal": "g",
1578                    "validationContract": [],
1579                    "milestones": []
1580                }
1581            }
1582        }"#;
1583        let event: Event = serde_json::from_str(event_json).unwrap();
1584        match event.kind {
1585            EventKind::PlanApproved { plan, .. } => {
1586                assert!(plan.command_grants.is_empty())
1587            }
1588            _ => panic!("wrong variant"),
1589        }
1590    }
1591
1592    #[test]
1593    fn milestone_unblocked_guidance_backcompat_and_round_trip() {
1594        // Pre-field wire shape (logs written before validatorGuidance
1595        // existed) must still parse, defaulting to None.
1596        let old_json = r#"{
1597            "seq": 4,
1598            "ts": "2026-01-02T03:04:05Z",
1599            "missionId": "m-1",
1600            "type": "milestone.unblocked",
1601            "payload": { "milestoneId": "ms-1", "reason": "cap raised" }
1602        }"#;
1603        let event: Event = serde_json::from_str(old_json).unwrap();
1604        match event.kind {
1605            EventKind::MilestoneUnblocked {
1606                validator_guidance, ..
1607            } => assert_eq!(validator_guidance, None),
1608            _ => panic!("wrong variant"),
1609        }
1610
1611        // The new field serializes when present (camelCase wire name) and is
1612        // omitted when absent (byte-identical to old logs).
1613        let with = EventKind::MilestoneUnblocked {
1614            block_context: None,
1615            milestone_id: "ms-1".into(),
1616            reason: "r".into(),
1617            validator_guidance: Some("FMT FIRST".into()),
1618        };
1619        let json = serde_json::to_value(&with).unwrap();
1620        assert_eq!(json["payload"]["validatorGuidance"], "FMT FIRST");
1621        let without = EventKind::MilestoneUnblocked {
1622            block_context: None,
1623            milestone_id: "ms-1".into(),
1624            reason: "r".into(),
1625            validator_guidance: None,
1626        };
1627        let json = serde_json::to_value(&without).unwrap();
1628        assert!(json["payload"].get("validatorGuidance").is_none());
1629    }
1630
1631    #[test]
1632    fn touch_set_backcompat_defaults_empty() {
1633        // A Plan JSON that omits touchSet deserializes to an empty vec.
1634        let plan_json = r#"{
1635            "goal": "g",
1636            "validationContract": [],
1637            "milestones": []
1638        }"#;
1639        let plan: Plan = serde_json::from_str(plan_json).unwrap();
1640        assert!(plan.touch_set.is_empty());
1641    }
1642
1643    #[test]
1644    fn touch_set_round_trips_through_serde() {
1645        let mut plan = sample_plan();
1646        plan.touch_set = vec!["src/**/*.rs".to_string(), "!src/generated/**".to_string()];
1647        let json = serde_json::to_value(&plan).unwrap();
1648        assert_eq!(
1649            json["touchSet"],
1650            serde_json::json!(["src/**/*.rs", "!src/generated/**"])
1651        );
1652        let round_tripped: Plan = serde_json::from_value(json).unwrap();
1653        assert_eq!(round_tripped.touch_set, plan.touch_set);
1654    }
1655
1656    #[test]
1657    fn plan_approved_base_sha_backcompat() {
1658        // Some(sha) round-trips through serialization.
1659        let with_sha = EventKind::PlanApproved {
1660            plan: sample_plan(),
1661            base_sha: Some("deadbeef".to_string()),
1662        };
1663        let json = serde_json::to_value(&with_sha).unwrap();
1664        assert_eq!(json["payload"]["baseSha"], "deadbeef");
1665        let back: EventKind = serde_json::from_value(json).unwrap();
1666        match back {
1667            EventKind::PlanApproved { base_sha, .. } => {
1668                assert_eq!(base_sha, Some("deadbeef".to_string()))
1669            }
1670            _ => panic!("wrong variant"),
1671        }
1672
1673        // None is omitted from the wire (byte-identical to pre-baseSha logs)
1674        // and round-trips back to None.
1675        let without_sha = EventKind::PlanApproved {
1676            plan: sample_plan(),
1677            base_sha: None,
1678        };
1679        let json = serde_json::to_value(&without_sha).unwrap();
1680        assert!(
1681            !json["payload"].as_object().unwrap().contains_key("baseSha"),
1682            "payload must not contain baseSha when None: {json}"
1683        );
1684        let back: EventKind = serde_json::from_value(json).unwrap();
1685        match back {
1686            EventKind::PlanApproved { base_sha, .. } => assert_eq!(base_sha, None),
1687            _ => panic!("wrong variant"),
1688        }
1689
1690        // Old-log event JSON with no baseSha key at all still deserializes.
1691        let old_log = r#"{"type":"plan.approved","payload":{"plan":{"goal":"g","validationContract":[],"milestones":[]}}}"#;
1692        let event: EventKind = serde_json::from_str(old_log).unwrap();
1693        match event {
1694            EventKind::PlanApproved { base_sha, .. } => assert_eq!(base_sha, None),
1695            _ => panic!("wrong variant"),
1696        }
1697    }
1698
1699    /// The additive `validator.tamper` event (ticket
1700    /// `validator-immutability-proof`): wire name, payload shape, and
1701    /// round-trip — the audit record of a failed immutability assertion.
1702    #[test]
1703    fn validator_tamper_round_trips() {
1704        let tamper = EventKind::ValidatorTamper {
1705            milestone_id: "ms-1".to_string(),
1706            run_id: "r-1".to_string(),
1707            role: Role::ValidatorScrutiny,
1708            head_before: "abc1234".to_string(),
1709            head_after: "def5678".to_string(),
1710            appeared: vec![" M README.md".to_string(), "?? sneaky.rs".to_string()],
1711            resolved: vec![],
1712            git_metadata_changed: false,
1713            git_metadata_fields: Vec::new(),
1714        };
1715        let json = serde_json::to_value(&tamper).unwrap();
1716        assert_eq!(json["type"], "validator.tamper");
1717        assert_eq!(json["payload"]["milestoneId"], "ms-1");
1718        assert_eq!(json["payload"]["headBefore"], "abc1234");
1719        assert_eq!(json["payload"]["headAfter"], "def5678");
1720        assert_eq!(json["payload"]["gitMetadataChanged"], false);
1721        // Back-compat: a pre-field log line (no gitMetadataChanged) still
1722        // parses, defaulting to false.
1723        let mut legacy = json.clone();
1724        legacy["payload"]
1725            .as_object_mut()
1726            .unwrap()
1727            .remove("gitMetadataChanged");
1728        let legacy_back: EventKind = serde_json::from_value(legacy).unwrap();
1729        match legacy_back {
1730            EventKind::ValidatorTamper {
1731                git_metadata_changed,
1732                ..
1733            } => assert!(!git_metadata_changed),
1734            _ => panic!("wrong variant"),
1735        }
1736        assert_eq!(
1737            json["payload"]["appeared"],
1738            serde_json::json!([" M README.md", "?? sneaky.rs"])
1739        );
1740        assert_eq!(tamper.type_name(), "validator.tamper");
1741        let back: EventKind = serde_json::from_value(json).unwrap();
1742        match back {
1743            EventKind::ValidatorTamper {
1744                milestone_id,
1745                role,
1746                appeared,
1747                resolved,
1748                ..
1749            } => {
1750                assert_eq!(milestone_id, "ms-1");
1751                assert_eq!(role, Role::ValidatorScrutiny);
1752                assert_eq!(appeared.len(), 2);
1753                assert!(resolved.is_empty());
1754            }
1755            _ => panic!("wrong variant"),
1756        }
1757    }
1758
1759    /// The additive `validation.snapshot` event (copy-on-write immutable
1760    /// validator snapshot, the follow-up to ticket
1761    /// `validator-immutability-proof`): wire name, payload shape, and
1762    /// round-trip — the audit record of which throwaway checkout a
1763    /// validator ran in and what warming it cost.
1764    #[test]
1765    fn validation_snapshot_round_trips() {
1766        let snap = EventKind::ValidationSnapshot {
1767            milestone_id: "ms-1".to_string(),
1768            role: Role::ValidatorFunctional,
1769            path: "/repo/.kranz/missions/m-1/runs/validator-snapshot-functional".to_string(),
1770            target_tier: "clonefile".to_string(),
1771            creation_ms: 42,
1772            detail: None,
1773        };
1774        let json = serde_json::to_value(&snap).unwrap();
1775        assert_eq!(json["type"], "validation.snapshot");
1776        assert_eq!(json["payload"]["milestoneId"], "ms-1");
1777        assert_eq!(json["payload"]["targetTier"], "clonefile");
1778        assert_eq!(json["payload"]["creationMs"], 42);
1779        assert_eq!(snap.type_name(), "validation.snapshot");
1780        let back: EventKind = serde_json::from_value(json).unwrap();
1781        match back {
1782            EventKind::ValidationSnapshot {
1783                milestone_id,
1784                role,
1785                target_tier,
1786                detail,
1787                ..
1788            } => {
1789                assert_eq!(milestone_id, "ms-1");
1790                assert_eq!(role, Role::ValidatorFunctional);
1791                assert_eq!(target_tier, "clonefile");
1792                assert_eq!(detail, None);
1793            }
1794            _ => panic!("wrong variant"),
1795        }
1796
1797        // The `fresh` tier's named cost rides `detail`, and a legacy line
1798        // without the field still decodes (serde default).
1799        let with_cost = EventKind::ValidationSnapshot {
1800            milestone_id: "ms-1".to_string(),
1801            role: Role::ValidatorScrutiny,
1802            path: "/snap".to_string(),
1803            target_tier: "fresh".to_string(),
1804            creation_ms: 7,
1805            detail: Some("target/ is 31 GiB; snapshot pays a cold rebuild".to_string()),
1806        };
1807        let mut json = serde_json::to_value(&with_cost).unwrap();
1808        assert!(json["payload"]["detail"].as_str().unwrap().contains("GiB"));
1809        json["payload"].as_object_mut().unwrap().remove("detail");
1810        let legacy_back: EventKind = serde_json::from_value(json).unwrap();
1811        match legacy_back {
1812            EventKind::ValidationSnapshot { detail, .. } => assert_eq!(detail, None),
1813            _ => panic!("wrong variant"),
1814        }
1815    }
1816
1817    /// The additive `validation.confirm` event (ticket
1818    /// `local-inference-validator-guarded`, KRZ-206b): wire name, payload
1819    /// shape, and round-trip — the miss-rate ground truth must survive serde
1820    /// verbatim, because the local-vs-frontier miss rate is computed from
1821    /// these bytes alone (misses = disagreement subjects; opportunities =
1822    /// confirmed + disagreement command assertions + judgmentOpportunity).
1823    #[test]
1824    fn guarded_local_validator_confirm_event_wire_shape_and_round_trip() {
1825        let event = EventKind::ValidationConfirm {
1826            milestone_id: "ms-1".to_string(),
1827            local_run_id: "run-local".to_string(),
1828            confirm_run_id: "run-frontier".to_string(),
1829            confirmed: vec!["a1".to_string()],
1830            disagreements: vec![Finding {
1831                subject: "a2".to_string(),
1832                severity: "major".to_string(),
1833                evidence: "frontier sees a failure the local pass missed".to_string(),
1834                suggested_fix: "fix a2".to_string(),
1835                class: String::new(),
1836                rule: None,
1837            }],
1838            judgment_opportunity: false,
1839        };
1840        let json = serde_json::to_value(&event).unwrap();
1841        assert_eq!(json["type"], "validation.confirm");
1842        assert_eq!(json["payload"]["milestoneId"], "ms-1");
1843        assert_eq!(json["payload"]["localRunId"], "run-local");
1844        assert_eq!(json["payload"]["confirmRunId"], "run-frontier");
1845        assert_eq!(json["payload"]["confirmed"], serde_json::json!(["a1"]));
1846        assert_eq!(
1847            json["payload"]["disagreements"][0]["subject"],
1848            serde_json::json!("a2")
1849        );
1850        assert_eq!(
1851            json["payload"]["judgmentOpportunity"],
1852            serde_json::json!(false)
1853        );
1854        assert_eq!(event.type_name(), "validation.confirm");
1855        let back: EventKind = serde_json::from_value(json).unwrap();
1856        match back {
1857            EventKind::ValidationConfirm {
1858                milestone_id,
1859                local_run_id,
1860                confirm_run_id,
1861                confirmed,
1862                disagreements,
1863                judgment_opportunity,
1864            } => {
1865                assert_eq!(milestone_id, "ms-1");
1866                assert_eq!(local_run_id, "run-local");
1867                assert_eq!(confirm_run_id, "run-frontier");
1868                assert_eq!(confirmed, vec!["a1".to_string()]);
1869                assert_eq!(disagreements.len(), 1);
1870                assert_eq!(disagreements[0].subject, "a2");
1871                assert!(!judgment_opportunity);
1872            }
1873            _ => panic!("wrong variant"),
1874        }
1875
1876        // A legacy line (the field predated) decodes with the additive
1877        // default — pre-field logs simply never recorded a judgment-only
1878        // confirmation.
1879        let mut legacy = serde_json::to_value(&event).unwrap();
1880        legacy["payload"]
1881            .as_object_mut()
1882            .unwrap()
1883            .remove("judgmentOpportunity");
1884        let back: EventKind = serde_json::from_value(legacy).unwrap();
1885        match back {
1886            EventKind::ValidationConfirm {
1887                judgment_opportunity,
1888                ..
1889            } => assert!(!judgment_opportunity, "absent reads as false"),
1890            _ => panic!("wrong variant"),
1891        }
1892    }
1893
1894    /// The additive `validation.pty.transcript` event (ticket
1895    /// `pty-functional-validation`): wire name, payload shape, and
1896    /// round-trip — the audit record binding a pty-script assertion's
1897    /// verdict to its transcript artifact must survive serde verbatim, and
1898    /// a legacy line without `detail` still decodes (serde default).
1899    #[test]
1900    fn validation_pty_transcript_round_trips() {
1901        let event = EventKind::ValidationPtyTranscript {
1902            milestone_id: "ms-1".to_string(),
1903            assertion_id: "a-pty".to_string(),
1904            verdict: crate::gate::GateVerdict::Fail,
1905            artefact_ref: "file:runs/pty-transcripts/a-pty-0123abcd.log".to_string(),
1906            detail: Some("step 1 ok step 2 FAILED (expect `echo:hello` timed out)".to_string()),
1907        };
1908        let json = serde_json::to_value(&event).unwrap();
1909        assert_eq!(json["type"], "validation.pty.transcript");
1910        assert_eq!(json["payload"]["milestoneId"], "ms-1");
1911        assert_eq!(json["payload"]["assertionId"], "a-pty");
1912        assert_eq!(
1913            json["payload"]["artefactRef"],
1914            "file:runs/pty-transcripts/a-pty-0123abcd.log"
1915        );
1916        assert_eq!(event.type_name(), "validation.pty.transcript");
1917        let back: EventKind = serde_json::from_value(json.clone()).unwrap();
1918        match back {
1919            EventKind::ValidationPtyTranscript {
1920                milestone_id,
1921                assertion_id,
1922                verdict,
1923                artefact_ref,
1924                detail,
1925            } => {
1926                assert_eq!(milestone_id, "ms-1");
1927                assert_eq!(assertion_id, "a-pty");
1928                assert_eq!(verdict, crate::gate::GateVerdict::Fail);
1929                assert_eq!(artefact_ref, "file:runs/pty-transcripts/a-pty-0123abcd.log");
1930                assert!(detail.unwrap().contains("FAILED"));
1931            }
1932            _ => panic!("wrong variant"),
1933        }
1934        // A legacy line without `detail` still decodes (serde default).
1935        let mut legacy = json;
1936        legacy["payload"].as_object_mut().unwrap().remove("detail");
1937        let back: EventKind = serde_json::from_value(legacy).unwrap();
1938        match back {
1939            EventKind::ValidationPtyTranscript { detail, .. } => assert_eq!(detail, None),
1940            _ => panic!("wrong variant"),
1941        }
1942    }
1943
1944    /// The additive `gate.result` event (ticket
1945    /// `gate-results-first-class-events`, KRZ-312): wire name, exact payload
1946    /// shape, and round-trip. A full record — surface, ladder section +
1947    /// index, stated verdict, artefact handle with captured detail, and the
1948    /// optional score pair — survives serde verbatim, because replay
1949    /// reconstructs the ladder from these bytes alone.
1950    #[test]
1951    fn gate_result_event_wire_shape_and_round_trip() {
1952        let result = EventKind::GateResult {
1953            gate: "vacuous-filter".to_string(),
1954            surface: crate::gate::GateSurface::Approval,
1955            kind: crate::gate::GateKind::Deterministic,
1956            index: 0,
1957            verdict: crate::gate::GateVerdict::Fail,
1958            artefact_ref: "contract gate vacuous-filter".to_string(),
1959            artefact_detail: Some("[a-1] test-runner pipeline's grep anchors no nonzero count: `cargo test | grep ok`".to_string()),
1960            score: Some(0.42),
1961            threshold: Some(0.75),
1962            rule_ids: Vec::new(),
1963        };
1964        let json = serde_json::to_value(&result).unwrap();
1965        assert_eq!(json["type"], "gate.result");
1966        assert_eq!(json["payload"]["gate"], "vacuous-filter");
1967        assert_eq!(json["payload"]["surface"], "approval");
1968        assert_eq!(json["payload"]["kind"], "deterministic");
1969        assert_eq!(json["payload"]["index"], 0);
1970        assert_eq!(json["payload"]["verdict"], "fail");
1971        assert_eq!(
1972            json["payload"]["artefactRef"],
1973            "contract gate vacuous-filter"
1974        );
1975        assert_eq!(json["payload"]["score"], 0.42);
1976        assert_eq!(json["payload"]["threshold"], 0.75);
1977        assert_eq!(result.type_name(), "gate.result");
1978        let back: EventKind = serde_json::from_value(json).unwrap();
1979        match back {
1980            EventKind::GateResult {
1981                gate,
1982                surface,
1983                kind,
1984                index,
1985                verdict,
1986                artefact_ref,
1987                artefact_detail,
1988                score,
1989                threshold,
1990                rule_ids,
1991            } => {
1992                assert_eq!(gate, "vacuous-filter");
1993                assert_eq!(surface, crate::gate::GateSurface::Approval);
1994                assert_eq!(kind, crate::gate::GateKind::Deterministic);
1995                assert_eq!(index, 0);
1996                assert_eq!(verdict, crate::gate::GateVerdict::Fail);
1997                assert_eq!(artefact_ref, "contract gate vacuous-filter");
1998                assert!(artefact_detail.as_deref().unwrap().contains("[a-1]"));
1999                assert_eq!(score, Some(0.42));
2000                assert_eq!(threshold, Some(0.75));
2001                assert!(rule_ids.is_empty());
2002            }
2003            _ => panic!("wrong variant"),
2004        }
2005    }
2006
2007    /// Boolean-only gates carry no score, and a reference without captured
2008    /// content carries no detail: all three are additive-optional — `None`
2009    /// stays OFF the wire (byte-identical to a payload that never had them)
2010    /// and a line without them parses back to `None` (serde default), so
2011    /// hand-written or future-trimmed logs fold like engine-written ones.
2012    /// KRZ-343's `ruleIds` follows the same rule: a gate with no standards
2013    /// linkage carries an empty list, which serializes as NO key.
2014    #[test]
2015    fn gate_result_event_optional_fields_are_additive() {
2016        let sparse = EventKind::GateResult {
2017            gate: "env-sensitive".to_string(),
2018            surface: crate::gate::GateSurface::FinalGate,
2019            kind: crate::gate::GateKind::ModelJudged,
2020            index: 2,
2021            verdict: crate::gate::GateVerdict::Pass,
2022            artefact_ref: "contract gate env-sensitive".to_string(),
2023            artefact_detail: None,
2024            score: None,
2025            threshold: None,
2026            rule_ids: Vec::new(),
2027        };
2028        let json = serde_json::to_value(&sparse).unwrap();
2029        assert_eq!(json["payload"]["surface"], "final-gate");
2030        assert_eq!(json["payload"]["kind"], "model-judged");
2031        assert_eq!(json["payload"]["verdict"], "pass");
2032        let payload = json["payload"].as_object().unwrap();
2033        for absent in ["artefactDetail", "score", "threshold", "ruleIds"] {
2034            assert!(
2035                !payload.contains_key(absent),
2036                "payload must not contain {absent} when None: {json}"
2037            );
2038        }
2039
2040        // A wire line naming only the required fields folds with the
2041        // optional ones defaulted to None.
2042        let line = r#"{
2043            "seq": 7,
2044            "ts": "2026-01-02T03:04:05Z",
2045            "missionId": "m-1",
2046            "type": "gate.result",
2047            "payload": {
2048                "gate": "merge-gate-suite",
2049                "surface": "final-gate",
2050                "kind": "deterministic",
2051                "index": 1,
2052                "verdict": "pass",
2053                "artefactRef": ".kranz/merge-gates.json"
2054            }
2055        }"#;
2056        let event: Event = serde_json::from_str(line).unwrap();
2057        match event.kind {
2058            EventKind::GateResult {
2059                gate,
2060                artefact_detail,
2061                score,
2062                threshold,
2063                ..
2064            } => {
2065                assert_eq!(gate, "merge-gate-suite");
2066                assert_eq!(artefact_detail, None);
2067                assert_eq!(score, None);
2068                assert_eq!(threshold, None);
2069            }
2070            _ => panic!("wrong variant"),
2071        }
2072    }
2073
2074    /// The additive `worker.escalated` event (ticket
2075    /// `backend-routing-abstraction`, KRZ-331): wire name, exact payload
2076    /// shape, and round-trip — the gate.result template. The payload names
2077    /// the source and target routes as capability classes (ExecutorTier's
2078    /// lowercase wire form), never model ids.
2079    #[test]
2080    fn routing_abstraction_worker_escalated_wire_shape_and_round_trip() {
2081        let kind = EventKind::WorkerEscalated {
2082            run_id: "r-1".to_string(),
2083            feature_id: "f-1-1".to_string(),
2084            from: ExecutorTier::Local,
2085            to: ExecutorTier::Frontier,
2086            reason: "spec ambiguity beyond my confidence".to_string(),
2087        };
2088        let json = serde_json::to_value(&kind).unwrap();
2089        assert_eq!(json["type"], "worker.escalated");
2090        assert_eq!(json["payload"]["runId"], "r-1");
2091        assert_eq!(json["payload"]["featureId"], "f-1-1");
2092        assert_eq!(json["payload"]["from"], "local");
2093        assert_eq!(json["payload"]["to"], "frontier");
2094        assert_eq!(
2095            json["payload"]["reason"],
2096            "spec ambiguity beyond my confidence"
2097        );
2098        assert_eq!(kind.type_name(), "worker.escalated");
2099        let back: EventKind = serde_json::from_value(json).unwrap();
2100        match back {
2101            EventKind::WorkerEscalated {
2102                run_id,
2103                feature_id,
2104                from,
2105                to,
2106                reason,
2107            } => {
2108                assert_eq!(run_id, "r-1");
2109                assert_eq!(feature_id, "f-1-1");
2110                assert_eq!(from, ExecutorTier::Local);
2111                assert_eq!(to, ExecutorTier::Frontier);
2112                assert_eq!(reason, "spec ambiguity beyond my confidence");
2113            }
2114            _ => panic!("wrong variant"),
2115        }
2116    }
2117
2118    /// The additive `candidate` on `worker.spawned` (ticket
2119    /// heterogeneous-dispatch-pool, KRZ-303): the sibling linkage round-trips
2120    /// when present, old log lines without it fold to None, and None never
2121    /// hits the wire.
2122    #[test]
2123    fn dispatch_pool_worker_spawned_candidate_is_additive() {
2124        fn spawned(candidate: Option<CandidateLink>) -> EventKind {
2125            EventKind::WorkerSpawned {
2126                backend: None,
2127                run_id: "r-1".into(),
2128                role: Role::Worker,
2129                feature_id: Some("f-1-1".into()),
2130                milestone_id: None,
2131                candidate,
2132                executor_route: None,
2133                sdk_session_id: "s".into(),
2134                model: "sonnet".into(),
2135                quant: "n/a".into(),
2136                weight_hash: None,
2137                prompt_hash: "h".into(),
2138                transcript_path: "t".into(),
2139            }
2140        }
2141        let link = CandidateLink {
2142            unit: "f-1-1".into(),
2143            index: 1,
2144            count: 2,
2145            backend: "codex".into(),
2146        };
2147
2148        // Some: camelCase wire shape, full round-trip.
2149        let json = serde_json::to_value(spawned(Some(link.clone()))).unwrap();
2150        assert_eq!(json["payload"]["candidate"]["unit"], "f-1-1");
2151        assert_eq!(json["payload"]["candidate"]["index"], 1);
2152        assert_eq!(json["payload"]["candidate"]["count"], 2);
2153        assert_eq!(json["payload"]["candidate"]["backend"], "codex");
2154        let back: EventKind = serde_json::from_value(json).unwrap();
2155        match back {
2156            EventKind::WorkerSpawned { candidate, .. } => {
2157                assert_eq!(candidate, Some(link))
2158            }
2159            _ => panic!("wrong variant"),
2160        }
2161
2162        // None: omitted from the wire (byte-identical to pre-pool logs).
2163        let json = serde_json::to_value(spawned(None)).unwrap();
2164        assert!(
2165            !json["payload"]
2166                .as_object()
2167                .unwrap()
2168                .contains_key("candidate"),
2169            "candidate must not serialize when None: {json}"
2170        );
2171
2172        // Old log line (pre-candidate): folds with candidate = None.
2173        let old: EventKind = serde_json::from_str(
2174            r#"{"type":"worker.spawned","payload":{"runId":"r-1","role":"worker","featureId":"f-1-1","sdkSessionId":"s","model":"sonnet","promptHash":"h","transcriptPath":"t"}}"#,
2175        )
2176        .unwrap();
2177        match old {
2178            EventKind::WorkerSpawned { candidate, .. } => assert_eq!(candidate, None),
2179            _ => panic!("wrong variant"),
2180        }
2181    }
2182
2183    /// The additive `executorRoute` field (ticket `routing-rules-config`):
2184    /// the effective route + deciding rule round-trips in camelCase when
2185    /// present, old log lines without it fold to None, and None never hits
2186    /// the wire (byte-identical to pre-provenance logs).
2187    #[test]
2188    fn routing_rules_config_worker_spawned_executor_route_is_additive() {
2189        fn spawned(executor_route: Option<crate::types::ExecutorRoute>) -> EventKind {
2190            EventKind::WorkerSpawned {
2191                backend: None,
2192                run_id: "r-1".into(),
2193                role: Role::Worker,
2194                feature_id: Some("f-1-1".into()),
2195                milestone_id: None,
2196                candidate: None,
2197                executor_route,
2198                sdk_session_id: "s".into(),
2199                model: "sonnet".into(),
2200                quant: "n/a".into(),
2201                weight_hash: None,
2202                prompt_hash: "h".into(),
2203                transcript_path: "t".into(),
2204            }
2205        }
2206
2207        // Some: camelCase wire shape, full round-trip — rule omitted when
2208        // the fall-through decided (None never serializes).
2209        let route = crate::types::ExecutorRoute {
2210            tier: crate::types::ExecutorTier::Local,
2211            rule: Some("taskClassRules[0]".to_string()),
2212        };
2213        let json = serde_json::to_value(spawned(Some(route.clone()))).unwrap();
2214        assert_eq!(json["payload"]["executorRoute"]["tier"], "local");
2215        assert_eq!(
2216            json["payload"]["executorRoute"]["rule"],
2217            "taskClassRules[0]"
2218        );
2219        let back: EventKind = serde_json::from_value(json).unwrap();
2220        match back {
2221            EventKind::WorkerSpawned { executor_route, .. } => {
2222                assert_eq!(executor_route, Some(route))
2223            }
2224            _ => panic!("wrong variant"),
2225        }
2226        let fall_through = crate::types::ExecutorRoute {
2227            tier: crate::types::ExecutorTier::Frontier,
2228            rule: None,
2229        };
2230        let json = serde_json::to_value(spawned(Some(fall_through))).unwrap();
2231        assert_eq!(json["payload"]["executorRoute"]["tier"], "frontier");
2232        assert!(
2233            !json["payload"]["executorRoute"]
2234                .as_object()
2235                .unwrap()
2236                .contains_key("rule"),
2237            "a fall-through route must not serialize a rule key: {json}"
2238        );
2239
2240        // None: omitted from the wire (byte-identical to pre-provenance logs).
2241        let json = serde_json::to_value(spawned(None)).unwrap();
2242        assert!(
2243            !json["payload"]
2244                .as_object()
2245                .unwrap()
2246                .contains_key("executorRoute"),
2247            "executorRoute must not serialize when None: {json}"
2248        );
2249
2250        // Old log line (pre-provenance): folds with executor_route = None.
2251        let old: EventKind = serde_json::from_str(
2252            r#"{"type":"worker.spawned","payload":{"runId":"r-1","role":"worker","featureId":"f-1-1","sdkSessionId":"s","model":"sonnet","promptHash":"h","transcriptPath":"t"}}"#,
2253        )
2254        .unwrap();
2255        match old {
2256            EventKind::WorkerSpawned { executor_route, .. } => {
2257                assert_eq!(executor_route, None)
2258            }
2259            _ => panic!("wrong variant"),
2260        }
2261    }
2262
2263    /// The additive `divergence.noted` event (ticket
2264    /// `divergence-first-class-event`, KRZ-304): wire name, payload shape,
2265    /// and round-trip — the comparison record naming every candidate ref
2266    /// (run id + branch + backend + tree hash) and the verdict. The
2267    /// `diverged: false` form IS the agreement record: logged, never
2268    /// trusted.
2269    #[test]
2270    fn divergence_event_noted_wire_shape_and_round_trip() {
2271        let noted = EventKind::DivergenceNoted {
2272            unit: "f-1-1".into(),
2273            candidates: vec![
2274                DivergenceCandidate {
2275                    run_id: "r-1".into(),
2276                    branch: "kranz/pool/m-1/f-1-1-c0".into(),
2277                    backend: "claude".into(),
2278                    tree: "aaa".into(),
2279                },
2280                DivergenceCandidate {
2281                    run_id: "r-2".into(),
2282                    branch: "kranz/pool/m-1/f-1-1-c1".into(),
2283                    backend: "codex".into(),
2284                    tree: "bbb".into(),
2285                },
2286            ],
2287            diverged: true,
2288        };
2289        let json = serde_json::to_value(&noted).unwrap();
2290        assert_eq!(json["type"], "divergence.noted");
2291        assert_eq!(json["payload"]["unit"], "f-1-1");
2292        assert_eq!(json["payload"]["diverged"], true);
2293        assert_eq!(json["payload"]["candidates"][0]["runId"], "r-1");
2294        assert_eq!(
2295            json["payload"]["candidates"][1]["branch"],
2296            "kranz/pool/m-1/f-1-1-c1"
2297        );
2298        assert_eq!(json["payload"]["candidates"][1]["backend"], "codex");
2299        assert_eq!(json["payload"]["candidates"][1]["tree"], "bbb");
2300        assert_eq!(noted.type_name(), "divergence.noted");
2301        let back: EventKind = serde_json::from_value(json).unwrap();
2302        match back {
2303            EventKind::DivergenceNoted {
2304                unit,
2305                candidates,
2306                diverged,
2307            } => {
2308                assert_eq!(unit, "f-1-1");
2309                assert!(diverged);
2310                assert_eq!(candidates.len(), 2);
2311                assert_eq!(candidates[0].run_id, "r-1");
2312                assert_eq!(candidates[1].tree, "bbb");
2313            }
2314            _ => panic!("wrong variant"),
2315        }
2316
2317        // The agreement record is the SAME kind with diverged = false —
2318        // there is no separate, trustable "agreement" event shape.
2319        let agreed = EventKind::DivergenceNoted {
2320            unit: "f-1-1".into(),
2321            candidates: vec![
2322                DivergenceCandidate {
2323                    run_id: "r-1".into(),
2324                    branch: "kranz/pool/m-1/f-1-1-c0".into(),
2325                    backend: "claude".into(),
2326                    tree: "aaa".into(),
2327                },
2328                DivergenceCandidate {
2329                    run_id: "r-2".into(),
2330                    branch: "kranz/pool/m-1/f-1-1-c1".into(),
2331                    backend: "codex".into(),
2332                    tree: "aaa".into(),
2333                },
2334            ],
2335            diverged: false,
2336        };
2337        let json = serde_json::to_value(&agreed).unwrap();
2338        assert_eq!(json["type"], "divergence.noted");
2339        assert_eq!(json["payload"]["diverged"], false);
2340    }
2341
2342    /// The additive `divergence.resolved` event (KRZ-304): the resolution
2343    /// naming WHICH candidate (or none), WHY, and decided by WHOM.
2344    /// `selected: None` means judged-and-abandoned, is omitted from the
2345    /// wire, and a wire line without it parses back to None (serde default)
2346    /// — so hand-written or future-trimmed logs fold like engine-written
2347    /// ones.
2348    #[test]
2349    fn divergence_event_resolved_wire_shape_and_round_trip() {
2350        let resolved = EventKind::DivergenceResolved {
2351            unit: "f-1-1".into(),
2352            selected: Some(1),
2353            reason: "the codex candidate keeps the parser total".into(),
2354            decided_by: "operator".into(),
2355        };
2356        let json = serde_json::to_value(&resolved).unwrap();
2357        assert_eq!(json["type"], "divergence.resolved");
2358        assert_eq!(json["payload"]["unit"], "f-1-1");
2359        assert_eq!(json["payload"]["selected"], 1);
2360        assert_eq!(
2361            json["payload"]["reason"],
2362            "the codex candidate keeps the parser total"
2363        );
2364        assert_eq!(json["payload"]["decidedBy"], "operator");
2365        assert_eq!(resolved.type_name(), "divergence.resolved");
2366        let back: EventKind = serde_json::from_value(json).unwrap();
2367        match back {
2368            EventKind::DivergenceResolved {
2369                unit,
2370                selected,
2371                reason,
2372                decided_by,
2373            } => {
2374                assert_eq!(unit, "f-1-1");
2375                assert_eq!(selected, Some(1));
2376                assert!(reason.contains("codex"));
2377                assert_eq!(decided_by, "operator");
2378            }
2379            _ => panic!("wrong variant"),
2380        }
2381
2382        // None = judged-and-abandoned: off the wire, and a wire line
2383        // without the key parses back to None.
2384        let none = EventKind::DivergenceResolved {
2385            unit: "f-1-1".into(),
2386            selected: None,
2387            reason: "neither candidate survives review".into(),
2388            decided_by: "operator".into(),
2389        };
2390        let json = serde_json::to_value(&none).unwrap();
2391        assert!(
2392            !json["payload"]
2393                .as_object()
2394                .unwrap()
2395                .contains_key("selected"),
2396            "selected must not serialize when None: {json}"
2397        );
2398        let line = r#"{
2399            "seq": 9,
2400            "ts": "2026-01-02T03:04:05Z",
2401            "missionId": "m-1",
2402            "type": "divergence.resolved",
2403            "payload": {
2404                "unit": "f-1-1",
2405                "reason": "milestone skipped by operator",
2406                "decidedBy": "operator"
2407            }
2408        }"#;
2409        let event: Event = serde_json::from_str(line).unwrap();
2410        match event.kind {
2411            EventKind::DivergenceResolved {
2412                selected,
2413                decided_by,
2414                ..
2415            } => {
2416                assert_eq!(selected, None);
2417                assert_eq!(decided_by, "operator");
2418            }
2419            _ => panic!("wrong variant"),
2420        }
2421    }
2422
2423    /// The additive `hook.gate.fired` event (ticket
2424    /// claude-code-hook-gate-projection, KRZ-302): wire name and payload
2425    /// round-trip, `detail` is omitted when None, and a sparse wire line
2426    /// folds with serde defaults — the gate.result additive template.
2427    #[test]
2428    fn hook_gate_projection_event_wire_shape_round_trips() {
2429        let fired = EventKind::HookGateFired {
2430            run_id: "r-1".into(),
2431            gate: "out-of-contract-write".into(),
2432            hook_event: "PreToolUse".into(),
2433            tool: "Write".into(),
2434            subject: "docs/oops.md".into(),
2435            verdict: "blocked".into(),
2436            detail: Some("matches none of the declared touch-set globs".into()),
2437        };
2438        let json = serde_json::to_value(&fired).unwrap();
2439        assert_eq!(json["type"], "hook.gate.fired");
2440        assert_eq!(json["payload"]["runId"], "r-1");
2441        assert_eq!(json["payload"]["gate"], "out-of-contract-write");
2442        assert_eq!(json["payload"]["hookEvent"], "PreToolUse");
2443        assert_eq!(json["payload"]["tool"], "Write");
2444        assert_eq!(json["payload"]["subject"], "docs/oops.md");
2445        assert_eq!(json["payload"]["verdict"], "blocked");
2446        assert_eq!(fired.type_name(), "hook.gate.fired");
2447        let back: EventKind = serde_json::from_value(json).unwrap();
2448        match back {
2449            EventKind::HookGateFired {
2450                run_id,
2451                gate,
2452                verdict,
2453                detail,
2454                ..
2455            } => {
2456                assert_eq!(run_id, "r-1");
2457                assert_eq!(gate, "out-of-contract-write");
2458                assert_eq!(verdict, "blocked");
2459                assert_eq!(
2460                    detail.as_deref(),
2461                    Some("matches none of the declared touch-set globs")
2462                );
2463            }
2464            _ => panic!("wrong variant"),
2465        }
2466
2467        // detail = None stays off the wire (additive; old readers never see
2468        // the key), and a wire line without it folds to None.
2469        let no_detail = EventKind::HookGateFired {
2470            run_id: "r-2".into(),
2471            gate: "out-of-contract-write".into(),
2472            hook_event: "PreToolUse".into(),
2473            tool: "Edit".into(),
2474            subject: "x".into(),
2475            verdict: "error".into(),
2476            detail: None,
2477        };
2478        let json = serde_json::to_value(&no_detail).unwrap();
2479        assert!(
2480            !json["payload"].as_object().unwrap().contains_key("detail"),
2481            "detail must not serialize when None: {json}"
2482        );
2483        let sparse: EventKind = serde_json::from_str(
2484            r#"{"type":"hook.gate.fired","payload":{"runId":"r-3","gate":"out-of-contract-write","hookEvent":"PreToolUse","tool":"Write","subject":"y","verdict":"blocked"}}"#,
2485        )
2486        .unwrap();
2487        match sparse {
2488            EventKind::HookGateFired { detail, .. } => assert_eq!(detail, None),
2489            _ => panic!("wrong variant"),
2490        }
2491    }
2492
2493    /// The additive question events (ticket
2494    /// `structured-human-question-events`): wire names, exact payload shapes,
2495    /// and round-trips. Every optional field stays OFF the wire when absent,
2496    /// and sparse wire lines (hand-written or future-trimmed logs) fold with
2497    /// serde defaults — the gate.result additive template.
2498    #[test]
2499    fn question_events_wire_shapes_and_round_trips() {
2500        let opened = EventKind::QuestionOpened {
2501            question_id: "q-1".into(),
2502            role: Role::Worker,
2503            text: "Which storage engine should the cache use?".into(),
2504            options: vec!["sqlite".into(), "in-memory".into()],
2505            run_id: Some("r-1".into()),
2506            feature_id: Some("f-1-1".into()),
2507            milestone_id: Some("ms-1".into()),
2508        };
2509        let json = serde_json::to_value(&opened).unwrap();
2510        assert_eq!(json["type"], "question.opened");
2511        assert_eq!(json["payload"]["questionId"], "q-1");
2512        assert_eq!(json["payload"]["role"], "worker");
2513        assert_eq!(
2514            json["payload"]["text"],
2515            "Which storage engine should the cache use?"
2516        );
2517        assert_eq!(
2518            json["payload"]["options"],
2519            serde_json::json!(["sqlite", "in-memory"])
2520        );
2521        assert_eq!(json["payload"]["runId"], "r-1");
2522        assert_eq!(json["payload"]["featureId"], "f-1-1");
2523        assert_eq!(json["payload"]["milestoneId"], "ms-1");
2524        assert_eq!(opened.type_name(), "question.opened");
2525        let back: EventKind = serde_json::from_value(json).unwrap();
2526        match back {
2527            EventKind::QuestionOpened {
2528                question_id,
2529                role,
2530                options,
2531                milestone_id,
2532                ..
2533            } => {
2534                assert_eq!(question_id, "q-1");
2535                assert_eq!(role, Role::Worker);
2536                assert_eq!(options.len(), 2);
2537                assert_eq!(milestone_id.as_deref(), Some("ms-1"));
2538            }
2539            _ => panic!("wrong variant"),
2540        }
2541
2542        // Empty options (a free-text ask) and absent context refs stay off
2543        // the wire, and a sparse line folds them to the defaults.
2544        let free_text = EventKind::QuestionOpened {
2545            question_id: "q-2".into(),
2546            role: Role::Worker,
2547            text: "What should the flag be called?".into(),
2548            options: vec![],
2549            run_id: None,
2550            feature_id: None,
2551            milestone_id: None,
2552        };
2553        let json = serde_json::to_value(&free_text).unwrap();
2554        let payload = json["payload"].as_object().unwrap();
2555        for absent in ["options", "runId", "featureId", "milestoneId"] {
2556            assert!(
2557                !payload.contains_key(absent),
2558                "payload must not contain {absent} when empty/None: {json}"
2559            );
2560        }
2561        let sparse: EventKind = serde_json::from_str(
2562            r#"{"type":"question.opened","payload":{"questionId":"q-2","role":"worker","text":"What should the flag be called?"}}"#,
2563        )
2564        .unwrap();
2565        match sparse {
2566            EventKind::QuestionOpened {
2567                options,
2568                run_id,
2569                feature_id,
2570                milestone_id,
2571                ..
2572            } => {
2573                assert!(options.is_empty());
2574                assert_eq!(run_id, None);
2575                assert_eq!(feature_id, None);
2576                assert_eq!(milestone_id, None);
2577            }
2578            _ => panic!("wrong variant"),
2579        }
2580
2581        let answered = EventKind::QuestionAnswered {
2582            question_id: "q-1".into(),
2583            answer: "sqlite".into(),
2584            via: "answer-question".into(),
2585            option: Some(0),
2586        };
2587        let json = serde_json::to_value(&answered).unwrap();
2588        assert_eq!(json["type"], "question.answered");
2589        assert_eq!(json["payload"]["questionId"], "q-1");
2590        assert_eq!(json["payload"]["answer"], "sqlite");
2591        assert_eq!(json["payload"]["via"], "answer-question");
2592        assert_eq!(json["payload"]["option"], 0);
2593        assert_eq!(answered.type_name(), "question.answered");
2594        let back: EventKind = serde_json::from_value(json).unwrap();
2595        assert!(matches!(back, EventKind::QuestionAnswered { .. }));
2596
2597        // option = None (free-text answer) stays off the wire; a sparse line
2598        // folds it to None.
2599        let free_answer = EventKind::QuestionAnswered {
2600            question_id: "q-2".into(),
2601            answer: "call it --cache-dir".into(),
2602            via: "answer-question".into(),
2603            option: None,
2604        };
2605        let json = serde_json::to_value(&free_answer).unwrap();
2606        assert!(
2607            !json["payload"].as_object().unwrap().contains_key("option"),
2608            "option must not serialize when None: {json}"
2609        );
2610        let sparse: EventKind = serde_json::from_str(
2611            r#"{"type":"question.answered","payload":{"questionId":"q-2","answer":"call it --cache-dir","via":"answer-question"}}"#,
2612        )
2613        .unwrap();
2614        match sparse {
2615            EventKind::QuestionAnswered { option, .. } => assert_eq!(option, None),
2616            _ => panic!("wrong variant"),
2617        }
2618
2619        let cleared = EventKind::QuestionCleared {
2620            question_id: "q-1".into(),
2621            why: "milestone completed".into(),
2622        };
2623        let json = serde_json::to_value(&cleared).unwrap();
2624        assert_eq!(json["type"], "question.cleared");
2625        assert_eq!(json["payload"]["questionId"], "q-1");
2626        assert_eq!(json["payload"]["why"], "milestone completed");
2627        assert_eq!(cleared.type_name(), "question.cleared");
2628        let back: EventKind = serde_json::from_value(json).unwrap();
2629        assert!(matches!(back, EventKind::QuestionCleared { .. }));
2630    }
2631}