Skip to main content

zeph_config/
loader.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::path::Path;
5
6use crate::error::ConfigError;
7use crate::root::Config;
8
9impl Config {
10    /// Load configuration from a TOML file with env var overrides.
11    ///
12    /// Falls back to sensible defaults when the file does not exist.
13    ///
14    /// # Errors
15    ///
16    /// Returns an error if the file exists but cannot be read or parsed.
17    pub fn load(path: &Path) -> Result<Self, ConfigError> {
18        let mut config = if path.exists() {
19            let content = std::fs::read_to_string(path)?;
20            toml::from_str::<Self>(&content)?
21        } else {
22            Self::default()
23        };
24
25        config.apply_env_overrides();
26        config.normalize_legacy_runtime_defaults();
27        Ok(config)
28    }
29
30    /// Serialize the default configuration to a TOML string.
31    ///
32    /// Produces a pretty-printed TOML representation of [`Config::default()`].
33    /// Useful for bootstrapping a new config file or documenting available options.
34    ///
35    /// The `secrets` field is always excluded from the output because it is
36    /// populated at runtime only and must never be written to disk.
37    ///
38    /// # Errors
39    ///
40    /// Returns an error if serialization fails (unlikely — the default value is
41    /// always structurally valid).
42    ///
43    /// # Examples
44    ///
45    /// ```no_run
46    /// use zeph_config::Config;
47    ///
48    /// let toml = Config::dump_defaults().expect("serialization failed");
49    /// assert!(toml.contains("[agent]"));
50    /// assert!(toml.contains("[memory]"));
51    /// ```
52    pub fn dump_defaults() -> Result<String, crate::error::ConfigError> {
53        let defaults = Self::default();
54        toml::to_string_pretty(&defaults).map_err(|e| {
55            crate::error::ConfigError::Validation(format!("failed to serialize defaults: {e}"))
56        })
57    }
58
59    /// Validate configuration values are within sane bounds.
60    ///
61    /// # Errors
62    ///
63    /// Returns an error if any value is out of range.
64    #[must_use = "validation result must be checked"]
65    pub fn validate(&self) -> Result<(), ConfigError> {
66        self.validate_scalar_bounds()?;
67        self.validate_memory_compression()?;
68        self.validate_memory_probe_and_graph()?;
69        self.validate_mcp_servers()?;
70        self.experiments
71            .validate()
72            .map_err(ConfigError::Validation)?;
73        if self.orchestration.plan_cache.enabled {
74            self.orchestration
75                .plan_cache
76                .validate()
77                .map_err(ConfigError::Validation)?;
78        }
79        self.validate_orchestration()?;
80        self.validate_focus_and_sidequest()?;
81        self.validate_llm_and_skills()?;
82        self.validate_provider_names()?;
83        self.validate_mcp_misc()?;
84        self.validate_scheduler()?;
85        self.acp
86            .validate_auth_clients()
87            .map_err(ConfigError::Validation)?;
88        // Provider pool: empty pool, duplicate names, and multiple `default = true`
89        // entries. Load-bearing guarantee relied on (verbatim) by
90        // `Agent::resolve_pool_entry_provider` (tier_loop.rs) and `arise.rs` — both assume
91        // a genuinely empty pool never occurs for a fully constructed production `Agent`.
92        crate::providers::validate_pool(&self.llm.providers)?;
93        self.llm.validate_stt()?;
94        self.security
95            .trajectory
96            .validate()
97            .map_err(ConfigError::Validation)?;
98        self.gateway.validate().map_err(ConfigError::Validation)?;
99        self.tools
100            .utility
101            .validate()
102            .map_err(ConfigError::Validation)?;
103        if let Some(fidelity) = &self.memory.fidelity {
104            fidelity.validate().map_err(ConfigError::Validation)?;
105        }
106        self.memory
107            .compression
108            .acon
109            .validate()
110            .map_err(ConfigError::Validation)?;
111        if self.memory.shadow_memory.enabled {
112            self.memory
113                .shadow_memory
114                .validate()
115                .map_err(ConfigError::Validation)?;
116        }
117        Ok(())
118    }
119
120    /// Validate scalar bounds for memory, agent, a2a, and gateway fields.
121    fn validate_scalar_bounds(&self) -> Result<(), ConfigError> {
122        if self.memory.history_limit > 10_000 {
123            return Err(ConfigError::Validation(format!(
124                "history_limit must be <= 10000, got {}",
125                self.memory.history_limit
126            )));
127        }
128        if self.memory.context_budget_tokens > 1_000_000 {
129            return Err(ConfigError::Validation(format!(
130                "context_budget_tokens must be <= 1000000, got {}",
131                self.memory.context_budget_tokens
132            )));
133        }
134        if self.agent.max_tool_iterations > 100 {
135            return Err(ConfigError::Validation(format!(
136                "max_tool_iterations must be <= 100, got {}",
137                self.agent.max_tool_iterations
138            )));
139        }
140        if self.a2a.rate_limit == 0 {
141            return Err(ConfigError::Validation("a2a.rate_limit must be > 0".into()));
142        }
143        self.validate_a2a_client_trust()?;
144        if self.gateway.rate_limit == 0 {
145            return Err(ConfigError::Validation(
146                "gateway.rate_limit must be > 0".into(),
147            ));
148        }
149        if self.gateway.max_body_size > 10_485_760 {
150            return Err(ConfigError::Validation(format!(
151                "gateway.max_body_size must be <= 10485760 (10 MiB), got {}",
152                self.gateway.max_body_size
153            )));
154        }
155        if self.memory.token_safety_margin <= 0.0 {
156            return Err(ConfigError::Validation(format!(
157                "token_safety_margin must be > 0.0, got {}",
158                self.memory.token_safety_margin
159            )));
160        }
161        if self.memory.tool_call_cutoff == 0 {
162            return Err(ConfigError::Validation(
163                "tool_call_cutoff must be >= 1".into(),
164            ));
165        }
166        if self.worktree.max_worktrees == Some(0) {
167            return Err(ConfigError::Validation(
168                "worktree.max_worktrees must be > 0 or unset (unlimited); 0 would block all \
169                 worktree creation"
170                    .into(),
171            ));
172        }
173        if self.worktree.disk_quota_mb == Some(0) {
174            return Err(ConfigError::Validation(
175                "worktree.disk_quota_mb must be > 0 or unset (no accounting); 0 would leave \
176                 every non-empty worktree permanently over quota"
177                    .into(),
178            ));
179        }
180        if self.worktree.disk_quota_mb.is_some()
181            && self.worktree.auto_reconcile_secs == 0
182            && !self.worktree.reconcile_on_startup
183        {
184            return Err(ConfigError::Validation(
185                "worktree.disk_quota_mb is set but neither reconcile_on_startup nor \
186                 auto_reconcile_secs is enabled — the quota will only be checked when you run \
187                 `zeph worktree list` manually, never automatically"
188                    .into(),
189            ));
190        }
191        if (1..60).contains(&self.worktree.auto_reconcile_secs) {
192            return Err(ConfigError::Validation(format!(
193                "worktree.auto_reconcile_secs must be 0 (disabled) or >= 60, got {}; a short \
194                 interval runs a full filesystem walk in a tight loop",
195                self.worktree.auto_reconcile_secs
196            )));
197        }
198        Ok(())
199    }
200
201    /// Fail fast if `[a2a_client].card_trust_policy = "require"` is set without the
202    /// `card-signing` feature compiled in anywhere in the binary (S3, #5928).
203    ///
204    /// Without this check, `require` would either silently degrade to no signature
205    /// enforcement or brick all discovery, depending on how the unreachable code path is
206    /// interpreted — both are worse than a loud config-load error. See
207    /// `zeph_a2a::discovery::signature_severity` for the runtime-side half of this
208    /// contract (treats `FeatureDisabled` the same as `Unverifiable`/`Invalid` under
209    /// `require`, which only matters if this validation is ever bypassed).
210    #[cfg_attr(
211        feature = "card-signing",
212        allow(clippy::unused_self, clippy::unnecessary_wraps)
213    )]
214    fn validate_a2a_client_trust(&self) -> Result<(), ConfigError> {
215        #[cfg(not(feature = "card-signing"))]
216        if self.a2a_client.card_trust_policy == crate::channels::CardTrustPolicy::Require {
217            return Err(ConfigError::Validation(
218                "a2a_client.card_trust_policy = require requires the card-signing feature \
219                 to be enabled at build time (see the `a2a` feature in the root Cargo.toml)"
220                    .into(),
221            ));
222        }
223        Ok(())
224    }
225
226    /// Validate memory compression strategy bounds and compaction thresholds.
227    fn validate_memory_compression(&self) -> Result<(), ConfigError> {
228        if let crate::memory::CompressionStrategy::Proactive {
229            threshold_tokens,
230            max_summary_tokens,
231        } = &self.memory.compression.strategy
232        {
233            if *threshold_tokens < 1_000 {
234                return Err(ConfigError::Validation(format!(
235                    "compression.threshold_tokens must be >= 1000, got {threshold_tokens}"
236                )));
237            }
238            if *max_summary_tokens < 128 {
239                return Err(ConfigError::Validation(format!(
240                    "compression.max_summary_tokens must be >= 128, got {max_summary_tokens}"
241                )));
242            }
243        }
244        if !self.memory.soft_compaction_threshold.is_finite()
245            || self.memory.soft_compaction_threshold <= 0.0
246            || self.memory.soft_compaction_threshold >= 1.0
247        {
248            return Err(ConfigError::Validation(format!(
249                "soft_compaction_threshold must be in (0.0, 1.0) exclusive, got {}",
250                self.memory.soft_compaction_threshold
251            )));
252        }
253        if !self.memory.hard_compaction_threshold.is_finite()
254            || self.memory.hard_compaction_threshold <= 0.0
255            || self.memory.hard_compaction_threshold >= 1.0
256        {
257            return Err(ConfigError::Validation(format!(
258                "hard_compaction_threshold must be in (0.0, 1.0) exclusive, got {}",
259                self.memory.hard_compaction_threshold
260            )));
261        }
262        if self.memory.soft_compaction_threshold >= self.memory.hard_compaction_threshold {
263            return Err(ConfigError::Validation(format!(
264                "soft_compaction_threshold ({}) must be less than hard_compaction_threshold ({})",
265                self.memory.soft_compaction_threshold, self.memory.hard_compaction_threshold,
266            )));
267        }
268        Ok(())
269    }
270
271    /// Validate memory probe thresholds and graph temporal decay rate.
272    fn validate_memory_probe_and_graph(&self) -> Result<(), ConfigError> {
273        if self.memory.graph.temporal_decay_rate < 0.0
274            || self.memory.graph.temporal_decay_rate > 10.0
275        {
276            return Err(ConfigError::Validation(format!(
277                "memory.graph.temporal_decay_rate must be in [0.0, 10.0], got {}",
278                self.memory.graph.temporal_decay_rate
279            )));
280        }
281        if self.memory.compression.probe.enabled {
282            let probe = &self.memory.compression.probe;
283            if !probe.threshold.is_finite() || probe.threshold <= 0.0 || probe.threshold > 1.0 {
284                return Err(ConfigError::Validation(format!(
285                    "memory.compression.probe.threshold must be in (0.0, 1.0], got {}",
286                    probe.threshold
287                )));
288            }
289            if !probe.hard_fail_threshold.is_finite()
290                || probe.hard_fail_threshold < 0.0
291                || probe.hard_fail_threshold >= 1.0
292            {
293                return Err(ConfigError::Validation(format!(
294                    "memory.compression.probe.hard_fail_threshold must be in [0.0, 1.0), got {}",
295                    probe.hard_fail_threshold
296                )));
297            }
298            if probe.hard_fail_threshold >= probe.threshold {
299                return Err(ConfigError::Validation(format!(
300                    "memory.compression.probe.hard_fail_threshold ({}) must be less than \
301                     memory.compression.probe.threshold ({})",
302                    probe.hard_fail_threshold, probe.threshold
303                )));
304            }
305            if probe.max_questions < 1 {
306                return Err(ConfigError::Validation(
307                    "memory.compression.probe.max_questions must be >= 1".into(),
308                ));
309            }
310            if probe.timeout_secs < 1 {
311                return Err(ConfigError::Validation(
312                    "memory.compression.probe.timeout_secs must be >= 1".into(),
313                ));
314            }
315        }
316        Ok(())
317    }
318
319    /// Validate MCP server entries for header/oauth exclusivity and vault key uniqueness.
320    fn validate_mcp_servers(&self) -> Result<(), ConfigError> {
321        use std::collections::HashSet;
322        let mut seen_oauth_vault_keys: HashSet<String> = HashSet::new();
323        for s in &self.mcp.servers {
324            // headers and oauth are mutually exclusive
325            if !s.headers.is_empty() && s.oauth.as_ref().is_some_and(|o| o.enabled) {
326                return Err(ConfigError::Validation(format!(
327                    "MCP server '{}': cannot use both 'headers' and 'oauth' simultaneously",
328                    s.id
329                )));
330            }
331            // vault key collision detection
332            if s.oauth.as_ref().is_some_and(|o| o.enabled) {
333                let key = format!("ZEPH_MCP_OAUTH_{}", s.id.to_uppercase().replace('-', "_"));
334                if !seen_oauth_vault_keys.insert(key.clone()) {
335                    return Err(ConfigError::Validation(format!(
336                        "MCP server '{}' has vault key collision ('{key}'): another server \
337                         with the same normalized ID already uses this key",
338                        s.id
339                    )));
340                }
341            }
342        }
343        Ok(())
344    }
345
346    /// Validate orchestration thresholds and cascade settings.
347    fn validate_orchestration(&self) -> Result<(), ConfigError> {
348        if self.orchestration.max_parallel == 0 {
349            return Err(ConfigError::Validation(
350                "orchestration.max_parallel must be > 0".into(),
351            ));
352        }
353        if self.orchestration.max_tasks == 0 {
354            return Err(ConfigError::Validation(
355                "orchestration.max_tasks must be > 0".into(),
356            ));
357        }
358        let ct = self.orchestration.completeness_threshold;
359        if !ct.is_finite() || !(0.0..=1.0).contains(&ct) {
360            return Err(ConfigError::Validation(format!(
361                "orchestration.completeness_threshold must be in [0.0, 1.0], got {ct}"
362            )));
363        }
364        // Ensemble member-list shape is only meaningful once the ensemble is actually wired
365        // into a verification decision (`enabled && verify`) — an unused/staged config with
366        // an invalid `members` list must not block startup (spec 073 FR-014).
367        let ensemble = &self.orchestration.ensemble;
368        if ensemble.enabled && ensemble.verify {
369            let n = ensemble.members.len();
370            if n.is_multiple_of(2) || n < 3 {
371                return Err(ConfigError::Validation(format!(
372                    "orchestration.ensemble.members must be odd and >= 3, got {n}"
373                )));
374            }
375            let unique: std::collections::HashSet<&str> =
376                ensemble.members.iter().map(String::as_str).collect();
377            if unique.len() != ensemble.members.len() {
378                return Err(ConfigError::Validation(
379                    "orchestration.ensemble.members contains a duplicate provider name".into(),
380                ));
381            }
382            // Defense-in-depth (security P3): EnsembleTracker's EMA params are telemetry-only
383            // in PR-1 and never gate a verification/dispatch decision, but an out-of-range value
384            // would still produce a meaningless displayed score and could bias a future phase
385            // that wires EMA into member selection.
386            let alpha = ensemble.ema_alpha;
387            if !alpha.is_finite() || !(0.0..=1.0).contains(&alpha) {
388                return Err(ConfigError::Validation(format!(
389                    "orchestration.ensemble.ema_alpha must be in [0.0, 1.0], got {alpha}"
390                )));
391            }
392            let decay = ensemble.ema_decay;
393            if !decay.is_finite() || !(0.0..=1.0).contains(&decay) {
394                return Err(ConfigError::Validation(format!(
395                    "orchestration.ensemble.ema_decay must be in [0.0, 1.0], got {decay}"
396                )));
397            }
398        }
399        // Cascade chain threshold must not be 1 — that would abort on every single failure.
400        if self.orchestration.cascade_chain_threshold == 1 {
401            return Err(ConfigError::Validation(
402                "orchestration.cascade_chain_threshold=1 aborts on every failure; \
403                 use 0 to disable linear-chain cascade abort instead"
404                    .into(),
405            ));
406        }
407        let cfrat = self.orchestration.cascade_failure_rate_abort_threshold;
408        if !cfrat.is_finite() || !(0.0..=1.0).contains(&cfrat) {
409            return Err(ConfigError::Validation(format!(
410                "orchestration.cascade_failure_rate_abort_threshold must be in [0.0, 1.0], got {cfrat}"
411            )));
412        }
413        if self.orchestration.lineage_ttl_secs == 0 {
414            return Err(ConfigError::Validation(
415                "orchestration.lineage_ttl_secs must be > 0; \
416                 set cascade_chain_threshold=0 to disable lineage tracking instead"
417                    .into(),
418            ));
419        }
420        if self.orchestration.aggregator_timeout_secs == 0 {
421            return Err(ConfigError::Validation(
422                "orchestration.aggregator_timeout_secs must be > 0".into(),
423            ));
424        }
425        if self.orchestration.planner_timeout_secs == 0 {
426            return Err(ConfigError::Validation(
427                "orchestration.planner_timeout_secs must be > 0".into(),
428            ));
429        }
430        if self.orchestration.verifier_timeout_secs == 0 {
431            return Err(ConfigError::Validation(
432                "orchestration.verifier_timeout_secs must be > 0".into(),
433            ));
434        }
435        if self.orchestration.default_idle_timeout_secs == Some(0) {
436            return Err(ConfigError::Validation(
437                "orchestration.default_idle_timeout_secs must be > 0 or unset; 0 would mean \
438                 an instant idle timeout"
439                    .into(),
440            ));
441        }
442        self.validate_command_handoff()?;
443        Ok(())
444    }
445
446    /// Validate `[orchestration.command]` (spec-080, GitHub #6363): `max_handoffs` bounds
447    /// and, when the feature is enabled, its cross-crate prerequisites.
448    fn validate_command_handoff(&self) -> Result<(), ConfigError> {
449        if self.orchestration.command.max_handoffs == 0 {
450            return Err(ConfigError::Validation(
451                "orchestration.command.max_handoffs must be > 0; set \
452                 orchestration.command.enabled = false to disable Command handoff instead"
453                    .into(),
454            ));
455        }
456        // SEC-2: an operator-set max_handoffs with no upper sanity bound defeats the
457        // livelock counter's purpose as a footgun guard (topology + forward-only still
458        // terminate a graph regardless, so this is not itself an exploitable hole — see
459        // the security audit handoff — but an unbounded value is never a deliberate,
460        // reasonable config).
461        if self.orchestration.command.max_handoffs > 10_000 {
462            return Err(ConfigError::Validation(format!(
463                "orchestration.command.max_handoffs must be <= 10000, got {}",
464                self.orchestration.command.max_handoffs
465            )));
466        }
467        // Deviation #4 / SEC-1: reject at config-validation time rather than discovering
468        // the misconfiguration per-task at runtime write-attempt time (spec-080 §7 edge
469        // case table; critic P2 confirmed the runtime-only check wastes real LLM-call
470        // work on non-handoff tasks in the same misconfigured graph before the first
471        // handoff attempt fails). Command handoff's produce-side seam
472        // (`determine_task_outcome`, zeph-core) has nowhere to persist `update` without
473        // the store, and its FR-B-003 sanitizer scan becomes a silent no-op without
474        // content isolation (`ContentSanitizer::sanitize` early-returns with empty
475        // `injection_flags` when `enabled = false`, and `flag_injection_patterns = false`
476        // has the same effect) — both are genuine security/correctness prerequisites of
477        // this feature, not merely convenient defaults.
478        if !self.orchestration.command.enabled {
479            return Ok(());
480        }
481        if !self.memory.store.enabled {
482            return Err(ConfigError::Validation(
483                "orchestration.command.enabled = true requires memory.store.enabled = \
484                 true — Command handoff has nowhere to persist its `update` payload \
485                 without the cross-thread store"
486                    .into(),
487            ));
488        }
489        if !self.security.content_isolation.enabled {
490            return Err(ConfigError::Validation(
491                "orchestration.command.enabled = true requires \
492                 security.content_isolation.enabled = true — the FR-B-003 sanitizer \
493                 scan that gates a Command handoff before it drives routing or a \
494                 store write becomes a silent no-op otherwise"
495                    .into(),
496            ));
497        }
498        if !self.security.content_isolation.flag_injection_patterns {
499            return Err(ConfigError::Validation(
500                "orchestration.command.enabled = true requires \
501                 security.content_isolation.flag_injection_patterns = true — the \
502                 FR-B-003 sanitizer scan never flags anything otherwise, silently \
503                 bypassing the reject gate"
504                    .into(),
505            ));
506        }
507        Ok(())
508    }
509
510    /// Validate focus and sidequest interval and ratio constraints.
511    fn validate_focus_and_sidequest(&self) -> Result<(), ConfigError> {
512        if self.agent.focus.compression_interval == 0 {
513            return Err(ConfigError::Validation(
514                "agent.focus.compression_interval must be >= 1".into(),
515            ));
516        }
517        if self.agent.focus.min_messages_per_focus == 0 {
518            return Err(ConfigError::Validation(
519                "agent.focus.min_messages_per_focus must be >= 1".into(),
520            ));
521        }
522        if self.agent.focus.auto_consolidate_min_window == 0 {
523            return Err(ConfigError::Validation(
524                "agent.focus.auto_consolidate_min_window must be >= 1 \
525                 (set focus.enabled = false to disable auto-consolidation)"
526                    .into(),
527            ));
528        }
529        if self.memory.sidequest.interval_turns == 0 {
530            return Err(ConfigError::Validation(
531                "memory.sidequest.interval_turns must be >= 1".into(),
532            ));
533        }
534        if !self.memory.sidequest.max_eviction_ratio.is_finite()
535            || self.memory.sidequest.max_eviction_ratio <= 0.0
536            || self.memory.sidequest.max_eviction_ratio > 1.0
537        {
538            return Err(ConfigError::Validation(format!(
539                "memory.sidequest.max_eviction_ratio must be in (0.0, 1.0], got {}",
540                self.memory.sidequest.max_eviction_ratio
541            )));
542        }
543        Ok(())
544    }
545
546    /// Validate LLM semantic cache threshold and skill evaluation weight sum.
547    fn validate_llm_and_skills(&self) -> Result<(), ConfigError> {
548        let sct = self.llm.semantic_cache_threshold;
549        if !(sct.is_finite() && (0.0..=1.0).contains(&sct)) {
550            return Err(ConfigError::Validation(format!(
551                "llm.semantic_cache_threshold must be in [0.0, 1.0], got {sct} \
552                 (override via ZEPH_LLM_SEMANTIC_CACHE_THRESHOLD env var)"
553            )));
554        }
555        // MemCoT distill provider fast-tier soft-warn (#3574).
556        if self.memory.memcot.enabled && !self.memory.memcot.distill_provider.is_empty() {
557            self.llm.warn_non_fast_tier_provider(
558                &self.memory.memcot.distill_provider,
559                "memory.memcot.distill_provider",
560                &self.memory.memcot.fast_tier_models,
561            );
562        }
563        self.skills
564            .learning
565            .validate()
566            .map_err(ConfigError::Validation)?;
567        // Skill evaluation weight-sum validation (#3319).
568        if self.skills.evaluation.enabled {
569            let weight_sum = self.skills.evaluation.weight_correctness
570                + self.skills.evaluation.weight_reusability
571                + self.skills.evaluation.weight_specificity;
572            if (weight_sum - 1.0_f32).abs() > 1e-3 {
573                return Err(ConfigError::Validation(format!(
574                    "skills.evaluation weights must sum to 1.0 (got {weight_sum:.4})"
575                )));
576            }
577        }
578        Ok(())
579    }
580
581    /// Validate miscellaneous MCP output schema hint size.
582    fn validate_mcp_misc(&self) -> Result<(), ConfigError> {
583        if self.mcp.output_schema_hint_bytes < 64 {
584            return Err(ConfigError::Validation(format!(
585                "mcp.output_schema_hint_bytes must be >= 64, got {}; \
586                 use forward_output_schema = false to disable forwarding",
587                self.mcp.output_schema_hint_bytes
588            )));
589        }
590        Ok(())
591    }
592
593    /// Validate that each `[[scheduler.tasks]]` entry has exactly one of `cron` or `run_at` set.
594    fn validate_scheduler(&self) -> Result<(), ConfigError> {
595        for task in &self.scheduler.tasks {
596            match (&task.cron, &task.run_at) {
597                (Some(_), Some(_)) => {
598                    return Err(ConfigError::Validation(format!(
599                        "scheduler task {:?}: only one of `cron` or `run_at` may be set, not both",
600                        task.name
601                    )));
602                }
603                (None, None) => {
604                    return Err(ConfigError::Validation(format!(
605                        "scheduler task {:?}: either `cron` or `run_at` must be set",
606                        task.name
607                    )));
608                }
609                _ => {}
610            }
611        }
612        Ok(())
613    }
614
615    fn validate_provider_names(&self) -> Result<(), ConfigError> {
616        let known = self.known_provider_names();
617        self.validate_named_provider_refs(&known)?;
618        self.validate_optional_provider_refs(&known)?;
619        Ok(())
620    }
621
622    /// Build the set of declared provider names from all `[[llm.providers]]` entries.
623    fn known_provider_names(&self) -> std::collections::HashSet<String> {
624        self.llm
625            .providers
626            .iter()
627            .map(super::providers::ProviderEntry::effective_name)
628            .collect()
629    }
630
631    /// Validate every required `*_provider` field references a declared provider.
632    ///
633    /// The field table lists all subsystem provider references. Each non-empty value must
634    /// match a name in `known`.
635    fn validate_named_provider_refs(
636        &self,
637        known: &std::collections::HashSet<String>,
638    ) -> Result<(), ConfigError> {
639        self.validate_core_provider_refs(known)?;
640        self.validate_tool_and_quality_provider_refs(known)
641    }
642
643    fn validate_core_provider_refs(
644        &self,
645        known: &std::collections::HashSet<String>,
646    ) -> Result<(), ConfigError> {
647        let fields: &[(&str, &crate::providers::ProviderName)] = &[
648            (
649                "memory.tiers.scene_provider",
650                &self.memory.tiers.scene_provider,
651            ),
652            (
653                "memory.compression.compress_provider",
654                &self.memory.compression.compress_provider,
655            ),
656            (
657                "memory.consolidation.consolidation_provider",
658                &self.memory.consolidation.consolidation_provider,
659            ),
660            (
661                "memory.admission.admission_provider",
662                &self.memory.admission.admission_provider,
663            ),
664            (
665                "memory.admission.goal_utility_provider",
666                &self.memory.admission.goal_utility_provider,
667            ),
668            (
669                "memory.store_routing.routing_classifier_provider",
670                &self.memory.store_routing.routing_classifier_provider,
671            ),
672            (
673                "skills.learning.feedback_provider",
674                &self.skills.learning.feedback_provider,
675            ),
676            (
677                "skills.learning.arise_trace_provider",
678                &self.skills.learning.arise_trace_provider,
679            ),
680            (
681                "skills.learning.stem_provider",
682                &self.skills.learning.stem_provider,
683            ),
684            (
685                "skills.learning.erl_extract_provider",
686                &self.skills.learning.erl_extract_provider,
687            ),
688            (
689                "mcp.pruning.pruning_provider",
690                &self.mcp.pruning.pruning_provider,
691            ),
692            (
693                "mcp.tool_discovery.embedding_provider",
694                &self.mcp.tool_discovery.embedding_provider,
695            ),
696            (
697                "security.response_verification.verifier_provider",
698                &self.security.response_verification.verifier_provider,
699            ),
700            (
701                "orchestration.planner_provider",
702                &self.orchestration.planner_provider,
703            ),
704            (
705                "orchestration.verify_provider",
706                &self.orchestration.verify_provider,
707            ),
708            (
709                "orchestration.tool_provider",
710                &self.orchestration.tool_provider,
711            ),
712            (
713                "skills.evaluation.provider",
714                &self.skills.evaluation.provider,
715            ),
716            (
717                "skills.proactive_exploration.provider",
718                &self.skills.proactive_exploration.provider,
719            ),
720            (
721                "memory.compression_spectrum.promotion_provider",
722                &self.memory.compression_spectrum.promotion_provider,
723            ),
724        ];
725        Self::check_provider_refs(fields, known)
726    }
727
728    fn validate_tool_and_quality_provider_refs(
729        &self,
730        known: &std::collections::HashSet<String>,
731    ) -> Result<(), ConfigError> {
732        let fields: &[(&str, &crate::providers::ProviderName)] = &[
733            (
734                "security.shadow_sentinel.probe_provider",
735                &self.security.shadow_sentinel.probe_provider,
736            ),
737            (
738                "tools.retry.parameter_reformat_provider",
739                &self.tools.retry.parameter_reformat_provider,
740            ),
741            (
742                "tools.policy.policy_provider",
743                &self.tools.policy.policy_provider,
744            ),
745            (
746                "tools.adversarial_policy.policy_provider",
747                &self.tools.adversarial_policy.policy_provider,
748            ),
749            (
750                "tools.speculative.pattern.rerank_provider",
751                &self.tools.speculative.pattern.rerank_provider,
752            ),
753            (
754                "tools.compression.evolution_provider",
755                &self.tools.compression.evolution_provider,
756            ),
757            ("quality.proposer_provider", &self.quality.proposer_provider),
758            ("quality.checker_provider", &self.quality.checker_provider),
759        ];
760        Self::check_provider_refs(fields, known)
761    }
762
763    fn check_provider_refs(
764        fields: &[(&str, &crate::providers::ProviderName)],
765        known: &std::collections::HashSet<String>,
766    ) -> Result<(), ConfigError> {
767        for (field, name) in fields {
768            if !name.is_empty() && !known.contains(name.as_str()) {
769                return Err(ConfigError::Validation(format!(
770                    "{field} = {:?} does not match any [[llm.providers]] entry",
771                    name.as_str()
772                )));
773            }
774        }
775        Ok(())
776    }
777
778    /// Validate optional provider references in complexity routing and router bandit config.
779    fn validate_optional_provider_refs(
780        &self,
781        known: &std::collections::HashSet<String>,
782    ) -> Result<(), ConfigError> {
783        if let Some(triage) = self
784            .llm
785            .complexity_routing
786            .as_ref()
787            .and_then(|cr| cr.triage_provider.as_ref())
788            .filter(|t| !t.is_empty() && !known.contains(t.as_str()))
789        {
790            return Err(ConfigError::Validation(format!(
791                "llm.complexity_routing.triage_provider = {:?} does not match any \
792                 [[llm.providers]] entry",
793                triage.as_str()
794            )));
795        }
796
797        if let Some(embed) = self
798            .llm
799            .router
800            .as_ref()
801            .and_then(|r| r.bandit.as_ref())
802            .map(|b| &b.embedding_provider)
803            .filter(|p| !p.is_empty() && !known.contains(p.as_str()))
804        {
805            return Err(ConfigError::Validation(format!(
806                "llm.router.bandit.embedding_provider = {:?} does not match any \
807                 [[llm.providers]] entry",
808                embed.as_str()
809            )));
810        }
811
812        Ok(())
813    }
814
815    fn normalize_legacy_runtime_defaults(&mut self) {
816        use crate::defaults::{
817            default_debug_dir, default_log_file_path, default_skills_dir, default_sqlite_path,
818            is_legacy_default_debug_dir, is_legacy_default_log_file, is_legacy_default_skills_path,
819            is_legacy_default_sqlite_path,
820        };
821
822        if is_legacy_default_sqlite_path(&self.memory.sqlite_path) {
823            self.memory.sqlite_path = default_sqlite_path();
824        }
825
826        for skill_path in &mut self.skills.paths {
827            if is_legacy_default_skills_path(skill_path) {
828                *skill_path = default_skills_dir();
829            }
830        }
831
832        if is_legacy_default_debug_dir(&self.debug.output_dir) {
833            self.debug.output_dir = default_debug_dir();
834        }
835
836        if is_legacy_default_log_file(&self.logging.file) {
837            self.logging.file = default_log_file_path();
838        }
839    }
840}
841
842#[cfg(test)]
843mod tests {
844    use super::*;
845
846    fn config_with_sct(threshold: f32) -> Config {
847        let mut cfg = Config::default();
848        cfg.llm.semantic_cache_threshold = threshold;
849        cfg
850    }
851
852    #[test]
853    fn semantic_cache_threshold_valid_zero() {
854        assert!(config_with_sct(0.0).validate().is_ok());
855    }
856
857    #[test]
858    fn semantic_cache_threshold_valid_mid() {
859        assert!(config_with_sct(0.5).validate().is_ok());
860    }
861
862    #[test]
863    fn semantic_cache_threshold_valid_one() {
864        assert!(config_with_sct(1.0).validate().is_ok());
865    }
866
867    #[test]
868    fn semantic_cache_threshold_invalid_negative() {
869        let err = config_with_sct(-0.1).validate().unwrap_err();
870        assert!(
871            err.to_string().contains("semantic_cache_threshold"),
872            "unexpected error: {err}"
873        );
874    }
875
876    #[test]
877    fn semantic_cache_threshold_invalid_above_one() {
878        let err = config_with_sct(1.1).validate().unwrap_err();
879        assert!(
880            err.to_string().contains("semantic_cache_threshold"),
881            "unexpected error: {err}"
882        );
883    }
884
885    #[test]
886    fn semantic_cache_threshold_invalid_nan() {
887        let err = config_with_sct(f32::NAN).validate().unwrap_err();
888        assert!(
889            err.to_string().contains("semantic_cache_threshold"),
890            "unexpected error: {err}"
891        );
892    }
893
894    #[cfg(not(feature = "card-signing"))]
895    #[test]
896    fn card_trust_policy_require_without_feature_fails_validation() {
897        let mut cfg = Config::default();
898        cfg.a2a_client.card_trust_policy = crate::channels::CardTrustPolicy::Require;
899        let err = cfg.validate().unwrap_err();
900        assert!(
901            err.to_string().contains("card_trust_policy"),
902            "unexpected error: {err}"
903        );
904    }
905
906    #[test]
907    fn card_trust_policy_ignore_and_prefer_always_pass_validation() {
908        let mut cfg = Config::default();
909        cfg.a2a_client.card_trust_policy = crate::channels::CardTrustPolicy::Ignore;
910        assert!(cfg.validate().is_ok());
911        cfg.a2a_client.card_trust_policy = crate::channels::CardTrustPolicy::Prefer;
912        assert!(cfg.validate().is_ok());
913    }
914
915    #[cfg(feature = "card-signing")]
916    #[test]
917    fn card_trust_policy_require_with_feature_passes_validation() {
918        let mut cfg = Config::default();
919        cfg.a2a_client.card_trust_policy = crate::channels::CardTrustPolicy::Require;
920        assert!(cfg.validate().is_ok());
921    }
922
923    #[test]
924    fn semantic_cache_threshold_invalid_infinity() {
925        let err = config_with_sct(f32::INFINITY).validate().unwrap_err();
926        assert!(
927            err.to_string().contains("semantic_cache_threshold"),
928            "unexpected error: {err}"
929        );
930    }
931
932    #[test]
933    fn semantic_cache_threshold_invalid_neg_infinity() {
934        let err = config_with_sct(f32::NEG_INFINITY).validate().unwrap_err();
935        assert!(
936            err.to_string().contains("semantic_cache_threshold"),
937            "unexpected error: {err}"
938        );
939    }
940
941    fn probe_config(enabled: bool, threshold: f32, hard_fail_threshold: f32) -> Config {
942        let mut cfg = Config::default();
943        cfg.memory.compression.probe.enabled = enabled;
944        cfg.memory.compression.probe.threshold = threshold;
945        cfg.memory.compression.probe.hard_fail_threshold = hard_fail_threshold;
946        cfg
947    }
948
949    #[test]
950    fn probe_disabled_skips_validation() {
951        // Invalid thresholds when probe is disabled must not cause errors.
952        let cfg = probe_config(false, 0.0, 1.0);
953        assert!(cfg.validate().is_ok());
954    }
955
956    #[test]
957    fn probe_valid_thresholds() {
958        let cfg = probe_config(true, 0.6, 0.35);
959        assert!(cfg.validate().is_ok());
960    }
961
962    #[test]
963    fn probe_threshold_zero_invalid() {
964        let err = probe_config(true, 0.0, 0.0).validate().unwrap_err();
965        assert!(
966            err.to_string().contains("probe.threshold"),
967            "unexpected error: {err}"
968        );
969    }
970
971    #[test]
972    fn probe_hard_fail_threshold_above_one_invalid() {
973        let err = probe_config(true, 0.6, 1.0).validate().unwrap_err();
974        assert!(
975            err.to_string().contains("probe.hard_fail_threshold"),
976            "unexpected error: {err}"
977        );
978    }
979
980    #[test]
981    fn probe_hard_fail_gte_threshold_invalid() {
982        let err = probe_config(true, 0.3, 0.9).validate().unwrap_err();
983        assert!(
984            err.to_string().contains("probe.hard_fail_threshold"),
985            "unexpected error: {err}"
986        );
987    }
988
989    fn config_with_completeness_threshold(ct: f32) -> Config {
990        let mut cfg = Config::default();
991        cfg.orchestration.completeness_threshold = ct;
992        cfg
993    }
994
995    #[test]
996    fn completeness_threshold_valid_zero() {
997        assert!(config_with_completeness_threshold(0.0).validate().is_ok());
998    }
999
1000    #[test]
1001    fn completeness_threshold_valid_default() {
1002        assert!(config_with_completeness_threshold(0.7).validate().is_ok());
1003    }
1004
1005    #[test]
1006    fn completeness_threshold_valid_one() {
1007        assert!(config_with_completeness_threshold(1.0).validate().is_ok());
1008    }
1009
1010    #[test]
1011    fn completeness_threshold_invalid_negative() {
1012        let err = config_with_completeness_threshold(-0.1)
1013            .validate()
1014            .unwrap_err();
1015        assert!(
1016            err.to_string().contains("completeness_threshold"),
1017            "unexpected error: {err}"
1018        );
1019    }
1020
1021    #[test]
1022    fn completeness_threshold_invalid_above_one() {
1023        let err = config_with_completeness_threshold(1.1)
1024            .validate()
1025            .unwrap_err();
1026        assert!(
1027            err.to_string().contains("completeness_threshold"),
1028            "unexpected error: {err}"
1029        );
1030    }
1031
1032    #[test]
1033    fn completeness_threshold_invalid_nan() {
1034        let err = config_with_completeness_threshold(f32::NAN)
1035            .validate()
1036            .unwrap_err();
1037        assert!(
1038            err.to_string().contains("completeness_threshold"),
1039            "unexpected error: {err}"
1040        );
1041    }
1042
1043    #[test]
1044    fn completeness_threshold_invalid_infinity() {
1045        let err = config_with_completeness_threshold(f32::INFINITY)
1046            .validate()
1047            .unwrap_err();
1048        assert!(
1049            err.to_string().contains("completeness_threshold"),
1050            "unexpected error: {err}"
1051        );
1052    }
1053
1054    fn config_with_provider(name: &str) -> Config {
1055        let mut cfg = Config::default();
1056        cfg.llm.providers.push(crate::providers::ProviderEntry {
1057            provider_type: crate::providers::ProviderKind::Ollama,
1058            name: Some(name.into()),
1059            ..Default::default()
1060        });
1061        cfg
1062    }
1063
1064    #[test]
1065    fn validate_provider_names_all_empty_ok() {
1066        let cfg = Config::default();
1067        assert!(cfg.validate_provider_names().is_ok());
1068    }
1069
1070    #[test]
1071    fn validate_provider_names_matching_provider_ok() {
1072        let mut cfg = config_with_provider("fast");
1073        cfg.memory.admission.admission_provider = crate::providers::ProviderName::new("fast");
1074        assert!(cfg.validate_provider_names().is_ok());
1075    }
1076
1077    #[test]
1078    fn validate_provider_names_unknown_provider_err() {
1079        let mut cfg = config_with_provider("fast");
1080        cfg.memory.admission.admission_provider =
1081            crate::providers::ProviderName::new("nonexistent");
1082        let err = cfg.validate_provider_names().unwrap_err();
1083        let msg = err.to_string();
1084        assert!(
1085            msg.contains("admission_provider") && msg.contains("nonexistent"),
1086            "unexpected error: {msg}"
1087        );
1088    }
1089
1090    #[test]
1091    fn validate_provider_names_triage_provider_none_ok() {
1092        let mut cfg = config_with_provider("fast");
1093        cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
1094            triage_provider: None,
1095            ..Default::default()
1096        });
1097        assert!(cfg.validate_provider_names().is_ok());
1098    }
1099
1100    #[test]
1101    fn validate_provider_names_triage_provider_matching_ok() {
1102        let mut cfg = config_with_provider("fast");
1103        cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
1104            triage_provider: Some(crate::providers::ProviderName::new("fast")),
1105            ..Default::default()
1106        });
1107        assert!(cfg.validate_provider_names().is_ok());
1108    }
1109
1110    #[test]
1111    fn validate_provider_names_triage_provider_unknown_err() {
1112        let mut cfg = config_with_provider("fast");
1113        cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
1114            triage_provider: Some(crate::providers::ProviderName::new("ghost")),
1115            ..Default::default()
1116        });
1117        let err = cfg.validate_provider_names().unwrap_err();
1118        let msg = err.to_string();
1119        assert!(
1120            msg.contains("triage_provider") && msg.contains("ghost"),
1121            "unexpected error: {msg}"
1122        );
1123    }
1124
1125    // Regression test for issue #2599: TOML float values must deserialise without error
1126    // across all config sections that contain f32/f64 fields.
1127    #[test]
1128    fn toml_float_fields_deserialise_correctly() {
1129        let toml = r"
1130[llm.router.reputation]
1131enabled = true
1132decay_factor = 0.95
1133weight = 0.3
1134
1135[llm.router.bandit]
1136enabled = false
1137cost_weight = 0.3
1138alpha = 1.0
1139decay_factor = 0.99
1140
1141[skills]
1142disambiguation_threshold = 0.25
1143cosine_weight = 0.7
1144";
1145        // Wrap in a full Config to exercise the nested paths.
1146        let wrapped = format!(
1147            "{}\n{}",
1148            toml,
1149            r"[memory.semantic]
1150mmr_lambda = 0.7
1151"
1152        );
1153        // We only need the sub-structs to round-trip; build minimal wrappers.
1154        let router: crate::providers::RouterConfig = toml::from_str(
1155            r"[reputation]
1156enabled = true
1157decay_factor = 0.95
1158weight = 0.3
1159",
1160        )
1161        .expect("RouterConfig with float fields must deserialise");
1162        assert!((router.reputation.unwrap().decay_factor - 0.95).abs() < f64::EPSILON);
1163
1164        let bandit: crate::providers::BanditConfig =
1165            toml::from_str("cost_weight = 0.3\nalpha = 1.0\n")
1166                .expect("BanditConfig with float fields must deserialise");
1167        assert!((bandit.cost_weight - 0.3_f32).abs() < f32::EPSILON);
1168
1169        let semantic: crate::memory::SemanticConfig = toml::from_str("mmr_lambda = 0.7\n")
1170            .expect("SemanticConfig with float fields must deserialise");
1171        assert!((semantic.mmr_lambda - 0.7_f32).abs() < f32::EPSILON);
1172
1173        let skills: crate::features::SkillsConfig =
1174            toml::from_str("disambiguation_threshold = 0.25\n")
1175                .expect("SkillsConfig with float fields must deserialise");
1176        assert!((skills.disambiguation_threshold - 0.25_f32).abs() < f32::EPSILON);
1177
1178        let _ = wrapped; // silence unused-variable lint
1179    }
1180
1181    #[test]
1182    fn validate_max_parallel_zero_rejected() {
1183        let mut cfg = Config::default();
1184        cfg.orchestration.max_parallel = 0;
1185        let err = cfg.validate().unwrap_err().to_string();
1186        assert!(
1187            err.contains("max_parallel"),
1188            "expected max_parallel in error, got: {err}"
1189        );
1190    }
1191
1192    #[test]
1193    fn validate_max_parallel_one_accepted() {
1194        let mut cfg = Config::default();
1195        cfg.orchestration.max_parallel = 1;
1196        assert!(cfg.validate().is_ok());
1197    }
1198
1199    #[test]
1200    fn validate_max_tasks_zero_rejected() {
1201        let mut cfg = Config::default();
1202        cfg.orchestration.max_tasks = 0;
1203        let err = cfg.validate().unwrap_err().to_string();
1204        assert!(
1205            err.contains("max_tasks"),
1206            "expected max_tasks in error, got: {err}"
1207        );
1208    }
1209
1210    #[test]
1211    fn validate_max_tasks_one_accepted() {
1212        let mut cfg = Config::default();
1213        cfg.orchestration.max_tasks = 1;
1214        assert!(cfg.validate().is_ok());
1215    }
1216
1217    #[test]
1218    fn validate_aggregator_timeout_zero_rejected() {
1219        let mut cfg = Config::default();
1220        cfg.orchestration.aggregator_timeout_secs = 0;
1221        let err = cfg.validate().unwrap_err().to_string();
1222        assert!(
1223            err.contains("aggregator_timeout_secs"),
1224            "expected aggregator_timeout_secs in error, got: {err}"
1225        );
1226    }
1227
1228    #[test]
1229    fn validate_planner_timeout_zero_rejected() {
1230        let mut cfg = Config::default();
1231        cfg.orchestration.planner_timeout_secs = 0;
1232        let err = cfg.validate().unwrap_err().to_string();
1233        assert!(
1234            err.contains("planner_timeout_secs"),
1235            "expected planner_timeout_secs in error, got: {err}"
1236        );
1237    }
1238
1239    #[test]
1240    fn validate_verifier_timeout_zero_rejected() {
1241        let mut cfg = Config::default();
1242        cfg.orchestration.verifier_timeout_secs = 0;
1243        let err = cfg.validate().unwrap_err().to_string();
1244        assert!(
1245            err.contains("verifier_timeout_secs"),
1246            "expected verifier_timeout_secs in error, got: {err}"
1247        );
1248    }
1249
1250    #[test]
1251    fn validate_default_idle_timeout_zero_rejected() {
1252        let mut cfg = Config::default();
1253        cfg.orchestration.default_idle_timeout_secs = Some(0);
1254        let err = cfg.validate().unwrap_err().to_string();
1255        assert!(
1256            err.contains("default_idle_timeout_secs"),
1257            "expected default_idle_timeout_secs in error, got: {err}"
1258        );
1259    }
1260
1261    #[test]
1262    fn validate_default_idle_timeout_none_accepted() {
1263        let mut cfg = Config::default();
1264        cfg.orchestration.default_idle_timeout_secs = None;
1265        assert!(cfg.validate().is_ok());
1266    }
1267
1268    #[test]
1269    fn validate_default_idle_timeout_positive_accepted() {
1270        let mut cfg = Config::default();
1271        cfg.orchestration.default_idle_timeout_secs = Some(60);
1272        assert!(cfg.validate().is_ok());
1273    }
1274
1275    #[test]
1276    fn validate_command_max_handoffs_zero_rejected() {
1277        let mut cfg = Config::default();
1278        cfg.orchestration.command.max_handoffs = 0;
1279        let err = cfg.validate().unwrap_err().to_string();
1280        assert!(
1281            err.contains("max_handoffs"),
1282            "expected max_handoffs in error, got: {err}"
1283        );
1284    }
1285
1286    #[test]
1287    fn validate_command_max_handoffs_default_accepted() {
1288        let cfg = Config::default();
1289        assert_eq!(cfg.orchestration.command.max_handoffs, 16);
1290        assert!(!cfg.orchestration.command.enabled);
1291        assert!(cfg.validate().is_ok());
1292    }
1293
1294    // --- SEC-2: max_handoffs upper sanity bound ---
1295
1296    #[test]
1297    fn validate_command_max_handoffs_over_10000_rejected() {
1298        let mut cfg = Config::default();
1299        cfg.orchestration.command.max_handoffs = 10_001;
1300        let err = cfg.validate().unwrap_err().to_string();
1301        assert!(
1302            err.contains("max_handoffs") && err.contains("<= 10000"),
1303            "expected max_handoffs upper-bound error, got: {err}"
1304        );
1305    }
1306
1307    #[test]
1308    fn validate_command_max_handoffs_exactly_10000_accepted() {
1309        let mut cfg = Config::default();
1310        cfg.orchestration.command.max_handoffs = 10_000;
1311        assert!(cfg.validate().is_ok());
1312    }
1313
1314    // --- Deviation #4 / SEC-1: command.enabled requires store.enabled + content_isolation ---
1315
1316    fn config_with_command_enabled() -> Config {
1317        let mut cfg = Config::default();
1318        cfg.orchestration.command.enabled = true;
1319        cfg.memory.store.enabled = true;
1320        cfg.security.content_isolation.enabled = true;
1321        cfg.security.content_isolation.flag_injection_patterns = true;
1322        cfg
1323    }
1324
1325    #[test]
1326    fn validate_command_enabled_with_all_prerequisites_accepted() {
1327        assert!(config_with_command_enabled().validate().is_ok());
1328    }
1329
1330    #[test]
1331    fn validate_command_enabled_without_store_enabled_rejected() {
1332        let mut cfg = config_with_command_enabled();
1333        cfg.memory.store.enabled = false;
1334        let err = cfg.validate().unwrap_err().to_string();
1335        assert!(
1336            err.contains("memory.store.enabled"),
1337            "expected store-prerequisite error, got: {err}"
1338        );
1339    }
1340
1341    #[test]
1342    fn validate_command_enabled_without_content_isolation_enabled_rejected() {
1343        let mut cfg = config_with_command_enabled();
1344        cfg.security.content_isolation.enabled = false;
1345        let err = cfg.validate().unwrap_err().to_string();
1346        assert!(
1347            err.contains("content_isolation.enabled"),
1348            "expected content_isolation-prerequisite error, got: {err}"
1349        );
1350    }
1351
1352    #[test]
1353    fn validate_command_enabled_without_flag_injection_patterns_rejected() {
1354        let mut cfg = config_with_command_enabled();
1355        cfg.security.content_isolation.flag_injection_patterns = false;
1356        let err = cfg.validate().unwrap_err().to_string();
1357        assert!(
1358            err.contains("flag_injection_patterns"),
1359            "expected flag_injection_patterns-prerequisite error, got: {err}"
1360        );
1361    }
1362
1363    #[test]
1364    fn validate_command_disabled_ignores_store_and_content_isolation_state() {
1365        // command.enabled = false (default): misconfigured store/content_isolation must
1366        // not block startup — the prerequisites only apply once the feature is opted in.
1367        let mut cfg = Config::default();
1368        cfg.memory.store.enabled = false;
1369        cfg.security.content_isolation.enabled = false;
1370        cfg.security.content_isolation.flag_injection_patterns = false;
1371        assert!(cfg.validate().is_ok());
1372    }
1373
1374    #[test]
1375    fn focus_auto_consolidate_min_window_zero_rejected() {
1376        let mut cfg = Config::default();
1377        cfg.agent.focus.auto_consolidate_min_window = 0;
1378        let err = cfg.validate().unwrap_err().to_string();
1379        assert!(
1380            err.contains("auto_consolidate_min_window"),
1381            "expected auto_consolidate_min_window in error, got: {err}"
1382        );
1383    }
1384
1385    #[test]
1386    fn focus_auto_consolidate_min_window_one_accepted() {
1387        let mut cfg = Config::default();
1388        cfg.agent.focus.auto_consolidate_min_window = 1;
1389        assert!(cfg.validate().is_ok());
1390    }
1391
1392    fn task_with(cron: Option<&str>, run_at: Option<&str>) -> crate::features::ScheduledTaskConfig {
1393        crate::features::ScheduledTaskConfig {
1394            name: "test-task".into(),
1395            cron: cron.map(Into::into),
1396            run_at: run_at.map(Into::into),
1397            kind: crate::features::ScheduledTaskKind::HealthCheck,
1398            config: serde_json::Value::Null,
1399        }
1400    }
1401
1402    #[test]
1403    fn scheduler_task_valid_cron() {
1404        let mut cfg = Config::default();
1405        cfg.scheduler.tasks.push(task_with(Some("0 9 * * *"), None));
1406        assert!(cfg.validate().is_ok());
1407    }
1408
1409    #[test]
1410    fn scheduler_task_valid_run_at() {
1411        let mut cfg = Config::default();
1412        cfg.scheduler
1413            .tasks
1414            .push(task_with(None, Some("2025-01-01T09:00:00Z")));
1415        assert!(cfg.validate().is_ok());
1416    }
1417
1418    #[test]
1419    fn scheduler_task_neither_cron_nor_run_at_rejected() {
1420        let mut cfg = Config::default();
1421        cfg.scheduler.tasks.push(task_with(None, None));
1422        let err = cfg.validate().unwrap_err().to_string();
1423        assert!(
1424            err.contains("either `cron` or `run_at` must be set"),
1425            "unexpected error: {err}"
1426        );
1427    }
1428
1429    #[test]
1430    fn scheduler_task_both_cron_and_run_at_rejected() {
1431        let mut cfg = Config::default();
1432        cfg.scheduler
1433            .tasks
1434            .push(task_with(Some("0 9 * * *"), Some("2025-01-01T09:00:00Z")));
1435        let err = cfg.validate().unwrap_err().to_string();
1436        assert!(
1437            err.contains("only one of `cron` or `run_at` may be set"),
1438            "unexpected error: {err}"
1439        );
1440    }
1441
1442    // ── #5932: 7 previously-dead validate() functions now wired into Config::validate() ──────
1443
1444    #[test]
1445    fn validate_rejects_empty_provider_pool() {
1446        // This is the most severe gap from #5932: `validate_pool` was documented (verbatim)
1447        // as a load-bearing guarantee by tier_loop.rs/arise.rs but was never actually wired
1448        // in. `Config::default()` itself now seeds one provider (critic S1 follow-up, so
1449        // `--dump-config-defaults` output stays self-consistent) — clear it explicitly to
1450        // exercise the empty-pool branch.
1451        let mut cfg = Config::default();
1452        cfg.llm.providers.clear();
1453        let err = cfg.validate().unwrap_err().to_string();
1454        assert!(
1455            err.contains("at least one LLM provider"),
1456            "expected empty-pool error, got: {err}"
1457        );
1458    }
1459
1460    #[test]
1461    fn validate_rejects_duplicate_provider_names() {
1462        let mut cfg = Config::default();
1463        cfg.llm.providers.push(crate::providers::ProviderEntry {
1464            provider_type: crate::providers::ProviderKind::Ollama,
1465            ..Default::default()
1466        });
1467        let err = cfg.validate().unwrap_err().to_string();
1468        assert!(
1469            err.contains("duplicate provider name"),
1470            "expected duplicate-name error, got: {err}"
1471        );
1472    }
1473
1474    #[test]
1475    fn validate_rejects_multiple_default_providers() {
1476        let mut cfg = Config::default();
1477        cfg.llm.providers[0].default = true;
1478        cfg.llm.providers.push(crate::providers::ProviderEntry {
1479            provider_type: crate::providers::ProviderKind::Ollama,
1480            name: Some("second".into()),
1481            default: true,
1482            ..Default::default()
1483        });
1484        let err = cfg.validate().unwrap_err().to_string();
1485        assert!(
1486            err.contains("default = true"),
1487            "expected multiple-default error, got: {err}"
1488        );
1489    }
1490
1491    #[test]
1492    fn validate_rejects_stt_provider_pointing_at_nonexistent_provider() {
1493        let mut cfg = Config::default();
1494        cfg.llm.stt = Some(crate::providers::SttConfig {
1495            provider: crate::providers::ProviderName::new("ghost"),
1496            language: crate::providers::default_stt_language(),
1497        });
1498        let err = cfg.validate().unwrap_err().to_string();
1499        assert!(
1500            err.contains("[llm.stt].provider") && err.contains("ghost"),
1501            "expected stt-provider-mismatch error, got: {err}"
1502        );
1503    }
1504
1505    #[test]
1506    fn validate_accepts_stt_provider_matching_existing_provider() {
1507        let mut cfg = Config::default();
1508        cfg.llm.stt = Some(crate::providers::SttConfig {
1509            provider: crate::providers::ProviderName::new("ollama"),
1510            language: crate::providers::default_stt_language(),
1511        });
1512        assert!(cfg.validate().is_ok());
1513    }
1514
1515    #[test]
1516    fn validate_rejects_trajectory_sentinel_inverted_thresholds() {
1517        let mut cfg = Config::default();
1518        cfg.security.trajectory.elevated_at = 0.9;
1519        cfg.security.trajectory.high_at = 0.5;
1520        let err = cfg.validate().unwrap_err().to_string();
1521        assert!(
1522            err.contains("elevated_at") && err.contains("high_at"),
1523            "expected trajectory threshold-ordering error, got: {err}"
1524        );
1525    }
1526
1527    #[test]
1528    fn validate_rejects_gateway_invalid_webhook_timeout() {
1529        // `rate_limit` and `max_body_size` are already covered by `validate_scalar_bounds`
1530        // (runs earlier in the pipeline), so testing those wouldn't prove
1531        // `GatewayConfig::validate()` is actually wired in — it would pass identically on
1532        // pre-#5932 code (critic-flagged shadowing, S2). `webhook_send_timeout_secs` is the
1533        // one field uniquely reachable only through the new call.
1534        let mut cfg = Config::default();
1535        cfg.gateway.webhook_send_timeout_secs = 0;
1536        let err = cfg.validate().unwrap_err().to_string();
1537        assert!(
1538            err.contains("webhook_send_timeout_secs"),
1539            "expected gateway webhook_send_timeout_secs error, got: {err}"
1540        );
1541    }
1542
1543    #[test]
1544    fn validate_rejects_negative_utility_scoring_weight() {
1545        let mut cfg = Config::default();
1546        cfg.tools.utility.gain_weight = -1.0;
1547        let err = cfg.validate().unwrap_err().to_string();
1548        assert!(
1549            err.contains("gain_weight"),
1550            "expected utility-scoring weight error, got: {err}"
1551        );
1552    }
1553
1554    #[test]
1555    fn validate_rejects_fidelity_threshold_ordering() {
1556        let mut cfg = Config::default();
1557        cfg.memory.fidelity = Some(crate::fidelity::FidelityConfig {
1558            full_threshold: 0.2,
1559            compressed_threshold: 0.5,
1560            ..Default::default()
1561        });
1562        let err = cfg.validate().unwrap_err().to_string();
1563        assert!(
1564            err.contains("full_threshold") && err.contains("compressed_threshold"),
1565            "expected fidelity threshold-ordering error, got: {err}"
1566        );
1567    }
1568
1569    #[test]
1570    fn validate_accepts_absent_fidelity_config() {
1571        let cfg = Config::default();
1572        assert!(cfg.memory.fidelity.is_none());
1573        assert!(cfg.validate().is_ok());
1574    }
1575
1576    #[test]
1577    fn validate_rejects_acon_inverted_thresholds() {
1578        let mut cfg = Config::default();
1579        cfg.memory.compression.acon.passthrough_threshold = 5000;
1580        cfg.memory.compression.acon.summarize_threshold = 1000;
1581        let err = cfg.validate().unwrap_err().to_string();
1582        assert!(
1583            err.contains("passthrough_threshold") && err.contains("summarize_threshold"),
1584            "expected acon threshold-ordering error, got: {err}"
1585        );
1586    }
1587
1588    #[test]
1589    fn validate_rejects_shadow_memory_inverted_thresholds() {
1590        let mut cfg = Config::default();
1591        cfg.memory.shadow_memory.enabled = true;
1592        cfg.memory.shadow_memory.escalation_threshold = 0.75;
1593        cfg.memory.shadow_memory.risk_threshold = 0.50;
1594        let err = cfg.validate().unwrap_err().to_string();
1595        assert!(
1596            err.contains("escalation_threshold") && err.contains("risk_threshold"),
1597            "expected shadow_memory threshold-ordering error, got: {err}"
1598        );
1599    }
1600
1601    #[test]
1602    fn validate_rejects_shadow_memory_equal_thresholds() {
1603        let mut cfg = Config::default();
1604        cfg.memory.shadow_memory.enabled = true;
1605        cfg.memory.shadow_memory.escalation_threshold = 0.6;
1606        cfg.memory.shadow_memory.risk_threshold = 0.6;
1607        assert!(
1608            cfg.validate().is_err(),
1609            "equal thresholds must be rejected — the escalation band would be empty"
1610        );
1611    }
1612
1613    #[test]
1614    fn validate_ignores_shadow_memory_thresholds_when_disabled() {
1615        let mut cfg = Config::default();
1616        cfg.memory.shadow_memory.enabled = false;
1617        cfg.memory.shadow_memory.escalation_threshold = 0.9;
1618        cfg.memory.shadow_memory.risk_threshold = 0.1;
1619        assert!(
1620            cfg.validate().is_ok(),
1621            "inverted thresholds on a disabled shadow_memory config must not fail validation"
1622        );
1623    }
1624
1625    #[test]
1626    fn validate_rejects_worktree_max_worktrees_zero() {
1627        let mut cfg = Config::default();
1628        cfg.worktree.max_worktrees = Some(0);
1629        let err = cfg.validate().unwrap_err().to_string();
1630        assert!(
1631            err.contains("max_worktrees"),
1632            "expected max_worktrees in error, got: {err}"
1633        );
1634    }
1635
1636    #[test]
1637    fn validate_accepts_worktree_max_worktrees_positive_or_unset() {
1638        let mut cfg = Config::default();
1639        cfg.worktree.max_worktrees = Some(1);
1640        assert!(cfg.validate().is_ok());
1641        cfg.worktree.max_worktrees = None;
1642        assert!(cfg.validate().is_ok());
1643    }
1644
1645    #[test]
1646    fn validate_rejects_worktree_disk_quota_mb_zero() {
1647        let mut cfg = Config::default();
1648        cfg.worktree.disk_quota_mb = Some(0);
1649        let err = cfg.validate().unwrap_err().to_string();
1650        assert!(
1651            err.contains("disk_quota_mb"),
1652            "expected disk_quota_mb in error, got: {err}"
1653        );
1654    }
1655
1656    #[test]
1657    fn validate_accepts_worktree_disk_quota_mb_positive_or_unset() {
1658        let mut cfg = Config::default();
1659        cfg.worktree.disk_quota_mb = Some(1);
1660        assert!(cfg.validate().is_ok());
1661        cfg.worktree.disk_quota_mb = None;
1662        assert!(cfg.validate().is_ok());
1663    }
1664
1665    /// Review N1 / critic M1(b): `disk_quota_mb` set with neither the startup sweep nor the
1666    /// periodic sweep enabled means the quota is evaluated nowhere automatically — must be a
1667    /// hard config error, not a silent no-op.
1668    #[test]
1669    fn validate_rejects_worktree_disk_quota_mb_with_no_evaluation_path_enabled() {
1670        let mut cfg = Config::default();
1671        cfg.worktree.disk_quota_mb = Some(100);
1672        cfg.worktree.auto_reconcile_secs = 0;
1673        cfg.worktree.reconcile_on_startup = false;
1674        let err = cfg.validate().unwrap_err().to_string();
1675        assert!(
1676            err.contains("disk_quota_mb") && err.contains("never automatically"),
1677            "expected inert-path error, got: {err}"
1678        );
1679    }
1680
1681    #[test]
1682    fn validate_accepts_worktree_disk_quota_mb_when_startup_sweep_enabled() {
1683        let mut cfg = Config::default();
1684        cfg.worktree.disk_quota_mb = Some(100);
1685        cfg.worktree.auto_reconcile_secs = 0;
1686        cfg.worktree.reconcile_on_startup = true;
1687        assert!(cfg.validate().is_ok());
1688    }
1689
1690    #[test]
1691    fn validate_accepts_worktree_disk_quota_mb_when_periodic_sweep_enabled() {
1692        let mut cfg = Config::default();
1693        cfg.worktree.disk_quota_mb = Some(100);
1694        cfg.worktree.auto_reconcile_secs = 3600;
1695        cfg.worktree.reconcile_on_startup = false;
1696        assert!(cfg.validate().is_ok());
1697    }
1698
1699    /// Review perf#3: a short `auto_reconcile_secs` runs a full filesystem walk in a tight
1700    /// loop — must be rejected, matching the `Some(0)` rejection style for the sibling fields.
1701    #[test]
1702    fn validate_rejects_worktree_auto_reconcile_secs_short_interval() {
1703        let mut cfg = Config::default();
1704        cfg.worktree.auto_reconcile_secs = 1;
1705        let err = cfg.validate().unwrap_err().to_string();
1706        assert!(
1707            err.contains("auto_reconcile_secs"),
1708            "expected auto_reconcile_secs in error, got: {err}"
1709        );
1710    }
1711
1712    #[test]
1713    fn validate_accepts_worktree_auto_reconcile_secs_zero_or_at_least_60() {
1714        let mut cfg = Config::default();
1715        cfg.worktree.auto_reconcile_secs = 0;
1716        assert!(cfg.validate().is_ok());
1717        cfg.worktree.auto_reconcile_secs = 60;
1718        assert!(cfg.validate().is_ok());
1719        cfg.worktree.auto_reconcile_secs = 3600;
1720        assert!(cfg.validate().is_ok());
1721    }
1722
1723    /// Regression test (critic S1): `Config::default()` must itself satisfy `validate_pool`
1724    /// so `--dump-config-defaults` (which serializes `Config::default()` verbatim,
1725    /// `src/runner.rs`) emits a config that `zeph --config <dump>` can actually load and
1726    /// validate, rather than a self-inconsistent onboarding trap.
1727    #[test]
1728    fn dump_defaults_output_is_self_consistent_and_validates() {
1729        assert!(Config::default().validate().is_ok());
1730
1731        let dumped = Config::dump_defaults().expect("dump defaults");
1732        assert!(
1733            dumped.contains("[[llm.providers]]"),
1734            "dumped defaults must include an active provider entry, got:\n{dumped}"
1735        );
1736        let reparsed: Config = toml::from_str(&dumped).expect("reparse dumped defaults");
1737        assert!(reparsed.validate().is_ok());
1738    }
1739
1740    // --- orchestration.ensemble validation (spec 073-orch-ensemble-merge, M5/M7) ---
1741
1742    fn config_with_ensemble(enabled: bool, verify: bool, members: Vec<&str>) -> Config {
1743        let mut cfg = Config::default();
1744        cfg.orchestration.ensemble.enabled = enabled;
1745        cfg.orchestration.ensemble.verify = verify;
1746        cfg.orchestration.ensemble.members = members.into_iter().map(String::from).collect();
1747        cfg
1748    }
1749
1750    #[test]
1751    fn ensemble_default_config_validates_trivially() {
1752        assert!(Config::default().validate().is_ok());
1753    }
1754
1755    #[test]
1756    fn ensemble_disabled_skips_member_list_validation() {
1757        // enabled=false: an invalid members list must not block startup.
1758        let cfg = config_with_ensemble(false, false, vec!["a", "b"]);
1759        assert!(cfg.validate().is_ok());
1760    }
1761
1762    #[test]
1763    fn ensemble_enabled_but_not_verify_skips_member_list_validation() {
1764        // enabled=true, verify=false: still an unused/staged config, checks skipped.
1765        let cfg = config_with_ensemble(true, false, vec!["a", "b"]);
1766        assert!(cfg.validate().is_ok());
1767    }
1768
1769    #[test]
1770    fn ensemble_active_even_length_members_rejected() {
1771        let cfg = config_with_ensemble(true, true, vec!["a", "b"]);
1772        let err = cfg.validate().unwrap_err();
1773        assert!(
1774            err.to_string().contains("must be odd and >= 3"),
1775            "unexpected error: {err}"
1776        );
1777    }
1778
1779    #[test]
1780    fn ensemble_active_short_members_rejected() {
1781        let cfg = config_with_ensemble(true, true, vec!["a"]);
1782        let err = cfg.validate().unwrap_err();
1783        assert!(
1784            err.to_string().contains("must be odd and >= 3"),
1785            "unexpected error: {err}"
1786        );
1787    }
1788
1789    #[test]
1790    fn ensemble_active_duplicate_members_rejected() {
1791        let cfg = config_with_ensemble(true, true, vec!["a", "b", "a"]);
1792        let err = cfg.validate().unwrap_err();
1793        assert!(
1794            err.to_string().contains("duplicate provider name"),
1795            "unexpected error: {err}"
1796        );
1797    }
1798
1799    #[test]
1800    fn ensemble_active_valid_odd_unique_members_accepted() {
1801        let cfg = config_with_ensemble(true, true, vec!["a", "b", "c"]);
1802        assert!(cfg.validate().is_ok());
1803    }
1804
1805    #[test]
1806    fn ensemble_active_valid_five_members_accepted() {
1807        let cfg = config_with_ensemble(true, true, vec!["a", "b", "c", "d", "e"]);
1808        assert!(cfg.validate().is_ok());
1809    }
1810
1811    // --- ema_alpha / ema_decay range validation (security P3) ---
1812
1813    #[test]
1814    fn ensemble_active_ema_alpha_above_one_rejected() {
1815        let mut cfg = config_with_ensemble(true, true, vec!["a", "b", "c"]);
1816        cfg.orchestration.ensemble.ema_alpha = 1.5;
1817        let err = cfg.validate().unwrap_err();
1818        assert!(
1819            err.to_string().contains("ema_alpha"),
1820            "unexpected error: {err}"
1821        );
1822    }
1823
1824    #[test]
1825    fn ensemble_active_ema_alpha_negative_rejected() {
1826        let mut cfg = config_with_ensemble(true, true, vec!["a", "b", "c"]);
1827        cfg.orchestration.ensemble.ema_alpha = -0.1;
1828        let err = cfg.validate().unwrap_err();
1829        assert!(
1830            err.to_string().contains("ema_alpha"),
1831            "unexpected error: {err}"
1832        );
1833    }
1834
1835    #[test]
1836    fn ensemble_active_ema_alpha_nan_rejected() {
1837        let mut cfg = config_with_ensemble(true, true, vec!["a", "b", "c"]);
1838        cfg.orchestration.ensemble.ema_alpha = f64::NAN;
1839        let err = cfg.validate().unwrap_err();
1840        assert!(
1841            err.to_string().contains("ema_alpha"),
1842            "unexpected error: {err}"
1843        );
1844    }
1845
1846    #[test]
1847    fn ensemble_active_ema_decay_above_one_rejected() {
1848        let mut cfg = config_with_ensemble(true, true, vec!["a", "b", "c"]);
1849        cfg.orchestration.ensemble.ema_decay = 1.1;
1850        let err = cfg.validate().unwrap_err();
1851        assert!(
1852            err.to_string().contains("ema_decay"),
1853            "unexpected error: {err}"
1854        );
1855    }
1856
1857    #[test]
1858    fn ensemble_active_ema_decay_negative_rejected() {
1859        let mut cfg = config_with_ensemble(true, true, vec!["a", "b", "c"]);
1860        cfg.orchestration.ensemble.ema_decay = -0.1;
1861        let err = cfg.validate().unwrap_err();
1862        assert!(
1863            err.to_string().contains("ema_decay"),
1864            "unexpected error: {err}"
1865        );
1866    }
1867
1868    #[test]
1869    fn ensemble_active_ema_boundaries_zero_and_one_accepted() {
1870        let mut cfg = config_with_ensemble(true, true, vec!["a", "b", "c"]);
1871        cfg.orchestration.ensemble.ema_alpha = 0.0;
1872        cfg.orchestration.ensemble.ema_decay = 1.0;
1873        assert!(cfg.validate().is_ok());
1874    }
1875
1876    #[test]
1877    fn ensemble_disabled_skips_ema_range_validation() {
1878        // enabled=false: an out-of-range EMA param must not block startup.
1879        let mut cfg = config_with_ensemble(false, false, vec![]);
1880        cfg.orchestration.ensemble.ema_alpha = 5.0;
1881        assert!(cfg.validate().is_ok());
1882    }
1883}