Skip to main content

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}