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