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