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