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