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