Skip to main content

kranz_engine/
config.rs

1//! Layered mission configuration (plan §6).
2//!
3//! Configuration is resolved from three layers, later layers winning:
4//!
5//! 1. [`MissionConfig::default()`] — compiled-in defaults
6//! 2. `~/.kranz/config.json` — the user's global config ([`crate::paths::global_config`])
7//! 3. `<repo>/.kranz/config.json` — per-project config ([`crate::paths::project_config`])
8//!
9//! Files may be *partial*: any subset of keys. The merge happens on
10//! `serde_json::Value` trees so a project file can override a single nested
11//! field (e.g. only `worker.model`) without restating the rest. Unknown keys
12//! are ignored on deserialization.
13
14use crate::cost::{
15    DEFAULT_CODEX_MODEL, DEFAULT_CURSOR_MODEL, DEFAULT_DROID_MODEL, DEFAULT_KIMI_MODEL,
16};
17use crate::error::{EngineError, Result};
18use crate::paths;
19use crate::types::{BackendKind, ExecutorTier, MissionConfig, Role, SandboxEnforce};
20use std::path::{Path, PathBuf};
21
22/// Reasoning-effort values accepted by `claude --effort`.
23const VALID_EFFORTS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
24
25/// Maximum dispatch-pool size (`workerCandidates`, KRZ-303). Each candidate
26/// is a full paid worker session per unit of work, so the same 8-wide bound
27/// as `maxParallelWorkers` applies — well past any useful fan-out.
28pub const MAX_WORKER_CANDIDATES: usize = 8;
29
30/// Coarse model capability tiers used by config safety floors.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
32pub enum ModelTier {
33    BelowDefault,
34    Default,
35    Frontier,
36}
37
38/// Parse the optional role backend field.
39pub fn parse_backend(raw: Option<&str>) -> std::result::Result<BackendKind, String> {
40    match raw {
41        None | Some("claude") => Ok(BackendKind::Claude),
42        Some("codex") => Ok(BackendKind::Codex),
43        Some("droid") => Ok(BackendKind::Droid),
44        Some("kimi") => Ok(BackendKind::Kimi),
45        Some("local") => Ok(BackendKind::Local),
46        Some("acp") => Ok(BackendKind::Acp),
47        Some("cursor") => Ok(BackendKind::Cursor),
48        Some(other) => Err(other.to_string()),
49    }
50}
51
52/// Deterministically map a ticket's `task-class` frontmatter to an executor
53/// tier. Literal table only — no heuristics: `execution-class` (case- and
54/// whitespace-insensitive) routes to [`ExecutorTier::Local`]; every other
55/// value, including absence, stays on [`ExecutorTier::Frontier`].
56pub fn task_class_to_tier(task_class: Option<&str>) -> ExecutorTier {
57    match task_class.map(|s| s.trim().to_ascii_lowercase()) {
58        Some(ref s) if s == "execution-class" => ExecutorTier::Local,
59        _ => ExecutorTier::Frontier,
60    }
61}
62
63/// An operator-configured OpenAI-compatible endpoint the Worker can be routed
64/// to for the local tier. Mirrors [`crate::types::RoleConfig`]'s local-backend
65/// fields (`base_url`/`context_budget`/`temperature`).
66#[derive(Debug, Clone, PartialEq)]
67pub struct LocalEndpoint {
68    pub base_url: String,
69    pub context_budget: u32,
70    pub temperature: Option<f64>,
71}
72
73/// Apply executor-tier routing to a mission config at seed time, so a fresh
74/// mission's `mission.created` config already reflects the routing decision.
75/// Pure: never touches `config.validator_scrutiny` or `config.validator_functional`.
76///
77/// Returns the APPLIED tier, which may differ from the requested `tier`: a
78/// `Local` request with no configured endpoint fails safe to `Frontier`
79/// (leaving the Worker on its frontier default) rather than routing to an
80/// endpoint that doesn't exist. A configured dispatch pool
81/// (`worker_candidates`) also pins `Frontier`: the pool is an explicit
82/// per-candidate backend declaration, and local routing's rewrite of
83/// `worker.backend` would sit next to it as a dead, misleading key (pool
84/// candidates are never local-backed — validation rejects `local` entries).
85pub fn apply_executor_routing(
86    config: &mut MissionConfig,
87    tier: ExecutorTier,
88    local_endpoint: Option<&LocalEndpoint>,
89) -> ExecutorTier {
90    if !config.worker_candidates.is_empty() {
91        return ExecutorTier::Frontier;
92    }
93    match (tier, local_endpoint) {
94        (ExecutorTier::Frontier, _) => ExecutorTier::Frontier,
95        (ExecutorTier::Local, None) => ExecutorTier::Frontier,
96        (ExecutorTier::Local, Some(endpoint)) => {
97            config.worker.backend = Some("local".to_string());
98            config.worker.base_url = Some(endpoint.base_url.clone());
99            config.worker.context_budget = Some(endpoint.context_budget);
100            config.worker.temperature = endpoint.temperature;
101            config.allow_below_default_worker_model = true;
102            ExecutorTier::Local
103        }
104    }
105}
106
107/// Route the executor tier for a mission seeded from `ticket`, so the
108/// resulting `mission.created` config already reflects the routing decision
109/// — the single engine-side entry point both the `kranz draft` and
110/// `kranz exec` seed paths call before [`crate::orchestrator::MissionEngine::create`].
111/// Returns the applied tier and a decision summary to record against the
112/// mission once it exists.
113pub fn route_ticket_executor(
114    cfg: &mut MissionConfig,
115    ticket: &crate::ticket::Ticket,
116) -> (ExecutorTier, &'static str) {
117    route_task_class_executor(cfg, ticket.task_class.as_deref())
118}
119
120/// Core of [`route_ticket_executor`], taking the raw `task-class` string
121/// directly. [`crate::orchestrator::MissionEngine::create`] calls this with
122/// the class recovered from its `goal` argument via
123/// [`crate::ticket::parse_task_class_from_goal`] — `create` only ever sees a
124/// folded goal string, never the originating [`crate::ticket::Ticket`], so
125/// the class has to travel through that one channel.
126///
127/// The routing table (KRZ-331): when `cfg.routing` declares rules, they are
128/// the floor — resolved deterministically by [`crate::routing::table_tier`]
129/// (first match wins, no match stays Frontier). An EMPTY table keeps the
130/// hardcoded literal floor ([`task_class_to_tier`]) byte-for-byte, so a
131/// config that never heard of the table routes exactly as before.
132pub fn route_task_class_executor(
133    cfg: &mut MissionConfig,
134    task_class: Option<&str>,
135) -> (ExecutorTier, &'static str) {
136    let table_configured = !cfg.routing.is_empty();
137    let requested = if table_configured {
138        crate::routing::table_tier(&cfg.routing, task_class)
139    } else {
140        task_class_to_tier(task_class)
141    };
142    let local_endpoint = match (&cfg.worker.base_url, cfg.worker.context_budget) {
143        (Some(base_url), Some(context_budget)) => Some(LocalEndpoint {
144            base_url: base_url.clone(),
145            context_budget,
146            temperature: cfg.worker.temperature,
147        }),
148        _ => None,
149    };
150    let applied = apply_executor_routing(cfg, requested, local_endpoint.as_ref());
151    let summary = match (requested, applied, table_configured) {
152        (ExecutorTier::Local, ExecutorTier::Local, true) => {
153            "executor routed local (routing-table rule)"
154        }
155        (ExecutorTier::Local, ExecutorTier::Local, false) => {
156            "executor routed local (execution-class)"
157        }
158        (ExecutorTier::Local, ExecutorTier::Frontier, true) => {
159            "routing-table rule routes local but no local endpoint configured; executor stays frontier"
160        }
161        (ExecutorTier::Local, ExecutorTier::Frontier, false) => {
162            "execution-class ticket but no local endpoint configured; executor stays frontier"
163        }
164        _ => "executor stays frontier",
165    };
166    (applied, summary)
167}
168
169/// The backend-native model used when an older config selected a non-Claude
170/// backend but left the role's Claude default model in place. `Local` has no
171/// backend default: local model ids are free-form and sent to the endpoint
172/// verbatim, with no Claude→backend rewrite.
173fn backend_default_model(kind: BackendKind) -> Option<&'static str> {
174    match kind {
175        BackendKind::Claude => None,
176        BackendKind::Codex => Some(DEFAULT_CODEX_MODEL),
177        BackendKind::Droid => Some(DEFAULT_DROID_MODEL),
178        BackendKind::Kimi => Some(DEFAULT_KIMI_MODEL),
179        BackendKind::Local => None,
180        // ACP has no standard model-selection parameter in v1: the peer's
181        // model is its own concern (encoded in acpCommand/acpArgs), so there
182        // is no backend default to rewrite to.
183        BackendKind::Acp => None,
184        BackendKind::Cursor => Some(DEFAULT_CURSOR_MODEL),
185    }
186}
187
188fn role_default_model(role: Role) -> &'static str {
189    match role {
190        Role::Orchestrator | Role::ValidatorScrutiny => "opus",
191        Role::Worker | Role::ValidatorFunctional => "sonnet",
192    }
193}
194
195/// Return the model actually sent to the backend for this role selection.
196///
197/// This preserves the existing scrutiny-backend backcompat: a config that set
198/// only `validatorScrutiny.backend = "codex"` or `"droid"` used to inherit
199/// the Claude default model and then be rewritten to the backend default at
200/// dispatch. The same rule is now role-wide.
201pub fn effective_model(role: Role, kind: BackendKind, configured: &str) -> String {
202    if kind != BackendKind::Claude && configured == role_default_model(role) {
203        if let Some(default_model) = backend_default_model(kind) {
204            return default_model.to_string();
205        }
206    }
207    configured.to_string()
208}
209
210/// Classify a validated backend/model pair. `None` means this model is not a
211/// supported model for the selected backend.
212pub fn model_tier(kind: BackendKind, model: &str) -> Option<ModelTier> {
213    let m = model.trim().to_ascii_lowercase();
214    if m.is_empty() {
215        return None;
216    }
217    match kind {
218        BackendKind::Claude => {
219            if m == "haiku" || m.contains("haiku") {
220                Some(ModelTier::BelowDefault)
221            } else if m == "sonnet" || m.contains("sonnet") {
222                Some(ModelTier::Default)
223            } else if m == "opus" || m.contains("opus") || m == "fable" || m.contains("fable") {
224                Some(ModelTier::Frontier)
225            } else {
226                None
227            }
228        }
229        BackendKind::Codex => {
230            if m == "codex" || m == DEFAULT_CODEX_MODEL || m.starts_with("gpt-5") {
231                Some(ModelTier::Frontier)
232            } else {
233                None
234            }
235        }
236        BackendKind::Droid => {
237            if m == DEFAULT_DROID_MODEL || m.contains("glm") || m.contains("fireworks") {
238                Some(ModelTier::BelowDefault)
239            } else if m == "fable" || m.contains("fable") {
240                Some(ModelTier::Frontier)
241            } else {
242                None
243            }
244        }
245        BackendKind::Kimi => {
246            if m == DEFAULT_KIMI_MODEL {
247                Some(ModelTier::Frontier)
248            } else if m == "kimi-code/kimi-for-coding" || m == "kimi-code/kimi-for-coding-highspeed"
249            {
250                Some(ModelTier::BelowDefault)
251            } else {
252                None
253            }
254        }
255        // Local model ids are free-form and cannot be allowlisted, so every
256        // non-empty model classifies uniformly below-default: workers need
257        // the allowBelowDefaultWorkerModel opt-in, and a local orchestrator
258        // always fails the frontier floor.
259        BackendKind::Local => Some(ModelTier::BelowDefault),
260        // ACP model ids are equally free-form (the string is recorded for
261        // attribution only; ACP v1 has no model-selection parameter), so the
262        // same uniform below-default classification applies.
263        BackendKind::Acp => Some(ModelTier::BelowDefault),
264        // Cursor model ids are drawn from an account-specific catalog
265        // (~190 entries on the probe account; `--list-models` output varies
266        // by entitlement), so no client-side allowlist is possible and every
267        // non-empty id classifies uniformly below-default: a cursor worker
268        // needs the allowBelowDefaultWorkerModel opt-in (a deliberate gate
269        // for a validator-first backend), and the orchestrator stays on its
270        // frontier floor. Model-availability failures themselves are
271        // diagnosed deterministically at session start (probe item 5).
272        BackendKind::Cursor => Some(ModelTier::BelowDefault),
273    }
274}
275
276/// Classify the role's configured selection after applying legacy/default
277/// model normalization.
278pub fn role_model_tier(cfg: &MissionConfig, role: Role) -> Option<ModelTier> {
279    let kind = cfg.backend_kind(role);
280    let model = effective_model(role, kind, &cfg.role(role).model);
281    model_tier(kind, &model)
282}
283
284// ---------------------------------------------------------------------------
285// The trust rule for repo-owned config (audit 2026-09-01 H1)
286// ---------------------------------------------------------------------------
287
288/// Which layer of the merge order a config file occupies — and therefore who
289/// is trusted to have written it.
290///
291/// `~/.kranz/config.json` is the OPERATOR's own file.
292/// `<repo>/.kranz/config.json` ships with the repository, so for any repo the
293/// operator did not author it is attacker-controlled input, and under
294/// `workerIsolation: "checkout"` it sits inside the session's writable cwd
295/// where a contained worker can plant it. Without a trust rule that layer
296/// wins the merge and can name the binary kranz executes (`claudeBinary`),
297/// the endpoint the engine POSTs prompts to (`baseUrl`), the ambient
298/// credentials copied into contract commands (`contractEnvPassthrough`), and
299/// the switches that turn containment off — before any sandbox, agent, or
300/// approval gate exists. Every other repo-owned surface already carries a
301/// trust distinction (routing rules are read from the base ref, packs carry a
302/// trust class); this closes the last one.
303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304pub enum Layer {
305    /// `~/.kranz/config.json` — written by the operator.
306    Global,
307    /// `<repo>/.kranz/config.json` — written by whoever authored the repo.
308    Project,
309}
310
311/// The four per-role config keys. Their sub-keys share one rule set, so a
312/// dotted path is normalized with the role name replaced by `<role>`.
313const ROLE_KEYS: [&str; 4] = [
314    "orchestrator",
315    "worker",
316    "validatorScrutiny",
317    "validatorFunctional",
318];
319
320/// Collapse the leading role segment of a dotted config path to the literal
321/// `<role>`, so one table entry covers all four roles.
322fn normalize_key_path(dotted: &str) -> String {
323    let mut segments: Vec<&str> = dotted.split('.').collect();
324    if let Some(first) = segments.first_mut() {
325        if ROLE_KEYS.contains(first) {
326            *first = "<role>";
327        }
328    }
329    segments.join(".")
330}
331
332/// True when `normalized` names `key` or sits underneath it.
333fn path_matches(normalized: &str, key: &str) -> bool {
334    normalized == key
335        || (normalized.len() > key.len()
336            && normalized.starts_with(key)
337            && normalized.as_bytes()[key.len()] == b'.')
338}
339
340/// A sensitive key declares both boundaries here: repository-file input and
341/// runtime changes. Ordinary tuning keys remain in `RUNTIME_PATCHABLE`;
342/// unclassified runtime keys always fail closed.
343#[derive(Clone, Copy)]
344enum ProjectPolicy {
345    OperatorOnly,
346    SandboxFloor,
347    ReviewerFloor,
348}
349
350#[derive(Clone, Copy)]
351enum PatchClass {
352    Runtime,
353    Consent,
354    SandboxFloor,
355    Never,
356}
357
358struct ConfigTrustRule {
359    key: &'static str,
360    project: ProjectPolicy,
361    runtime: PatchClass,
362    reason: &'static str,
363}
364
365impl ConfigTrustRule {
366    const fn operator_only(key: &'static str, runtime: PatchClass, reason: &'static str) -> Self {
367        Self {
368            key,
369            project: ProjectPolicy::OperatorOnly,
370            runtime,
371            reason,
372        }
373    }
374}
375
376const CONFIG_TRUST_RULES: &[ConfigTrustRule] = &[
377    // Consent-bearing keys. `apply_validated_patch` already refuses these
378    // from the control inbox as a human decision; a file the repository
379    // ships is no more a human decision than a file an agent drops
380    // (2026-09-01 audit follow-up review, F-7 and F-8).
381    ConfigTrustRule::operator_only(
382        "skipScrutiny",
383        PatchClass::Consent,
384        "it removes the scrutiny validation round",
385    ),
386    ConfigTrustRule::operator_only(
387        "skipFunctional",
388        PatchClass::Consent,
389        "it removes the functional validation round",
390    ),
391    ConfigTrustRule::operator_only(
392        "denyPatterns",
393        PatchClass::Consent,
394        "it is the Bash deny list every session inherits",
395    ),
396    ConfigTrustRule::operator_only(
397        "<role>.tools",
398        PatchClass::Never,
399        "it lands in the session's tool allow list, including the read-only validators'",
400    ),
401    ConfigTrustRule::operator_only(
402        "allowBelowDefaultWorkerModel",
403        PatchClass::Runtime,
404        "it lifts the worker model floor the operator set",
405    ),
406    ConfigTrustRule::operator_only(
407        "workerIsolation",
408        PatchClass::Never,
409        "checkout isolation makes the repository root the worker's writable cwd",
410    ),
411    ConfigTrustRule::operator_only(
412        "claudeBinary",
413        PatchClass::Never,
414        "it names the binary kranz executes, with the operator's full environment and no sandbox",
415    ),
416    ConfigTrustRule::operator_only(
417        "packDir",
418        PatchClass::Never,
419        "the pack it names supplies shell gate commands the engine runs",
420    ),
421    ConfigTrustRule::operator_only(
422        "contractEnvPassthrough",
423        PatchClass::Never,
424        "it copies named ambient credentials verbatim into contract-command environments",
425    ),
426    ConfigTrustRule::operator_only(
427        "dangerouslyAllowAll",
428        PatchClass::Consent,
429        "it puts every agent session in bypassPermissions",
430    ),
431    ConfigTrustRule::operator_only(
432        "validatorAllowUncontainedDegrade",
433        PatchClass::Consent,
434        "it reopens the uncontained-validator degrade the containment work closed",
435    ),
436    ConfigTrustRule::operator_only(
437        "allowValidatorCommands",
438        PatchClass::Consent,
439        "it grants validators shell commands with no human step",
440    ),
441    ConfigTrustRule::operator_only(
442        "localBackendAllowedHosts",
443        PatchClass::Never,
444        "it is the operator's own escape hatch from the local-backend loopback rule",
445    ),
446    // `hooks` is deliberately ABSENT from this list. The github webhook
447    // secret is per-repository by design (`hooks::load_hooks` reads the
448    // project layer, and a test pins that), it lives in a file kranz's own
449    // materialized gitignore keeps untracked, and an attacker who sets it
450    // gains nothing: it is the HMAC key the server checks INBOUND webhooks
451    // against, not a program, an outbound endpoint, or a containment
452    // escape. Its exposure problem is the `config show` one, closed by
453    // redaction there.
454    ConfigTrustRule::operator_only(
455        "slack",
456        PatchClass::Never,
457        "it carries the Slack bot and app tokens, and the channel mission output is posted to \
458         (the Slack bridge reads the global layer only)",
459    ),
460    ConfigTrustRule::operator_only(
461        "hookStatus",
462        PatchClass::Never,
463        "the per-run capability token rides its endpoint",
464    ),
465    ConfigTrustRule::operator_only(
466        "workspace.remote",
467        PatchClass::Never,
468        "it names a remote workspace URL and the env var holding its token",
469    ),
470    ConfigTrustRule::operator_only(
471        "<role>.acpCommand",
472        PatchClass::Never,
473        "it names the ACP agent program",
474    ),
475    ConfigTrustRule::operator_only(
476        "<role>.acpArgs",
477        PatchClass::Never,
478        "it is argv for the ACP agent program",
479    ),
480    ConfigTrustRule::operator_only(
481        "<role>.baseUrl",
482        PatchClass::Never,
483        "the engine POSTs the assembled prompt to it from outside every sandbox",
484    ),
485    ConfigTrustRule::operator_only(
486        "<role>.sandbox.extraWrite",
487        PatchClass::Never,
488        "it widens the sandbox write allowlist",
489    ),
490    ConfigTrustRule::operator_only(
491        "<role>.sandbox.egress",
492        PatchClass::Never,
493        "it widens the sandbox egress allowlist",
494    ),
495    ConfigTrustRule::operator_only(
496        "<role>.sandbox.provider",
497        PatchClass::Never,
498        "it selects which containment mechanism wraps sessions",
499    ),
500    ConfigTrustRule::operator_only(
501        "<role>.sandbox.image",
502        PatchClass::Never,
503        "it names the container image sessions run inside",
504    ),
505    ConfigTrustRule {
506        key: "<role>.sandbox.enforce",
507        project: ProjectPolicy::SandboxFloor,
508        runtime: PatchClass::SandboxFloor,
509        reason: "a repository may raise sandbox enforcement, never lower it",
510    },
511    ConfigTrustRule {
512        key: "reviewerIndependence",
513        project: ProjectPolicy::ReviewerFloor,
514        runtime: PatchClass::Never,
515        reason: "a repository may strengthen reviewer independence, never weaken it",
516    },
517];
518
519fn config_trust_rule(normalized: &str) -> Option<&'static ConfigTrustRule> {
520    CONFIG_TRUST_RULES
521        .iter()
522        .find(|rule| path_matches(normalized, rule.key))
523}
524
525/// Rank a `sandbox.enforce` value so raising and lowering can be told apart:
526/// `off` < `fs` < `fs+net`. An absent or unrecognized value ranks `off`,
527/// which is the compiled-in default.
528fn enforce_rank(value: Option<&serde_json::Value>) -> u8 {
529    use serde::Deserialize as _;
530    // Serde also accepts maps for unit enum variants. Rank the same typed
531    // value config deserialization sees, rather than treating those as Off.
532    match value.and_then(|value| SandboxEnforce::deserialize(value).ok()) {
533        Some(SandboxEnforce::Fs) => 1,
534        Some(SandboxEnforce::FsNet) => 2,
535        Some(SandboxEnforce::Off) | None => 0,
536    }
537}
538
539fn project_layer_refusal(file: &Path, dotted: &str, reason: &str) -> EngineError {
540    EngineError::Config(format!(
541        "{}: the project config layer may not set {dotted:?} — {reason}. \
542         Operator-only keys are settable from the global layer \
543         (~/.kranz/config.json) only.",
544        file.display()
545    ))
546}
547
548/// Refuse a project-layer patch that sets an operator-only key.
549///
550/// `base` is the tree the layers before this one already merged to, which is
551/// what makes the sandbox rule directional: a repository may RAISE
552/// `<role>.sandbox.enforce` (asking for more containment than the operator
553/// configured is always safe) and may never lower it. Likewise, it may add
554/// independent reviewer requirements but cannot remove the operator's floor.
555pub fn check_project_layer_keys(
556    patch: &serde_json::Value,
557    base: &serde_json::Value,
558    file: &Path,
559) -> Result<()> {
560    if !patch.is_object() || !base.is_object() {
561        return Err(EngineError::Config(format!(
562            "{}: project config changes require JSON objects at the top level",
563            file.display()
564        )));
565    }
566    let mut trail: Vec<String> = Vec::new();
567    walk_project_layer(patch, Some(base), file, &mut trail)
568}
569
570fn check_project_reviewer_floor(
571    policy: &serde_json::Value,
572    base: Option<&serde_json::Value>,
573    file: &Path,
574    dotted: &str,
575    reason: &str,
576) -> Result<()> {
577    for role in ["scrutiny", "functional"] {
578        let required = base
579            .and_then(|policy| policy.get(role))
580            .and_then(serde_json::Value::as_bool)
581            == Some(true);
582        // Object omissions inherit through deep_merge; replacing the whole
583        // policy or explicitly disabling a role removes the operator's floor.
584        let retained = policy.is_object()
585            && policy
586                .get(role)
587                .is_none_or(|value| value.as_bool() == Some(true));
588        if required && !retained {
589            return Err(project_layer_refusal(
590                file,
591                &format!("{dotted}.{role}"),
592                reason,
593            ));
594        }
595    }
596    Ok(())
597}
598
599fn walk_project_layer(
600    patch: &serde_json::Value,
601    base: Option<&serde_json::Value>,
602    file: &Path,
603    trail: &mut Vec<String>,
604) -> Result<()> {
605    let serde_json::Value::Object(map) = patch else {
606        return Ok(());
607    };
608    for (key, value) in map {
609        trail.push(key.clone());
610        let dotted = trail.join(".");
611        let normalized = normalize_key_path(&dotted);
612
613        let base_value = base.and_then(|b| b.get(key));
614        // Serde accepts positional arrays for structs. Replacing a role or
615        // sandbox that way would skip this object walk while still changing
616        // protected fields. A positional base would also hide its floor.
617        let protected_container = CONFIG_TRUST_RULES.iter().any(|rule| {
618            (normalized != rule.key && path_matches(rule.key, &normalized))
619                || (normalized == rule.key && matches!(rule.project, ProjectPolicy::ReviewerFloor))
620        });
621        if protected_container {
622            if base_value.is_some_and(|base| !base.is_object()) {
623                return Err(EngineError::Config(format!(
624                    "{}: cannot safely merge project config over non-object inherited field {dotted:?}; protected config containers must be JSON objects",
625                    file.display()
626                )));
627            }
628            if !value.is_object() {
629                return Err(EngineError::Config(format!(
630                    "{}: project config field {dotted:?} must be a JSON object; positional arrays and scalar replacements bypass protected child checks",
631                    file.display()
632                )));
633            }
634        }
635        if let Some(rule) = config_trust_rule(&normalized) {
636            match rule.project {
637                ProjectPolicy::OperatorOnly => {
638                    return Err(project_layer_refusal(file, &dotted, rule.reason));
639                }
640                ProjectPolicy::SandboxFloor if normalized == rule.key => {
641                    if enforce_rank(Some(value)) < enforce_rank(base_value) {
642                        return Err(project_layer_refusal(file, &dotted, rule.reason));
643                    }
644                }
645                ProjectPolicy::ReviewerFloor if normalized == rule.key => {
646                    check_project_reviewer_floor(value, base_value, file, &dotted, rule.reason)?;
647                }
648                _ => {}
649            }
650        }
651
652        walk_project_layer(value, base_value, file, trail)?;
653        trail.pop();
654    }
655    Ok(())
656}
657
658/// Refuse a `claudeBinary` whose resolution depends on where kranz was
659/// invoked, or which the repository itself supplies.
660///
661/// A relative path resolves against the process working directory, so `kranz
662/// ready` run one directory over executes a different program. A path inside
663/// the repository is repo-authored content executed as the operator with the
664/// operator's full environment — the H1 primary path, closed here as well as
665/// at the layer rule so a global-layer typo or an operator-set in-repo path
666/// is caught too.
667pub fn validate_claude_binary(cfg: &MissionConfig, repo_root: &Path) -> Result<()> {
668    let Some(raw) = cfg.claude_binary.as_deref() else {
669        return Ok(());
670    };
671    let trimmed = raw.trim();
672    if trimmed.is_empty() {
673        return Err(EngineError::Config(
674            "claudeBinary must not be empty; omit the key to auto-discover".into(),
675        ));
676    }
677    let candidate = Path::new(trimmed);
678    if !candidate.is_absolute() {
679        return Err(EngineError::Config(format!(
680            "claudeBinary {trimmed:?} must be an absolute path: a relative path resolves \
681             against the process working directory, so which program runs depends on where \
682             kranz was invoked"
683        )));
684    }
685    // Both spellings of the candidate and both spellings of the root are
686    // compared: a not-yet-existing binary cannot be canonicalized (the
687    // planted-then-created case), and on macOS a temp root canonicalizes
688    // through /private while its unresolved form does not, so a single pair
689    // would miss one side of the comparison.
690    let candidate_forms = [
691        candidate.to_path_buf(),
692        std::fs::canonicalize(candidate).unwrap_or_else(|_| candidate.to_path_buf()),
693    ];
694    let root_forms = [
695        repo_root.to_path_buf(),
696        std::fs::canonicalize(repo_root).unwrap_or_else(|_| repo_root.to_path_buf()),
697    ];
698    for resolved in &candidate_forms {
699        for root in &root_forms {
700            if resolved.starts_with(root) {
701                return Err(EngineError::Config(format!(
702                    "claudeBinary {trimmed:?} resolves inside the repository at {}: repository \
703                     content must never name the binary kranz executes",
704                    root.display()
705                )));
706            }
707        }
708    }
709    Ok(())
710}
711
712/// Load the effective config for a repo: defaults, then the global file,
713/// then the project file (later layers win). Missing files are fine;
714/// unreadable or unparseable files are a [`EngineError::Config`] naming the
715/// offending path. The project layer is additionally held to the
716/// operator-only key rule ([`check_project_layer_keys`]).
717pub fn load(repo_root: &Path) -> Result<MissionConfig> {
718    let mut layers: Vec<(PathBuf, Layer)> = Vec::new();
719    if let Some(global) = paths::global_config() {
720        layers.push((global, Layer::Global));
721    }
722    layers.push((paths::project_config(repo_root), Layer::Project));
723    let cfg = load_layers_with_roles(&layers)?;
724    validate_claude_binary(&cfg, repo_root)?;
725    Ok(cfg)
726}
727
728/// Merge the given config files (in order, later wins) over the compiled-in
729/// defaults. Exposed so callers (and tests) can supply explicit layer paths
730/// instead of the real home directory.
731///
732/// Every layer is treated as [`Layer::Global`]: an explicit-path caller is
733/// the operator (or a test), not a repository. Use
734/// [`load_layers_with_roles`] when a layer's provenance matters.
735pub fn load_layers(layers: &[PathBuf]) -> Result<MissionConfig> {
736    let with_roles: Vec<(PathBuf, Layer)> = layers
737        .iter()
738        .map(|path| (path.clone(), Layer::Global))
739        .collect();
740    load_layers_with_roles(&with_roles)
741}
742
743/// [`load_layers`] with each layer's provenance declared, so the
744/// operator-only key rule ([`check_project_layer_keys`]) can refuse a
745/// repository-owned layer that names the binary kranz executes, the endpoint
746/// it POSTs prompts to, a credential, or a containment escape.
747pub fn load_layers_with_roles(layers: &[(PathBuf, Layer)]) -> Result<MissionConfig> {
748    let mut merged = serde_json::to_value(MissionConfig::default())?;
749
750    for (path, layer) in layers {
751        let text = match std::fs::read_to_string(path) {
752            Ok(text) => text,
753            // Absent layers are simply skipped; anything else is an error.
754            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
755            Err(e) => {
756                return Err(EngineError::Config(format!(
757                    "cannot read config file {}: {e}",
758                    path.display()
759                )))
760            }
761        };
762
763        let patch: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
764            EngineError::Config(format!(
765                "invalid JSON in config file {}: {e}",
766                path.display()
767            ))
768        })?;
769
770        if !patch.is_object() {
771            return Err(EngineError::Config(format!(
772                "config file {} must contain a JSON object at the top level",
773                path.display()
774            )));
775        }
776
777        if *layer == Layer::Project {
778            check_project_layer_keys(&patch, &merged, path)?;
779        }
780
781        deep_merge(&mut merged, &patch);
782    }
783
784    serde_json::from_value(merged)
785        .map_err(|e| EngineError::Config(format!("merged configuration does not deserialize: {e}")))
786}
787
788/// Recursively merge `patch` into `base`: objects merge key-wise, everything
789/// else (scalars, arrays, nulls) is replaced wholesale by the patch value.
790///
791/// Public because the server/CLI reuse it for `config.changed` patches.
792pub fn deep_merge(base: &mut serde_json::Value, patch: &serde_json::Value) {
793    match (base, patch) {
794        (serde_json::Value::Object(base_map), serde_json::Value::Object(patch_map)) => {
795            for (key, patch_val) in patch_map {
796                match base_map.get_mut(key) {
797                    Some(slot) => deep_merge(slot, patch_val),
798                    None => {
799                        base_map.insert(key.clone(), patch_val.clone());
800                    }
801                }
802            }
803        }
804        (slot, patch_val) => *slot = patch_val.clone(),
805    }
806}
807
808// ---------------------------------------------------------------------------
809// Runtime `config-change` patches: who may set what (audit 2026-09-01 C1)
810// ---------------------------------------------------------------------------
811
812/// Where a runtime `config-change` patch came from.
813///
814/// The control inbox (`<mission>/control/*.json`) is an unauthenticated
815/// filesystem channel: the only authorization is the ability to create a
816/// file, which under the default `sandbox.enforce: off` posture every worker
817/// session has. A patch that arrives that way must never be able to carry
818/// the consent-bearing keys a human is supposed to decide — the resulting
819/// `config.changed` event is otherwise indistinguishable from an operator's.
820#[derive(Debug, Clone, Copy, PartialEq, Eq)]
821pub enum PatchSource {
822    /// A human-driven surface: the CLI, the mutation-token REST route, or an
823    /// authorized Slack command.
824    Operator,
825    /// The mission's control inbox, drained by the run loop.
826    Inbox,
827}
828
829/// Keys a running mission may legitimately be re-tuned with, from either
830/// source. This is the complete list the submission surfaces actually
831/// produce: `kranz exec --max-cycles`, `kranz config role` / Slack
832/// `/kranz config` (role backend/model/effort), and the dashboard's role
833/// selection — plus the neighbouring bounds an operator re-tunes with them.
834const RUNTIME_PATCHABLE: &[&str] = &[
835    "maxFixCyclesPerMilestone",
836    "maxRespawns",
837    "maxParallelWorkers",
838    "eventStreamThrottleMs",
839    "planningIdleReleaseMinutes",
840    "autoWork",
841    "consideredAlternativesFeatureThreshold",
842    "consideredAlternativesTouchSetThreshold",
843    "consideredAlternativesHighUsdThreshold",
844    "rubberStampThresholdMs",
845    "<role>.model",
846    "<role>.backend",
847    "<role>.reasoningEffort",
848    "<role>.maxTurns",
849    "<role>.maxBudgetUsd",
850    "<role>.contextBudget",
851    "<role>.temperature",
852];
853
854fn classify_patch_key(normalized: &str) -> (PatchClass, &'static str) {
855    if let Some(rule) = config_trust_rule(normalized) {
856        // Only the enforcement scalar is directional. Unknown descendants
857        // retain the existing default-deny runtime policy.
858        let class = match rule.runtime {
859            PatchClass::SandboxFloor if normalized != rule.key => PatchClass::Never,
860            class => class,
861        };
862        return (class, rule.reason);
863    }
864    if RUNTIME_PATCHABLE
865        .iter()
866        .any(|key| path_matches(normalized, key))
867    {
868        return (PatchClass::Runtime, "");
869    }
870    (PatchClass::Never, "")
871}
872
873/// Refuse a runtime `config-change` patch that reaches past the keys its
874/// source is allowed to set.
875///
876/// `base` is the current effective config as JSON, which makes the sandbox
877/// rule directional exactly as the layer rule is: raising
878/// `<role>.sandbox.enforce` is fine from either source, lowering it is a
879/// consent act.
880pub fn check_runtime_patch(
881    patch: &serde_json::Value,
882    base: &serde_json::Value,
883    source: PatchSource,
884) -> Result<()> {
885    let mut trail: Vec<String> = Vec::new();
886    walk_runtime_patch(patch, Some(base), source, &mut trail)
887}
888
889fn walk_runtime_patch(
890    patch: &serde_json::Value,
891    base: Option<&serde_json::Value>,
892    source: PatchSource,
893    trail: &mut Vec<String>,
894) -> Result<()> {
895    if let serde_json::Value::Object(map) = patch {
896        for (key, value) in map {
897            trail.push(key.clone());
898            walk_runtime_patch(value, base.and_then(|b| b.get(key)), source, trail)?;
899            trail.pop();
900        }
901        return Ok(());
902    }
903
904    let dotted = trail.join(".");
905    let normalized = normalize_key_path(&dotted);
906
907    let (class, reason) = classify_patch_key(&normalized);
908    match class {
909        PatchClass::SandboxFloor => {
910            if enforce_rank(Some(patch)) >= enforce_rank(base) {
911                return Ok(());
912            }
913            match source {
914                PatchSource::Operator => Ok(()),
915                PatchSource::Inbox => Err(EngineError::Config(format!(
916                    "refusing a control-inbox config change to {dotted:?}: lowering sandbox \
917                     enforcement is a consent act, and the control inbox is an \
918                     unauthenticated filesystem channel"
919                ))),
920            }
921        }
922        PatchClass::Runtime => Ok(()),
923        PatchClass::Consent => match source {
924            PatchSource::Operator => Ok(()),
925            PatchSource::Inbox => Err(EngineError::Config(format!(
926                "refusing a control-inbox config change to {dotted:?}: {reason}, so it is a \
927                 human decision — the control inbox is an unauthenticated filesystem \
928                 channel and cannot carry consent"
929            ))),
930        },
931        PatchClass::Never => Err(EngineError::Config(format!(
932            "{dotted:?} is not runtime-patchable: it is seed-time or operator-file \
933             configuration (a program, an endpoint, a credential, or the containment \
934             shape), not a mission knob"
935        ))),
936    }
937}
938
939/// Apply a partial JSON patch to an effective mission config and validate the
940/// merged result exactly as the engine would before accepting it.
941///
942/// Submission surfaces use this before enqueueing `config-change`, while the
943/// engine repeats the check when it drains the command. The second check is
944/// still required because another queued patch may win the race in between.
945///
946/// This is the OPERATOR entry point (every caller of it is a human-driven,
947/// authorized surface). The run loop's drain path uses
948/// [`apply_validated_patch_from`] with [`PatchSource::Inbox`].
949pub fn apply_validated_patch(
950    current: &MissionConfig,
951    patch: &serde_json::Value,
952) -> Result<MissionConfig> {
953    apply_validated_patch_from(current, patch, PatchSource::Operator)
954}
955
956/// [`apply_validated_patch`] with the patch's origin declared, so the
957/// consent-bearing keys can be refused when the patch came off the
958/// unauthenticated control inbox.
959pub fn apply_validated_patch_from(
960    current: &MissionConfig,
961    patch: &serde_json::Value,
962    source: PatchSource,
963) -> Result<MissionConfig> {
964    let mut value = serde_json::to_value(current)?;
965    check_runtime_patch(patch, &value, source)?;
966    deep_merge(&mut value, patch);
967    let merged: MissionConfig = serde_json::from_value(value)
968        .map_err(|e| EngineError::Config(format!("patch produces invalid config: {e}")))?;
969    validate(&merged)?;
970    Ok(merged)
971}
972
973/// The host of an `http(s)://` URL, or `""` when the string is not one.
974/// Userinfo is stripped (`http://user@host/` is `host`) and a bracketed IPv6
975/// literal keeps its own colons (`http://[::1]:8080` is `::1`).
976fn base_url_host(url: &str) -> &str {
977    let Some(rest) = url
978        .strip_prefix("http://")
979        .or_else(|| url.strip_prefix("https://"))
980    else {
981        return "";
982    };
983    let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
984    let after_userinfo = match authority.rfind('@') {
985        Some(idx) => &authority[idx + 1..],
986        None => authority,
987    };
988    if let Some(bracketed) = after_userinfo.strip_prefix('[') {
989        return match bracketed.split_once(']') {
990            Some((host, _)) => host,
991            None => "",
992        };
993    }
994    after_userinfo.split(':').next().unwrap_or(after_userinfo)
995}
996
997/// `localhost` or any address in a loopback range (127.0.0.0/8, ::1).
998fn host_is_loopback(host: &str) -> bool {
999    host.eq_ignore_ascii_case("localhost")
1000        || host
1001            .parse::<std::net::IpAddr>()
1002            .is_ok_and(|ip| ip.is_loopback())
1003}
1004
1005/// Validate invariants the engine relies on (plan §6). Returns
1006/// [`EngineError::Config`] describing the first violation found.
1007pub fn validate(cfg: &MissionConfig) -> Result<()> {
1008    crate::reviewer_independence::validate_config(cfg)?;
1009    let roles = [
1010        ("orchestrator", &cfg.orchestrator),
1011        ("worker", &cfg.worker),
1012        ("validatorScrutiny", &cfg.validator_scrutiny),
1013        ("validatorFunctional", &cfg.validator_functional),
1014    ];
1015    for (name, role) in roles {
1016        if !VALID_EFFORTS.contains(&role.reasoning_effort.as_str()) {
1017            return Err(EngineError::Config(format!(
1018                "{name}.reasoningEffort must be one of {VALID_EFFORTS:?}, got {:?}",
1019                role.reasoning_effort
1020            )));
1021        }
1022    }
1023
1024    if cfg.max_fix_cycles_per_milestone < 1 {
1025        return Err(EngineError::Config(
1026            "maxFixCyclesPerMilestone must be at least 1".into(),
1027        ));
1028    }
1029
1030    if cfg.max_respawns > 5 {
1031        return Err(EngineError::Config(format!(
1032            "maxRespawns must be at most 5, got {}",
1033            cfg.max_respawns
1034        )));
1035    }
1036
1037    if !(10..=5000).contains(&cfg.event_stream_throttle_ms) {
1038        return Err(EngineError::Config(format!(
1039            "eventStreamThrottleMs must be in 10..=5000, got {}",
1040            cfg.event_stream_throttle_ms
1041        )));
1042    }
1043    if !cfg.considered_alternatives_high_usd_threshold.is_finite()
1044        || cfg.considered_alternatives_high_usd_threshold < 0.0
1045    {
1046        return Err(EngineError::Config(format!(
1047            "consideredAlternativesHighUsdThreshold must be finite and non-negative, got {}",
1048            cfg.considered_alternatives_high_usd_threshold
1049        )));
1050    }
1051
1052    // Parallel workers (roadmap M3): `1` (the default) keeps the sequential
1053    // run loop byte-for-byte; `2..=8` opts into parallel-within-milestone
1054    // execution (independent features run concurrently, each in its own git
1055    // worktree, then merge in declared order). `0` is meaningless (no worker
1056    // can ever run) and anything above 8 is well past any useful fan-out for a
1057    // single repo, so both are rejected.
1058    if !(1..=8).contains(&cfg.max_parallel_workers) {
1059        return Err(EngineError::Config(format!(
1060            "maxParallelWorkers must be in 1..=8 (1 = sequential; >1 opts into M3 \
1061             parallel workers), got {}",
1062            cfg.max_parallel_workers
1063        )));
1064    }
1065
1066    // Heterogeneous dispatch pool (ticket heterogeneous-dispatch-pool,
1067    // KRZ-303): `workerCandidates` is the COMPLETE backend list the worker
1068    // role fans out to (worker.backend applies only when the list is empty).
1069    // Every entry is checked by the same rules as the worker role itself —
1070    // known backend, supported backend/model pair, the worker model floor,
1071    // and the sandbox fail-closed pairs — because each one WILL drive real
1072    // worker sessions.
1073    if cfg.worker_candidates.len() == 1 {
1074        return Err(EngineError::Config(
1075            "workerCandidates with exactly one entry is a roundabout worker.backend; \
1076             use worker.backend (the pool exists for N >= 2 heterogeneous candidates)"
1077                .into(),
1078        ));
1079    }
1080    if cfg.worker_candidates.len() > MAX_WORKER_CANDIDATES {
1081        return Err(EngineError::Config(format!(
1082            "workerCandidates supports at most {MAX_WORKER_CANDIDATES} candidates, got {}",
1083            cfg.worker_candidates.len()
1084        )));
1085    }
1086    // The pool and M3 parallel features are two different fan-out models
1087    // (same unit to N backends vs N units to one backend each). Combining
1088    // them has no defined semantics in this pass — reject rather than pick
1089    // one silently.
1090    if !cfg.worker_candidates.is_empty() && cfg.max_parallel_workers > 1 {
1091        return Err(EngineError::Config(
1092            "workerCandidates (dispatch pool: one unit to N backends) and \
1093             maxParallelWorkers > 1 (M3: N independent units concurrently) are mutually \
1094             exclusive in this pass; configure one fan-out model"
1095                .into(),
1096        ));
1097    }
1098    for (i, candidate) in cfg.worker_candidates.iter().enumerate() {
1099        let kind = parse_backend(Some(&candidate.backend)).map_err(|other| {
1100            EngineError::Config(format!(
1101                "workerCandidates[{i}].backend must be one of \"claude\", \"codex\", \"droid\", \"kimi\", \"cursor\", got {other:?}"
1102            ))
1103        })?;
1104        // local/acp need per-role endpoint/command config (baseUrl /
1105        // contextBudget / acpCommand) that has no per-candidate home in this
1106        // pass; refuse rather than silently share the worker role's.
1107        if matches!(kind, BackendKind::Local | BackendKind::Acp) {
1108            return Err(EngineError::Config(format!(
1109                "workerCandidates[{i}].backend {:?} is not supported in this pass: local/acp \
1110                 need per-candidate endpoint/command config (a deliberate widening); use \
1111                 claude, codex, droid, kimi, or cursor candidates",
1112                candidate.backend
1113            )));
1114        }
1115        // Same fail-closed sandbox pair as the worker role: a candidate that
1116        // cannot honor the requested enforcement must never run with the
1117        // operator believing it contained.
1118        if cfg.worker.sandbox.enforce != SandboxEnforce::Off && !kind.supports_sandbox_enforcement()
1119        {
1120            return Err(EngineError::Config(format!(
1121                "workerCandidates[{i}].backend {:?} cannot honor sandbox.enforce={:?}: only the \
1122                 claude backend applies the resolved OS sandbox; run with sandbox.enforce=off, \
1123                 or drop the non-claude candidate",
1124                candidate.backend,
1125                cfg.worker.sandbox.enforce.as_str()
1126            )));
1127        }
1128        let effective = effective_model(Role::Worker, kind, &candidate.model);
1129        let tier = model_tier(kind, &effective).ok_or_else(|| {
1130            EngineError::Config(format!(
1131                "workerCandidates[{i}] effective model {effective:?} (configured as {:?}) is not supported by backend {:?}",
1132                candidate.model,
1133                candidate.backend
1134            ))
1135        })?;
1136        // The worker model floor applies per candidate — a below-default
1137        // stream is exactly as much a worker session as the role's own.
1138        if tier < ModelTier::Default && !cfg.allow_below_default_worker_model {
1139            return Err(EngineError::Config(format!(
1140                "workerCandidates[{i}] effective model {effective:?} (configured as {:?}) on backend {:?} is below the default worker tier; set \
1141                 allowBelowDefaultWorkerModel=true on this mission to opt in",
1142                candidate.model,
1143                candidate.backend
1144            )));
1145        }
1146    }
1147
1148    // Backend routing table (ticket `backend-routing-abstraction`, KRZ-331):
1149    // shape-only checks (blank/duplicate task classes) live in
1150    // `routing::validate_table` and fail closed naming the offending rule. A
1151    // rule routing `local` with no endpoint configured is NOT an error here:
1152    // `apply_executor_routing` already fails safe to Frontier for exactly
1153    // that case, with the decision recorded against the mission.
1154    if let Err(err) = crate::routing::validate_table(&cfg.routing) {
1155        return Err(EngineError::Config(err));
1156    }
1157
1158    // Hook-status lane (ticket `agent-hooks-status-signals`): when enabled,
1159    // the endpoint is REQUIRED and must be a loopback HTTP(S) URL — the
1160    // per-run capability token rides it, so pointing it at a remote host
1161    // would leak signal authority off-machine. A disabled lane ignores the
1162    // endpoint entirely (byte-identical pre-lane behavior).
1163    if let Some(hook_status) = &cfg.hook_status {
1164        if hook_status.enabled {
1165            if hook_status.endpoint.trim().is_empty() {
1166                return Err(EngineError::Config(
1167                    "hookStatus.enabled requires hookStatus.endpoint (the loopback signal \
1168                     POST URL, e.g. http://127.0.0.1:4560/api/hook-status)"
1169                        .to_string(),
1170                ));
1171            }
1172            if !crate::hook_status::endpoint_is_loopback_http(&hook_status.endpoint) {
1173                return Err(EngineError::Config(format!(
1174                    "hookStatus.endpoint must be a loopback http(s) URL (the per-run \
1175                     capability token rides it), got {:?}",
1176                    hook_status.endpoint
1177                )));
1178            }
1179        }
1180    }
1181
1182    for (role, name) in [
1183        (Role::Orchestrator, "orchestrator"),
1184        (Role::Worker, "worker"),
1185        (Role::ValidatorScrutiny, "validatorScrutiny"),
1186        (Role::ValidatorFunctional, "validatorFunctional"),
1187    ] {
1188        let role_cfg = cfg.role(role);
1189        let kind = parse_backend(role_cfg.backend.as_deref()).map_err(|other| {
1190            EngineError::Config(format!(
1191                "{name}.backend must be one of None, \"claude\", \"codex\", \"droid\", \"kimi\", \"local\", \"acp\", \"cursor\", got {other:?}"
1192            ))
1193        })?;
1194        // Fail closed on a silently-unenforced sandbox: only the claude
1195        // backend wraps its sessions in the engine-resolved OS sandbox
1196        // (fs/extraWrite/egress policy, container provider); every other
1197        // backend spawns unsandboxed and discards the requested enforcement.
1198        // Reject the pair at validation so a mission never runs with the
1199        // operator believing workers are contained when they are not.
1200        if role_cfg.sandbox.enforce != SandboxEnforce::Off && !kind.supports_sandbox_enforcement() {
1201            return Err(EngineError::Config(format!(
1202                "{name}.backend {:?} cannot honor sandbox.enforce={:?}: only the claude backend \
1203                 applies the resolved OS sandbox; run with sandbox.enforce=off to proceed \
1204                 unsandboxed, or use the claude backend",
1205                kind.as_str(),
1206                role_cfg.sandbox.enforce.as_str()
1207            )));
1208        }
1209        let effective = effective_model(role, kind, &role_cfg.model);
1210        let tier = model_tier(kind, &effective).ok_or_else(|| {
1211            EngineError::Config(format!(
1212                "{name} effective model {effective:?} (configured as {:?}) is not supported by backend {:?}",
1213                role_cfg.model,
1214                kind.as_str()
1215            ))
1216        })?;
1217
1218        if kind == BackendKind::Local {
1219            // Guarded validator role split (ticket
1220            // `local-inference-validator-guarded`, KRZ-206b; review addendum
1221            // §4 of docs/scoping/local-inference-executor-tier.md): the local
1222            // validator tier exists for DETERMINISTIC mechanical checks only
1223            // — compile/test/lint exit codes and contract-command pass/fail,
1224            // where the engine runs the command itself and the model only
1225            // reads verbatim PASS/FAIL evidence. Scrutiny is judgment (diff
1226            // review against criteria), and routing judgment local is exactly
1227            // the "silent green" attack the split exists to prevent: a weak
1228            // local validator that wrongly PASSES bad work never looks like a
1229            // failure, so no escalation valve ever fires on it. Only the
1230            // functional role may pair with the local backend — and every
1231            // local functional PASS is frontier-confirmed before it greens a
1232            // gate (confirm-on-pass in the validation round); the scrutiny
1233            // role is rejected outright here. Checked FIRST, before the
1234            // endpoint fields, so the error names the real problem.
1235            if role == Role::ValidatorScrutiny {
1236                return Err(EngineError::Config(format!(
1237                    "{name}.backend \"local\" is rejected: scrutiny is judgment, not a \
1238                     deterministic mechanical check, and the local validator tier is the \
1239                     functional role only (KRZ-206b) — a local judgment PASS is the \
1240                     silent-green failure mode the guarded role split exists to prevent"
1241                )));
1242            }
1243            match role_cfg.base_url.as_deref() {
1244                Some(url) if !url.trim().is_empty() => {
1245                    if base_url_host(url).is_empty() {
1246                        return Err(EngineError::Config(format!(
1247                            "{name}.baseUrl {url:?} is not a valid http/https URL"
1248                        )));
1249                    }
1250                    // Loopback gate, mirroring hookStatus.endpoint: the
1251                    // engine POSTs the assembled system + user prompt to
1252                    // this URL from the engine process, OUTSIDE every
1253                    // sandbox, and takes the reply as the role's model
1254                    // output; the readiness probe connects to whatever
1255                    // host:port it names and records reachable/unreachable.
1256                    // A repo-named remote host is therefore prompt
1257                    // exfiltration plus a config-driven internal-network
1258                    // oracle. Operators who really do run a shared endpoint
1259                    // name its host in the global-layer allowlist.
1260                    let host = base_url_host(url);
1261                    if !host_is_loopback(host)
1262                        && !cfg
1263                            .local_backend_allowed_hosts
1264                            .iter()
1265                            .any(|allowed| allowed.trim().eq_ignore_ascii_case(host))
1266                    {
1267                        return Err(EngineError::Config(format!(
1268                            "{name}.baseUrl host {host:?} is not loopback: the engine POSTs \
1269                             the assembled prompt to it from outside every sandbox and takes \
1270                             the reply as model output. Use a loopback endpoint, or name the \
1271                             host in localBackendAllowedHosts in ~/.kranz/config.json (the \
1272                             global layer only)"
1273                        )));
1274                    }
1275                }
1276                _ => {
1277                    return Err(EngineError::Config(format!(
1278                        "{name}.baseUrl is required when {name}.backend is \"local\""
1279                    )));
1280                }
1281            }
1282
1283            match role_cfg.context_budget {
1284                Some(budget) if (1024..=200_000).contains(&budget) => {}
1285                Some(budget) => {
1286                    return Err(EngineError::Config(format!(
1287                        "{name}.contextBudget must be in 1024..=200000, got {budget}"
1288                    )));
1289                }
1290                None => {
1291                    return Err(EngineError::Config(format!(
1292                        "{name}.contextBudget is required when {name}.backend is \"local\""
1293                    )));
1294                }
1295            }
1296
1297            if let Some(temperature) = role_cfg.temperature {
1298                if !temperature.is_finite() || !(0.0..=2.0).contains(&temperature) {
1299                    return Err(EngineError::Config(format!(
1300                        "{name}.temperature must be finite and in 0.0..=2.0, got {temperature}"
1301                    )));
1302                }
1303            }
1304        }
1305
1306        if kind == BackendKind::Acp {
1307            // KRZ-301 lands worker-first: the orchestrator needs
1308            // streaming-input + resume semantics this backend deliberately
1309            // rejects at the seam, and the validator roles wait for live
1310            // soak — refuse those pairings here rather than degrading
1311            // mid-mission.
1312            if role != Role::Worker {
1313                return Err(EngineError::Config(format!(
1314                    "{name}.backend \"acp\" is supported for the worker role only in this pass \
1315                     (KRZ-301); validators and the orchestrator stay on their existing backends"
1316                )));
1317            }
1318            match role_cfg.acp_command.as_deref() {
1319                Some(command) if !command.trim().is_empty() => {}
1320                _ => {
1321                    return Err(EngineError::Config(format!(
1322                        "{name}.acpCommand is required when {name}.backend is \"acp\" \
1323                         (the ACP agent executable; extra argv goes in {name}.acpArgs)"
1324                    )));
1325                }
1326            }
1327        }
1328
1329        // Kimi is the first backend where reasoning effort is model-constrained:
1330        // k3 (the thinking-capable flagship) only supports low/high/max, while
1331        // kimi-for-coding[-highspeed] (not thinking-capable) impose no effort
1332        // constraint.
1333        if kind == BackendKind::Kimi
1334            && effective == DEFAULT_KIMI_MODEL
1335            && !["low", "high", "max"].contains(&role_cfg.reasoning_effort.as_str())
1336        {
1337            return Err(EngineError::Config(format!(
1338                "{name}.reasoningEffort must be one of [\"low\", \"high\", \"max\"] for kimi model {DEFAULT_KIMI_MODEL:?}, got {:?}",
1339                role_cfg.reasoning_effort
1340            )));
1341        }
1342
1343        if role == Role::Worker
1344            && tier < ModelTier::Default
1345            && !cfg.allow_below_default_worker_model
1346        {
1347            return Err(EngineError::Config(format!(
1348                "worker effective model {effective:?} (configured as {:?}) on backend {:?} is below the default worker tier; set \
1349                 allowBelowDefaultWorkerModel=true on this mission to opt in",
1350                role_cfg.model,
1351                kind.as_str()
1352            )));
1353        }
1354
1355        if role == Role::Orchestrator && tier < ModelTier::Frontier {
1356            return Err(EngineError::Config(format!(
1357                "orchestrator effective model {effective:?} (configured as {:?}) on backend {:?} is below the frontier-model floor",
1358                role_cfg.model,
1359                kind.as_str()
1360            )));
1361        }
1362    }
1363
1364    Ok(())
1365}
1366
1367#[cfg(test)]
1368mod tests {
1369    use super::*;
1370
1371    #[test]
1372    fn default_planning_idle_release_minutes_is_30() {
1373        assert_eq!(MissionConfig::default().planning_idle_release_minutes, 30);
1374    }
1375
1376    #[test]
1377    fn default_serializes_camel_case_planning_idle_release_minutes() {
1378        let value = serde_json::to_value(MissionConfig::default()).unwrap();
1379        assert_eq!(value["planningIdleReleaseMinutes"], 30);
1380    }
1381
1382    #[test]
1383    fn layer_overrides_planning_idle_release_minutes() {
1384        let dir = tempfile::tempdir().unwrap();
1385        let layer_path = dir.path().join("config.json");
1386        std::fs::write(&layer_path, r#"{"planningIdleReleaseMinutes": 5}"#).unwrap();
1387
1388        let cfg = load_layers(&[layer_path]).unwrap();
1389        assert_eq!(cfg.planning_idle_release_minutes, 5);
1390    }
1391
1392    #[test]
1393    fn absent_key_in_layer_keeps_default() {
1394        let dir = tempfile::tempdir().unwrap();
1395        let layer_path = dir.path().join("config.json");
1396        std::fs::write(&layer_path, r#"{"maxRespawns": 3}"#).unwrap();
1397
1398        let cfg = load_layers(&[layer_path]).unwrap();
1399        assert_eq!(cfg.planning_idle_release_minutes, 30);
1400    }
1401
1402    #[test]
1403    fn default_auto_work_is_false() {
1404        assert!(!MissionConfig::default().auto_work);
1405    }
1406
1407    #[test]
1408    fn default_serializes_camel_case_auto_work() {
1409        let value = serde_json::to_value(MissionConfig::default()).unwrap();
1410        assert_eq!(value["autoWork"], false);
1411    }
1412
1413    #[test]
1414    fn default_serializes_camel_case_considered_alternatives_thresholds() {
1415        let value = serde_json::to_value(MissionConfig::default()).unwrap();
1416        assert_eq!(value["consideredAlternativesFeatureThreshold"], 4);
1417        assert_eq!(value["consideredAlternativesTouchSetThreshold"], 4);
1418        assert_eq!(value["consideredAlternativesHighUsdThreshold"], 0.0);
1419    }
1420
1421    #[test]
1422    fn layer_overrides_considered_alternatives_thresholds() {
1423        let dir = tempfile::tempdir().unwrap();
1424        let layer_path = dir.path().join("config.json");
1425        std::fs::write(
1426            &layer_path,
1427            r#"{
1428                "consideredAlternativesFeatureThreshold": 2,
1429                "consideredAlternativesTouchSetThreshold": 3,
1430                "consideredAlternativesHighUsdThreshold": 9.5
1431            }"#,
1432        )
1433        .unwrap();
1434
1435        let cfg = load_layers(&[layer_path]).unwrap();
1436        assert_eq!(cfg.considered_alternatives_feature_threshold, 2);
1437        assert_eq!(cfg.considered_alternatives_touch_set_threshold, 3);
1438        assert_eq!(cfg.considered_alternatives_high_usd_threshold, 9.5);
1439    }
1440
1441    #[test]
1442    fn default_serializes_camel_case_worker_floor_opt_in() {
1443        let value = serde_json::to_value(MissionConfig::default()).unwrap();
1444        assert_eq!(value["allowBelowDefaultWorkerModel"], false);
1445    }
1446
1447    #[test]
1448    fn layer_overrides_auto_work() {
1449        let dir = tempfile::tempdir().unwrap();
1450        let layer_path = dir.path().join("config.json");
1451        std::fs::write(&layer_path, r#"{"autoWork": true}"#).unwrap();
1452
1453        let cfg = load_layers(&[layer_path]).unwrap();
1454        assert!(cfg.auto_work);
1455    }
1456
1457    /// The uncontained-validator degrade opt-in (ticket
1458    /// `validator-containment-degrade-fail-closed`): additive — absent (every
1459    /// pre-existing config and old `mission.created` payload) deserializes to
1460    /// the FAIL-CLOSED default; the explicit `true` opts back into the loud
1461    /// degrade.
1462    #[test]
1463    fn validator_allow_uncontained_degrade_defaults_off_and_parses_opt_in() {
1464        assert!(!MissionConfig::default().validator_allow_uncontained_degrade);
1465        let value = serde_json::to_value(MissionConfig::default()).unwrap();
1466        assert_eq!(value["validatorAllowUncontainedDegrade"], false);
1467
1468        let dir = tempfile::tempdir().unwrap();
1469        let layer_path = dir.path().join("config.json");
1470        std::fs::write(&layer_path, r#"{"validatorAllowUncontainedDegrade": true}"#).unwrap();
1471        let cfg = load_layers(&[layer_path]).unwrap();
1472        assert!(cfg.validator_allow_uncontained_degrade);
1473
1474        // A layer naming unrelated keys only (the old-config shape) keeps the
1475        // fail-closed default.
1476        let layer_path = dir.path().join("config-old.json");
1477        std::fs::write(&layer_path, r#"{"maxRespawns": 3}"#).unwrap();
1478        let cfg = load_layers(&[layer_path]).unwrap();
1479        assert!(!cfg.validator_allow_uncontained_degrade);
1480    }
1481
1482    #[test]
1483    fn contract_env_passthrough_defaults_empty_and_parses_camel_case() {
1484        // Additive contract change: absent key (every pre-existing config and
1485        // every old mission.created event payload) deserializes to empty.
1486        assert!(MissionConfig::default().contract_env_passthrough.is_empty());
1487        let value = serde_json::to_value(MissionConfig::default()).unwrap();
1488        assert_eq!(value["contractEnvPassthrough"], serde_json::json!([]));
1489
1490        let dir = tempfile::tempdir().unwrap();
1491        let layer_path = dir.path().join("config.json");
1492        std::fs::write(
1493            &layer_path,
1494            r#"{"contractEnvPassthrough": ["NPM_TOKEN", "REGISTRY_BASIC_AUTH"]}"#,
1495        )
1496        .unwrap();
1497        let cfg = load_layers(&[layer_path]).unwrap();
1498        assert_eq!(
1499            cfg.contract_env_passthrough,
1500            vec!["NPM_TOKEN".to_string(), "REGISTRY_BASIC_AUTH".to_string()]
1501        );
1502        // A layer naming unrelated keys only (the old-config shape) leaves
1503        // the passthrough empty.
1504        let layer_path = dir.path().join("config-old.json");
1505        std::fs::write(&layer_path, r#"{"maxRespawns": 3}"#).unwrap();
1506        let cfg = load_layers(&[layer_path]).unwrap();
1507        assert!(cfg.contract_env_passthrough.is_empty());
1508    }
1509
1510    #[test]
1511    fn absent_auto_work_key_keeps_default() {
1512        let dir = tempfile::tempdir().unwrap();
1513        let layer_path = dir.path().join("config.json");
1514        std::fs::write(&layer_path, r#"{"maxRespawns": 3}"#).unwrap();
1515
1516        let cfg = load_layers(&[layer_path]).unwrap();
1517        assert!(!cfg.auto_work);
1518    }
1519
1520    /// Composition audit (ticket `config-fail-open-audit`): layered config
1521    /// arrays REPLACE wholesale (deep_merge semantics — a project layer
1522    /// overrides a global layer's list). That replace is safe ONLY because
1523    /// the deny floor is compiled in: `denyPatterns` from any layer can
1524    /// replace another layer's entries but can never strip the built-in
1525    /// worker deny list, which `permissions::for_role` appends to. This pins
1526    /// both halves of the contract: the documented replace semantics, and
1527    /// the floor's unreachability by replacement.
1528    #[test]
1529    fn composition_audit_layered_deny_patterns_replace_but_never_strip_the_builtin_floor() {
1530        let dir = tempfile::tempdir().unwrap();
1531        let global = dir.path().join("global.json");
1532        std::fs::write(&global, r#"{"denyPatterns": ["git push --force"]}"#).unwrap();
1533        let project = dir.path().join("project.json");
1534        std::fs::write(&project, r#"{"denyPatterns": ["rm -rf *"]}"#).unwrap();
1535
1536        let cfg = load_layers(&[global, project]).unwrap();
1537        // Replace semantics across layers: the later list wins wholesale.
1538        assert_eq!(cfg.deny_patterns, vec!["rm -rf *".to_string()]);
1539
1540        // The built-in §4.7 floor is compiled in, so no layer shape can
1541        // remove it: the worker profile carries every built-in rule plus
1542        // (only) the winning layer's custom entry.
1543        let profile = crate::permissions::for_role(Role::Worker, &cfg, &[], &[], &[]);
1544        for builtin in [
1545            "Bash(git push*)",
1546            "Bash(sudo*)",
1547            "Bash(curl*)",
1548            "WebFetch",
1549            "WebSearch",
1550        ] {
1551            assert!(
1552                profile.disallowed_tools.iter().any(|r| r == builtin),
1553                "the built-in deny {builtin} must survive layered replacement"
1554            );
1555        }
1556        assert!(profile
1557            .disallowed_tools
1558            .iter()
1559            .any(|r| r == "Bash(rm -rf *)"));
1560        assert!(!profile
1561            .disallowed_tools
1562            .iter()
1563            .any(|r| r == "Bash(git push --force*)"));
1564    }
1565
1566    #[test]
1567    fn default_config_serializes_without_backend_field() {
1568        let value = serde_json::to_value(MissionConfig::default()).unwrap();
1569        for role in [
1570            "orchestrator",
1571            "worker",
1572            "validatorScrutiny",
1573            "validatorFunctional",
1574        ] {
1575            let obj = value[role].as_object().unwrap();
1576            assert!(
1577                !obj.contains_key("backend"),
1578                "{role} should not serialize a backend key by default"
1579            );
1580        }
1581    }
1582
1583    #[test]
1584    fn validate_accepts_known_backends_for_each_role() {
1585        for role in [
1586            Role::Orchestrator,
1587            Role::Worker,
1588            Role::ValidatorScrutiny,
1589            Role::ValidatorFunctional,
1590        ] {
1591            for backend in [None, Some("claude"), Some("codex")] {
1592                let mut cfg = MissionConfig::default();
1593                cfg.role_mut_for_test(role).backend = backend.map(|s| s.to_string());
1594                assert!(
1595                    validate(&cfg).is_ok(),
1596                    "{role:?} backend {backend:?} should be accepted"
1597                );
1598            }
1599        }
1600    }
1601
1602    #[test]
1603    fn validate_accepts_droid_scrutiny_backend_with_legacy_default_model() {
1604        let mut cfg = MissionConfig::default();
1605        cfg.validator_scrutiny.backend = Some("droid".into());
1606        assert!(validate(&cfg).is_ok());
1607        assert_eq!(
1608            effective_model(
1609                Role::ValidatorScrutiny,
1610                BackendKind::Droid,
1611                &cfg.validator_scrutiny.model
1612            ),
1613            DEFAULT_DROID_MODEL
1614        );
1615    }
1616
1617    #[test]
1618    fn validate_rejects_unknown_backend_on_any_role() {
1619        for role in [
1620            Role::Orchestrator,
1621            Role::Worker,
1622            Role::ValidatorScrutiny,
1623            Role::ValidatorFunctional,
1624        ] {
1625            let mut cfg = MissionConfig::default();
1626            cfg.role_mut_for_test(role).backend = Some("gemini".into());
1627            assert!(validate(&cfg).is_err(), "{role:?} should reject gemini");
1628        }
1629    }
1630
1631    #[test]
1632    fn validate_rejects_unknown_backend_model_combos() {
1633        let mut cfg = MissionConfig::default();
1634        cfg.worker.model = "kranz-test-model".into();
1635        assert!(validate(&cfg).is_err());
1636
1637        let mut cfg = MissionConfig::default();
1638        cfg.validator_functional.backend = Some("codex".into());
1639        cfg.validator_functional.model = "claude-sonnet-5".into();
1640        assert!(validate(&cfg).is_err());
1641
1642        let mut cfg = MissionConfig::default();
1643        cfg.validator_scrutiny.backend = Some("droid".into());
1644        cfg.validator_scrutiny.model = "gpt-5-codex".into();
1645        assert!(validate(&cfg).is_err());
1646    }
1647
1648    #[test]
1649    fn validate_enforces_worker_floor_with_explicit_opt_in() {
1650        let mut cfg = MissionConfig::default();
1651        cfg.worker.model = "haiku".into();
1652        assert!(validate(&cfg).is_err());
1653        cfg.allow_below_default_worker_model = true;
1654        assert!(validate(&cfg).is_ok());
1655
1656        let mut cfg = MissionConfig::default();
1657        cfg.worker.backend = Some("droid".into());
1658        assert!(
1659            validate(&cfg).is_err(),
1660            "droid's legacy default GLM worker is below the default tier"
1661        );
1662        cfg.allow_below_default_worker_model = true;
1663        assert!(validate(&cfg).is_ok());
1664    }
1665
1666    #[test]
1667    fn floor_violations_lead_with_the_effective_model() {
1668        // A role that keeps its default model on a non-Claude backend runs
1669        // the backend default, not the configured name — floor messages must
1670        // lead with that effective model so a revert to naming only the
1671        // configured model cannot ship silently.
1672        let mut cfg = MissionConfig::default();
1673        cfg.worker.backend = Some("droid".into());
1674        let configured = cfg.worker.model.clone();
1675        let err = validate(&cfg).unwrap_err().to_string();
1676        assert!(
1677            err.contains(&format!("worker effective model {DEFAULT_DROID_MODEL:?}")),
1678            "{err}"
1679        );
1680        assert!(
1681            err.contains(&format!("(configured as {configured:?})")),
1682            "{err}"
1683        );
1684
1685        let mut cfg = MissionConfig::default();
1686        cfg.orchestrator.backend = Some("droid".into());
1687        let configured = cfg.orchestrator.model.clone();
1688        let err = validate(&cfg).unwrap_err().to_string();
1689        assert!(
1690            err.contains(&format!(
1691                "orchestrator effective model {DEFAULT_DROID_MODEL:?}"
1692            )),
1693            "{err}"
1694        );
1695        assert!(
1696            err.contains(&format!("(configured as {configured:?})")),
1697            "{err}"
1698        );
1699    }
1700
1701    #[test]
1702    fn validate_enforces_orchestrator_frontier_floor() {
1703        let mut cfg = MissionConfig::default();
1704        cfg.orchestrator.model = "sonnet".into();
1705        assert!(validate(&cfg).is_err());
1706
1707        let mut cfg = MissionConfig::default();
1708        cfg.orchestrator.backend = Some("droid".into());
1709        assert!(
1710            validate(&cfg).is_err(),
1711            "droid's legacy default GLM model is not a planner frontier model"
1712        );
1713
1714        let mut cfg = MissionConfig::default();
1715        cfg.orchestrator.backend = Some("droid".into());
1716        cfg.orchestrator.model = "claude-fable-5".into();
1717        assert!(validate(&cfg).is_ok());
1718    }
1719
1720    #[test]
1721    fn validate_allows_scrutiny_on_any_supported_tier() {
1722        for (backend, model) in [
1723            (Some("claude"), "haiku"),
1724            (Some("claude"), "sonnet"),
1725            (Some("claude"), "opus"),
1726            (Some("codex"), DEFAULT_CODEX_MODEL),
1727            (Some("droid"), DEFAULT_DROID_MODEL),
1728            (Some("droid"), "claude-fable-5"),
1729            (Some("kimi"), DEFAULT_KIMI_MODEL),
1730            (Some("kimi"), "kimi-code/kimi-for-coding"),
1731        ] {
1732            let mut cfg = MissionConfig::default();
1733            cfg.validator_scrutiny.backend = backend.map(|s| s.to_string());
1734            cfg.validator_scrutiny.model = model.to_string();
1735            assert!(
1736                validate(&cfg).is_ok(),
1737                "scrutiny should accept {backend:?} / {model}"
1738            );
1739        }
1740    }
1741
1742    #[test]
1743    fn validate_accepts_kimi_k3_for_supported_efforts() {
1744        for effort in ["low", "high", "max"] {
1745            let mut cfg = MissionConfig::default();
1746            cfg.validator_scrutiny.backend = Some("kimi".into());
1747            cfg.validator_scrutiny.model = DEFAULT_KIMI_MODEL.into();
1748            cfg.validator_scrutiny.reasoning_effort = effort.into();
1749            assert!(
1750                validate(&cfg).is_ok(),
1751                "kimi k3 should accept effort {effort}"
1752            );
1753        }
1754    }
1755
1756    #[test]
1757    fn guarded_local_validator_scrutiny_cannot_be_configured_local() {
1758        // KRZ-206b: scrutiny is judgment; the local validator tier is the
1759        // functional role only. The rejection names the role, and fires
1760        // whether or not the endpoint fields are present (the role guard is
1761        // the real problem, never the missing baseUrl).
1762        let mut cfg = MissionConfig::default();
1763        cfg.validator_scrutiny.backend = Some("local".into());
1764        cfg.validator_scrutiny.base_url = Some("http://127.0.0.1:8080".into());
1765        cfg.validator_scrutiny.context_budget = Some(8192);
1766        let err = validate(&cfg).unwrap_err().to_string();
1767        assert!(
1768            err.contains("validatorScrutiny.backend \"local\" is rejected"),
1769            "the rejection must name the role: {err}"
1770        );
1771
1772        let mut cfg = MissionConfig::default();
1773        cfg.validator_scrutiny.backend = Some("local".into());
1774        let err = validate(&cfg).unwrap_err().to_string();
1775        assert!(
1776            err.contains("validatorScrutiny.backend \"local\" is rejected"),
1777            "the role guard must fire before the endpoint checks: {err}"
1778        );
1779    }
1780
1781    #[test]
1782    fn guarded_local_validator_functional_may_be_configured_local() {
1783        // KRZ-206b: the functional role may select the local backend for
1784        // deterministic mechanical checks (contract-command pass/fail); the
1785        // same endpoint requirements as any local-backed role apply, and
1786        // every local PASS is frontier-confirmed at the validation round.
1787        let mut cfg = MissionConfig::default();
1788        cfg.validator_functional.backend = Some("local".into());
1789        cfg.validator_functional.base_url = Some("http://127.0.0.1:8080".into());
1790        cfg.validator_functional.context_budget = Some(8192);
1791        assert!(
1792            validate(&cfg).is_ok(),
1793            "functional + local with a valid endpoint must be accepted"
1794        );
1795
1796        // The endpoint fields stay required — a local functional validator
1797        // with nowhere to point is a config error, exactly as before.
1798        let mut cfg = MissionConfig::default();
1799        cfg.validator_functional.backend = Some("local".into());
1800        let err = validate(&cfg).unwrap_err().to_string();
1801        assert!(
1802            err.contains("validatorFunctional.baseUrl is required"),
1803            "endpoint requirements must still apply to the functional role: {err}"
1804        );
1805    }
1806
1807    #[test]
1808    fn validate_rejects_kimi_k3_for_unsupported_efforts() {
1809        for effort in ["medium", "xhigh"] {
1810            let mut cfg = MissionConfig::default();
1811            cfg.validator_scrutiny.backend = Some("kimi".into());
1812            cfg.validator_scrutiny.model = DEFAULT_KIMI_MODEL.into();
1813            cfg.validator_scrutiny.reasoning_effort = effort.into();
1814            let err = validate(&cfg).unwrap_err().to_string();
1815            assert!(
1816                err.contains("reasoningEffort"),
1817                "kimi k3 should reject effort {effort}: {err}"
1818            );
1819        }
1820    }
1821
1822    #[test]
1823    fn validate_kimi_for_coding_imposes_no_effort_constraint() {
1824        for effort in ["low", "medium", "high", "xhigh", "max"] {
1825            let mut cfg = MissionConfig::default();
1826            cfg.validator_scrutiny.backend = Some("kimi".into());
1827            cfg.validator_scrutiny.model = "kimi-code/kimi-for-coding".into();
1828            cfg.validator_scrutiny.reasoning_effort = effort.into();
1829            assert!(
1830                validate(&cfg).is_ok(),
1831                "kimi-for-coding should accept any effort, got {effort} err"
1832            );
1833        }
1834    }
1835
1836    #[test]
1837    fn validate_rejects_unsupported_kimi_model() {
1838        let mut cfg = MissionConfig::default();
1839        cfg.validator_scrutiny.backend = Some("kimi".into());
1840        cfg.validator_scrutiny.model = "kimi-unknown-model".into();
1841        assert!(validate(&cfg).is_err());
1842    }
1843
1844    #[test]
1845    fn sandbox_config_defaults_to_off() {
1846        let cfg = MissionConfig::default();
1847        for role in [
1848            &cfg.orchestrator,
1849            &cfg.worker,
1850            &cfg.validator_scrutiny,
1851            &cfg.validator_functional,
1852        ] {
1853            assert_eq!(role.sandbox.enforce, crate::types::SandboxEnforce::Off);
1854            assert!(role.sandbox.extra_write.is_empty());
1855            assert!(role.sandbox.egress.is_empty());
1856        }
1857        assert!(validate(&cfg).is_ok());
1858    }
1859
1860    #[test]
1861    fn sandbox_config_parses_fs() {
1862        let dir = tempfile::tempdir().unwrap();
1863        let layer_path = dir.path().join("config.json");
1864        std::fs::write(
1865            &layer_path,
1866            r#"{"worker":{"sandbox":{"enforce":"fs","extraWrite":["~/.cargo"]}}}"#,
1867        )
1868        .unwrap();
1869
1870        let cfg = load_layers(&[layer_path]).unwrap();
1871        assert_eq!(cfg.worker.sandbox.enforce, crate::types::SandboxEnforce::Fs);
1872        assert_eq!(cfg.worker.sandbox.extra_write, vec!["~/.cargo".to_string()]);
1873        // Other roles remain untouched by the partial patch.
1874        assert_eq!(
1875            cfg.orchestrator.sandbox.enforce,
1876            crate::types::SandboxEnforce::Off
1877        );
1878    }
1879
1880    #[test]
1881    fn sandbox_config_extra_write_roundtrips() {
1882        let mut cfg = MissionConfig::default();
1883        cfg.worker.sandbox.enforce = crate::types::SandboxEnforce::FsNet;
1884        cfg.worker.sandbox.extra_write = vec!["~/.cargo".into(), "~/.npm".into()];
1885        cfg.worker.sandbox.egress = vec!["registry.npmjs.org:443".into()];
1886
1887        let value = serde_json::to_value(&cfg).unwrap();
1888        assert_eq!(value["worker"]["sandbox"]["enforce"], "fs+net");
1889        assert_eq!(
1890            value["worker"]["sandbox"]["extraWrite"],
1891            serde_json::json!(["~/.cargo", "~/.npm"])
1892        );
1893        assert_eq!(
1894            value["worker"]["sandbox"]["egress"],
1895            serde_json::json!(["registry.npmjs.org:443"])
1896        );
1897
1898        let roundtripped: MissionConfig = serde_json::from_value(value).unwrap();
1899        assert_eq!(roundtripped, cfg);
1900    }
1901
1902    #[test]
1903    fn sandbox_config_parses_fs_plus_net() {
1904        let dir = tempfile::tempdir().unwrap();
1905        let layer_path = dir.path().join("config.json");
1906        std::fs::write(
1907            &layer_path,
1908            r#"{"worker":{"sandbox":{"enforce":"fs+net","egress":["crates.io:443"]}}}"#,
1909        )
1910        .unwrap();
1911
1912        let cfg = load_layers(&[layer_path]).unwrap();
1913        assert_eq!(
1914            cfg.worker.sandbox.enforce,
1915            crate::types::SandboxEnforce::FsNet
1916        );
1917        assert_eq!(cfg.worker.sandbox.egress, vec!["crates.io:443"]);
1918    }
1919
1920    #[test]
1921    fn only_claude_declares_sandbox_enforcement_support() {
1922        assert!(BackendKind::Claude.supports_sandbox_enforcement());
1923        for kind in [
1924            BackendKind::Codex,
1925            BackendKind::Droid,
1926            BackendKind::Kimi,
1927            BackendKind::Local,
1928            BackendKind::Cursor,
1929        ] {
1930            assert!(
1931                !kind.supports_sandbox_enforcement(),
1932                "{kind:?} must not claim sandbox enforcement support"
1933            );
1934        }
1935    }
1936
1937    #[test]
1938    fn validate_rejects_enforced_sandbox_on_non_claude_backends() {
1939        for backend in ["codex", "droid", "kimi", "cursor"] {
1940            for enforce in [
1941                crate::types::SandboxEnforce::Fs,
1942                crate::types::SandboxEnforce::FsNet,
1943            ] {
1944                let mut cfg = MissionConfig::default();
1945                cfg.validator_scrutiny.backend = Some(backend.into());
1946                cfg.validator_scrutiny.sandbox.enforce = enforce;
1947                let err = validate(&cfg).unwrap_err().to_string();
1948                // The error must name the backend, the requested enforce
1949                // mode, and the remedy.
1950                assert!(err.contains("validatorScrutiny"), "{err}");
1951                assert!(err.contains(backend), "{err}");
1952                assert!(err.contains(enforce.as_str()), "{err}");
1953                assert!(err.contains("sandbox.enforce=off"), "{err}");
1954                assert!(err.contains("claude"), "{err}");
1955            }
1956        }
1957    }
1958
1959    #[test]
1960    fn validate_rejects_enforced_sandbox_on_codex_worker() {
1961        let mut cfg = MissionConfig::default();
1962        cfg.worker.backend = Some("codex".into());
1963        cfg.worker.sandbox.enforce = crate::types::SandboxEnforce::Fs;
1964        let err = validate(&cfg).unwrap_err().to_string();
1965        assert!(err.contains("worker.backend"), "{err}");
1966        assert!(err.contains("codex"), "{err}");
1967        assert!(err.contains("sandbox.enforce=off"), "{err}");
1968    }
1969
1970    #[test]
1971    fn validate_rejects_enforced_sandbox_on_local_backend() {
1972        // The local backend makes its HTTP call in the engine process — no
1973        // child to wrap — so an enforced sandbox would be silently ignored.
1974        let mut cfg = local_worker_cfg();
1975        cfg.worker.sandbox.enforce = crate::types::SandboxEnforce::FsNet;
1976        let err = validate(&cfg).unwrap_err().to_string();
1977        assert!(err.contains("local"), "{err}");
1978        assert!(err.contains("fs+net"), "{err}");
1979    }
1980
1981    #[test]
1982    fn validate_rejects_container_provider_on_non_claude_backend() {
1983        // `provider = "container"` with an enforced mode is still an enforced
1984        // sandbox the backend cannot honor.
1985        let mut cfg = MissionConfig::default();
1986        cfg.validator_scrutiny.backend = Some("droid".into());
1987        cfg.validator_scrutiny.sandbox.enforce = crate::types::SandboxEnforce::Fs;
1988        cfg.validator_scrutiny.sandbox.provider = crate::types::SandboxProvider::Container;
1989        assert!(validate(&cfg).is_err());
1990    }
1991
1992    #[test]
1993    fn container_net_boundary_is_hard_only_for_process_or_empty_egress() {
1994        // The process provider's fs+net boundary is the OS profile itself
1995        // (Seatbelt loopback-only on macOS, bwrap --unshare-net on Linux), so
1996        // the proxy hop is the only reachable way out regardless of the list.
1997        assert!(crate::types::SandboxProvider::Process
1998            .enforces_hard_net_boundary(&["crates.io:443".to_string()]));
1999        // This static helper remains false for container + non-empty egress
2000        // because only session runtime provisioning supplies that boundary;
2001        // engine-run gates use the helper to keep refusing the pair.
2002        assert!(crate::types::SandboxProvider::Container.enforces_hard_net_boundary(&[]));
2003        assert!(!crate::types::SandboxProvider::Container
2004            .enforces_hard_net_boundary(&["crates.io:443".to_string()]));
2005    }
2006
2007    #[test]
2008    fn validate_accepts_container_fs_net_with_egress_list_for_runtime_resolution() {
2009        // The role config can now request the hard internal-network relay.
2010        // Runtime resolution still fails closed unless Docker is available.
2011        let mut cfg = MissionConfig::default();
2012        cfg.worker.sandbox.enforce = crate::types::SandboxEnforce::FsNet;
2013        cfg.worker.sandbox.provider = crate::types::SandboxProvider::Container;
2014        cfg.worker.sandbox.egress = vec!["crates.io:443".into()];
2015        assert!(validate(&cfg).is_ok());
2016    }
2017
2018    #[test]
2019    fn validate_accepts_container_fs_and_container_fs_net_with_empty_egress() {
2020        // `fs` claims no network enforcement at all, and fs+net with an empty
2021        // egress list maps to the hard `--network none` boundary — both
2022        // honest postures for the container provider.
2023        for enforce in [
2024            crate::types::SandboxEnforce::Fs,
2025            crate::types::SandboxEnforce::FsNet,
2026        ] {
2027            let mut cfg = MissionConfig::default();
2028            cfg.worker.sandbox.enforce = enforce;
2029            cfg.worker.sandbox.provider = crate::types::SandboxProvider::Container;
2030            assert!(
2031                validate(&cfg).is_ok(),
2032                "container provider with sandbox.enforce={} and an empty egress list must validate",
2033                enforce.as_str()
2034            );
2035        }
2036    }
2037
2038    #[test]
2039    fn validate_accepts_process_fs_net_with_egress_list() {
2040        // The process provider keeps its kernel boundary (Seatbelt loopback /
2041        // bwrap --unshare-net) regardless of the egress list — unchanged.
2042        let mut cfg = MissionConfig::default();
2043        cfg.worker.sandbox.enforce = crate::types::SandboxEnforce::FsNet;
2044        cfg.worker.sandbox.egress = vec!["crates.io:443".into()];
2045        assert!(
2046            validate(&cfg).is_ok(),
2047            "process provider fs+net with an egress list must still validate"
2048        );
2049    }
2050
2051    #[test]
2052    fn validate_accepts_enforced_sandbox_on_claude_backend() {
2053        for backend in [None, Some("claude")] {
2054            for enforce in [
2055                crate::types::SandboxEnforce::Fs,
2056                crate::types::SandboxEnforce::FsNet,
2057            ] {
2058                let mut cfg = MissionConfig::default();
2059                cfg.worker.backend = backend.map(|s| s.to_string());
2060                cfg.worker.sandbox.enforce = enforce;
2061                assert!(
2062                    validate(&cfg).is_ok(),
2063                    "claude worker with sandbox.enforce={} must validate",
2064                    enforce.as_str()
2065                );
2066            }
2067        }
2068    }
2069
2070    #[test]
2071    fn validate_accepts_sandbox_off_on_every_backend() {
2072        for backend in ["codex", "droid", "kimi", "cursor"] {
2073            let mut cfg = MissionConfig::default();
2074            cfg.validator_scrutiny.backend = Some(backend.into());
2075            assert_eq!(
2076                cfg.validator_scrutiny.sandbox.enforce,
2077                crate::types::SandboxEnforce::Off
2078            );
2079            assert!(
2080                validate(&cfg).is_ok(),
2081                "{backend} with sandbox.enforce=off must validate"
2082            );
2083        }
2084        assert!(
2085            validate(&local_worker_cfg()).is_ok(),
2086            "local with sandbox.enforce=off must validate"
2087        );
2088    }
2089
2090    fn local_role_cfg() -> crate::types::RoleConfig {
2091        crate::types::RoleConfig {
2092            backend: Some("local".into()),
2093            model: "my-local-model".into(),
2094            base_url: Some("http://localhost:8080".into()),
2095            context_budget: Some(8192),
2096            ..MissionConfig::default().worker
2097        }
2098    }
2099
2100    fn local_worker_cfg() -> MissionConfig {
2101        MissionConfig {
2102            worker: local_role_cfg(),
2103            allow_below_default_worker_model: true,
2104            ..MissionConfig::default()
2105        }
2106    }
2107
2108    /// ACP (KRZ-301): the worker-role-only backend wiring — parse, role
2109    /// restriction, required command, model-tier opt-in.
2110    fn acp_worker_cfg() -> MissionConfig {
2111        let mut cfg = MissionConfig {
2112            allow_below_default_worker_model: true,
2113            ..MissionConfig::default()
2114        };
2115        cfg.worker.backend = Some("acp".into());
2116        cfg.worker.acp_command = Some("/opt/bin/my-acp-agent".into());
2117        cfg.worker.acp_args = vec!["--serve".into()];
2118        cfg
2119    }
2120
2121    #[test]
2122    fn backend_acp_config_round_trips_and_parses() {
2123        assert_eq!(parse_backend(Some("acp")), Ok(BackendKind::Acp));
2124        let cfg = acp_worker_cfg();
2125        assert_eq!(cfg.backend_kind(Role::Worker), BackendKind::Acp);
2126        assert_eq!(BackendKind::Acp.as_str(), "acp");
2127        assert!(!BackendKind::Acp.supports_sandbox_enforcement());
2128        assert!(!BackendKind::Acp.reports_cache_read_tokens());
2129        assert!(!BackendKind::Acp.reports_cache_write_tokens());
2130        assert!(
2131            validate(&cfg).is_ok(),
2132            "worker + acpCommand + the below-default opt-in must validate"
2133        );
2134    }
2135
2136    #[test]
2137    fn backend_acp_config_requires_worker_role_and_command() {
2138        // Validators and the orchestrator are refused (KRZ-301 lands
2139        // worker-first).
2140        for role in [
2141            Role::Orchestrator,
2142            Role::ValidatorScrutiny,
2143            Role::ValidatorFunctional,
2144        ] {
2145            let mut cfg = acp_worker_cfg();
2146            let role_cfg = match role {
2147                Role::Orchestrator => &mut cfg.orchestrator,
2148                Role::ValidatorScrutiny => &mut cfg.validator_scrutiny,
2149                Role::ValidatorFunctional => &mut cfg.validator_functional,
2150                Role::Worker => unreachable!("loop excludes the worker"),
2151            };
2152            role_cfg.backend = Some("acp".into());
2153            role_cfg.acp_command = Some("/opt/bin/my-acp-agent".into());
2154            let err = validate(&cfg).expect_err("non-worker acp must be refused");
2155            assert!(
2156                err.to_string().contains("worker role only"),
2157                "refusal must name the role restriction: {err}"
2158            );
2159        }
2160
2161        // The command is required (and a blank one is as good as absent).
2162        let mut cfg = acp_worker_cfg();
2163        cfg.worker.acp_command = None;
2164        let err = validate(&cfg).expect_err("missing acpCommand must be refused");
2165        assert!(err.to_string().contains("acpCommand"), "{err}");
2166        cfg.worker.acp_command = Some("   ".into());
2167        assert!(validate(&cfg).is_err(), "blank acpCommand must be refused");
2168
2169        // ACP model ids are free-form → uniformly below-default → the
2170        // worker needs the explicit opt-in, same as local.
2171        let mut cfg = acp_worker_cfg();
2172        cfg.allow_below_default_worker_model = false;
2173        let err = validate(&cfg).expect_err("below-default acp worker needs the opt-in");
2174        assert!(
2175            err.to_string().contains("allowBelowDefaultWorkerModel"),
2176            "{err}"
2177        );
2178    }
2179
2180    #[test]
2181    fn local_config_requires_base_url() {
2182        let mut cfg = local_worker_cfg();
2183
2184        cfg.worker.base_url = None;
2185        assert!(
2186            validate(&cfg).is_err(),
2187            "missing baseUrl should be rejected"
2188        );
2189
2190        cfg.worker.base_url = Some("not a url".into());
2191        assert!(
2192            validate(&cfg).is_err(),
2193            "unparseable baseUrl should be rejected"
2194        );
2195
2196        cfg.worker.base_url = Some("http://localhost:8080".into());
2197        assert!(
2198            validate(&cfg).is_ok(),
2199            "valid http baseUrl should be accepted"
2200        );
2201
2202        // A well-formed https URL at a NON-loopback host is refused unless
2203        // the operator allowlisted the host (audit 2026-09-01, MEDIUM
2204        // baseUrl): the engine POSTs the assembled prompt there from outside
2205        // every sandbox.
2206        cfg.worker.base_url = Some("https://models.internal/v1".into());
2207        assert!(
2208            validate(&cfg).is_err(),
2209            "a remote baseUrl needs the operator's allowlist"
2210        );
2211        cfg.local_backend_allowed_hosts = vec!["models.internal".into()];
2212        assert!(
2213            validate(&cfg).is_ok(),
2214            "valid https baseUrl at an allowlisted host should be accepted"
2215        );
2216        cfg.local_backend_allowed_hosts.clear();
2217
2218        cfg.worker.base_url = Some("http://127.0.0.1".into());
2219        assert!(
2220            validate(&cfg).is_ok(),
2221            "bare ip host baseUrl should be accepted"
2222        );
2223
2224        cfg.worker.base_url = Some("http://:8080".into());
2225        assert!(
2226            validate(&cfg).is_err(),
2227            "host-less authority with port should be rejected"
2228        );
2229
2230        cfg.worker.base_url = Some("http://@".into());
2231        assert!(
2232            validate(&cfg).is_err(),
2233            "userinfo-only authority should be rejected"
2234        );
2235
2236        cfg.worker.base_url = Some("http://@:8080".into());
2237        assert!(
2238            validate(&cfg).is_err(),
2239            "userinfo with port and no host should be rejected"
2240        );
2241    }
2242
2243    #[test]
2244    fn local_config_requires_context_budget_in_range() {
2245        let mut cfg = local_worker_cfg();
2246
2247        cfg.worker.context_budget = Some(1023);
2248        assert!(validate(&cfg).is_err(), "1023 is below the floor");
2249
2250        cfg.worker.context_budget = Some(200_001);
2251        assert!(validate(&cfg).is_err(), "200001 is above the ceiling");
2252
2253        cfg.worker.context_budget = None;
2254        assert!(validate(&cfg).is_err(), "missing contextBudget is rejected");
2255
2256        cfg.worker.context_budget = Some(8192);
2257        assert!(validate(&cfg).is_ok(), "8192 is in range");
2258    }
2259
2260    #[test]
2261    fn local_config_rejects_out_of_range_temperature() {
2262        let mut cfg = local_worker_cfg();
2263
2264        cfg.worker.temperature = Some(2.1);
2265        assert!(validate(&cfg).is_err(), "2.1 is above the ceiling");
2266
2267        cfg.worker.temperature = Some(-0.1);
2268        assert!(validate(&cfg).is_err(), "-0.1 is below the floor");
2269
2270        cfg.worker.temperature = Some(0.7);
2271        assert!(validate(&cfg).is_ok(), "0.7 is in range");
2272
2273        cfg.worker.temperature = None;
2274        assert!(validate(&cfg).is_ok(), "absent temperature is fine");
2275    }
2276
2277    #[test]
2278    fn local_config_worker_below_default_needs_optin() {
2279        let mut cfg = local_worker_cfg();
2280        cfg.allow_below_default_worker_model = false;
2281        assert!(
2282            validate(&cfg).is_err(),
2283            "local worker below-default tier requires opt-in"
2284        );
2285
2286        cfg.allow_below_default_worker_model = true;
2287        assert!(
2288            validate(&cfg).is_ok(),
2289            "local worker accepted once opted in"
2290        );
2291    }
2292
2293    #[test]
2294    fn local_config_orchestrator_local_always_rejected() {
2295        let cfg = MissionConfig {
2296            orchestrator: local_role_cfg(),
2297            allow_below_default_worker_model: true,
2298            ..MissionConfig::default()
2299        };
2300        assert!(
2301            validate(&cfg).is_err(),
2302            "local orchestrator always fails the frontier floor"
2303        );
2304    }
2305
2306    #[test]
2307    fn local_config_model_tier_below_default_for_any_nonempty() {
2308        assert_eq!(
2309            model_tier(BackendKind::Local, "any-model-id"),
2310            Some(ModelTier::BelowDefault)
2311        );
2312        assert_eq!(model_tier(BackendKind::Local, ""), None);
2313        assert_eq!(model_tier(BackendKind::Local, "   "), None);
2314    }
2315
2316    #[test]
2317    fn local_config_effective_model_passes_through_verbatim_and_never_panics() {
2318        assert_eq!(
2319            effective_model(Role::Worker, BackendKind::Local, "my-local-model"),
2320            "my-local-model"
2321        );
2322        // Even if the configured string happens to equal the Claude role
2323        // default, Local has no backend default to rewrite to.
2324        assert_eq!(
2325            effective_model(Role::Worker, BackendKind::Local, "sonnet"),
2326            "sonnet"
2327        );
2328    }
2329
2330    #[test]
2331    fn task_class_routing_maps_execution_class_to_local() {
2332        assert_eq!(
2333            task_class_to_tier(Some("execution-class")),
2334            ExecutorTier::Local
2335        );
2336    }
2337
2338    #[test]
2339    fn task_class_routing_defaults_to_frontier() {
2340        assert_eq!(
2341            task_class_to_tier(Some("planning-class")),
2342            ExecutorTier::Frontier
2343        );
2344        assert_eq!(
2345            task_class_to_tier(Some("some-arbitrary-value")),
2346            ExecutorTier::Frontier
2347        );
2348        assert_eq!(task_class_to_tier(None), ExecutorTier::Frontier);
2349    }
2350
2351    #[test]
2352    fn task_class_routing_is_case_and_whitespace_insensitive() {
2353        assert_eq!(
2354            task_class_to_tier(Some("  Execution-Class ")),
2355            ExecutorTier::Local
2356        );
2357    }
2358
2359    fn test_local_endpoint() -> LocalEndpoint {
2360        LocalEndpoint {
2361            base_url: "http://127.0.0.1:8080".to_string(),
2362            context_budget: 16_384,
2363            temperature: Some(0.2),
2364        }
2365    }
2366
2367    #[test]
2368    fn executor_routing_applies_local_backend_when_execution_class_and_endpoint_configured() {
2369        let mut cfg = MissionConfig::default();
2370        let validator_scrutiny_before = cfg.validator_scrutiny.clone();
2371        let validator_functional_before = cfg.validator_functional.clone();
2372        let endpoint = test_local_endpoint();
2373
2374        let applied = apply_executor_routing(&mut cfg, ExecutorTier::Local, Some(&endpoint));
2375
2376        assert_eq!(applied, ExecutorTier::Local);
2377        assert_eq!(cfg.worker.backend.as_deref(), Some("local"));
2378        assert_eq!(
2379            cfg.worker.base_url.as_deref(),
2380            Some(endpoint.base_url.as_str())
2381        );
2382        assert_eq!(cfg.worker.context_budget, Some(endpoint.context_budget));
2383        assert_eq!(cfg.worker.temperature, endpoint.temperature);
2384        assert!(cfg.allow_below_default_worker_model);
2385        assert_eq!(cfg.validator_scrutiny, validator_scrutiny_before);
2386        assert_eq!(cfg.validator_functional, validator_functional_before);
2387    }
2388
2389    #[test]
2390    fn executor_routing_applies_fail_safe_frontier_when_no_endpoint_configured() {
2391        let mut cfg = MissionConfig::default();
2392        let worker_backend_before = cfg.worker.backend.clone();
2393
2394        let applied = apply_executor_routing(&mut cfg, ExecutorTier::Local, None);
2395
2396        assert_eq!(applied, ExecutorTier::Frontier);
2397        assert_eq!(cfg.worker.backend, worker_backend_before);
2398        assert!(!cfg.allow_below_default_worker_model);
2399    }
2400
2401    #[test]
2402    fn executor_routing_applies_no_change_for_frontier_tier() {
2403        let mut cfg = MissionConfig::default();
2404        let before = cfg.clone();
2405        let endpoint = test_local_endpoint();
2406
2407        let applied = apply_executor_routing(&mut cfg, ExecutorTier::Frontier, Some(&endpoint));
2408
2409        assert_eq!(applied, ExecutorTier::Frontier);
2410        assert_eq!(cfg, before);
2411    }
2412
2413    #[test]
2414    fn route_task_class_executor_routes_local_when_endpoint_configured() {
2415        // Mirrors what `MissionEngine::create` calls with the class recovered
2416        // from a folded goal string (f-1-2: this is the single engine-side
2417        // wiring point every seed path — draft, exec, REST, Slack — shares).
2418        let mut cfg = MissionConfig::default();
2419        cfg.worker.base_url = Some("http://127.0.0.1:8080".to_string());
2420        cfg.worker.context_budget = Some(16_384);
2421
2422        let (applied, summary) = route_task_class_executor(&mut cfg, Some("execution-class"));
2423
2424        assert_eq!(applied, ExecutorTier::Local);
2425        assert_eq!(cfg.worker.backend.as_deref(), Some("local"));
2426        assert_eq!(summary, "executor routed local (execution-class)");
2427    }
2428
2429    #[test]
2430    fn route_task_class_executor_stays_frontier_without_endpoint() {
2431        let mut cfg = MissionConfig::default();
2432        let (applied, summary) = route_task_class_executor(&mut cfg, Some("execution-class"));
2433
2434        assert_eq!(applied, ExecutorTier::Frontier);
2435        assert_eq!(cfg.worker.backend, None);
2436        assert!(summary.contains("no local endpoint configured"));
2437    }
2438
2439    #[test]
2440    fn route_task_class_executor_stays_frontier_for_non_execution_class() {
2441        let mut cfg = MissionConfig::default();
2442        cfg.worker.base_url = Some("http://127.0.0.1:8080".to_string());
2443        cfg.worker.context_budget = Some(16_384);
2444
2445        let (applied, summary) = route_task_class_executor(&mut cfg, None);
2446
2447        assert_eq!(applied, ExecutorTier::Frontier);
2448        assert_eq!(cfg.worker.backend, None);
2449        assert_eq!(summary, "executor stays frontier");
2450    }
2451
2452    #[test]
2453    fn validator_stays_frontier_after_local_executor_routing() {
2454        let mut cfg = MissionConfig::default();
2455        let endpoint = test_local_endpoint();
2456
2457        apply_executor_routing(&mut cfg, ExecutorTier::Local, Some(&endpoint));
2458
2459        assert_ne!(cfg.validator_scrutiny.backend.as_deref(), Some("local"));
2460        assert_ne!(cfg.validator_functional.backend.as_deref(), Some("local"));
2461    }
2462
2463    // -----------------------------------------------------------------------
2464    // Backend routing table (ticket backend-routing-abstraction, KRZ-331)
2465    // -----------------------------------------------------------------------
2466
2467    use crate::types::TaskClassRoute;
2468
2469    fn routing_table(rules: &[(&str, ExecutorTier)]) -> Vec<TaskClassRoute> {
2470        rules
2471            .iter()
2472            .map(|(task_class, tier)| TaskClassRoute {
2473                task_class: task_class.to_string(),
2474                tier: *tier,
2475            })
2476            .collect()
2477    }
2478
2479    #[test]
2480    fn routing_abstraction_table_defaults_empty_and_parses_camel_case() {
2481        // Additive contract change: an absent key (every pre-existing config
2482        // and every old mission.created event payload) deserializes to the
2483        // EMPTY table — the byte-identical literal floor.
2484        assert!(MissionConfig::default().routing.task_class_rules.is_empty());
2485        let value = serde_json::to_value(MissionConfig::default()).unwrap();
2486        assert_eq!(value["routing"]["taskClassRules"], serde_json::json!([]));
2487
2488        let dir = tempfile::tempdir().unwrap();
2489        let layer_path = dir.path().join("config.json");
2490        std::fs::write(
2491            &layer_path,
2492            r#"{"routing": {"taskClassRules": [{"taskClass": "execution-class", "tier": "local"}, {"taskClass": "docs-class", "tier": "frontier"}]}}"#,
2493        )
2494        .unwrap();
2495        let cfg = load_layers(&[layer_path]).unwrap();
2496        assert_eq!(cfg.routing.task_class_rules.len(), 2);
2497        assert_eq!(
2498            cfg.routing.task_class_rules[0].task_class,
2499            "execution-class"
2500        );
2501        assert_eq!(cfg.routing.task_class_rules[0].tier, ExecutorTier::Local);
2502        assert_eq!(cfg.routing.task_class_rules[1].tier, ExecutorTier::Frontier);
2503
2504        // A layer naming unrelated keys only (the old-config shape) leaves
2505        // the table empty.
2506        let layer_path = dir.path().join("config-old.json");
2507        std::fs::write(&layer_path, r#"{"maxRespawns": 3}"#).unwrap();
2508        let cfg = load_layers(&[layer_path]).unwrap();
2509        assert!(cfg.routing.task_class_rules.is_empty());
2510    }
2511
2512    #[test]
2513    fn routing_abstraction_unconfigured_table_keeps_byte_identical_floor() {
2514        // The regression pin: with NO table configured, routing a task class
2515        // must produce exactly the pre-table behavior — the literal floor
2516        // (`task_class_to_tier`) fed through `apply_executor_routing` —
2517        // including the applied config edits, for every input shape.
2518        for task_class in [
2519            None,
2520            Some("execution-class"),
2521            Some("  Execution-Class "),
2522            Some("planning-class"),
2523            Some("some-arbitrary-value"),
2524        ] {
2525            for endpoint_configured in [false, true] {
2526                let wire = |cfg: &mut MissionConfig| {
2527                    if endpoint_configured {
2528                        cfg.worker.base_url = Some("http://127.0.0.1:8080".to_string());
2529                        cfg.worker.context_budget = Some(16_384);
2530                    }
2531                };
2532                let mut cfg = MissionConfig::default();
2533                wire(&mut cfg);
2534                assert!(cfg.routing.task_class_rules.is_empty());
2535                let (applied, _) = route_task_class_executor(&mut cfg, task_class);
2536
2537                // The pre-table reference computation.
2538                let mut reference = MissionConfig::default();
2539                wire(&mut reference);
2540                let endpoint = match (&reference.worker.base_url, reference.worker.context_budget) {
2541                    (Some(base_url), Some(context_budget)) => Some(LocalEndpoint {
2542                        base_url: base_url.clone(),
2543                        context_budget,
2544                        temperature: reference.worker.temperature,
2545                    }),
2546                    _ => None,
2547                };
2548                let expected = apply_executor_routing(
2549                    &mut reference,
2550                    task_class_to_tier(task_class),
2551                    endpoint.as_ref(),
2552                );
2553
2554                assert_eq!(applied, expected, "task class {task_class:?}");
2555                assert_eq!(
2556                    cfg, reference,
2557                    "an empty table must apply byte-identical config changes for {task_class:?}"
2558                );
2559            }
2560        }
2561    }
2562
2563    #[test]
2564    fn routing_abstraction_table_routes_configured_class_to_local() {
2565        // A configured table is the complete floor: it routes the classes it
2566        // names — beyond the literal floor's single hardcoded class...
2567        let mut cfg = MissionConfig::default();
2568        cfg.routing.task_class_rules = routing_table(&[("docs-class", ExecutorTier::Local)]);
2569        cfg.worker.base_url = Some("http://127.0.0.1:8080".to_string());
2570        cfg.worker.context_budget = Some(16_384);
2571
2572        let (applied, summary) = route_task_class_executor(&mut cfg, Some("docs-class"));
2573
2574        assert_eq!(applied, ExecutorTier::Local);
2575        assert_eq!(cfg.worker.backend.as_deref(), Some("local"));
2576        assert_eq!(summary, "executor routed local (routing-table rule)");
2577
2578        // ...and the literal floor's own class stays frontier when the table
2579        // does not name it (the table replaces the literal map, it does not
2580        // amend it).
2581        let mut cfg = MissionConfig::default();
2582        cfg.routing.task_class_rules = routing_table(&[("docs-class", ExecutorTier::Local)]);
2583        cfg.worker.base_url = Some("http://127.0.0.1:8080".to_string());
2584        cfg.worker.context_budget = Some(16_384);
2585
2586        let (applied, summary) = route_task_class_executor(&mut cfg, Some("execution-class"));
2587
2588        assert_eq!(applied, ExecutorTier::Frontier);
2589        assert_eq!(cfg.worker.backend, None);
2590        assert_eq!(summary, "executor stays frontier");
2591    }
2592
2593    #[test]
2594    fn routing_abstraction_table_local_route_fails_safe_without_endpoint() {
2595        // The pre-table fail-safe is unchanged under a table: a local route
2596        // with no configured endpoint stays frontier rather than routing to
2597        // an endpoint that doesn't exist.
2598        let mut cfg = MissionConfig::default();
2599        cfg.routing.task_class_rules = routing_table(&[("execution-class", ExecutorTier::Local)]);
2600
2601        let (applied, summary) = route_task_class_executor(&mut cfg, Some("execution-class"));
2602
2603        assert_eq!(applied, ExecutorTier::Frontier);
2604        assert_eq!(cfg.worker.backend, None);
2605        assert!(
2606            summary.contains("no local endpoint configured"),
2607            "{summary}"
2608        );
2609    }
2610
2611    #[test]
2612    fn routing_abstraction_validate_fails_closed_on_malformed_table() {
2613        // Duplicate after normalization: refused, naming the rule (a
2614        // shadowed rule is dead config under first-match-wins).
2615        let mut cfg = MissionConfig::default();
2616        cfg.routing.task_class_rules = routing_table(&[
2617            ("execution-class", ExecutorTier::Local),
2618            (" Execution-Class", ExecutorTier::Frontier),
2619        ]);
2620        let err = validate(&cfg).unwrap_err().to_string();
2621        assert!(err.contains("routing.taskClassRules[1].taskClass"), "{err}");
2622        assert!(err.contains("duplicates rule 0"), "{err}");
2623
2624        // Blank class: refused (it could never match honestly).
2625        let mut cfg = MissionConfig::default();
2626        cfg.routing.task_class_rules = routing_table(&[("   ", ExecutorTier::Local)]);
2627        let err = validate(&cfg).unwrap_err().to_string();
2628        assert!(err.contains("routing.taskClassRules[0].taskClass"), "{err}");
2629
2630        // A clean table validates.
2631        let mut cfg = MissionConfig::default();
2632        cfg.routing.task_class_rules = routing_table(&[
2633            ("execution-class", ExecutorTier::Local),
2634            ("docs-class", ExecutorTier::Frontier),
2635        ]);
2636        assert!(validate(&cfg).is_ok(), "a clean table must validate");
2637    }
2638
2639    #[test]
2640    fn routing_abstraction_hosted_fine_tune_is_plain_local_endpoint_config() {
2641        // KRZ-331: a hosted fine-tune is configuration of the
2642        // OpenAI-compatible local backend (baseUrl + model), NOT a new
2643        // backend kind — an https endpoint carrying a free-form
2644        // fine-tune-shaped model id validates exactly like a localhost one,
2645        // and a table can route a task class to it by capability class.
2646        let mut cfg = local_worker_cfg();
2647        cfg.worker.base_url = Some("https://models.internal.example/v1".into());
2648        cfg.worker.model = "ft:some-model:some-org:some-id".into();
2649        // A hosted (non-loopback) endpoint now needs the operator's
2650        // global-layer host allowlist — the model id is still free-form, and
2651        // the routing behavior below is unchanged.
2652        cfg.local_backend_allowed_hosts = vec!["models.internal.example".into()];
2653        assert!(
2654            validate(&cfg).is_ok(),
2655            "a hosted fine-tune endpoint is ordinary local-backend config"
2656        );
2657
2658        cfg.routing.task_class_rules = routing_table(&[("execution-class", ExecutorTier::Local)]);
2659        let (applied, _) = route_task_class_executor(&mut cfg, Some("execution-class"));
2660        assert_eq!(applied, ExecutorTier::Local);
2661        assert_eq!(cfg.worker.backend.as_deref(), Some("local"));
2662    }
2663
2664    // -----------------------------------------------------------------------
2665    // Heterogeneous dispatch pool (ticket heterogeneous-dispatch-pool, KRZ-303)
2666    // -----------------------------------------------------------------------
2667
2668    use crate::types::CandidateSpec;
2669
2670    fn dispatch_pool_pair() -> Vec<CandidateSpec> {
2671        vec![
2672            CandidateSpec {
2673                backend: "claude".into(),
2674                model: "sonnet".into(),
2675            },
2676            CandidateSpec {
2677                backend: "codex".into(),
2678                model: DEFAULT_CODEX_MODEL.into(),
2679            },
2680        ]
2681    }
2682
2683    #[test]
2684    fn dispatch_pool_defaults_empty_and_parses_camel_case() {
2685        // Additive contract change: absent key (every pre-existing config and
2686        // every old mission.created event payload) deserializes to empty —
2687        // today's single-backend behavior exactly.
2688        assert!(MissionConfig::default().worker_candidates.is_empty());
2689        let value = serde_json::to_value(MissionConfig::default()).unwrap();
2690        assert_eq!(value["workerCandidates"], serde_json::json!([]));
2691
2692        let dir = tempfile::tempdir().unwrap();
2693        let layer_path = dir.path().join("config.json");
2694        std::fs::write(
2695            &layer_path,
2696            r#"{"workerCandidates": [{"backend": "claude", "model": "sonnet"}, {"backend": "codex", "model": "gpt-5.6-sol"}]}"#,
2697        )
2698        .unwrap();
2699        let cfg = load_layers(&[layer_path]).unwrap();
2700        assert_eq!(cfg.worker_candidates, dispatch_pool_pair());
2701
2702        // A layer naming unrelated keys only (the old-config shape) leaves
2703        // the pool empty.
2704        let layer_path = dir.path().join("config-old.json");
2705        std::fs::write(&layer_path, r#"{"maxRespawns": 3}"#).unwrap();
2706        let cfg = load_layers(&[layer_path]).unwrap();
2707        assert!(cfg.worker_candidates.is_empty());
2708    }
2709
2710    #[test]
2711    fn dispatch_pool_validate_accepts_heterogeneous_pair() {
2712        let cfg = MissionConfig {
2713            worker_candidates: dispatch_pool_pair(),
2714            ..MissionConfig::default()
2715        };
2716        validate(&cfg).unwrap();
2717    }
2718
2719    #[test]
2720    fn dispatch_pool_validate_rejects_single_entry() {
2721        let cfg = MissionConfig {
2722            worker_candidates: vec![CandidateSpec {
2723                backend: "claude".into(),
2724                model: "sonnet".into(),
2725            }],
2726            ..MissionConfig::default()
2727        };
2728        let err = validate(&cfg).unwrap_err().to_string();
2729        assert!(
2730            err.contains("workerCandidates with exactly one entry"),
2731            "{err}"
2732        );
2733    }
2734
2735    #[test]
2736    fn dispatch_pool_validate_rejects_local_and_acp_candidates() {
2737        for backend in ["local", "acp"] {
2738            let cfg = MissionConfig {
2739                worker_candidates: vec![
2740                    CandidateSpec {
2741                        backend: "claude".into(),
2742                        model: "sonnet".into(),
2743                    },
2744                    CandidateSpec {
2745                        backend: backend.into(),
2746                        model: "anything".into(),
2747                    },
2748                ],
2749                ..MissionConfig::default()
2750            };
2751            let err = validate(&cfg).unwrap_err().to_string();
2752            assert!(
2753                err.contains("not supported in this pass"),
2754                "{backend}: {err}"
2755            );
2756        }
2757    }
2758
2759    #[test]
2760    fn dispatch_pool_validate_rejects_parallel_workers_combination() {
2761        let cfg = MissionConfig {
2762            worker_candidates: dispatch_pool_pair(),
2763            max_parallel_workers: 2,
2764            ..MissionConfig::default()
2765        };
2766        let err = validate(&cfg).unwrap_err().to_string();
2767        assert!(err.contains("mutually exclusive"), "{err}");
2768    }
2769
2770    #[test]
2771    fn dispatch_pool_validate_rejects_unknown_backend_and_model() {
2772        let cfg = MissionConfig {
2773            worker_candidates: vec![
2774                CandidateSpec {
2775                    backend: "claude".into(),
2776                    model: "sonnet".into(),
2777                },
2778                CandidateSpec {
2779                    backend: "gemini".into(),
2780                    model: "sonnet".into(),
2781                },
2782            ],
2783            ..MissionConfig::default()
2784        };
2785        let err = validate(&cfg).unwrap_err().to_string();
2786        assert!(err.contains("workerCandidates[1].backend"), "{err}");
2787
2788        let cfg = MissionConfig {
2789            worker_candidates: vec![
2790                CandidateSpec {
2791                    backend: "claude".into(),
2792                    model: "sonnet".into(),
2793                },
2794                CandidateSpec {
2795                    backend: "codex".into(),
2796                    model: "kranz-test-model".into(),
2797                },
2798            ],
2799            ..MissionConfig::default()
2800        };
2801        let err = validate(&cfg).unwrap_err().to_string();
2802        assert!(err.contains("not supported by backend"), "{err}");
2803    }
2804
2805    #[test]
2806    fn dispatch_pool_validate_enforces_worker_floor_per_candidate() {
2807        // droid's default GLM is below the default worker tier — rejected
2808        // without the opt-in, accepted with it, exactly like the role check.
2809        let mut cfg = MissionConfig {
2810            worker_candidates: vec![
2811                CandidateSpec {
2812                    backend: "claude".into(),
2813                    model: "sonnet".into(),
2814                },
2815                CandidateSpec {
2816                    backend: "droid".into(),
2817                    model: DEFAULT_DROID_MODEL.into(),
2818                },
2819            ],
2820            ..MissionConfig::default()
2821        };
2822        let err = validate(&cfg).unwrap_err().to_string();
2823        assert!(err.contains("below the default worker tier"), "{err}");
2824        cfg.allow_below_default_worker_model = true;
2825        validate(&cfg).unwrap();
2826    }
2827
2828    #[test]
2829    fn dispatch_pool_validate_rejects_sandboxed_non_claude_candidate() {
2830        let mut cfg = MissionConfig {
2831            worker_candidates: dispatch_pool_pair(),
2832            ..MissionConfig::default()
2833        };
2834        cfg.worker.sandbox.enforce = crate::types::SandboxEnforce::Fs;
2835        let err = validate(&cfg).unwrap_err().to_string();
2836        assert!(err.contains("cannot honor sandbox.enforce"), "{err}");
2837    }
2838
2839    #[test]
2840    fn dispatch_pool_executor_routing_never_goes_local() {
2841        // An execution-class ticket with a configured pool must NOT get
2842        // worker.backend rewritten to local: the pool is the explicit
2843        // per-candidate backend declaration, and the local key would sit
2844        // next to it dead and misleading.
2845        let mut cfg = MissionConfig {
2846            worker_candidates: dispatch_pool_pair(),
2847            ..MissionConfig::default()
2848        };
2849        let endpoint = test_local_endpoint();
2850        let applied = apply_executor_routing(&mut cfg, ExecutorTier::Local, Some(&endpoint));
2851        assert_eq!(applied, ExecutorTier::Frontier);
2852        assert!(cfg.worker.backend.is_none());
2853    }
2854
2855    trait RoleConfigTestExt {
2856        fn role_mut_for_test(&mut self, role: Role) -> &mut crate::types::RoleConfig;
2857    }
2858
2859    impl RoleConfigTestExt for MissionConfig {
2860        fn role_mut_for_test(&mut self, role: Role) -> &mut crate::types::RoleConfig {
2861            match role {
2862                Role::Orchestrator => &mut self.orchestrator,
2863                Role::Worker => &mut self.worker,
2864                Role::ValidatorScrutiny => &mut self.validator_scrutiny,
2865                Role::ValidatorFunctional => &mut self.validator_functional,
2866            }
2867        }
2868    }
2869}