kranz_engine/types.rs
1//! Core data model for Kranz missions (plan §4.2).
2//!
3//! CONTRACT FILE — do not modify in implementation phases. If a change seems
4//! necessary, report it instead of editing.
5//!
6//! All types serialize camelCase to match the plan document's JSON shapes.
7//! `plan.json`, `state.json`, and event payloads are built from these types.
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use std::collections::{BTreeMap, BTreeSet};
12
13/// Typed source and cause of a milestone block or its resolution. A present
14/// context is authoritative; unknown values never inherit legacy prose meaning.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "camelCase")]
17pub struct BlockContext {
18 #[serde(default)]
19 pub owner: BlockOwner,
20 #[serde(default)]
21 pub cause: BlockCause,
22}
23
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "camelCase")]
26pub enum BlockOwner {
27 WorkspaceGate,
28 Engine,
29 Operator,
30 #[default]
31 #[serde(other)]
32 Unknown,
33}
34
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "camelCase")]
37pub enum BlockCause {
38 WorkspaceCheck,
39 Grant,
40 SecretScan,
41 ContractBug,
42 FixCycleCap,
43 UntrustedValidator,
44 ValidatorTamper,
45 ReviewerIndependence,
46 Validation,
47 Authentication,
48 Operator,
49 #[default]
50 #[serde(other)]
51 Unknown,
52}
53
54impl BlockContext {
55 pub const WORKSPACE_GATE: Self = Self {
56 owner: BlockOwner::WorkspaceGate,
57 cause: BlockCause::WorkspaceCheck,
58 };
59
60 pub const OPERATOR: Self = Self {
61 owner: BlockOwner::Operator,
62 cause: BlockCause::Operator,
63 };
64
65 pub const fn engine(cause: BlockCause) -> Self {
66 Self {
67 owner: BlockOwner::Engine,
68 cause,
69 }
70 }
71
72 pub fn is_workspace_gate(self) -> bool {
73 self == Self::WORKSPACE_GATE
74 }
75}
76
77// ---------------------------------------------------------------------------
78// Mission
79// ---------------------------------------------------------------------------
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "lowercase")]
83pub enum MissionStatus {
84 Planning,
85 /// Plan approved; run loop has not yet started (folded on `plan.approved`,
86 /// transitions to `Running` on the first `milestone.started` or
87 /// `worker.spawned`).
88 Approved,
89 Running,
90 Paused,
91 Blocked,
92 Validating,
93 Complete,
94 Failed,
95 /// Explicitly retired by the operator (`kranz abandon`) — a terminal state
96 /// distinct from Failed (the mission didn't fail, it was called off).
97 Abandoned,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101#[serde(rename_all = "camelCase")]
102pub struct Mission {
103 pub id: String,
104 pub goal: String,
105 /// Defined BEFORE features (plan §2.3).
106 pub validation_contract: Vec<Assertion>,
107 pub milestones: Vec<Milestone>,
108 pub status: MissionStatus,
109 pub created_at: DateTime<Utc>,
110 /// e.g. "main"
111 pub base_branch: String,
112 /// Base-branch commit SHA pinned at plan approval; `None` until approved
113 /// or for missions created before this field existed.
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub base_sha: Option<String>,
116 /// e.g. `kranz/mission-<id>`
117 pub mission_branch: String,
118 /// Read-only shell commands granted mission-wide to worker AND validator
119 /// sessions; single source of truth carried from the approved `Plan`.
120 #[serde(default, skip_serializing_if = "Vec::is_empty")]
121 pub command_grants: Vec<String>,
122 /// Gitignore/glob-style repo-relative path patterns the mission is
123 /// allowed to touch; single source of truth carried from the approved
124 /// `Plan`.
125 #[serde(default, skip_serializing_if = "Vec::is_empty")]
126 pub touch_set: Vec<String>,
127 /// Worker deny rules (e.g. `Bash(git push*)`) an operator has LIFTED for
128 /// this mission via a `WorkerDeny` grant — subtracted from the worker deny
129 /// set by `permissions::for_role`. Extend-only, runtime-only (never plan-
130 /// declared): a deliberate, logged erosion of a safety guardrail.
131 #[serde(default, skip_serializing_if = "Vec::is_empty")]
132 pub deny_exceptions: Vec<String>,
133 /// Egress destinations (`host:port`) an operator has GRANTED for this
134 /// mission — folded into the egress proxy's allowlist for `fs+net`
135 /// sessions (`crate::egress_proxy`). Extend-only, runtime-only (never
136 /// plan-declared): the fold target a `GrantKind::Egress` approval extends;
137 /// read into the proxy allowlist at spec build so that approval needs no
138 /// plumbing change.
139 #[serde(default, skip_serializing_if = "Vec::is_empty")]
140 pub egress_grants: Vec<String>,
141 /// The executor route this mission was seeded with (ticket
142 /// `routing-rules-config`): derived at fold time from `mission.created`'s
143 /// original folded goal + routed config ([`crate::routing::seed_executor_route`])
144 /// — `plan.approved` overwrites `goal` with the plan's own, so the task
145 /// class exists only on that first event and the decision is folded here
146 /// once, then replayed onto every `worker.spawned`. `None` when the seed
147 /// carried no task class; additive (absent in pre-existing state
148 /// snapshots). A mid-mission `config.changed` backend flip moves the
149 /// LIVE tier ([`MissionState::executor_tier`]), not this seed-time record.
150 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub executor_route: Option<ExecutorRoute>,
152 /// The Flight Rules standards pin folded from the approved plan
153 /// (`plan.approved` / `plan.revised`; KRZ-342 D-E) — the single source of
154 /// truth every later mission stage resolves against. `None` for missions
155 /// without a standards-configured pack and in every pre-KRZ-342 state
156 /// snapshot; additive.
157 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub standards_manifest: Option<StandardsPin>,
159 /// Operator policy pinned by plan approval, never replaced by live config.
160 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub reviewer_independence: Option<ReviewerIndependence>,
162}
163
164// ---------------------------------------------------------------------------
165// Plan (the orchestrator's structured output; committed as plan.json)
166// ---------------------------------------------------------------------------
167
168/// The approved plan as emitted by the orchestrator and committed by the
169/// engine as the first commit on the mission branch (plan §4.4).
170#[derive(Debug, Clone, Serialize, Deserialize)]
171#[serde(rename_all = "camelCase")]
172pub struct Plan {
173 pub goal: String,
174 pub validation_contract: Vec<Assertion>,
175 pub milestones: Vec<PlanMilestone>,
176 /// Review material required for broad/expensive plans: the approach the
177 /// planner chose and at least two rejected shapes with their trade-offs.
178 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub considered_alternatives: Option<ConsideredAlternatives>,
180 /// Read-only shell commands the plan declares as runnable by BOTH worker
181 /// and validator sessions.
182 #[serde(default, skip_serializing_if = "Vec::is_empty")]
183 pub command_grants: Vec<String>,
184 /// Gitignore/glob-style repo-relative path patterns the mission is
185 /// allowed to touch.
186 #[serde(default, skip_serializing_if = "Vec::is_empty")]
187 pub touch_set: Vec<String>,
188 /// The Flight Rules standards manifest pinned at approval (ticket
189 /// `flight-rules-resolution-pin`, KRZ-342; design D-E): the
190 /// engine-resolved applicable-rule snapshot — pack identity + digest,
191 /// selection inputs, and every applicable rule's id, revision, effective
192 /// status, statement, scopes, and checker binding. The ENGINE resolves
193 /// and writes it from the trusted source at `approve_plan`; a plan
194 /// carrying a stale or substituted manifest is rejected there. Additive:
195 /// `None` in every pre-KRZ-342 plan and whenever no standards-configured
196 /// pack governs, and `skip_serializing_if` keeps those plans
197 /// byte-identical. Boxed: the pin is a rare, sizable field, and an
198 /// inline `StandardsPin` would push `Plan` past the
199 /// `large_enum_variant` budget on `PlanRequest::Ready`.
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub standards_manifest: Option<Box<StandardsPin>>,
202 /// Engine-owned review requirement, copied from configuration at approval.
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub reviewer_independence: Option<ReviewerIndependence>,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(rename_all = "camelCase")]
209pub struct ConsideredAlternatives {
210 pub chosen: String,
211 pub rejected: Vec<RejectedAlternative>,
212}
213
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(rename_all = "camelCase")]
216pub struct RejectedAlternative {
217 pub approach: String,
218 pub trade_off: String,
219}
220
221// ---------------------------------------------------------------------------
222// Flight Rules approval pin (KRZ-342, design D-D/D-E)
223// ---------------------------------------------------------------------------
224
225/// Where the pinned standards bytes came from (KRZ-342 D-A/D-E) — the trust
226/// posture approval resolved under, recorded so later stages know whether a
227/// live-base re-read exists at all.
228#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
229#[serde(rename_all = "kebab-case")]
230pub enum StandardsPinSource {
231 /// A tracked, repo-relative pack read from the pinned base git tree
232 /// (`pack_dir` is its repo-relative slash path). Enforced rules may be
233 /// active; merge re-reads the LIVE base for the policy-drift check.
234 RepoTracked,
235 /// An external/untracked pack capability-read once at approval: the
236 /// pinned bytes are the only authority (advisory rules only — the loader
237 /// refuses enforced ones), and a later filesystem edit cannot change the
238 /// run. `pack_dir` is informational (the as-configured path).
239 ExternalPinned,
240}
241
242impl StandardsPinSource {
243 pub fn as_str(&self) -> &'static str {
244 match self {
245 Self::RepoTracked => "repo-tracked",
246 Self::ExternalPinned => "external-pinned",
247 }
248 }
249}
250
251/// One rule's approval-pinned snapshot inside [`StandardsPin`] — the consent
252/// surface (D-E): what the operator accepted, verbatim. Strings carry the
253/// pack contract's canonical spellings (`must`/`should`,
254/// `approved`/`enforced`, stage names, the rendered checker binding) so an
255/// old log folds even if the pack vocabulary later grows additively.
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
257#[serde(rename_all = "camelCase")]
258pub struct PinnedRule {
259 pub id: String,
260 pub revision: u64,
261 /// Parent RFC id (lifecycle grouping; the identity findings join on).
262 pub rfc: String,
263 pub level: String,
264 /// The rule's EFFECTIVE lifecycle at resolution time (`approved` or
265 /// `enforced` — the resolver never pins retired or draft rules).
266 pub effective_status: String,
267 /// The one-line normative statement — the canonical machine/human text.
268 pub statement: String,
269 /// Browsing/reporting labels (D-D: never a selection input). Kept in the
270 /// pin so review and reports render them without a corpus read.
271 #[serde(default, skip_serializing_if = "Vec::is_empty")]
272 pub domains: Vec<String>,
273 #[serde(default, skip_serializing_if = "Vec::is_empty")]
274 pub stages: Vec<String>,
275 #[serde(default, skip_serializing_if = "Vec::is_empty")]
276 pub when_paths: Vec<String>,
277 #[serde(default, skip_serializing_if = "Vec::is_empty")]
278 pub task_classes: Vec<String>,
279 /// The rendered checker binding (`gate:<id>`, `agent-judgement`,
280 /// `manual-attestation`); pinned so evaluation never re-reads it from a
281 /// moved source.
282 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub checker: Option<String>,
284 #[serde(default)]
285 pub waivable: bool,
286}
287
288/// One pack gate declaration copied into the approval pin. Flight Rules
289/// checker execution consumes this snapshot, never a later mission-worktree
290/// lookup, so changing `pack.toml` on the mission branch cannot rewrite the
291/// command that judges that same mission.
292#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(rename_all = "camelCase")]
294pub struct PinnedGate {
295 pub id: String,
296 pub command: String,
297 #[serde(default, skip_serializing_if = "Vec::is_empty")]
298 pub when_paths: Vec<String>,
299}
300
301/// The approval-pinned standards manifest (KRZ-342, design D-E), carried on
302/// the plan contract as `standardsManifest`: pack identity + content digest,
303/// the selection inputs resolution ran with, and the applicable rule
304/// snapshots. The engine resolves and writes it at approval from the trusted
305/// source; every later mission stage consumes THIS snapshot — a mission
306/// branch edit or an external pack edit cannot reshape it.
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308#[serde(rename_all = "camelCase")]
309pub struct StandardsPin {
310 pub pack_name: String,
311 /// The pack directory: the repo-relative slash path for `repo-tracked`
312 /// (merge/final-validation re-reads address it against a git ref), the
313 /// as-configured path for `external-pinned` (display only — external
314 /// pins never re-read it).
315 pub pack_dir: String,
316 /// The normalized `[standards] root` inside the pack.
317 pub standards_root: String,
318 /// Lowercase hex sha256 over the pack's normalized canonical manifest
319 /// text ([`crate::pack::standards::StandardsManifest::digest`]).
320 pub digest: String,
321 pub source: StandardsPinSource,
322 /// The mission task class resolution ran with; `None` when the goal
323 /// carried no class (task-class-scoped rules then never apply).
324 #[serde(default, skip_serializing_if = "Option::is_none")]
325 pub task_class: Option<String>,
326 /// The approved touch-set globs resolution ran against (D-D input 3).
327 #[serde(default, skip_serializing_if = "Vec::is_empty")]
328 pub touch_set: Vec<String>,
329 /// Read-only paths that participate in applicability without granting
330 /// write authority. Review-artifact missions use this for the immutable
331 /// spec/incident input; additive for pre-KRZ-349 plans and logs.
332 #[serde(default, skip_serializing_if = "Vec::is_empty")]
333 pub context_paths: Vec<String>,
334 /// Every pack gate declaration from the trusted approval source. Rules
335 /// reference these by stable id; non-rule pack gates also retain their
336 /// pre-Flight-Rules advisory behavior without a live worktree re-read.
337 #[serde(default, skip_serializing_if = "Vec::is_empty")]
338 pub gates: Vec<PinnedGate>,
339 /// The applicable rules (D-D's mission-wide set — the union over the
340 /// four workflow stages), stable-sorted by id.
341 pub rules: Vec<PinnedRule>,
342}
343
344/// One rule reference on a `standards.resolved` event (D-H): the compact,
345/// queryable form of a selection. The full snapshots ride in the plan's
346/// [`StandardsPin`]; the event stays replay-cheap.
347#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
348#[serde(rename_all = "camelCase")]
349pub struct StandardsRuleRef {
350 pub id: String,
351 pub revision: u64,
352 pub effective_status: String,
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize)]
356#[serde(rename_all = "camelCase")]
357pub struct PlanMilestone {
358 pub title: String,
359 pub features: Vec<PlanFeature>,
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize)]
363#[serde(rename_all = "camelCase")]
364pub struct PlanFeature {
365 pub title: String,
366 /// What to build.
367 pub spec: String,
368 /// How we know it's done.
369 pub validation_criteria: Vec<String>,
370}
371
372// ---------------------------------------------------------------------------
373// Milestone / Feature
374// ---------------------------------------------------------------------------
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
377#[serde(rename_all = "lowercase")]
378pub enum MilestoneStatus {
379 Pending,
380 Active,
381 Validating,
382 Complete,
383 Blocked,
384}
385
386#[derive(Debug, Clone, Serialize, Deserialize)]
387#[serde(rename_all = "camelCase")]
388pub struct Milestone {
389 pub id: String,
390 pub title: String,
391 pub features: Vec<Feature>,
392 pub status: MilestoneStatus,
393 /// Loop-guard counter (plan §4.5). Incremented per validation round that
394 /// produced findings; milestone blocks when it exceeds the configured cap.
395 pub fix_cycles: u32,
396 /// Recorded at milestone.started so validators diff start..HEAD (§4.4).
397 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub start_sha: Option<String>,
399 /// Operator guidance folded from the latest milestone.unblocked event;
400 /// injected verbatim into validator tasks until the milestone completes.
401 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub validator_guidance: Option<String>,
403}
404
405#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
406#[serde(rename_all = "lowercase")]
407pub enum FeatureOrigin {
408 /// Part of the approved plan.
409 Plan,
410 /// Created by the orchestrator from a validation finding.
411 Fix,
412}
413
414#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
415#[serde(rename_all = "lowercase")]
416pub enum FeatureStatus {
417 Pending,
418 Active,
419 Complete,
420 Skipped,
421 Failed,
422}
423
424#[derive(Debug, Clone, Serialize, Deserialize)]
425#[serde(rename_all = "camelCase")]
426pub struct Feature {
427 pub id: String,
428 pub title: String,
429 pub spec: String,
430 pub validation_criteria: Vec<String>,
431 pub origin: FeatureOrigin,
432 pub status: FeatureStatus,
433 /// Run ids (full WorkerRun records live in MissionState.runs).
434 pub worker_runs: Vec<String>,
435 pub commits: Vec<String>,
436 /// Times this feature's worker was respawned (bounded by config.max_respawns).
437 pub respawns: u32,
438}
439
440// ---------------------------------------------------------------------------
441// Worker runs
442// ---------------------------------------------------------------------------
443
444/// Sibling-candidate linkage for one stream of a heterogeneous dispatch pool
445/// (ticket `heterogeneous-dispatch-pool`, KRZ-303; the positioning ADR's
446/// 2026-07-31 boundary gloss): one unit of work (a feature) fanned out to N
447/// configured backends concurrently, every output recorded as a CANDIDATE FOR
448/// JUDGEMENT tied to the same unit — never auto-merged into a winner.
449///
450/// The sibling set is every run sharing `unit` (the feature id, duplicated
451/// here so the linkage is first-class on the record rather than implied by
452/// `feature_id`). `index` is the stream's zero-based position in the
453/// mission's `workerCandidates` config list; `count` is N. Purely
454/// evidentiary: no code path ranks, selects, or merges candidates — selection
455/// is a later human judgement act (the divergence follow-up ticket), and the
456/// claimed value is divergence for scrutiny, never throughput.
457#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
458#[serde(rename_all = "camelCase")]
459pub struct CandidateLink {
460 /// The dispatch unit — the feature id fanned out to the pool.
461 pub unit: String,
462 /// Zero-based position of this stream's backend in `workerCandidates`.
463 pub index: u32,
464 /// Total streams dispatched for the unit (N).
465 pub count: u32,
466 /// Backend this stream ran (the run's `model` field alone cannot name
467 /// the harness — e.g. a claude-routed and a codex-routed stream may both
468 /// record a gpt-family model name after alias normalization).
469 pub backend: String,
470}
471
472/// One compared candidate stream on a `divergence.noted` event (ticket
473/// `divergence-first-class-event`, KRZ-304): the reference to the candidate
474/// DIFF the judgement act inspects — the run that produced it, the branch
475/// that carries it (KEPT: `kranz/pool/<mission>/<unit>-c<index>` is the
476/// deliverable), the backend that ran it, and the tree hash of the branch
477/// HEAD at record time. The hash pins the exact bytes the `diverged`
478/// verdict was computed from, so replay (provenance, the training corpus)
479/// re-reads the record without git; only streams that produced a run
480/// record appear (a stream that never started has no diff to compare).
481#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
482#[serde(rename_all = "camelCase")]
483pub struct DivergenceCandidate {
484 /// The candidate stream's run id — its `worker.spawned` carries the
485 /// [`CandidateLink`] for the same unit and index.
486 pub run_id: String,
487 /// The candidate branch — the deliverable the judging human inspects.
488 pub branch: String,
489 /// Backend the stream ran ([`CandidateLink::backend`] verbatim).
490 pub backend: String,
491 /// Tree hash of the branch HEAD at record time.
492 pub tree: String,
493}
494
495#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
496#[serde(rename_all = "kebab-case")]
497pub enum Role {
498 Orchestrator,
499 Worker,
500 ValidatorScrutiny,
501 ValidatorFunctional,
502}
503
504#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
505#[serde(rename_all = "lowercase")]
506pub enum RunResult {
507 Pass,
508 Fail,
509 Partial,
510}
511
512#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
513#[serde(rename_all = "camelCase")]
514pub struct TokenUsage {
515 pub input: u64,
516 pub output: u64,
517 pub cache_read: u64,
518 pub cache_write: u64,
519}
520
521impl TokenUsage {
522 pub fn add(&mut self, other: &TokenUsage) {
523 self.input += other.input;
524 self.output += other.output;
525 self.cache_read += other.cache_read;
526 self.cache_write += other.cache_write;
527 }
528}
529
530/// Default `quant` for worker runs predating provenance fields.
531fn default_quant() -> String {
532 "n/a".to_string()
533}
534
535/// `skip_serializing_if` for counters whose zero means "predates the field"
536/// (e.g. [`MissionState::question_count`]) — zero stays off the wire so
537/// pre-field snapshots compare byte-identical.
538fn is_zero(value: &u32) -> bool {
539 *value == 0
540}
541
542#[derive(Debug, Clone, Serialize, Deserialize)]
543#[serde(rename_all = "camelCase")]
544pub struct WorkerRun {
545 pub id: String,
546 pub role: Role,
547 /// Feature this run worked on (workers) — validators have milestone_id instead.
548 #[serde(skip_serializing_if = "Option::is_none")]
549 pub feature_id: Option<String>,
550 #[serde(skip_serializing_if = "Option::is_none")]
551 pub milestone_id: Option<String>,
552 /// Sibling-candidate linkage when this run is one stream of a
553 /// heterogeneous dispatch pool (KRZ-303). Absent on ordinary runs (and in
554 /// every pre-pool state snapshot); `None` never hits the wire.
555 #[serde(default, skip_serializing_if = "Option::is_none")]
556 pub candidate: Option<CandidateLink>,
557 /// Claude Code session id (UUID chosen by the engine, used for --resume).
558 pub sdk_session_id: String,
559 pub model: String,
560 /// Resolved dispatch backend, including fallback. Unknown in older logs.
561 #[serde(default, skip_serializing_if = "Option::is_none")]
562 pub backend: Option<BackendKind>,
563 /// Quantization of the model weights used for this run (provenance).
564 #[serde(default = "default_quant")]
565 pub quant: String,
566 /// Hash of the model weights used for this run, when known (provenance).
567 #[serde(skip_serializing_if = "Option::is_none")]
568 pub weight_hash: Option<String>,
569 pub started_at: DateTime<Utc>,
570 #[serde(skip_serializing_if = "Option::is_none")]
571 pub ended_at: Option<DateTime<Utc>>,
572 pub tokens: TokenUsage,
573 /// Cost as reported by the CLI result message when available, else estimated.
574 #[serde(skip_serializing_if = "Option::is_none")]
575 pub cost_usd: Option<f64>,
576 /// Relative path (under the mission dir) of the run transcript JSONL.
577 pub transcript_path: String,
578 #[serde(skip_serializing_if = "Option::is_none")]
579 pub result: Option<RunResult>,
580 #[serde(skip_serializing_if = "Option::is_none")]
581 pub report: Option<WorkerReport>,
582 /// Hash of the role prompt file used (traceability, plan §4.6).
583 pub prompt_hash: String,
584}
585
586// ---------------------------------------------------------------------------
587// Validation contract
588// ---------------------------------------------------------------------------
589
590#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
591#[serde(rename_all = "kebab-case")]
592pub enum AssertionCheck {
593 /// Verified by running `command` (hard gate at mission completion).
594 Command,
595 /// Verified by orchestrator judgement against the full mission diff.
596 AgentJudgement,
597 /// Verified by driving `pty_script`'s interactive target through its
598 /// scripted terminal session (ticket `pty-functional-validation`):
599 /// the engine runs the script in the validation round's evidence pass
600 /// (the same pass that executes command assertions, under the same
601 /// gate-sandbox wrap), captures the bounded session transcript as a
602 /// validation artifact, and hands the functional validator the
603 /// per-step verdicts as authoritative evidence — the M5 lane extended
604 /// to terminal-native deliverables (REPLs, TUIs, interactive CLIs).
605 PtyScript,
606}
607
608#[derive(Debug, Clone, Serialize, Deserialize)]
609#[serde(rename_all = "camelCase")]
610pub struct Assertion {
611 pub id: String,
612 /// Behavioural, testable statement.
613 pub statement: String,
614 pub check: AssertionCheck,
615 #[serde(skip_serializing_if = "Option::is_none")]
616 pub command: Option<String>,
617 /// The scripted terminal session when `check` is `pty-script`
618 /// ([`AssertionCheck::PtyScript`]); ignored for every other check.
619 /// Additive: `None` in every pre-field contract, and
620 /// `skip_serializing_if` keeps old plans byte-identical.
621 #[serde(default, skip_serializing_if = "Option::is_none")]
622 pub pty_script: Option<PtyScript>,
623 /// Explicit, approval-pinned valid/defective controls for this command.
624 /// Absent in legacy plans; controls produce advisory evidence only.
625 #[serde(default, skip_serializing_if = "Option::is_none")]
626 pub negative_control: Option<crate::contract_controls::ControlSpec>,
627}
628
629/// One scripted terminal session against an interactive target — the
630/// contract-side declaration a `pty-script` assertion carries (ticket
631/// `pty-functional-validation`). This is VALIDATOR tooling: the script
632/// judges what the delivered software DOES on a terminal, it never feeds
633/// work back into the mission (positioning ADR's retained list).
634///
635/// WHY inline in the assertion (not a referenced script file): the
636/// validation contract is drafted and approved as ONE self-contained
637/// plan.json, committed on the mission branch — a script living at a
638/// repo path could be edited by the very worker the validation judges,
639/// while the contract itself is approval-locked.
640#[derive(Debug, Clone, Serialize, Deserialize)]
641#[serde(rename_all = "camelCase")]
642pub struct PtyScript {
643 /// The interactive target, as a shell command line. Executed exactly
644 /// like a contract command: the cleared contract env, the resolved
645 /// gate-sandbox wrap, and the gate tree as cwd — a pty session never
646 /// widens the posture the validator's other evidence runs under.
647 pub command: String,
648 /// The steps to drive, in order. Every `expect` is one assertion
649 /// verdict; the script FAILS at the first unmatched `expect`.
650 #[serde(default)]
651 pub steps: Vec<PtyStep>,
652 /// Overall session cap in seconds (default
653 /// [`crate::pty_harness::DEFAULT_SESSION_TIMEOUT_SECS`]): the
654 /// whole-script wall-clock bound regardless of per-step timeouts, so
655 /// a script of generous expects still cannot hang the round.
656 #[serde(default, skip_serializing_if = "Option::is_none")]
657 pub timeout_secs: Option<u64>,
658}
659
660/// One step of a [`PtyScript`], serde-tagged on `op`:
661/// `{"op":"send","text":"…"}` / `{"op":"expect","pattern":"…",…}`.
662#[derive(Debug, Clone, Serialize, Deserialize)]
663#[serde(tag = "op", rename_all = "kebab-case")]
664pub enum PtyStep {
665 /// Write `text` to the pty verbatim — JSON string escapes carry the
666 /// control bytes (`\n` submits a line to a canonical-mode REPL, `\r`
667 /// for raw-mode TUIs, `\u001b` for escape sequences), so no separate
668 /// key-name vocabulary is needed.
669 Send { text: String },
670 /// Block until the accumulated session output contains `pattern`
671 /// (a literal substring; a regular expression when `regex` is true)
672 /// or the step's timeout elapses. A timeout — or the target exiting
673 /// unmatched — FAILS the assertion at this step.
674 Expect {
675 pattern: String,
676 #[serde(default)]
677 regex: bool,
678 /// Per-step timeout in milliseconds (default
679 /// [`crate::pty_harness::DEFAULT_EXPECT_TIMEOUT_MS`]).
680 #[serde(rename = "timeoutMs")]
681 #[serde(default, skip_serializing_if = "Option::is_none")]
682 timeout_ms: Option<u64>,
683 },
684}
685
686// ---------------------------------------------------------------------------
687// Worker report (plan §4.6) — the enforced final message of every worker run
688// ---------------------------------------------------------------------------
689
690#[derive(Debug, Clone, Serialize, Deserialize)]
691#[serde(rename_all = "camelCase")]
692pub struct WorkerReport {
693 pub result: RunResult,
694 pub summary: String,
695 #[serde(default)]
696 pub files_touched: Vec<String>,
697 #[serde(default)]
698 pub tests_added: Vec<String>,
699 #[serde(default)]
700 pub test_evidence: String,
701 #[serde(default)]
702 pub dependencies_added: Vec<String>,
703 #[serde(default)]
704 pub known_gaps: Vec<String>,
705 #[serde(default)]
706 pub commits: Vec<String>,
707 #[serde(default)]
708 pub commands_run: Vec<String>,
709 /// Worker-initiated escalation to the frontier advisor (ticket
710 /// `backend-routing-abstraction`, KRZ-331): when set, the worker judged
711 /// the task beyond its route's confidence and asked for frontier-tier
712 /// advice — the VALUE is the worker's reason, verbatim. The engine folds
713 /// the request into a record-only `worker.escalated` event naming the
714 /// source and target routes; the judgement turn (the frontier advisor)
715 /// reads the request from this same report. Never a way to skip
716 /// validation: the floor's validator requirements are unaffected.
717 #[serde(default, skip_serializing_if = "Option::is_none")]
718 pub escalation: Option<String>,
719 /// Structured "ask the human" questions (ticket
720 /// `structured-human-question-events`): the worker's structured choices
721 /// for a decision only a human can make. The engine opens each as a
722 /// `question.opened` event feeding the ONE pending-decision projection
723 /// the dashboard and Slack render beside grants (the D-X channel
724 /// unification — never a parallel inbox to grants, NeedsContext, or
725 /// blocked prose). Absent on prose-only reports (the fallback every
726 /// backend without a structured ask keeps), capped at write
727 /// (orchestrator.rs), and never a park: the report's own `result` drives
728 /// the mission's course exactly as before.
729 #[serde(default, skip_serializing_if = "Option::is_none")]
730 pub questions: Option<Vec<ReportQuestion>>,
731}
732
733/// One structured human question inside a [`WorkerReport`] (ticket
734/// `structured-human-question-events`). Deliberately id-less: the engine
735/// mints the question id at emit time (`q-<n>`, per-mission monotonic from
736/// the folded count), so a model-supplied id can never collide with or
737/// shadow another question's.
738#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
739#[serde(rename_all = "camelCase")]
740pub struct ReportQuestion {
741 /// The question text (size-capped + scrubbed at event write).
742 pub text: String,
743 /// Structured choices the worker offered; EMPTY asks for free text.
744 /// Capped per-option and per-list at event write.
745 #[serde(default, skip_serializing_if = "Vec::is_empty")]
746 pub options: Vec<String>,
747}
748
749/// A finding emitted by a validator (scrutiny or functional).
750#[derive(Debug, Clone, Serialize, Deserialize)]
751#[serde(rename_all = "camelCase")]
752pub struct Finding {
753 /// Assertion id or feature criterion this finding is about.
754 pub subject: String,
755 /// "critical" | "major" | "minor"
756 pub severity: String,
757 pub evidence: String,
758 #[serde(default)]
759 pub suggested_fix: String,
760 /// Free-form finding class, e.g. "out-of-contract-write"; default "" for
761 /// existing scrutiny/functional/gate findings.
762 #[serde(default)]
763 pub class: String,
764 /// The Flight Rules standards rule this finding cites (ticket
765 /// `flight-rules-finding-provenance`, KRZ-343; design D-H): the stable
766 /// rule/revision join key plus the pinned source identity, carried as
767 /// structured data so provenance joins never parse `subject` or
768 /// `evidence` prose. Additive: `None` on every pre-KRZ-343 finding and
769 /// on every finding that does not cite a rule, and
770 /// `skip_serializing_if` keeps those findings byte-identical on the
771 /// wire. `subject` stays the human/assertion handle for old consumers;
772 /// rule provenance is never smuggled into it.
773 #[serde(default, skip_serializing_if = "Option::is_none")]
774 pub rule: Option<RuleCitation>,
775}
776
777/// The standards rule a [`Finding`] cites (KRZ-343, design D-H): the join
778/// key that makes a checker verdict answerable to the approved manifest pin
779/// without parsing prose. Every field is the PINNED spelling (the consent
780/// snapshot [`StandardsPin`] carries), so a citation joins the mission's
781/// approved policy even after the live pack moves on.
782///
783/// WHY a group and not loose optional fields: a citation is only joinable
784/// whole — a rule id without its revision names a moving target (revisions
785/// are the semantic-change unit, D-C), and either without the source digest
786/// cannot say WHICH approved manifest it answered to. The group is all-or-
787/// nothing: `Some` carries the full join key, `None` cites nothing.
788#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
789#[serde(rename_all = "camelCase")]
790pub struct RuleCitation {
791 /// The stable rule id (frontmatter `id:`), matching
792 /// [`StandardsRuleRef`]'s naming so log-wide joins use one spelling.
793 pub id: String,
794 /// The pinned revision the verdict was rendered against. A citation at
795 /// any other revision does not join the pin — the coverage fold renders
796 /// it `not-applicable` rather than joining stale policy.
797 pub revision: u64,
798 /// The pinned pack identity: pack name + standards root, the display
799 /// spelling [`crate::pack::resolution::render_pin_section`] uses.
800 pub source: String,
801 /// Lowercase hex sha256 of the pinned normalized manifest
802 /// ([`StandardsPin::digest`]) — the content binding of the citation.
803 pub digest: String,
804 /// The rule's pinned EFFECTIVE lifecycle (`approved` or `enforced`):
805 /// whether the cited verdict could block (D-B).
806 pub lifecycle: String,
807 /// The rule's RFC-2119 level (`must` or `should`).
808 pub level: String,
809 /// The rule's pinned checker binding (`gate:<id>`, `agent-judgement`,
810 /// `manual-attestation`) — the mechanism the verdict came from; `None`
811 /// when the pinned rule declared none.
812 #[serde(default, skip_serializing_if = "Option::is_none")]
813 pub checker: Option<String>,
814}
815
816#[derive(Debug, Clone, Serialize, Deserialize)]
817#[serde(rename_all = "camelCase")]
818pub struct ValidatorReport {
819 #[serde(default)]
820 pub findings: Vec<Finding>,
821 #[serde(default)]
822 pub summary: String,
823}
824
825// ---------------------------------------------------------------------------
826// Derived state (pure fold over the event log; state.json is a cache of this)
827// ---------------------------------------------------------------------------
828
829/// The workspace provider identity pinned at plan approval (design D-B,
830/// ticket `workspace-provider-pin-at-approval`) — the consent artifact naming
831/// the environment the operator approved running against. Free-form strings
832/// on purpose: the shape must not assume a provider kind. Per-kind meanings:
833/// - `local-worktree` / `container`: `template` = the isolation mode
834/// (`"worktree"` | `"checkout"` — source isolation, not a runnable
835/// workspace, D-H), `version` = the workspace contract's schemaVersion
836/// when a contract exists, else `"none"`.
837/// - `remote` (ticket `workspace-remote-coder-provider`): `template` = the
838/// configured substrate template/image id (as configured at approval —
839/// the pin stays pure, no substrate contact), `version` = the adapter
840/// version string (`"coder-v1"`).
841#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
842#[serde(rename_all = "camelCase", default)]
843pub struct WorkspacePin {
844 pub provider: String,
845 pub template: String,
846 pub version: String,
847}
848
849/// The last known workspace lifecycle transition (ticket
850/// `workspace-idle-hibernate`), folded from `workspace.teardown` events
851/// carrying a `state`: `"kept"` (mode keep), `"stopped"` (hibernate),
852/// `"destroyed"` (destroy), or `"failed"` (the provider call failed — the
853/// workspace may still be live). `state` is free-form on purpose so a
854/// future substrate-reported transition (e.g. an idle hibernate the
855/// substrate owns) folds into the same field without a schema change.
856/// `ts` is the transition event's own timestamp — the workspace-hours
857/// anchor for cost tooling.
858#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
859#[serde(rename_all = "camelCase", default)]
860pub struct WorkspaceLifecycle {
861 pub state: String,
862 pub ts: DateTime<Utc>,
863}
864
865/// A preview as provisioned by a remote workspace provider (ticket
866/// `workspace-remote-coder-provider`), recorded on `workspace.provisioned`:
867/// the URL the SUBSTRATE reported for a contract `previews[]` entry
868/// (name-matched — never fabricated), plus whether the substrate reports
869/// the URL is fronted with auth (design D-E: previews authenticated by
870/// default — recorded, never disabled by the adapter).
871#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
872#[serde(rename_all = "camelCase", default)]
873pub struct ProvisionedPreview {
874 pub name: String,
875 pub url: String,
876 /// Substrate-reported auth fronting; absent when the substrate did not
877 /// say (never read as "no auth" — consumers must degrade on absent).
878 #[serde(default, skip_serializing_if = "Option::is_none")]
879 pub auth: Option<bool>,
880}
881
882#[derive(Debug, Clone, Serialize, Deserialize)]
883#[serde(rename_all = "camelCase")]
884pub struct MissionState {
885 pub mission: Mission,
886 /// Sequential feature baselines pinned before worker execution. Empty in
887 /// older logs; reconstructed from feature.progress rather than trusted
888 /// from the cached state file.
889 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
890 pub feature_base_shas: BTreeMap<String, String>,
891 /// All runs keyed by run id (BTreeMap for deterministic serialization).
892 pub runs: BTreeMap<String, WorkerRun>,
893 pub totals: TokenUsage,
894 pub total_cost_usd: f64,
895 /// Queued user messages not yet consumed by the orchestrator.
896 pub pending_user_messages: Vec<String>,
897 /// Recent orchestrator decision summaries (newest last; capped by reducer).
898 pub recent_decisions: Vec<String>,
899 /// Per-role config overrides applied mid-mission via config.changed.
900 pub config: MissionConfig,
901 /// Latest revision number observed in the durable log. `0` means the
902 /// original approved plan is still the only plan of record.
903 #[serde(default)]
904 pub latest_plan_revision: u32,
905 /// A proposed revised plan awaiting human approve/reject. The mission
906 /// status does not change while this is set; the run loop parks on this
907 /// gate and the repo stays busy until consent arrives.
908 #[serde(default, skip_serializing_if = "Option::is_none")]
909 pub pending_revision: Option<PendingRevision>,
910 /// A capability denial (today: a validator command outside its allow-set)
911 /// awaiting an operator approve/deny decision. Like `pending_revision`, the
912 /// run loop parks on this gate; approving extends `command_grants` and
913 /// respawns, denying (or a timeout) fails the feature closed.
914 #[serde(default, skip_serializing_if = "Option::is_none")]
915 pub pending_grant_request: Option<PendingGrantRequest>,
916 /// The pending-decision projection (ticket
917 /// `structured-human-question-events`): open structured human questions,
918 /// folded from `question.opened`, in open order; `question.answered` /
919 /// `question.cleared` remove their entry. Rendered by the dashboard and
920 /// Slack in the SAME "your move" area as the parked grant (distinct kind,
921 /// shared chrome — the D-X channel unification). Unlike
922 /// `pending_grant_request` the run loop never gates on this list; empty
923 /// on pre-field logs and omitted from the wire then.
924 #[serde(default, skip_serializing_if = "Vec::is_empty")]
925 pub pending_questions: Vec<PendingQuestion>,
926 /// Total `question.opened` events folded — the per-mission monotonic
927 /// counter the engine mints the next question id (`q-<n+1>`) from.
928 /// Restart-safe by construction (derived from the log, never reset by
929 /// answers or clears), so an id is never reused after a process restart.
930 /// `0` on pre-field logs and omitted from the wire then.
931 #[serde(default, skip_serializing_if = "is_zero")]
932 pub question_count: u32,
933 /// Seq of the last event folded in.
934 pub last_seq: u64,
935 /// Count of `tier.escalated` events folded (executor bumped from local to
936 /// frontier after repeated failed local validations).
937 #[serde(default)]
938 pub escalated_milestones: u32,
939 /// Count of milestones whose `milestone.started` folded while the
940 /// executor tier was [`ExecutorTier::Local`] — the denominator for
941 /// [`MissionState::escalation_rate`].
942 #[serde(default)]
943 pub local_executor_milestones: u32,
944 /// Kind of the workspace provider that last provisioned this mission's
945 /// workspace, folded from `workspace.provisioned` (design D-B/D-E).
946 /// `None` in logs predating the provider seam.
947 #[serde(default, skip_serializing_if = "Option::is_none")]
948 pub workspace_provider: Option<String>,
949 /// The workspace provider identity pinned at plan approval, folded from
950 /// `workspace.provider.pinned` (design D-B). `None` in logs predating
951 /// the pin event (missions approved before pinning existed).
952 #[serde(default, skip_serializing_if = "Option::is_none")]
953 pub workspace_pin: Option<WorkspacePin>,
954 /// The last known workspace lifecycle transition (state + ts), folded
955 /// from `workspace.teardown` events carrying a `state` (ticket
956 /// `workspace-idle-hibernate`). `None` in logs predating the state
957 /// field — v1 keep-only teardowns carried no outcome.
958 #[serde(default, skip_serializing_if = "Option::is_none")]
959 pub workspace_lifecycle: Option<WorkspaceLifecycle>,
960 /// Dispatch-pool units with a recorded resolution, folded from
961 /// `divergence.resolved` (ticket `divergence-first-class-event`,
962 /// KRZ-304). The engine emits at most one resolution per unit — the
963 /// FIRST operator judgement stands — and this set is how the unblock
964 /// path knows, across a process restart, that a unit's judgement
965 /// already landed. Derived at fold time (state.json is only a cache of
966 /// the fold); empty on pre-pool logs and omitted from the wire then.
967 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
968 pub resolved_divergence_units: BTreeSet<String>,
969}
970
971impl MissionState {
972 /// Share of local-tier milestones that were escalated to Frontier.
973 /// `0.0` when no milestone has ever started under the Local tier (no
974 /// divide-by-zero).
975 pub fn escalation_rate(&self) -> f64 {
976 if self.local_executor_milestones == 0 {
977 0.0
978 } else {
979 self.escalated_milestones as f64 / self.local_executor_milestones as f64
980 }
981 }
982
983 /// Which inference tier the Worker executes this mission on, derived
984 /// from the current Worker `RoleConfig.backend` rather than stored:
985 /// [`ExecutorTier::Local`] when the Worker backend is
986 /// [`BackendKind::Local`] (applied at seed time by
987 /// [`crate::config::apply_executor_routing`] or by any later
988 /// `config.changed`), else [`ExecutorTier::Frontier`]. A configured
989 /// dispatch pool (`worker_candidates`) is always Frontier: pool
990 /// candidates are never local-backed (validation rejects `local`
991 /// entries), so a stray local `worker.backend` alongside a pool must not
992 /// classify the mission's spend as $0-marginal.
993 pub fn executor_tier(&self) -> ExecutorTier {
994 self.config.executor_tier()
995 }
996}
997
998#[derive(Debug, Clone, Serialize, Deserialize)]
999#[serde(rename_all = "camelCase")]
1000pub struct PendingRevision {
1001 pub revision: u32,
1002 pub plan: Plan,
1003 pub instructions: String,
1004}
1005
1006/// What a grant would extend on approval. All kinds park through the SAME
1007/// operator approve/deny gate (and reuse its timeout + per-milestone cap); they
1008/// differ only in the boundary that triggered them and what the reducer
1009/// extends. `#[default]` = `Command` so pre-`kind` events (and the wire
1010/// default) fold as the original command-grant behaviour.
1011#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1012#[serde(rename_all = "kebab-case")]
1013pub enum GrantKind {
1014 /// A validator command outside its allow-set → extend `command_grants`.
1015 #[default]
1016 Command,
1017 /// A worker write outside the `touch_set` → extend `touch_set`.
1018 TouchPath,
1019 /// A worker command blocked by a deny rule (deny-wins) → lift that rule by
1020 /// adding it to `deny_exceptions`. Unlike the others this SUBTRACTS from a
1021 /// safety guardrail, so it is per-mission, explicit, and logged.
1022 WorkerDeny,
1023 /// A sandboxed (`fs+net`) run whose egress proxy refused a destination →
1024 /// extend `egress_grants`, so the re-run's proxy allowlist covers it.
1025 Egress,
1026}
1027
1028/// A parked capability-grant request (see [`MissionState::pending_grant_request`]).
1029/// Names the exact target a grant would unblock and the milestone whose
1030/// validation hit the boundary, so the operator's approve/deny decision — and
1031/// the reducer's cross-check on `grant.approved`/`grant.denied` — key off the
1032/// same target that was requested.
1033#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1034#[serde(rename_all = "camelCase")]
1035pub struct PendingGrantRequest {
1036 pub milestone_id: String,
1037 #[serde(default)]
1038 pub kind: GrantKind,
1039 /// The granted target: a command string (`Command`), a repo-relative path
1040 /// glob (`TouchPath`), a deny rule (`WorkerDeny`), or a `host:port`
1041 /// destination (`Egress`). Named `command` for wire back-compat with the
1042 /// original command-only grant events.
1043 pub command: String,
1044}
1045
1046/// An open structured human question (ticket
1047/// `structured-human-question-events`), one entry of the pending-decision
1048/// projection folded from `question.opened` (see
1049/// [`MissionState::pending_questions`]). Carries the full context the
1050/// dashboard and Slack need to render the decision without a join: the ask,
1051/// its structured choices (empty = free text), and who/where it came from.
1052/// Unlike [`PendingGrantRequest`] this parks NOTHING — the run loop does not
1053/// gate on it; the question rides alongside the mission until the operator
1054/// answers (`question.answered`) or it stops being actionable
1055/// (`question.cleared`).
1056#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1057#[serde(rename_all = "camelCase")]
1058pub struct PendingQuestion {
1059 /// Engine-minted id (`q-<n>`, per-mission monotonic) — the handle every
1060 /// answer path (REST/Slack/CLI) names.
1061 pub question_id: String,
1062 /// Who asked — `worker` in this pass.
1063 pub role: Role,
1064 /// The question text (scrubbed + capped at write).
1065 pub text: String,
1066 /// The structured choices offered (empty = free-text answer expected).
1067 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1068 pub options: Vec<String>,
1069 /// The run whose report carried the ask (context ref).
1070 #[serde(default, skip_serializing_if = "Option::is_none")]
1071 pub run_id: Option<String>,
1072 /// Feature the asking run worked on (context ref).
1073 #[serde(default, skip_serializing_if = "Option::is_none")]
1074 pub feature_id: Option<String>,
1075 /// Milestone the asking run worked under (context ref; the
1076 /// clear-on-complete sweep keys on it).
1077 #[serde(default, skip_serializing_if = "Option::is_none")]
1078 pub milestone_id: Option<String>,
1079}
1080
1081// ---------------------------------------------------------------------------
1082// Configuration (plan §6)
1083// ---------------------------------------------------------------------------
1084
1085#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1086#[serde(rename_all = "camelCase")]
1087pub struct RoleConfig {
1088 /// Model alias or full id passed to `claude --model` (e.g. "opus", "sonnet").
1089 pub model: String,
1090 /// Passed to `claude --effort`: low | medium | high | xhigh | max.
1091 pub reasoning_effort: String,
1092 #[serde(skip_serializing_if = "Option::is_none")]
1093 pub max_turns: Option<u32>,
1094 #[serde(skip_serializing_if = "Option::is_none")]
1095 pub max_budget_usd: Option<f64>,
1096 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1097 pub tools: Vec<String>,
1098 /// Backend override for this role. `None` or `"claude"` keeps the default
1099 /// Claude Code backend, `"codex"` selects
1100 /// [`crate::backend_codex::CodexBackend`], `"droid"` selects
1101 /// [`crate::backend_droid::DroidBackend`], `"kimi"` selects
1102 /// [`crate::backend_kimi::KimiBackend`], `"local"` selects an
1103 /// OpenAI-compatible HTTP endpoint, `"acp"` selects
1104 /// [`crate::backend_acp::AcpBackend`] (worker role only), and `"cursor"`
1105 /// selects [`crate::backend_cursor::CursorBackend`] (opt-in,
1106 /// validator-first; docs/scoping/cursor-cli-backend.md).
1107 /// `config::validate` checks that
1108 /// the selected backend/model pair is supported for the role.
1109 ///
1110 /// The guarded local-validator boundary (KRZ-206b): on the
1111 /// `validatorFunctional` role `"local"` is allowed for DETERMINISTIC
1112 /// mechanical checks only (contract-command pass/fail against
1113 /// engine-captured exit codes) — every local PASS is frontier-confirmed
1114 /// before it greens a gate, and a local FAIL is trusted unconfirmed
1115 /// (failures are visible; misses are the danger). On the
1116 /// `validatorScrutiny` role `"local"` is rejected outright: scrutiny is
1117 /// judgment, and a local judgment PASS is the silent-green failure mode
1118 /// the split exists to prevent.
1119 #[serde(default, skip_serializing_if = "Option::is_none")]
1120 pub backend: Option<String>,
1121 /// Base URL of the OpenAI-compatible HTTP endpoint for `backend = "local"`.
1122 /// Required and validated when a role selects the local backend.
1123 #[serde(default, skip_serializing_if = "Option::is_none")]
1124 pub base_url: Option<String>,
1125 /// Executable of the ACP (Agent Client Protocol) agent for
1126 /// `backend = "acp"`. Required and validated when a role selects the acp
1127 /// backend (KRZ-301; worker role only for now).
1128 #[serde(default, skip_serializing_if = "Option::is_none")]
1129 pub acp_command: Option<String>,
1130 /// Extra argv for `acpCommand` (model flags, agent-specific options —
1131 /// ACP itself has no standard model-selection parameter in v1).
1132 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1133 pub acp_args: Vec<String>,
1134 /// Context window budget (tokens) for `backend = "local"`, used to guard
1135 /// against KV-cache blowout. Required and validated when a role selects
1136 /// the local backend.
1137 #[serde(default, skip_serializing_if = "Option::is_none")]
1138 pub context_budget: Option<u32>,
1139 /// Sampling temperature for `backend = "local"`. Optional; validated when
1140 /// present.
1141 #[serde(default, skip_serializing_if = "Option::is_none")]
1142 pub temperature: Option<f64>,
1143 /// Per-role OS sandbox opt-in.
1144 #[serde(default)]
1145 pub sandbox: SandboxConfig,
1146}
1147
1148/// OS sandbox enforcement level for a role's sessions.
1149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1150#[serde(rename_all = "lowercase")]
1151pub enum SandboxEnforce {
1152 #[default]
1153 Off,
1154 Fs,
1155 #[serde(rename = "fs+net")]
1156 FsNet,
1157}
1158
1159impl SandboxEnforce {
1160 /// The config-file spelling of this mode (`"off"` / `"fs"` / `"fs+net"`),
1161 /// for errors and reports that name the requested enforcement.
1162 pub fn as_str(self) -> &'static str {
1163 match self {
1164 SandboxEnforce::Off => "off",
1165 SandboxEnforce::Fs => "fs",
1166 SandboxEnforce::FsNet => "fs+net",
1167 }
1168 }
1169}
1170
1171/// Which sandbox mechanism wraps a role's sessions when `enforce` is on.
1172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1173#[serde(rename_all = "lowercase")]
1174pub enum SandboxProvider {
1175 /// Tier-2 process sandboxing (Seatbelt on macOS, bubblewrap on Linux).
1176 #[default]
1177 Process,
1178 /// Tier-3 container sandboxing on live-proven Linux hosts (see
1179 /// `crate::sandbox_container`). macOS and Windows refuse until their
1180 /// mount, authority-mask, and egress contracts have continuously enforced
1181 /// release receipts; macOS uses native Seatbelt through the process
1182 /// provider.
1183 Container,
1184}
1185
1186impl SandboxProvider {
1187 /// The config-file spelling of this provider (`"process"` / `"container"`),
1188 /// for errors and reports that name it.
1189 pub fn as_str(self) -> &'static str {
1190 match self {
1191 SandboxProvider::Process => "process",
1192 SandboxProvider::Container => "container",
1193 }
1194 }
1195
1196 /// Whether `enforce = "fs+net"` under this provider is backed by a HARD
1197 /// network boundary for the given egress list — a session that ignores the
1198 /// run's egress-proxy env vars still cannot open a direct socket. The
1199 /// process provider qualifies on both supported platforms (macOS Seatbelt
1200 /// cuts outbound TCP to loopback; Linux bwrap `--unshare-net` removes the
1201 /// network entirely), so its proxy hop is the only reachable way out. The
1202 /// container provider has a static hard boundary with an EMPTY egress
1203 /// list (`--network none`). A non-empty list is provisioned dynamically
1204 /// for sessions by `crate::container_egress`; this helper remains false
1205 /// for that pair because engine-run gates do not own that lifecycle and
1206 /// must continue to refuse it. The match is deliberately
1207 /// exhaustive: a future provider must declare itself here.
1208 pub fn enforces_hard_net_boundary(self, egress: &[String]) -> bool {
1209 match self {
1210 SandboxProvider::Process => true,
1211 SandboxProvider::Container => egress.is_empty(),
1212 }
1213 }
1214}
1215
1216/// Per-role OS sandbox config.
1217#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1218#[serde(rename_all = "camelCase", default)]
1219pub struct SandboxConfig {
1220 pub enforce: SandboxEnforce,
1221 /// Sandbox provider: `process` (default, tier 2) or `container` (tier 3).
1222 /// `container` with `enforce = "off"` means no sandboxing, same as today.
1223 #[serde(default)]
1224 pub provider: SandboxProvider,
1225 /// Container image used when `provider = "container"`. Defaults to
1226 /// `sandbox_container::DEFAULT_IMAGE` when unset.
1227 #[serde(default, skip_serializing_if = "Option::is_none")]
1228 pub image: Option<String>,
1229 /// Extra paths the operator opts into as writable (e.g. "~/.cargo").
1230 /// Stored as raw strings; not expanded or canonicalized here.
1231 pub extra_write: Vec<String>,
1232 /// Extra network destinations allowed under `enforce = "fs+net"` (for
1233 /// example package registries). Stored as `host:port` strings. With
1234 /// `provider = "container"` a non-empty list is refused by
1235 /// `config::validate` (proxy-env advisory only — see
1236 /// [`SandboxProvider::enforces_hard_net_boundary`]); an empty list keeps
1237 /// the hard `--network none` boundary.
1238 pub egress: Vec<String>,
1239}
1240
1241/// One backend+model pairing in the heterogeneous dispatch pool
1242/// (`MissionConfig::worker_candidates`, ticket `heterogeneous-dispatch-pool`
1243/// / KRZ-303). Structured rather than a `"backend/model"` string so
1244/// `config::validate` can apply the exact same backend/model pair checks as a
1245/// role selection.
1246#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1247#[serde(rename_all = "camelCase")]
1248pub struct CandidateSpec {
1249 /// Backend name, same vocabulary as [`RoleConfig::backend`]. `local` and
1250 /// `acp` are rejected at validation in this pass: their per-role
1251 /// endpoint/command config (`baseUrl`/`acpCommand`) has no per-candidate
1252 /// home yet — a deliberate widening, never a silent share.
1253 pub backend: String,
1254 /// Model alias or id, interpreted against `backend`'s table by the same
1255 /// `effective_model` / `model_tier` rules as a role selection.
1256 pub model: String,
1257}
1258
1259/// Which [`AgentBackend`](crate::backend::AgentBackend) drives a role's sessions.
1260#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1261#[serde(rename_all = "lowercase")]
1262pub enum BackendKind {
1263 Claude,
1264 Codex,
1265 Droid,
1266 Kimi,
1267 /// OpenAI-compatible HTTP endpoint, configured via the role's `baseUrl`,
1268 /// `contextBudget`, and optional `temperature`.
1269 Local,
1270 /// ACP (Agent Client Protocol) agent executable, configured via the
1271 /// role's `acpCommand`/`acpArgs` (KRZ-301; worker role only).
1272 Acp,
1273 /// Cursor CLI (`agent --print --output-format stream-json`), the decided
1274 /// `direct-parser` route (docs/scoping/cursor-cli-backend.md). Opt-in
1275 /// only, validator-first; never a default.
1276 Cursor,
1277}
1278
1279impl BackendKind {
1280 pub fn as_str(self) -> &'static str {
1281 match self {
1282 BackendKind::Claude => "claude",
1283 BackendKind::Codex => "codex",
1284 BackendKind::Droid => "droid",
1285 BackendKind::Kimi => "kimi",
1286 BackendKind::Local => "local",
1287 BackendKind::Acp => "acp",
1288 BackendKind::Cursor => "cursor",
1289 }
1290 }
1291
1292 /// Whether this backend applies the engine-resolved OS sandbox
1293 /// ([`crate::backend::SessionSpec::sandbox`]) to its sessions. Only the
1294 /// claude backend wraps its spawned CLI in the resolved sandbox today;
1295 /// the other CLI backends spawn their binaries directly (and `local`
1296 /// runs in the engine process), so an enforced sandbox on them would be
1297 /// silently unenforced — `config::validate` rejects that pair (fail
1298 /// closed). Cursor's own `--sandbox` flag is observed to be no isolation
1299 /// boundary (docs/scoping/cursor-cli-backend.md), so it does not count
1300 /// either. The match is deliberately exhaustive: a future backend must
1301 /// declare itself here.
1302 pub fn supports_sandbox_enforcement(self) -> bool {
1303 match self {
1304 BackendKind::Claude => true,
1305 BackendKind::Codex
1306 | BackendKind::Droid
1307 | BackendKind::Kimi
1308 | BackendKind::Local
1309 | BackendKind::Acp
1310 | BackendKind::Cursor => false,
1311 }
1312 }
1313
1314 /// Whether this backend's wire reports cache-READ input tokens the
1315 /// parser records (outcomes-report context-reuse split, ticket
1316 /// `outcomes-report-task-class`). Claude/droid read
1317 /// `cache_read_input_tokens`; codex reads `cached_input_tokens`; cursor
1318 /// reads `cacheReadTokens` off the terminal result event (present in the
1319 /// committed fixture). Kimi's
1320 /// wire carries no usage at all, local hardcodes zeros, and ACP v1's
1321 /// `usage_update` reports context-window state rather than a token
1322 /// split — for all three, a zero would be fabricated, so they report
1323 /// nothing (absent, never 0%).
1324 /// The match is deliberately exhaustive: a future backend must declare
1325 /// itself here.
1326 pub fn reports_cache_read_tokens(self) -> bool {
1327 match self {
1328 BackendKind::Claude | BackendKind::Codex | BackendKind::Droid | BackendKind::Cursor => {
1329 true
1330 }
1331 BackendKind::Kimi | BackendKind::Local | BackendKind::Acp => false,
1332 }
1333 }
1334
1335 /// Whether this backend's wire reports cache-WRITE (creation) input
1336 /// tokens. Claude/droid carry `cache_creation_input_tokens` and cursor
1337 /// carries `cacheWriteTokens` on its terminal result event; codex
1338 /// has no such field (its cache write side is never recorded, so the
1339 /// split's cache-write column is absent for codex, never zero-filled).
1340 pub fn reports_cache_write_tokens(self) -> bool {
1341 match self {
1342 BackendKind::Claude | BackendKind::Droid | BackendKind::Cursor => true,
1343 BackendKind::Codex | BackendKind::Kimi | BackendKind::Local | BackendKind::Acp => false,
1344 }
1345 }
1346
1347 /// Whether this backend exposes a lifecycle-hook surface the
1348 /// hook-status lane can project onto (ticket
1349 /// `agent-hooks-status-signals`, [`crate::hook_status`]). Only the
1350 /// cursor CLI's documented `hooks.json` lifecycle events qualify today;
1351 /// every other backend IGNORES [`crate::backend::SessionSpec::hook_status`]
1352 /// exactly like `settings_json`, so an enabled lane is a byte-identical
1353 /// no-op there (the hooks-disabled regression). The match is
1354 /// deliberately exhaustive: a future backend must declare itself here.
1355 pub fn supports_hook_status_signals(self) -> bool {
1356 match self {
1357 BackendKind::Cursor => true,
1358 BackendKind::Claude
1359 | BackendKind::Codex
1360 | BackendKind::Droid
1361 | BackendKind::Kimi
1362 | BackendKind::Local
1363 | BackendKind::Acp => false,
1364 }
1365 }
1366}
1367
1368/// Which inference tier executes a ticket, derived deterministically from its
1369/// `task-class` frontmatter via [`crate::config::task_class_to_tier`].
1370#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1371#[serde(rename_all = "lowercase")]
1372pub enum ExecutorTier {
1373 Local,
1374 Frontier,
1375}
1376
1377impl Default for ExecutorTier {
1378 /// Frontier is the safe default when no task class is present or recognized.
1379 fn default() -> Self {
1380 ExecutorTier::Frontier
1381 }
1382}
1383
1384/// The backend routing table (ticket `backend-routing-abstraction`, KRZ-331):
1385/// the declarative form of "task class → executor route", making local
1386/// endpoints, hosted frontier models, and hosted fine-tunes peers behind one
1387/// routing interface. Resolved deterministically by [`crate::routing`]:
1388/// the FIRST matching rule wins, no match falls through to
1389/// [`ExecutorTier::Frontier`], and an EMPTY table keeps the hardcoded literal
1390/// floor ([`crate::config::task_class_to_tier`]) byte-for-byte.
1391///
1392/// Rules name CAPABILITY CLASSES ([`ExecutorTier`]), never model ids
1393/// (docs/reviews/local-llm-and-triumvirate.md §1): a local endpoint, a
1394/// hosted OpenAI-compatible frontier endpoint, and a hosted fine-tune are
1395/// all the `local` class — which concrete endpoint the class resolves to is
1396/// ordinary local-backend role config (`baseUrl` + `model`), not routing
1397/// table content and not a new backend kind. The tracked, base-branch-owned
1398/// rules FILE surface ([`crate::routing_rules`], ticket
1399/// `routing-rules-config`) is the tracked way to populate this table.
1400#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1401#[serde(rename_all = "camelCase", default)]
1402pub struct RoutingConfig {
1403 /// Ordered routing rules; the first rule whose `taskClass` matches the
1404 /// ticket's task class (case- and whitespace-insensitively, the same
1405 /// normalization as the hardcoded floor) decides the executor tier.
1406 pub task_class_rules: Vec<TaskClassRoute>,
1407 /// Ordered PATTERN rules (ticket `routing-rules-config`), consulted only
1408 /// when no exact `taskClassRules` entry matched: the first pattern that
1409 /// matches the normalized task class decides the executor tier. An exact
1410 /// class rule always beats a pattern (specific over general); within this
1411 /// list, order is the only precedence knob. Additive: absent in every
1412 /// pre-pattern config and every pre-pattern `mission.created` payload,
1413 /// where it deserializes to empty.
1414 #[serde(default)]
1415 pub pattern_rules: Vec<PatternRoute>,
1416}
1417
1418impl RoutingConfig {
1419 /// No rules of either form — the empty table that keeps the hardcoded
1420 /// literal floor ([`crate::config::task_class_to_tier`]) byte-for-byte.
1421 pub fn is_empty(&self) -> bool {
1422 self.task_class_rules.is_empty() && self.pattern_rules.is_empty()
1423 }
1424}
1425
1426/// One routing rule: a task class routed to an executor capability class.
1427#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1428#[serde(rename_all = "camelCase")]
1429pub struct TaskClassRoute {
1430 /// The ticket `task-class` frontmatter value this rule matches, compared
1431 /// trimmed and case-insensitively. Must be non-empty and unique within
1432 /// the table after normalization — `config::validate` fails closed
1433 /// otherwise (a duplicate is dead config under first-match-wins).
1434 pub task_class: String,
1435 /// The capability class the matched task class routes to.
1436 pub tier: ExecutorTier,
1437}
1438
1439/// One PATTERN routing rule (ticket `routing-rules-config`): a task-class
1440/// pattern routed to an executor capability class. The pattern language is
1441/// deliberately tiny and deterministic — `*` matches any (possibly empty)
1442/// run of characters, every other character is literal, comparison happens
1443/// after the floor's normalization (trim + ASCII-lowercase). Must be
1444/// non-empty and unique within the pattern list after normalization —
1445/// `config::validate` fails closed otherwise (a duplicate is dead config
1446/// under first-match-wins).
1447#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1448#[serde(rename_all = "camelCase")]
1449pub struct PatternRoute {
1450 /// The glob-style pattern matched against the normalized task class.
1451 pub pattern: String,
1452 /// The capability class a matching task class routes to.
1453 pub tier: ExecutorTier,
1454}
1455
1456/// The effective executor route of one worker session (ticket
1457/// `routing-rules-config`), recorded additively on `worker.spawned`: routing
1458/// is provenance, not a hidden implementation detail. Derived once at fold
1459/// time from `mission.created`'s original folded goal and routed config
1460/// ([`crate::routing::seed_executor_route`]) — the determinism contract
1461/// guarantees the recomputation equals the seed-time decision, so no new
1462/// event payload is needed — then replayed onto each worker spawn.
1463#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1464#[serde(rename_all = "camelCase")]
1465pub struct ExecutorRoute {
1466 /// The EFFECTIVE capability class the session runs on — after the
1467 /// fail-safes (a `local` route with no configured endpoint lands the
1468 /// worker back on `frontier`), derived from the routed config exactly as
1469 /// [`MissionState::executor_tier`] derives it.
1470 pub tier: ExecutorTier,
1471 /// The rule that decided the route, named by its position in the table
1472 /// (`taskClassRules[i]` / `patternRules[i]`). `None` when no rule
1473 /// decided it: the fall-through to `frontier`, or the legacy literal
1474 /// floor with no table configured at all. Never serialized when absent.
1475 #[serde(default, skip_serializing_if = "Option::is_none")]
1476 pub rule: Option<String>,
1477}
1478
1479/// How worker/validator sessions are isolated from the primary checkout.
1480///
1481/// Default is [`WorkerIsolation::Worktree`]: the primary checkout must stay
1482/// byte-untouched across a mission (AGENTS.md). Operators may still opt into
1483/// [`WorkerIsolation::Checkout`]
1484/// for backends that cannot write into temp-dir worktrees.
1485#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1486#[serde(rename_all = "lowercase")]
1487pub enum WorkerIsolation {
1488 #[default]
1489 Worktree,
1490 Checkout,
1491}
1492
1493/// Workspace provider seam config (design D-B, ticket
1494/// `workspace-provider-seam`): which
1495/// [`crate::workspace_provider::WorkspaceProvider`] supplies the mission's
1496/// runnable environment. A DIFFERENT config surface from
1497/// [`SandboxConfig`] — sandbox = process containment, workspace = the
1498/// runnable environment — and the two stay separate even where runtime code
1499/// could later be shared.
1500#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1501#[serde(rename_all = "camelCase", default)]
1502pub struct WorkspaceConfig {
1503 /// Workspace provider name. Absent (or `"local-worktree"`) selects
1504 /// today's isolation cwd (the default). Unknown names fail closed at run
1505 /// start — never a silent fallback to local.
1506 #[serde(default, skip_serializing_if = "Option::is_none")]
1507 pub provider: Option<String>,
1508 /// Remote-substrate settings (ticket `workspace-remote-coder-provider`),
1509 /// consulted only when `provider = "remote"`: `"remote"` resolves ONLY
1510 /// with these complete — a missing key fails closed at resolve (plan
1511 /// approval AND run start) with the key named, never a silent fallback
1512 /// to local.
1513 #[serde(default, skip_serializing_if = "Option::is_none")]
1514 pub remote: Option<RemoteWorkspaceConfig>,
1515 /// The teardown mode the engine drives when a run reaches a TERMINAL
1516 /// state — Complete/Failed/Abandoned (ticket
1517 /// `workspace-idle-hibernate`): `"keep"` (the default) | `"hibernate"`
1518 /// | `"destroy"`. A non-terminal run end (Blocked/Paused) always Keeps
1519 /// so the mission can resume; local-worktree is always Keep regardless
1520 /// (its filesystem lifecycle belongs to the mission-branch/merge
1521 /// machinery). Unknown modes fail closed at run start with this key
1522 /// named — never a silent default.
1523 #[serde(default, skip_serializing_if = "Option::is_none")]
1524 pub teardown_mode: Option<String>,
1525}
1526
1527/// Remote substrate (Coder-shaped) connection config (ticket
1528/// `workspace-remote-coder-provider`). Additive and serde-defaulted like the
1529/// rest of the config contract. The substrate token comes from the
1530/// environment variable NAMED by `tokenEnv`, read lazily at provision —
1531/// never a value in config, logs, or events. VPN/SSH reachability of the
1532/// substrate is an operator/network concern: no public IP is required, and
1533/// the adapter never opens one.
1534#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1535#[serde(rename_all = "camelCase", default)]
1536pub struct RemoteWorkspaceConfig {
1537 /// Substrate API base URL (e.g. `"https://coder.internal.example.com"`).
1538 #[serde(default, skip_serializing_if = "Option::is_none")]
1539 pub base_url: Option<String>,
1540 /// The template/image id workspaces are provisioned from (pinned at
1541 /// approval into `workspace.provider.pinned` as the remote `template`).
1542 #[serde(default, skip_serializing_if = "Option::is_none")]
1543 pub template: Option<String>,
1544 /// NAME of the environment variable holding the substrate API token
1545 /// (e.g. `"CODER_SESSION_TOKEN"`) — the name only, never the value.
1546 #[serde(default, skip_serializing_if = "Option::is_none")]
1547 pub token_env: Option<String>,
1548 /// Substrate-side idle policy VALUE (ticket `workspace-idle-hibernate`):
1549 /// hours of inactivity after which the SUBSTRATE hibernates the
1550 /// workspace. kranz never schedules: the value is passed through to the
1551 /// substrate at provision (when the substrate accepts an idle policy)
1552 /// and recorded in `workspace.provisioned`'s detail; the substrate owns
1553 /// the policy's execution.
1554 #[serde(default, skip_serializing_if = "Option::is_none")]
1555 pub idle_after_hours: Option<f64>,
1556}
1557
1558/// Default for [`MissionConfig::rubber_stamp_threshold_ms`] (ticket
1559/// `rubber-stamp-grant-flag`): the docs/metrics.md §2 sub-ten-second bucket,
1560/// made configurable. A grant APPROVED in under this latency is flagged as a
1561/// rubber-stamp signal in the outcomes report — a flag, never an enforcement.
1562pub const DEFAULT_RUBBER_STAMP_THRESHOLD_MS: u64 = 10_000;
1563
1564/// Hook-derived status-signal lane config (ticket
1565/// `agent-hooks-status-signals`, [`crate::hook_status`]): an OPTIONAL,
1566/// off-by-default observability lane for backends with a lifecycle-hook
1567/// surface ([`BackendKind::supports_hook_status_signals`] — cursor only
1568/// today). When enabled, worker sessions on hook-capable backends get a
1569/// per-run capability token + hook install that reports coarse signals
1570/// ("running" / "needs input" / "interrupted" / "turn finished") to
1571/// `endpoint`; the signals land ONLY in the ephemeral `.kranz/hook-status/`
1572/// projection, never in mission state. When disabled (the default) every
1573/// session is byte-identical to today — no hook config anywhere.
1574#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1575#[serde(rename_all = "camelCase", default)]
1576pub struct HookStatusConfig {
1577 /// Master switch, off by default.
1578 pub enabled: bool,
1579 /// The full loopback signal POST URL the per-session relay
1580 /// (`kranz hook-status`) delivers to — e.g.
1581 /// `http://127.0.0.1:4560/api/hook-status`. Required when `enabled`;
1582 /// `config::validate` refuses non-loopback or non-HTTP(S) values
1583 /// (the per-run capability token rides this URL).
1584 pub endpoint: String,
1585}
1586
1587/// Require a known model family different from every recorded worker attempt.
1588/// Dispatch identity does not prove statistically independent mistakes.
1589#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1590#[serde(rename_all = "camelCase", default, deny_unknown_fields)]
1591pub struct ReviewerIndependence {
1592 pub scrutiny: bool,
1593 pub functional: bool,
1594}
1595
1596impl ReviewerIndependence {
1597 pub fn is_empty(&self) -> bool {
1598 !self.scrutiny && !self.functional
1599 }
1600
1601 pub fn requires(&self, role: Role) -> bool {
1602 match role {
1603 Role::ValidatorScrutiny => self.scrutiny,
1604 Role::ValidatorFunctional => self.functional,
1605 _ => false,
1606 }
1607 }
1608}
1609
1610#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1611#[serde(rename_all = "camelCase", default)]
1612pub struct MissionConfig {
1613 pub orchestrator: RoleConfig,
1614 pub worker: RoleConfig,
1615 pub validator_scrutiny: RoleConfig,
1616 pub validator_functional: RoleConfig,
1617 pub skip_scrutiny: bool,
1618 pub skip_functional: bool,
1619 /// Require a different model family for the selected reviewer roles.
1620 /// Pinned at approval; cannot be changed through runtime config patches.
1621 #[serde(default, skip_serializing_if = "ReviewerIndependence::is_empty")]
1622 pub reviewer_independence: ReviewerIndependence,
1623 pub max_fix_cycles_per_milestone: u32,
1624 pub max_respawns: u32,
1625 pub max_parallel_workers: u32,
1626 pub event_stream_throttle_ms: u64,
1627 /// An in-planning mission whose hosted engine sits idle this many minutes
1628 /// is released (its events.jsonl lock freed); 0 disables auto-release.
1629 pub planning_idle_release_minutes: u64,
1630 /// When true, the serve process drains the queue automatically whenever
1631 /// entries are waiting. Default off.
1632 pub auto_work: bool,
1633 /// Require `Plan.consideredAlternatives` when feature count reaches this
1634 /// threshold. `0` disables this trigger.
1635 pub considered_alternatives_feature_threshold: usize,
1636 /// Require `Plan.consideredAlternatives` when `touchSet` breadth reaches
1637 /// this threshold. `0` disables this trigger.
1638 pub considered_alternatives_touch_set_threshold: usize,
1639 /// Require `Plan.consideredAlternatives` when the estimated high cost
1640 /// reaches this threshold. `0.0` disables this trigger.
1641 pub considered_alternatives_high_usd_threshold: f64,
1642 /// Extra Bash deny patterns beyond the built-in list (§4.7).
1643 pub deny_patterns: Vec<String>,
1644 /// Commands validators may run, in addition to contract `command`s.
1645 pub allow_validator_commands: Vec<String>,
1646 /// Loud, never-default escape hatch.
1647 pub dangerously_allow_all: bool,
1648 /// Explicit mission opt-in for worker models below the default worker tier.
1649 ///
1650 /// Default false: cheap/lower-tier workers must be chosen deliberately on
1651 /// the mission config rather than becoming a silent global default.
1652 pub allow_below_default_worker_model: bool,
1653 /// Path to the claude binary (auto-discovered when None).
1654 #[serde(skip_serializing_if = "Option::is_none")]
1655 pub claude_binary: Option<String>,
1656 /// How worker/validator sessions are isolated (§M7 tier 1).
1657 pub worker_isolation: WorkerIsolation,
1658 /// Escape hatch for the cleared contract-command environment (ticket
1659 /// `agent-env-clear`): NAMES of ambient env vars copied verbatim into
1660 /// the env of contract `command` assertions (validation round, final
1661 /// gate, approval-time lint). This is the sanctioned way to give a
1662 /// contract command one credential (e.g. a private-registry token the
1663 /// toolchain-cache passthrough does not cover). Values are never
1664 /// logged — decision records list names only. Names colliding with the
1665 /// contract env's own managed keys (`PATH`/`HOME`/`TMPDIR`/
1666 /// `KRANZ_BASE_SHA`/toolchain caches) are refused. Everything else
1667 /// ambient is cleared before the command runs.
1668 #[serde(default)]
1669 pub contract_env_passthrough: Vec<String>,
1670 /// Workspace provider seam (design D-B). `provider` absent =
1671 /// local-worktree; unknown names fail closed at run start.
1672 #[serde(default)]
1673 pub workspace: WorkspaceConfig,
1674 /// Pack contract (ticket `pack-contract-gates-prompts`): directory of the
1675 /// pack this mission runs with — a `pack.toml` declaring deterministic
1676 /// gates, role prompts, checklists, and artefact stores
1677 /// (docs/pack-contract.md). Relative paths resolve against the repo
1678 /// root. Loaded and validated (fail-closed, naming the offending field)
1679 /// at run start and at each consuming surface; absent ⇒ byte-identical
1680 /// pack-less behavior.
1681 #[serde(default, skip_serializing_if = "Option::is_none")]
1682 pub pack_dir: Option<String>,
1683 /// Outcomes-report flag threshold (ticket `rubber-stamp-grant-flag`):
1684 /// grants APPROVED in under this many milliseconds are flagged as
1685 /// rubber-stamp signals — a flag on a report row, never an enforcement.
1686 /// The default (10s) is the docs/metrics.md §2 bucket made configurable.
1687 pub rubber_stamp_threshold_ms: u64,
1688 /// Heterogeneous dispatch pool (ticket `heterogeneous-dispatch-pool`,
1689 /// KRZ-303; the positioning ADR's 2026-07-31 boundary gloss). Empty (the
1690 /// default) is today's single-backend worker behavior EXACTLY. With ≥2
1691 /// candidates, every worker feature — the unit of work — is dispatched to
1692 /// ALL of them concurrently, one git worktree per stream, and every
1693 /// output is recorded as a sibling [`CandidateLink`]ed run: a candidate
1694 /// for judgement, never auto-merged into a winner (no code path selects
1695 /// or merges one), and the mission then parks for the human judgement act
1696 /// the follow-up divergence ticket surfaces. The claimed value is
1697 /// divergence for scrutiny, never throughput; cost multiplies by N and
1698 /// the approval-time estimate prices the SUM. `config::validate` rejects
1699 /// a 1-entry list (use `worker.backend`), `local`/`acp` entries (no
1700 /// per-candidate endpoint config in this pass), and combining the pool
1701 /// with `maxParallelWorkers > 1` (a different fan-out model).
1702 #[serde(default)]
1703 pub worker_candidates: Vec<CandidateSpec>,
1704 /// The backend routing table (ticket `backend-routing-abstraction`,
1705 /// KRZ-331): ordered task-class → executor-tier rules, resolved
1706 /// deterministically by [`crate::routing`] at mission seed time
1707 /// ([`crate::config::route_task_class_executor`]). Empty (the default)
1708 /// keeps today's hardcoded literal floor byte-for-byte. Capability
1709 /// classes only — a rule names an [`ExecutorTier`], never a model id;
1710 /// which endpoint a `local` route resolves to is ordinary local-backend
1711 /// role config, so a hosted fine-tune needs no new kind here.
1712 #[serde(default)]
1713 pub routing: RoutingConfig,
1714 /// Hook-derived status-signal lane (ticket
1715 /// `agent-hooks-status-signals`). Absent/disabled = byte-identical
1716 /// pre-lane behavior; enabled installs hook config ONLY into
1717 /// session-private scratch HOMEs on hook-capable backends.
1718 #[serde(default, skip_serializing_if = "Option::is_none")]
1719 pub hook_status: Option<HookStatusConfig>,
1720 /// EXPLICIT per-repo opt-in for the uncontained-validator degrade
1721 /// (ticket `validator-containment-degrade-fail-closed`, 14th-pass
1722 /// review): when the mandatory validator containment wrap cannot apply
1723 /// (an uncontainable platform, linux without `bwrap`, a validator
1724 /// backend that does not honor the resolved sandbox), validation now
1725 /// FAILS CLOSED by default — the degrade reopens the modify→use→restore
1726 /// path the mandatory-containment work was built to close. This reverses
1727 /// the recorded 224fa73 decision (loud-degrade-by-default); setting this
1728 /// true restores that posture: the validator runs uncontained with the
1729 /// loud per-round degradation decision, snapshot isolation, and the
1730 /// after-fingerprint tripwire as the only remaining layers.
1731 #[serde(default)]
1732 pub validator_allow_uncontained_degrade: bool,
1733 /// Non-loopback hosts a local-backend `baseUrl` may name (audit
1734 /// 2026-09-01, MEDIUM `baseUrl`).
1735 ///
1736 /// The engine POSTs the assembled system + user prompt to `baseUrl` from
1737 /// the engine process, outside every sandbox, and the readiness probe
1738 /// connects to whatever host:port it names. `config::validate` therefore
1739 /// requires the host to be loopback unless it is listed here — the same
1740 /// reasoning `hookStatus.endpoint` already carries. OPERATOR-ONLY: the
1741 /// project config layer may not set this key (it is the escape hatch
1742 /// from the rule, so a repo that could set it would face no rule at
1743 /// all). Empty (the default) means loopback only.
1744 #[serde(default)]
1745 pub local_backend_allowed_hosts: Vec<String>,
1746}
1747
1748impl Default for MissionConfig {
1749 fn default() -> Self {
1750 MissionConfig {
1751 orchestrator: RoleConfig {
1752 model: "opus".into(),
1753 reasoning_effort: "high".into(),
1754 max_turns: None,
1755 max_budget_usd: Some(20.0),
1756 tools: vec![],
1757 backend: None,
1758 base_url: None,
1759 context_budget: None,
1760 temperature: None,
1761 acp_command: None,
1762 acp_args: vec![],
1763 sandbox: SandboxConfig::default(),
1764 },
1765 worker: RoleConfig {
1766 model: "sonnet".into(),
1767 reasoning_effort: "medium".into(),
1768 max_turns: Some(50),
1769 max_budget_usd: Some(10.0),
1770 tools: vec![],
1771 backend: None,
1772 base_url: None,
1773 context_budget: None,
1774 temperature: None,
1775 acp_command: None,
1776 acp_args: vec![],
1777 sandbox: SandboxConfig::default(),
1778 },
1779 validator_scrutiny: RoleConfig {
1780 model: "opus".into(),
1781 reasoning_effort: "high".into(),
1782 max_turns: Some(40),
1783 max_budget_usd: Some(10.0),
1784 tools: vec![],
1785 backend: None,
1786 base_url: None,
1787 context_budget: None,
1788 temperature: None,
1789 acp_command: None,
1790 acp_args: vec![],
1791 sandbox: SandboxConfig::default(),
1792 },
1793 validator_functional: RoleConfig {
1794 model: "sonnet".into(),
1795 reasoning_effort: "medium".into(),
1796 max_turns: Some(40),
1797 max_budget_usd: Some(5.0),
1798 tools: vec![],
1799 backend: None,
1800 base_url: None,
1801 context_budget: None,
1802 temperature: None,
1803 acp_command: None,
1804 acp_args: vec![],
1805 sandbox: SandboxConfig::default(),
1806 },
1807 skip_scrutiny: false,
1808 skip_functional: false,
1809 reviewer_independence: ReviewerIndependence::default(),
1810 max_fix_cycles_per_milestone: 2,
1811 max_respawns: 2,
1812 max_parallel_workers: 1,
1813 event_stream_throttle_ms: 250,
1814 planning_idle_release_minutes: 30,
1815 auto_work: false,
1816 considered_alternatives_feature_threshold: 4,
1817 considered_alternatives_touch_set_threshold: 4,
1818 considered_alternatives_high_usd_threshold: 0.0,
1819 deny_patterns: vec![],
1820 allow_validator_commands: vec![],
1821 dangerously_allow_all: false,
1822 allow_below_default_worker_model: false,
1823 claude_binary: None,
1824 worker_isolation: WorkerIsolation::Worktree,
1825 contract_env_passthrough: vec![],
1826 workspace: WorkspaceConfig::default(),
1827 pack_dir: None,
1828 rubber_stamp_threshold_ms: DEFAULT_RUBBER_STAMP_THRESHOLD_MS,
1829 worker_candidates: vec![],
1830 routing: RoutingConfig::default(),
1831 hook_status: None,
1832 validator_allow_uncontained_degrade: false,
1833 local_backend_allowed_hosts: Vec::new(),
1834 }
1835 }
1836}
1837
1838impl MissionConfig {
1839 pub fn isolation(&self) -> WorkerIsolation {
1840 self.worker_isolation
1841 }
1842
1843 pub fn role(&self, role: Role) -> &RoleConfig {
1844 match role {
1845 Role::Orchestrator => &self.orchestrator,
1846 Role::Worker => &self.worker,
1847 Role::ValidatorScrutiny => &self.validator_scrutiny,
1848 Role::ValidatorFunctional => &self.validator_functional,
1849 }
1850 }
1851
1852 /// Which backend drives a role's sessions. `config::validate` rejects
1853 /// unknown backend strings before runtime; this accessor treats any
1854 /// unexpected value as Claude as a conservative fallback for callers that
1855 /// operate on already-validated config.
1856 pub fn backend_kind(&self, role: Role) -> BackendKind {
1857 match self.role(role).backend.as_deref() {
1858 Some("codex") => BackendKind::Codex,
1859 Some("droid") => BackendKind::Droid,
1860 Some("kimi") => BackendKind::Kimi,
1861 Some("local") => BackendKind::Local,
1862 Some("acp") => BackendKind::Acp,
1863 Some("cursor") => BackendKind::Cursor,
1864 _ => BackendKind::Claude,
1865 }
1866 }
1867
1868 /// Which inference tier the Worker executes on under this config, derived
1869 /// from the Worker `RoleConfig.backend` rather than stored:
1870 /// [`ExecutorTier::Local`] when the Worker backend is
1871 /// [`BackendKind::Local`], else [`ExecutorTier::Frontier`]. A configured
1872 /// dispatch pool (`worker_candidates`) is always Frontier: pool
1873 /// candidates are never local-backed (validation rejects `local`
1874 /// entries), so a stray local `worker.backend` alongside a pool must not
1875 /// classify the spend as $0-marginal. The config-level home of the
1876 /// derivation — [`MissionState::executor_tier`] delegates here, and the
1877 /// per-session route record ([`ExecutorRoute`]) reads the same source.
1878 pub fn executor_tier(&self) -> ExecutorTier {
1879 if self.worker_candidates.is_empty()
1880 && self.backend_kind(Role::Worker) == BackendKind::Local
1881 {
1882 ExecutorTier::Local
1883 } else {
1884 ExecutorTier::Frontier
1885 }
1886 }
1887}
1888
1889// ---------------------------------------------------------------------------
1890// Control commands (cross-process: CLI/server -> engine, via control dir)
1891// ---------------------------------------------------------------------------
1892
1893#[derive(Debug, Clone, Serialize, Deserialize)]
1894#[serde(tag = "kind", rename_all = "kebab-case")]
1895pub enum ControlCommand {
1896 Pause,
1897 Resume,
1898 Msg {
1899 text: String,
1900 interrupt: bool,
1901 },
1902 ConfigChange {
1903 patch: serde_json::Value,
1904 },
1905 RequestRevision {
1906 instructions: String,
1907 },
1908 ApproveRevision {
1909 revision: u32,
1910 },
1911 RejectRevision {
1912 revision: u32,
1913 },
1914 /// Approve the parked grant request for `command` (extend `command_grants`
1915 /// and respawn). The command is echoed back so a stale approval can't apply
1916 /// to a different pending request than the operator saw.
1917 ApproveGrant {
1918 command: String,
1919 },
1920 /// Deny the parked grant request for `command` (fail the feature closed).
1921 DenyGrant {
1922 command: String,
1923 reason: String,
1924 },
1925 /// Answer an open structured question (ticket
1926 /// `structured-human-question-events`) — the pending-decision
1927 /// projection's input edge, submitted through this EXISTING control path
1928 /// (the D-X ruling: no new server). `answer` is the chosen option's text
1929 /// verbatim or the operator's free text (scrubbed + capped when the
1930 /// engine lands it as `question.answered`); `option` records the 0-based
1931 /// index when an offered option was picked, and the engine cross-checks
1932 /// it against the parked question exactly like the grant approve/deny
1933 /// commands echo their target — a stale answer can't land on a different
1934 /// question than the operator saw.
1935 AnswerQuestion {
1936 #[serde(rename = "questionId")]
1937 question_id: String,
1938 answer: String,
1939 #[serde(default, skip_serializing_if = "Option::is_none")]
1940 option: Option<u32>,
1941 },
1942}
1943
1944#[cfg(test)]
1945mod tests {
1946 use super::*;
1947
1948 #[test]
1949 fn commands_run_backcompat_defaults_empty() {
1950 let json = r#"{"result": "pass", "summary": "did the thing"}"#;
1951 let report: WorkerReport = serde_json::from_str(json).unwrap();
1952 assert!(report.commands_run.is_empty());
1953 }
1954
1955 #[test]
1956 fn routing_abstraction_worker_report_escalation_is_additive() {
1957 // KRZ-331: old reports (no escalation key) parse with no request…
1958 let json = r#"{"result": "pass", "summary": "did the thing"}"#;
1959 let report: WorkerReport = serde_json::from_str(json).unwrap();
1960 assert_eq!(report.escalation, None);
1961 // …None never hits the wire…
1962 let value = serde_json::to_value(&report).unwrap();
1963 assert!(value.get("escalation").is_none());
1964 // …and a request round-trips camelCase verbatim.
1965 let json = r#"{"result": "partial", "summary": "s", "escalation": "spec ambiguity beyond my confidence"}"#;
1966 let report: WorkerReport = serde_json::from_str(json).unwrap();
1967 assert_eq!(
1968 report.escalation.as_deref(),
1969 Some("spec ambiguity beyond my confidence")
1970 );
1971 let value = serde_json::to_value(&report).unwrap();
1972 assert_eq!(value["escalation"], "spec ambiguity beyond my confidence");
1973 }
1974
1975 /// The additive `questions` on the worker report (ticket
1976 /// `structured-human-question-events`): prose-only reports (the fallback)
1977 /// parse with no questions, None never hits the wire, and a structured
1978 /// ask round-trips — options included, or absent for a free-text ask.
1979 #[test]
1980 fn question_events_worker_report_questions_are_additive() {
1981 // Old/prose-only report (no questions key): parses with None…
1982 let json = r#"{"result": "pass", "summary": "did the thing"}"#;
1983 let report: WorkerReport = serde_json::from_str(json).unwrap();
1984 assert_eq!(report.questions, None);
1985 // …and None stays off the wire.
1986 let value = serde_json::to_value(&report).unwrap();
1987 assert!(value.get("questions").is_none());
1988
1989 // A structured ask (with options and without) round-trips verbatim.
1990 let json = r#"{
1991 "result": "partial",
1992 "summary": "blocked on a human choice",
1993 "questions": [
1994 { "text": "Which storage engine?", "options": ["sqlite", "in-memory"] },
1995 { "text": "What should the flag be called?" }
1996 ]
1997 }"#;
1998 let report: WorkerReport = serde_json::from_str(json).unwrap();
1999 let questions = report.questions.as_ref().expect("questions parsed");
2000 assert_eq!(questions.len(), 2);
2001 assert_eq!(questions[0].text, "Which storage engine?");
2002 assert_eq!(questions[0].options, vec!["sqlite", "in-memory"]);
2003 assert_eq!(questions[1].options, Vec::<String>::new());
2004 let value = serde_json::to_value(&report).unwrap();
2005 assert_eq!(value["questions"][0]["options"][1], "in-memory");
2006 // Empty options stay off the wire (a free-text ask carries no key).
2007 assert!(value["questions"][1].get("options").is_none());
2008 }
2009
2010 /// The `answer-question` control kind (ticket
2011 /// `structured-human-question-events`): kebab-case wire name, camelCase
2012 /// `questionId` (matching the event payload + REST body convention), and
2013 /// `option` additive — absent for free-text answers and never on the
2014 /// wire then.
2015 #[test]
2016 fn question_events_answer_control_kind_wire_shape() {
2017 let cmd = ControlCommand::AnswerQuestion {
2018 question_id: "q-1".into(),
2019 answer: "sqlite".into(),
2020 option: Some(0),
2021 };
2022 let json = serde_json::to_value(&cmd).unwrap();
2023 assert_eq!(json["kind"], "answer-question");
2024 assert_eq!(json["questionId"], "q-1");
2025 assert_eq!(json["answer"], "sqlite");
2026 assert_eq!(json["option"], 0);
2027 let back: ControlCommand = serde_json::from_value(json).unwrap();
2028 match back {
2029 ControlCommand::AnswerQuestion {
2030 question_id,
2031 answer,
2032 option,
2033 } => {
2034 assert_eq!(question_id, "q-1");
2035 assert_eq!(answer, "sqlite");
2036 assert_eq!(option, Some(0));
2037 }
2038 _ => panic!("wrong variant"),
2039 }
2040
2041 // A free-text answer (no option index) omits the key, and a wire
2042 // line without it parses back to None (serde default).
2043 let cmd = ControlCommand::AnswerQuestion {
2044 question_id: "q-2".into(),
2045 answer: "call it --cache-dir".into(),
2046 option: None,
2047 };
2048 let json = serde_json::to_value(&cmd).unwrap();
2049 assert!(
2050 !json.as_object().unwrap().contains_key("option"),
2051 "option must not serialize when None: {json}"
2052 );
2053 let sparse: ControlCommand = serde_json::from_str(
2054 r#"{"kind":"answer-question","questionId":"q-2","answer":"call it --cache-dir"}"#,
2055 )
2056 .unwrap();
2057 match sparse {
2058 ControlCommand::AnswerQuestion { option, .. } => assert_eq!(option, None),
2059 _ => panic!("wrong variant"),
2060 }
2061 }
2062
2063 #[test]
2064 fn finding_class_round_trips_through_serde() {
2065 let finding = Finding {
2066 subject: "a-1".to_string(),
2067 severity: "major".to_string(),
2068 evidence: "wrote outside touch-set".to_string(),
2069 suggested_fix: String::new(),
2070 class: "out-of-contract-write".to_string(),
2071 rule: None,
2072 };
2073 let json = serde_json::to_value(&finding).unwrap();
2074 assert_eq!(json["class"], "out-of-contract-write");
2075 let round_tripped: Finding = serde_json::from_value(json).unwrap();
2076 assert_eq!(round_tripped.class, "out-of-contract-write");
2077 }
2078
2079 #[test]
2080 fn finding_class_backcompat_defaults_empty() {
2081 let json = r#"{
2082 "subject": "a-1",
2083 "severity": "major",
2084 "evidence": "it broke"
2085 }"#;
2086 let finding: Finding = serde_json::from_str(json).unwrap();
2087 assert_eq!(finding.class, "");
2088 }
2089}