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        Ok(())
436    }
437
438    /// Validate focus and sidequest interval and ratio constraints.
439    fn validate_focus_and_sidequest(&self) -> Result<(), ConfigError> {
440        if self.agent.focus.compression_interval == 0 {
441            return Err(ConfigError::Validation(
442                "agent.focus.compression_interval must be >= 1".into(),
443            ));
444        }
445        if self.agent.focus.min_messages_per_focus == 0 {
446            return Err(ConfigError::Validation(
447                "agent.focus.min_messages_per_focus must be >= 1".into(),
448            ));
449        }
450        if self.agent.focus.auto_consolidate_min_window == 0 {
451            return Err(ConfigError::Validation(
452                "agent.focus.auto_consolidate_min_window must be >= 1 \
453                 (set focus.enabled = false to disable auto-consolidation)"
454                    .into(),
455            ));
456        }
457        if self.memory.sidequest.interval_turns == 0 {
458            return Err(ConfigError::Validation(
459                "memory.sidequest.interval_turns must be >= 1".into(),
460            ));
461        }
462        if !self.memory.sidequest.max_eviction_ratio.is_finite()
463            || self.memory.sidequest.max_eviction_ratio <= 0.0
464            || self.memory.sidequest.max_eviction_ratio > 1.0
465        {
466            return Err(ConfigError::Validation(format!(
467                "memory.sidequest.max_eviction_ratio must be in (0.0, 1.0], got {}",
468                self.memory.sidequest.max_eviction_ratio
469            )));
470        }
471        Ok(())
472    }
473
474    /// Validate LLM semantic cache threshold and skill evaluation weight sum.
475    fn validate_llm_and_skills(&self) -> Result<(), ConfigError> {
476        let sct = self.llm.semantic_cache_threshold;
477        if !(sct.is_finite() && (0.0..=1.0).contains(&sct)) {
478            return Err(ConfigError::Validation(format!(
479                "llm.semantic_cache_threshold must be in [0.0, 1.0], got {sct} \
480                 (override via ZEPH_LLM_SEMANTIC_CACHE_THRESHOLD env var)"
481            )));
482        }
483        // MemCoT distill provider fast-tier soft-warn (#3574).
484        if self.memory.memcot.enabled && !self.memory.memcot.distill_provider.is_empty() {
485            self.llm.warn_non_fast_tier_provider(
486                &self.memory.memcot.distill_provider,
487                "memory.memcot.distill_provider",
488                &self.memory.memcot.fast_tier_models,
489            );
490        }
491        self.skills
492            .learning
493            .validate()
494            .map_err(ConfigError::Validation)?;
495        // Skill evaluation weight-sum validation (#3319).
496        if self.skills.evaluation.enabled {
497            let weight_sum = self.skills.evaluation.weight_correctness
498                + self.skills.evaluation.weight_reusability
499                + self.skills.evaluation.weight_specificity;
500            if (weight_sum - 1.0_f32).abs() > 1e-3 {
501                return Err(ConfigError::Validation(format!(
502                    "skills.evaluation weights must sum to 1.0 (got {weight_sum:.4})"
503                )));
504            }
505        }
506        Ok(())
507    }
508
509    /// Validate miscellaneous MCP output schema hint size.
510    fn validate_mcp_misc(&self) -> Result<(), ConfigError> {
511        if self.mcp.output_schema_hint_bytes < 64 {
512            return Err(ConfigError::Validation(format!(
513                "mcp.output_schema_hint_bytes must be >= 64, got {}; \
514                 use forward_output_schema = false to disable forwarding",
515                self.mcp.output_schema_hint_bytes
516            )));
517        }
518        Ok(())
519    }
520
521    /// Validate that each `[[scheduler.tasks]]` entry has exactly one of `cron` or `run_at` set.
522    fn validate_scheduler(&self) -> Result<(), ConfigError> {
523        for task in &self.scheduler.tasks {
524            match (&task.cron, &task.run_at) {
525                (Some(_), Some(_)) => {
526                    return Err(ConfigError::Validation(format!(
527                        "scheduler task {:?}: only one of `cron` or `run_at` may be set, not both",
528                        task.name
529                    )));
530                }
531                (None, None) => {
532                    return Err(ConfigError::Validation(format!(
533                        "scheduler task {:?}: either `cron` or `run_at` must be set",
534                        task.name
535                    )));
536                }
537                _ => {}
538            }
539        }
540        Ok(())
541    }
542
543    fn validate_provider_names(&self) -> Result<(), ConfigError> {
544        let known = self.known_provider_names();
545        self.validate_named_provider_refs(&known)?;
546        self.validate_optional_provider_refs(&known)?;
547        Ok(())
548    }
549
550    /// Build the set of declared provider names from all `[[llm.providers]]` entries.
551    fn known_provider_names(&self) -> std::collections::HashSet<String> {
552        self.llm
553            .providers
554            .iter()
555            .map(super::providers::ProviderEntry::effective_name)
556            .collect()
557    }
558
559    /// Validate every required `*_provider` field references a declared provider.
560    ///
561    /// The field table lists all subsystem provider references. Each non-empty value must
562    /// match a name in `known`.
563    fn validate_named_provider_refs(
564        &self,
565        known: &std::collections::HashSet<String>,
566    ) -> Result<(), ConfigError> {
567        self.validate_core_provider_refs(known)?;
568        self.validate_tool_and_quality_provider_refs(known)
569    }
570
571    fn validate_core_provider_refs(
572        &self,
573        known: &std::collections::HashSet<String>,
574    ) -> Result<(), ConfigError> {
575        let fields: &[(&str, &crate::providers::ProviderName)] = &[
576            (
577                "memory.tiers.scene_provider",
578                &self.memory.tiers.scene_provider,
579            ),
580            (
581                "memory.compression.compress_provider",
582                &self.memory.compression.compress_provider,
583            ),
584            (
585                "memory.consolidation.consolidation_provider",
586                &self.memory.consolidation.consolidation_provider,
587            ),
588            (
589                "memory.admission.admission_provider",
590                &self.memory.admission.admission_provider,
591            ),
592            (
593                "memory.admission.goal_utility_provider",
594                &self.memory.admission.goal_utility_provider,
595            ),
596            (
597                "memory.store_routing.routing_classifier_provider",
598                &self.memory.store_routing.routing_classifier_provider,
599            ),
600            (
601                "skills.learning.feedback_provider",
602                &self.skills.learning.feedback_provider,
603            ),
604            (
605                "skills.learning.arise_trace_provider",
606                &self.skills.learning.arise_trace_provider,
607            ),
608            (
609                "skills.learning.stem_provider",
610                &self.skills.learning.stem_provider,
611            ),
612            (
613                "skills.learning.erl_extract_provider",
614                &self.skills.learning.erl_extract_provider,
615            ),
616            (
617                "mcp.pruning.pruning_provider",
618                &self.mcp.pruning.pruning_provider,
619            ),
620            (
621                "mcp.tool_discovery.embedding_provider",
622                &self.mcp.tool_discovery.embedding_provider,
623            ),
624            (
625                "security.response_verification.verifier_provider",
626                &self.security.response_verification.verifier_provider,
627            ),
628            (
629                "orchestration.planner_provider",
630                &self.orchestration.planner_provider,
631            ),
632            (
633                "orchestration.verify_provider",
634                &self.orchestration.verify_provider,
635            ),
636            (
637                "orchestration.tool_provider",
638                &self.orchestration.tool_provider,
639            ),
640            (
641                "skills.evaluation.provider",
642                &self.skills.evaluation.provider,
643            ),
644            (
645                "skills.proactive_exploration.provider",
646                &self.skills.proactive_exploration.provider,
647            ),
648            (
649                "memory.compression_spectrum.promotion_provider",
650                &self.memory.compression_spectrum.promotion_provider,
651            ),
652        ];
653        Self::check_provider_refs(fields, known)
654    }
655
656    fn validate_tool_and_quality_provider_refs(
657        &self,
658        known: &std::collections::HashSet<String>,
659    ) -> Result<(), ConfigError> {
660        let fields: &[(&str, &crate::providers::ProviderName)] = &[
661            (
662                "security.shadow_sentinel.probe_provider",
663                &self.security.shadow_sentinel.probe_provider,
664            ),
665            (
666                "tools.retry.parameter_reformat_provider",
667                &self.tools.retry.parameter_reformat_provider,
668            ),
669            (
670                "tools.policy.policy_provider",
671                &self.tools.policy.policy_provider,
672            ),
673            (
674                "tools.adversarial_policy.policy_provider",
675                &self.tools.adversarial_policy.policy_provider,
676            ),
677            (
678                "tools.speculative.pattern.rerank_provider",
679                &self.tools.speculative.pattern.rerank_provider,
680            ),
681            (
682                "tools.compression.evolution_provider",
683                &self.tools.compression.evolution_provider,
684            ),
685            ("quality.proposer_provider", &self.quality.proposer_provider),
686            ("quality.checker_provider", &self.quality.checker_provider),
687        ];
688        Self::check_provider_refs(fields, known)
689    }
690
691    fn check_provider_refs(
692        fields: &[(&str, &crate::providers::ProviderName)],
693        known: &std::collections::HashSet<String>,
694    ) -> Result<(), ConfigError> {
695        for (field, name) in fields {
696            if !name.is_empty() && !known.contains(name.as_str()) {
697                return Err(ConfigError::Validation(format!(
698                    "{field} = {:?} does not match any [[llm.providers]] entry",
699                    name.as_str()
700                )));
701            }
702        }
703        Ok(())
704    }
705
706    /// Validate optional provider references in complexity routing and router bandit config.
707    fn validate_optional_provider_refs(
708        &self,
709        known: &std::collections::HashSet<String>,
710    ) -> Result<(), ConfigError> {
711        if let Some(triage) = self
712            .llm
713            .complexity_routing
714            .as_ref()
715            .and_then(|cr| cr.triage_provider.as_ref())
716            .filter(|t| !t.is_empty() && !known.contains(t.as_str()))
717        {
718            return Err(ConfigError::Validation(format!(
719                "llm.complexity_routing.triage_provider = {:?} does not match any \
720                 [[llm.providers]] entry",
721                triage.as_str()
722            )));
723        }
724
725        if let Some(embed) = self
726            .llm
727            .router
728            .as_ref()
729            .and_then(|r| r.bandit.as_ref())
730            .map(|b| &b.embedding_provider)
731            .filter(|p| !p.is_empty() && !known.contains(p.as_str()))
732        {
733            return Err(ConfigError::Validation(format!(
734                "llm.router.bandit.embedding_provider = {:?} does not match any \
735                 [[llm.providers]] entry",
736                embed.as_str()
737            )));
738        }
739
740        Ok(())
741    }
742
743    fn normalize_legacy_runtime_defaults(&mut self) {
744        use crate::defaults::{
745            default_debug_dir, default_log_file_path, default_skills_dir, default_sqlite_path,
746            is_legacy_default_debug_dir, is_legacy_default_log_file, is_legacy_default_skills_path,
747            is_legacy_default_sqlite_path,
748        };
749
750        if is_legacy_default_sqlite_path(&self.memory.sqlite_path) {
751            self.memory.sqlite_path = default_sqlite_path();
752        }
753
754        for skill_path in &mut self.skills.paths {
755            if is_legacy_default_skills_path(skill_path) {
756                *skill_path = default_skills_dir();
757            }
758        }
759
760        if is_legacy_default_debug_dir(&self.debug.output_dir) {
761            self.debug.output_dir = default_debug_dir();
762        }
763
764        if is_legacy_default_log_file(&self.logging.file) {
765            self.logging.file = default_log_file_path();
766        }
767    }
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773
774    fn config_with_sct(threshold: f32) -> Config {
775        let mut cfg = Config::default();
776        cfg.llm.semantic_cache_threshold = threshold;
777        cfg
778    }
779
780    #[test]
781    fn semantic_cache_threshold_valid_zero() {
782        assert!(config_with_sct(0.0).validate().is_ok());
783    }
784
785    #[test]
786    fn semantic_cache_threshold_valid_mid() {
787        assert!(config_with_sct(0.5).validate().is_ok());
788    }
789
790    #[test]
791    fn semantic_cache_threshold_valid_one() {
792        assert!(config_with_sct(1.0).validate().is_ok());
793    }
794
795    #[test]
796    fn semantic_cache_threshold_invalid_negative() {
797        let err = config_with_sct(-0.1).validate().unwrap_err();
798        assert!(
799            err.to_string().contains("semantic_cache_threshold"),
800            "unexpected error: {err}"
801        );
802    }
803
804    #[test]
805    fn semantic_cache_threshold_invalid_above_one() {
806        let err = config_with_sct(1.1).validate().unwrap_err();
807        assert!(
808            err.to_string().contains("semantic_cache_threshold"),
809            "unexpected error: {err}"
810        );
811    }
812
813    #[test]
814    fn semantic_cache_threshold_invalid_nan() {
815        let err = config_with_sct(f32::NAN).validate().unwrap_err();
816        assert!(
817            err.to_string().contains("semantic_cache_threshold"),
818            "unexpected error: {err}"
819        );
820    }
821
822    #[cfg(not(feature = "card-signing"))]
823    #[test]
824    fn card_trust_policy_require_without_feature_fails_validation() {
825        let mut cfg = Config::default();
826        cfg.a2a_client.card_trust_policy = crate::channels::CardTrustPolicy::Require;
827        let err = cfg.validate().unwrap_err();
828        assert!(
829            err.to_string().contains("card_trust_policy"),
830            "unexpected error: {err}"
831        );
832    }
833
834    #[test]
835    fn card_trust_policy_ignore_and_prefer_always_pass_validation() {
836        let mut cfg = Config::default();
837        cfg.a2a_client.card_trust_policy = crate::channels::CardTrustPolicy::Ignore;
838        assert!(cfg.validate().is_ok());
839        cfg.a2a_client.card_trust_policy = crate::channels::CardTrustPolicy::Prefer;
840        assert!(cfg.validate().is_ok());
841    }
842
843    #[cfg(feature = "card-signing")]
844    #[test]
845    fn card_trust_policy_require_with_feature_passes_validation() {
846        let mut cfg = Config::default();
847        cfg.a2a_client.card_trust_policy = crate::channels::CardTrustPolicy::Require;
848        assert!(cfg.validate().is_ok());
849    }
850
851    #[test]
852    fn semantic_cache_threshold_invalid_infinity() {
853        let err = config_with_sct(f32::INFINITY).validate().unwrap_err();
854        assert!(
855            err.to_string().contains("semantic_cache_threshold"),
856            "unexpected error: {err}"
857        );
858    }
859
860    #[test]
861    fn semantic_cache_threshold_invalid_neg_infinity() {
862        let err = config_with_sct(f32::NEG_INFINITY).validate().unwrap_err();
863        assert!(
864            err.to_string().contains("semantic_cache_threshold"),
865            "unexpected error: {err}"
866        );
867    }
868
869    fn probe_config(enabled: bool, threshold: f32, hard_fail_threshold: f32) -> Config {
870        let mut cfg = Config::default();
871        cfg.memory.compression.probe.enabled = enabled;
872        cfg.memory.compression.probe.threshold = threshold;
873        cfg.memory.compression.probe.hard_fail_threshold = hard_fail_threshold;
874        cfg
875    }
876
877    #[test]
878    fn probe_disabled_skips_validation() {
879        // Invalid thresholds when probe is disabled must not cause errors.
880        let cfg = probe_config(false, 0.0, 1.0);
881        assert!(cfg.validate().is_ok());
882    }
883
884    #[test]
885    fn probe_valid_thresholds() {
886        let cfg = probe_config(true, 0.6, 0.35);
887        assert!(cfg.validate().is_ok());
888    }
889
890    #[test]
891    fn probe_threshold_zero_invalid() {
892        let err = probe_config(true, 0.0, 0.0).validate().unwrap_err();
893        assert!(
894            err.to_string().contains("probe.threshold"),
895            "unexpected error: {err}"
896        );
897    }
898
899    #[test]
900    fn probe_hard_fail_threshold_above_one_invalid() {
901        let err = probe_config(true, 0.6, 1.0).validate().unwrap_err();
902        assert!(
903            err.to_string().contains("probe.hard_fail_threshold"),
904            "unexpected error: {err}"
905        );
906    }
907
908    #[test]
909    fn probe_hard_fail_gte_threshold_invalid() {
910        let err = probe_config(true, 0.3, 0.9).validate().unwrap_err();
911        assert!(
912            err.to_string().contains("probe.hard_fail_threshold"),
913            "unexpected error: {err}"
914        );
915    }
916
917    fn config_with_completeness_threshold(ct: f32) -> Config {
918        let mut cfg = Config::default();
919        cfg.orchestration.completeness_threshold = ct;
920        cfg
921    }
922
923    #[test]
924    fn completeness_threshold_valid_zero() {
925        assert!(config_with_completeness_threshold(0.0).validate().is_ok());
926    }
927
928    #[test]
929    fn completeness_threshold_valid_default() {
930        assert!(config_with_completeness_threshold(0.7).validate().is_ok());
931    }
932
933    #[test]
934    fn completeness_threshold_valid_one() {
935        assert!(config_with_completeness_threshold(1.0).validate().is_ok());
936    }
937
938    #[test]
939    fn completeness_threshold_invalid_negative() {
940        let err = config_with_completeness_threshold(-0.1)
941            .validate()
942            .unwrap_err();
943        assert!(
944            err.to_string().contains("completeness_threshold"),
945            "unexpected error: {err}"
946        );
947    }
948
949    #[test]
950    fn completeness_threshold_invalid_above_one() {
951        let err = config_with_completeness_threshold(1.1)
952            .validate()
953            .unwrap_err();
954        assert!(
955            err.to_string().contains("completeness_threshold"),
956            "unexpected error: {err}"
957        );
958    }
959
960    #[test]
961    fn completeness_threshold_invalid_nan() {
962        let err = config_with_completeness_threshold(f32::NAN)
963            .validate()
964            .unwrap_err();
965        assert!(
966            err.to_string().contains("completeness_threshold"),
967            "unexpected error: {err}"
968        );
969    }
970
971    #[test]
972    fn completeness_threshold_invalid_infinity() {
973        let err = config_with_completeness_threshold(f32::INFINITY)
974            .validate()
975            .unwrap_err();
976        assert!(
977            err.to_string().contains("completeness_threshold"),
978            "unexpected error: {err}"
979        );
980    }
981
982    fn config_with_provider(name: &str) -> Config {
983        let mut cfg = Config::default();
984        cfg.llm.providers.push(crate::providers::ProviderEntry {
985            provider_type: crate::providers::ProviderKind::Ollama,
986            name: Some(name.into()),
987            ..Default::default()
988        });
989        cfg
990    }
991
992    #[test]
993    fn validate_provider_names_all_empty_ok() {
994        let cfg = Config::default();
995        assert!(cfg.validate_provider_names().is_ok());
996    }
997
998    #[test]
999    fn validate_provider_names_matching_provider_ok() {
1000        let mut cfg = config_with_provider("fast");
1001        cfg.memory.admission.admission_provider = crate::providers::ProviderName::new("fast");
1002        assert!(cfg.validate_provider_names().is_ok());
1003    }
1004
1005    #[test]
1006    fn validate_provider_names_unknown_provider_err() {
1007        let mut cfg = config_with_provider("fast");
1008        cfg.memory.admission.admission_provider =
1009            crate::providers::ProviderName::new("nonexistent");
1010        let err = cfg.validate_provider_names().unwrap_err();
1011        let msg = err.to_string();
1012        assert!(
1013            msg.contains("admission_provider") && msg.contains("nonexistent"),
1014            "unexpected error: {msg}"
1015        );
1016    }
1017
1018    #[test]
1019    fn validate_provider_names_triage_provider_none_ok() {
1020        let mut cfg = config_with_provider("fast");
1021        cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
1022            triage_provider: None,
1023            ..Default::default()
1024        });
1025        assert!(cfg.validate_provider_names().is_ok());
1026    }
1027
1028    #[test]
1029    fn validate_provider_names_triage_provider_matching_ok() {
1030        let mut cfg = config_with_provider("fast");
1031        cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
1032            triage_provider: Some(crate::providers::ProviderName::new("fast")),
1033            ..Default::default()
1034        });
1035        assert!(cfg.validate_provider_names().is_ok());
1036    }
1037
1038    #[test]
1039    fn validate_provider_names_triage_provider_unknown_err() {
1040        let mut cfg = config_with_provider("fast");
1041        cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
1042            triage_provider: Some(crate::providers::ProviderName::new("ghost")),
1043            ..Default::default()
1044        });
1045        let err = cfg.validate_provider_names().unwrap_err();
1046        let msg = err.to_string();
1047        assert!(
1048            msg.contains("triage_provider") && msg.contains("ghost"),
1049            "unexpected error: {msg}"
1050        );
1051    }
1052
1053    // Regression test for issue #2599: TOML float values must deserialise without error
1054    // across all config sections that contain f32/f64 fields.
1055    #[test]
1056    fn toml_float_fields_deserialise_correctly() {
1057        let toml = r"
1058[llm.router.reputation]
1059enabled = true
1060decay_factor = 0.95
1061weight = 0.3
1062
1063[llm.router.bandit]
1064enabled = false
1065cost_weight = 0.3
1066alpha = 1.0
1067decay_factor = 0.99
1068
1069[skills]
1070disambiguation_threshold = 0.25
1071cosine_weight = 0.7
1072";
1073        // Wrap in a full Config to exercise the nested paths.
1074        let wrapped = format!(
1075            "{}\n{}",
1076            toml,
1077            r"[memory.semantic]
1078mmr_lambda = 0.7
1079"
1080        );
1081        // We only need the sub-structs to round-trip; build minimal wrappers.
1082        let router: crate::providers::RouterConfig = toml::from_str(
1083            r"[reputation]
1084enabled = true
1085decay_factor = 0.95
1086weight = 0.3
1087",
1088        )
1089        .expect("RouterConfig with float fields must deserialise");
1090        assert!((router.reputation.unwrap().decay_factor - 0.95).abs() < f64::EPSILON);
1091
1092        let bandit: crate::providers::BanditConfig =
1093            toml::from_str("cost_weight = 0.3\nalpha = 1.0\n")
1094                .expect("BanditConfig with float fields must deserialise");
1095        assert!((bandit.cost_weight - 0.3_f32).abs() < f32::EPSILON);
1096
1097        let semantic: crate::memory::SemanticConfig = toml::from_str("mmr_lambda = 0.7\n")
1098            .expect("SemanticConfig with float fields must deserialise");
1099        assert!((semantic.mmr_lambda - 0.7_f32).abs() < f32::EPSILON);
1100
1101        let skills: crate::features::SkillsConfig =
1102            toml::from_str("disambiguation_threshold = 0.25\n")
1103                .expect("SkillsConfig with float fields must deserialise");
1104        assert!((skills.disambiguation_threshold - 0.25_f32).abs() < f32::EPSILON);
1105
1106        let _ = wrapped; // silence unused-variable lint
1107    }
1108
1109    #[test]
1110    fn validate_max_parallel_zero_rejected() {
1111        let mut cfg = Config::default();
1112        cfg.orchestration.max_parallel = 0;
1113        let err = cfg.validate().unwrap_err().to_string();
1114        assert!(
1115            err.contains("max_parallel"),
1116            "expected max_parallel in error, got: {err}"
1117        );
1118    }
1119
1120    #[test]
1121    fn validate_max_parallel_one_accepted() {
1122        let mut cfg = Config::default();
1123        cfg.orchestration.max_parallel = 1;
1124        assert!(cfg.validate().is_ok());
1125    }
1126
1127    #[test]
1128    fn validate_max_tasks_zero_rejected() {
1129        let mut cfg = Config::default();
1130        cfg.orchestration.max_tasks = 0;
1131        let err = cfg.validate().unwrap_err().to_string();
1132        assert!(
1133            err.contains("max_tasks"),
1134            "expected max_tasks in error, got: {err}"
1135        );
1136    }
1137
1138    #[test]
1139    fn validate_max_tasks_one_accepted() {
1140        let mut cfg = Config::default();
1141        cfg.orchestration.max_tasks = 1;
1142        assert!(cfg.validate().is_ok());
1143    }
1144
1145    #[test]
1146    fn validate_aggregator_timeout_zero_rejected() {
1147        let mut cfg = Config::default();
1148        cfg.orchestration.aggregator_timeout_secs = 0;
1149        let err = cfg.validate().unwrap_err().to_string();
1150        assert!(
1151            err.contains("aggregator_timeout_secs"),
1152            "expected aggregator_timeout_secs in error, got: {err}"
1153        );
1154    }
1155
1156    #[test]
1157    fn validate_planner_timeout_zero_rejected() {
1158        let mut cfg = Config::default();
1159        cfg.orchestration.planner_timeout_secs = 0;
1160        let err = cfg.validate().unwrap_err().to_string();
1161        assert!(
1162            err.contains("planner_timeout_secs"),
1163            "expected planner_timeout_secs in error, got: {err}"
1164        );
1165    }
1166
1167    #[test]
1168    fn validate_verifier_timeout_zero_rejected() {
1169        let mut cfg = Config::default();
1170        cfg.orchestration.verifier_timeout_secs = 0;
1171        let err = cfg.validate().unwrap_err().to_string();
1172        assert!(
1173            err.contains("verifier_timeout_secs"),
1174            "expected verifier_timeout_secs in error, got: {err}"
1175        );
1176    }
1177
1178    #[test]
1179    fn focus_auto_consolidate_min_window_zero_rejected() {
1180        let mut cfg = Config::default();
1181        cfg.agent.focus.auto_consolidate_min_window = 0;
1182        let err = cfg.validate().unwrap_err().to_string();
1183        assert!(
1184            err.contains("auto_consolidate_min_window"),
1185            "expected auto_consolidate_min_window in error, got: {err}"
1186        );
1187    }
1188
1189    #[test]
1190    fn focus_auto_consolidate_min_window_one_accepted() {
1191        let mut cfg = Config::default();
1192        cfg.agent.focus.auto_consolidate_min_window = 1;
1193        assert!(cfg.validate().is_ok());
1194    }
1195
1196    fn task_with(cron: Option<&str>, run_at: Option<&str>) -> crate::features::ScheduledTaskConfig {
1197        crate::features::ScheduledTaskConfig {
1198            name: "test-task".into(),
1199            cron: cron.map(Into::into),
1200            run_at: run_at.map(Into::into),
1201            kind: crate::features::ScheduledTaskKind::HealthCheck,
1202            config: serde_json::Value::Null,
1203        }
1204    }
1205
1206    #[test]
1207    fn scheduler_task_valid_cron() {
1208        let mut cfg = Config::default();
1209        cfg.scheduler.tasks.push(task_with(Some("0 9 * * *"), None));
1210        assert!(cfg.validate().is_ok());
1211    }
1212
1213    #[test]
1214    fn scheduler_task_valid_run_at() {
1215        let mut cfg = Config::default();
1216        cfg.scheduler
1217            .tasks
1218            .push(task_with(None, Some("2025-01-01T09:00:00Z")));
1219        assert!(cfg.validate().is_ok());
1220    }
1221
1222    #[test]
1223    fn scheduler_task_neither_cron_nor_run_at_rejected() {
1224        let mut cfg = Config::default();
1225        cfg.scheduler.tasks.push(task_with(None, None));
1226        let err = cfg.validate().unwrap_err().to_string();
1227        assert!(
1228            err.contains("either `cron` or `run_at` must be set"),
1229            "unexpected error: {err}"
1230        );
1231    }
1232
1233    #[test]
1234    fn scheduler_task_both_cron_and_run_at_rejected() {
1235        let mut cfg = Config::default();
1236        cfg.scheduler
1237            .tasks
1238            .push(task_with(Some("0 9 * * *"), Some("2025-01-01T09:00:00Z")));
1239        let err = cfg.validate().unwrap_err().to_string();
1240        assert!(
1241            err.contains("only one of `cron` or `run_at` may be set"),
1242            "unexpected error: {err}"
1243        );
1244    }
1245
1246    // ── #5932: 7 previously-dead validate() functions now wired into Config::validate() ──────
1247
1248    #[test]
1249    fn validate_rejects_empty_provider_pool() {
1250        // This is the most severe gap from #5932: `validate_pool` was documented (verbatim)
1251        // as a load-bearing guarantee by tier_loop.rs/arise.rs but was never actually wired
1252        // in. `Config::default()` itself now seeds one provider (critic S1 follow-up, so
1253        // `--dump-config-defaults` output stays self-consistent) — clear it explicitly to
1254        // exercise the empty-pool branch.
1255        let mut cfg = Config::default();
1256        cfg.llm.providers.clear();
1257        let err = cfg.validate().unwrap_err().to_string();
1258        assert!(
1259            err.contains("at least one LLM provider"),
1260            "expected empty-pool error, got: {err}"
1261        );
1262    }
1263
1264    #[test]
1265    fn validate_rejects_duplicate_provider_names() {
1266        let mut cfg = Config::default();
1267        cfg.llm.providers.push(crate::providers::ProviderEntry {
1268            provider_type: crate::providers::ProviderKind::Ollama,
1269            ..Default::default()
1270        });
1271        let err = cfg.validate().unwrap_err().to_string();
1272        assert!(
1273            err.contains("duplicate provider name"),
1274            "expected duplicate-name error, got: {err}"
1275        );
1276    }
1277
1278    #[test]
1279    fn validate_rejects_multiple_default_providers() {
1280        let mut cfg = Config::default();
1281        cfg.llm.providers[0].default = true;
1282        cfg.llm.providers.push(crate::providers::ProviderEntry {
1283            provider_type: crate::providers::ProviderKind::Ollama,
1284            name: Some("second".into()),
1285            default: true,
1286            ..Default::default()
1287        });
1288        let err = cfg.validate().unwrap_err().to_string();
1289        assert!(
1290            err.contains("default = true"),
1291            "expected multiple-default error, got: {err}"
1292        );
1293    }
1294
1295    #[test]
1296    fn validate_rejects_stt_provider_pointing_at_nonexistent_provider() {
1297        let mut cfg = Config::default();
1298        cfg.llm.stt = Some(crate::providers::SttConfig {
1299            provider: crate::providers::ProviderName::new("ghost"),
1300            language: crate::providers::default_stt_language(),
1301        });
1302        let err = cfg.validate().unwrap_err().to_string();
1303        assert!(
1304            err.contains("[llm.stt].provider") && err.contains("ghost"),
1305            "expected stt-provider-mismatch error, got: {err}"
1306        );
1307    }
1308
1309    #[test]
1310    fn validate_accepts_stt_provider_matching_existing_provider() {
1311        let mut cfg = Config::default();
1312        cfg.llm.stt = Some(crate::providers::SttConfig {
1313            provider: crate::providers::ProviderName::new("ollama"),
1314            language: crate::providers::default_stt_language(),
1315        });
1316        assert!(cfg.validate().is_ok());
1317    }
1318
1319    #[test]
1320    fn validate_rejects_trajectory_sentinel_inverted_thresholds() {
1321        let mut cfg = Config::default();
1322        cfg.security.trajectory.elevated_at = 0.9;
1323        cfg.security.trajectory.high_at = 0.5;
1324        let err = cfg.validate().unwrap_err().to_string();
1325        assert!(
1326            err.contains("elevated_at") && err.contains("high_at"),
1327            "expected trajectory threshold-ordering error, got: {err}"
1328        );
1329    }
1330
1331    #[test]
1332    fn validate_rejects_gateway_invalid_webhook_timeout() {
1333        // `rate_limit` and `max_body_size` are already covered by `validate_scalar_bounds`
1334        // (runs earlier in the pipeline), so testing those wouldn't prove
1335        // `GatewayConfig::validate()` is actually wired in — it would pass identically on
1336        // pre-#5932 code (critic-flagged shadowing, S2). `webhook_send_timeout_secs` is the
1337        // one field uniquely reachable only through the new call.
1338        let mut cfg = Config::default();
1339        cfg.gateway.webhook_send_timeout_secs = 0;
1340        let err = cfg.validate().unwrap_err().to_string();
1341        assert!(
1342            err.contains("webhook_send_timeout_secs"),
1343            "expected gateway webhook_send_timeout_secs error, got: {err}"
1344        );
1345    }
1346
1347    #[test]
1348    fn validate_rejects_negative_utility_scoring_weight() {
1349        let mut cfg = Config::default();
1350        cfg.tools.utility.gain_weight = -1.0;
1351        let err = cfg.validate().unwrap_err().to_string();
1352        assert!(
1353            err.contains("gain_weight"),
1354            "expected utility-scoring weight error, got: {err}"
1355        );
1356    }
1357
1358    #[test]
1359    fn validate_rejects_fidelity_threshold_ordering() {
1360        let mut cfg = Config::default();
1361        cfg.memory.fidelity = Some(crate::fidelity::FidelityConfig {
1362            full_threshold: 0.2,
1363            compressed_threshold: 0.5,
1364            ..Default::default()
1365        });
1366        let err = cfg.validate().unwrap_err().to_string();
1367        assert!(
1368            err.contains("full_threshold") && err.contains("compressed_threshold"),
1369            "expected fidelity threshold-ordering error, got: {err}"
1370        );
1371    }
1372
1373    #[test]
1374    fn validate_accepts_absent_fidelity_config() {
1375        let cfg = Config::default();
1376        assert!(cfg.memory.fidelity.is_none());
1377        assert!(cfg.validate().is_ok());
1378    }
1379
1380    #[test]
1381    fn validate_rejects_acon_inverted_thresholds() {
1382        let mut cfg = Config::default();
1383        cfg.memory.compression.acon.passthrough_threshold = 5000;
1384        cfg.memory.compression.acon.summarize_threshold = 1000;
1385        let err = cfg.validate().unwrap_err().to_string();
1386        assert!(
1387            err.contains("passthrough_threshold") && err.contains("summarize_threshold"),
1388            "expected acon threshold-ordering error, got: {err}"
1389        );
1390    }
1391
1392    #[test]
1393    fn validate_rejects_shadow_memory_inverted_thresholds() {
1394        let mut cfg = Config::default();
1395        cfg.memory.shadow_memory.enabled = true;
1396        cfg.memory.shadow_memory.escalation_threshold = 0.75;
1397        cfg.memory.shadow_memory.risk_threshold = 0.50;
1398        let err = cfg.validate().unwrap_err().to_string();
1399        assert!(
1400            err.contains("escalation_threshold") && err.contains("risk_threshold"),
1401            "expected shadow_memory threshold-ordering error, got: {err}"
1402        );
1403    }
1404
1405    #[test]
1406    fn validate_rejects_shadow_memory_equal_thresholds() {
1407        let mut cfg = Config::default();
1408        cfg.memory.shadow_memory.enabled = true;
1409        cfg.memory.shadow_memory.escalation_threshold = 0.6;
1410        cfg.memory.shadow_memory.risk_threshold = 0.6;
1411        assert!(
1412            cfg.validate().is_err(),
1413            "equal thresholds must be rejected — the escalation band would be empty"
1414        );
1415    }
1416
1417    #[test]
1418    fn validate_ignores_shadow_memory_thresholds_when_disabled() {
1419        let mut cfg = Config::default();
1420        cfg.memory.shadow_memory.enabled = false;
1421        cfg.memory.shadow_memory.escalation_threshold = 0.9;
1422        cfg.memory.shadow_memory.risk_threshold = 0.1;
1423        assert!(
1424            cfg.validate().is_ok(),
1425            "inverted thresholds on a disabled shadow_memory config must not fail validation"
1426        );
1427    }
1428
1429    #[test]
1430    fn validate_rejects_worktree_max_worktrees_zero() {
1431        let mut cfg = Config::default();
1432        cfg.worktree.max_worktrees = Some(0);
1433        let err = cfg.validate().unwrap_err().to_string();
1434        assert!(
1435            err.contains("max_worktrees"),
1436            "expected max_worktrees in error, got: {err}"
1437        );
1438    }
1439
1440    #[test]
1441    fn validate_accepts_worktree_max_worktrees_positive_or_unset() {
1442        let mut cfg = Config::default();
1443        cfg.worktree.max_worktrees = Some(1);
1444        assert!(cfg.validate().is_ok());
1445        cfg.worktree.max_worktrees = None;
1446        assert!(cfg.validate().is_ok());
1447    }
1448
1449    #[test]
1450    fn validate_rejects_worktree_disk_quota_mb_zero() {
1451        let mut cfg = Config::default();
1452        cfg.worktree.disk_quota_mb = Some(0);
1453        let err = cfg.validate().unwrap_err().to_string();
1454        assert!(
1455            err.contains("disk_quota_mb"),
1456            "expected disk_quota_mb in error, got: {err}"
1457        );
1458    }
1459
1460    #[test]
1461    fn validate_accepts_worktree_disk_quota_mb_positive_or_unset() {
1462        let mut cfg = Config::default();
1463        cfg.worktree.disk_quota_mb = Some(1);
1464        assert!(cfg.validate().is_ok());
1465        cfg.worktree.disk_quota_mb = None;
1466        assert!(cfg.validate().is_ok());
1467    }
1468
1469    /// Review N1 / critic M1(b): `disk_quota_mb` set with neither the startup sweep nor the
1470    /// periodic sweep enabled means the quota is evaluated nowhere automatically — must be a
1471    /// hard config error, not a silent no-op.
1472    #[test]
1473    fn validate_rejects_worktree_disk_quota_mb_with_no_evaluation_path_enabled() {
1474        let mut cfg = Config::default();
1475        cfg.worktree.disk_quota_mb = Some(100);
1476        cfg.worktree.auto_reconcile_secs = 0;
1477        cfg.worktree.reconcile_on_startup = false;
1478        let err = cfg.validate().unwrap_err().to_string();
1479        assert!(
1480            err.contains("disk_quota_mb") && err.contains("never automatically"),
1481            "expected inert-path error, got: {err}"
1482        );
1483    }
1484
1485    #[test]
1486    fn validate_accepts_worktree_disk_quota_mb_when_startup_sweep_enabled() {
1487        let mut cfg = Config::default();
1488        cfg.worktree.disk_quota_mb = Some(100);
1489        cfg.worktree.auto_reconcile_secs = 0;
1490        cfg.worktree.reconcile_on_startup = true;
1491        assert!(cfg.validate().is_ok());
1492    }
1493
1494    #[test]
1495    fn validate_accepts_worktree_disk_quota_mb_when_periodic_sweep_enabled() {
1496        let mut cfg = Config::default();
1497        cfg.worktree.disk_quota_mb = Some(100);
1498        cfg.worktree.auto_reconcile_secs = 3600;
1499        cfg.worktree.reconcile_on_startup = false;
1500        assert!(cfg.validate().is_ok());
1501    }
1502
1503    /// Review perf#3: a short `auto_reconcile_secs` runs a full filesystem walk in a tight
1504    /// loop — must be rejected, matching the `Some(0)` rejection style for the sibling fields.
1505    #[test]
1506    fn validate_rejects_worktree_auto_reconcile_secs_short_interval() {
1507        let mut cfg = Config::default();
1508        cfg.worktree.auto_reconcile_secs = 1;
1509        let err = cfg.validate().unwrap_err().to_string();
1510        assert!(
1511            err.contains("auto_reconcile_secs"),
1512            "expected auto_reconcile_secs in error, got: {err}"
1513        );
1514    }
1515
1516    #[test]
1517    fn validate_accepts_worktree_auto_reconcile_secs_zero_or_at_least_60() {
1518        let mut cfg = Config::default();
1519        cfg.worktree.auto_reconcile_secs = 0;
1520        assert!(cfg.validate().is_ok());
1521        cfg.worktree.auto_reconcile_secs = 60;
1522        assert!(cfg.validate().is_ok());
1523        cfg.worktree.auto_reconcile_secs = 3600;
1524        assert!(cfg.validate().is_ok());
1525    }
1526
1527    /// Regression test (critic S1): `Config::default()` must itself satisfy `validate_pool`
1528    /// so `--dump-config-defaults` (which serializes `Config::default()` verbatim,
1529    /// `src/runner.rs`) emits a config that `zeph --config <dump>` can actually load and
1530    /// validate, rather than a self-inconsistent onboarding trap.
1531    #[test]
1532    fn dump_defaults_output_is_self_consistent_and_validates() {
1533        assert!(Config::default().validate().is_ok());
1534
1535        let dumped = Config::dump_defaults().expect("dump defaults");
1536        assert!(
1537            dumped.contains("[[llm.providers]]"),
1538            "dumped defaults must include an active provider entry, got:\n{dumped}"
1539        );
1540        let reparsed: Config = toml::from_str(&dumped).expect("reparse dumped defaults");
1541        assert!(reparsed.validate().is_ok());
1542    }
1543
1544    // --- orchestration.ensemble validation (spec 073-orch-ensemble-merge, M5/M7) ---
1545
1546    fn config_with_ensemble(enabled: bool, verify: bool, members: Vec<&str>) -> Config {
1547        let mut cfg = Config::default();
1548        cfg.orchestration.ensemble.enabled = enabled;
1549        cfg.orchestration.ensemble.verify = verify;
1550        cfg.orchestration.ensemble.members = members.into_iter().map(String::from).collect();
1551        cfg
1552    }
1553
1554    #[test]
1555    fn ensemble_default_config_validates_trivially() {
1556        assert!(Config::default().validate().is_ok());
1557    }
1558
1559    #[test]
1560    fn ensemble_disabled_skips_member_list_validation() {
1561        // enabled=false: an invalid members list must not block startup.
1562        let cfg = config_with_ensemble(false, false, vec!["a", "b"]);
1563        assert!(cfg.validate().is_ok());
1564    }
1565
1566    #[test]
1567    fn ensemble_enabled_but_not_verify_skips_member_list_validation() {
1568        // enabled=true, verify=false: still an unused/staged config, checks skipped.
1569        let cfg = config_with_ensemble(true, false, vec!["a", "b"]);
1570        assert!(cfg.validate().is_ok());
1571    }
1572
1573    #[test]
1574    fn ensemble_active_even_length_members_rejected() {
1575        let cfg = config_with_ensemble(true, true, vec!["a", "b"]);
1576        let err = cfg.validate().unwrap_err();
1577        assert!(
1578            err.to_string().contains("must be odd and >= 3"),
1579            "unexpected error: {err}"
1580        );
1581    }
1582
1583    #[test]
1584    fn ensemble_active_short_members_rejected() {
1585        let cfg = config_with_ensemble(true, true, vec!["a"]);
1586        let err = cfg.validate().unwrap_err();
1587        assert!(
1588            err.to_string().contains("must be odd and >= 3"),
1589            "unexpected error: {err}"
1590        );
1591    }
1592
1593    #[test]
1594    fn ensemble_active_duplicate_members_rejected() {
1595        let cfg = config_with_ensemble(true, true, vec!["a", "b", "a"]);
1596        let err = cfg.validate().unwrap_err();
1597        assert!(
1598            err.to_string().contains("duplicate provider name"),
1599            "unexpected error: {err}"
1600        );
1601    }
1602
1603    #[test]
1604    fn ensemble_active_valid_odd_unique_members_accepted() {
1605        let cfg = config_with_ensemble(true, true, vec!["a", "b", "c"]);
1606        assert!(cfg.validate().is_ok());
1607    }
1608
1609    #[test]
1610    fn ensemble_active_valid_five_members_accepted() {
1611        let cfg = config_with_ensemble(true, true, vec!["a", "b", "c", "d", "e"]);
1612        assert!(cfg.validate().is_ok());
1613    }
1614
1615    // --- ema_alpha / ema_decay range validation (security P3) ---
1616
1617    #[test]
1618    fn ensemble_active_ema_alpha_above_one_rejected() {
1619        let mut cfg = config_with_ensemble(true, true, vec!["a", "b", "c"]);
1620        cfg.orchestration.ensemble.ema_alpha = 1.5;
1621        let err = cfg.validate().unwrap_err();
1622        assert!(
1623            err.to_string().contains("ema_alpha"),
1624            "unexpected error: {err}"
1625        );
1626    }
1627
1628    #[test]
1629    fn ensemble_active_ema_alpha_negative_rejected() {
1630        let mut cfg = config_with_ensemble(true, true, vec!["a", "b", "c"]);
1631        cfg.orchestration.ensemble.ema_alpha = -0.1;
1632        let err = cfg.validate().unwrap_err();
1633        assert!(
1634            err.to_string().contains("ema_alpha"),
1635            "unexpected error: {err}"
1636        );
1637    }
1638
1639    #[test]
1640    fn ensemble_active_ema_alpha_nan_rejected() {
1641        let mut cfg = config_with_ensemble(true, true, vec!["a", "b", "c"]);
1642        cfg.orchestration.ensemble.ema_alpha = f64::NAN;
1643        let err = cfg.validate().unwrap_err();
1644        assert!(
1645            err.to_string().contains("ema_alpha"),
1646            "unexpected error: {err}"
1647        );
1648    }
1649
1650    #[test]
1651    fn ensemble_active_ema_decay_above_one_rejected() {
1652        let mut cfg = config_with_ensemble(true, true, vec!["a", "b", "c"]);
1653        cfg.orchestration.ensemble.ema_decay = 1.1;
1654        let err = cfg.validate().unwrap_err();
1655        assert!(
1656            err.to_string().contains("ema_decay"),
1657            "unexpected error: {err}"
1658        );
1659    }
1660
1661    #[test]
1662    fn ensemble_active_ema_decay_negative_rejected() {
1663        let mut cfg = config_with_ensemble(true, true, vec!["a", "b", "c"]);
1664        cfg.orchestration.ensemble.ema_decay = -0.1;
1665        let err = cfg.validate().unwrap_err();
1666        assert!(
1667            err.to_string().contains("ema_decay"),
1668            "unexpected error: {err}"
1669        );
1670    }
1671
1672    #[test]
1673    fn ensemble_active_ema_boundaries_zero_and_one_accepted() {
1674        let mut cfg = config_with_ensemble(true, true, vec!["a", "b", "c"]);
1675        cfg.orchestration.ensemble.ema_alpha = 0.0;
1676        cfg.orchestration.ensemble.ema_decay = 1.0;
1677        assert!(cfg.validate().is_ok());
1678    }
1679
1680    #[test]
1681    fn ensemble_disabled_skips_ema_range_validation() {
1682        // enabled=false: an out-of-range EMA param must not block startup.
1683        let mut cfg = config_with_ensemble(false, false, vec![]);
1684        cfg.orchestration.ensemble.ema_alpha = 5.0;
1685        assert!(cfg.validate().is_ok());
1686    }
1687}