Skip to content

Commit de900e0

Browse files
authored
fix(config): wire dead validate() functions and fix migrate idempotency guards (#6058)
Config::validate() now calls 7 subsystem validate() functions that had real invariant checks but were unreachable from the production config-load path, most severely validate_pool() which two code comments elsewhere treat as a load-bearing guarantee against an empty [[llm.providers]] list. Config::default() now seeds one provider so --dump-config-defaults and the no-config-file TUI-remote fallback stay self-consistent with the newly enforced invariant. Also fixes two --migrate-config --in-place steps that re-appended their advisory block on every run instead of converging after one, same defect class as #5945: the mcp steps anchored on a raw substring that matched inside a commented catch-all stub, and the memory query-bias step guarded on the wrong (uncommented) form of its own commented output. Closes #5932 Closes #6018
1 parent 03e162f commit de900e0

8 files changed

Lines changed: 436 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
8989

9090
### Fixed
9191

92+
- `fix(config)`: `Config::validate()` now calls 7 subsystem `validate()` functions that existed
93+
with real invariant checks but were unreachable from the production config-load path — only
94+
their own unit tests exercised them (#5932). `validate_pool()` was the most severe gap: two
95+
code comments elsewhere in the codebase state, verbatim, that it "rejects an empty
96+
`[[llm.providers]]` list at config-validation time" and treat that as a load-bearing guarantee,
97+
but it was never actually invoked outside its own test module — a config with zero providers,
98+
duplicate provider names, or multiple `default = true` entries previously loaded and validated
99+
without error. The other six wired checks: `LlmConfig::validate_stt()` (dangling
100+
`[llm.stt].provider` reference), `TrajectorySentinelConfig::validate()` (inverted risk
101+
thresholds), `GatewayConfig::validate()`, `UtilityScoringConfig::validate()`,
102+
`FidelityConfig::validate()`, and `AconConfig::validate()`. Also added `#[must_use]` to
103+
`LlmConfig::validate_stt()` (missed by the earlier #4943/#4963 sweep since added afterward).
104+
Because `Config::default()` had an empty provider pool, wiring `validate_pool()` made
105+
`Config::default().validate()` fail, which in turn broke `--dump-config-defaults` and the
106+
no-config-file fallback in `zeph --tui --connect`; fixed by seeding `Config::default()` with one
107+
`ProviderEntry::default()` (`type = "ollama"`), matching the shipped `config/default.toml`
108+
reference, which was already self-consistent.
109+
- `fix(config)`: two more `--migrate-config --in-place` steps re-appended their advisory block on
110+
every run instead of converging after one, same defect class as #5945 (#6018). The
111+
`mcp`/`mcp.elicitation`/`mcp` max-connect-attempts/retry-and-tool-timeout steps anchored on a
112+
raw `toml_src.contains("[mcp]\n")` substring, which also matches inside a *commented* `# [mcp]`
113+
stub left by the top-level migrator's catch-all pass when `[mcp]` was absent on a prior run —
114+
now gated by `section_header_present(toml_src, "mcp")`, which correctly excludes commented
115+
headers. `migrate_memory_retrieval_query_bias`'s idempotency guard checked for an *uncommented*
116+
`query_bias_correction` field while the step only ever writes a *commented* advisory line, so
117+
the guard never matched its own prior output; changed to a `contains` check on the raw source
118+
(and switched to `section_header_present` for its section-presence check, for consistency with
119+
the sibling `[memory.hebbian]` steps fixed in #5945).
92120
- `fix(tui)`: the durable executions overlay panel (`D` key) no longer bleeds stray glyphs from
93121
whatever widget last drew into the same sidebar `Rect` earlier in the frame (e.g. a trailing
94122
`e> t` fragment left over from the subagents/plan/security view rendered underneath) — `Clear`

crates/zeph-config/src/loader.rs

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,29 @@ impl Config {
8585
self.acp
8686
.validate_auth_clients()
8787
.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)?;
88111
Ok(())
89112
}
90113

@@ -1091,4 +1114,167 @@ weight = 0.3
10911114
"unexpected error: {err}"
10921115
);
10931116
}
1117+
1118+
// ── #5932: 7 previously-dead validate() functions now wired into Config::validate() ──────
1119+
1120+
#[test]
1121+
fn validate_rejects_empty_provider_pool() {
1122+
// This is the most severe gap from #5932: `validate_pool` was documented (verbatim)
1123+
// as a load-bearing guarantee by tier_loop.rs/arise.rs but was never actually wired
1124+
// in. `Config::default()` itself now seeds one provider (critic S1 follow-up, so
1125+
// `--dump-config-defaults` output stays self-consistent) — clear it explicitly to
1126+
// exercise the empty-pool branch.
1127+
let mut cfg = Config::default();
1128+
cfg.llm.providers.clear();
1129+
let err = cfg.validate().unwrap_err().to_string();
1130+
assert!(
1131+
err.contains("at least one LLM provider"),
1132+
"expected empty-pool error, got: {err}"
1133+
);
1134+
}
1135+
1136+
#[test]
1137+
fn validate_rejects_duplicate_provider_names() {
1138+
let mut cfg = Config::default();
1139+
cfg.llm.providers.push(crate::providers::ProviderEntry {
1140+
provider_type: crate::providers::ProviderKind::Ollama,
1141+
..Default::default()
1142+
});
1143+
let err = cfg.validate().unwrap_err().to_string();
1144+
assert!(
1145+
err.contains("duplicate provider name"),
1146+
"expected duplicate-name error, got: {err}"
1147+
);
1148+
}
1149+
1150+
#[test]
1151+
fn validate_rejects_multiple_default_providers() {
1152+
let mut cfg = Config::default();
1153+
cfg.llm.providers[0].default = true;
1154+
cfg.llm.providers.push(crate::providers::ProviderEntry {
1155+
provider_type: crate::providers::ProviderKind::Ollama,
1156+
name: Some("second".into()),
1157+
default: true,
1158+
..Default::default()
1159+
});
1160+
let err = cfg.validate().unwrap_err().to_string();
1161+
assert!(
1162+
err.contains("default = true"),
1163+
"expected multiple-default error, got: {err}"
1164+
);
1165+
}
1166+
1167+
#[test]
1168+
fn validate_rejects_stt_provider_pointing_at_nonexistent_provider() {
1169+
let mut cfg = Config::default();
1170+
cfg.llm.stt = Some(crate::providers::SttConfig {
1171+
provider: crate::providers::ProviderName::new("ghost"),
1172+
language: crate::providers::default_stt_language(),
1173+
});
1174+
let err = cfg.validate().unwrap_err().to_string();
1175+
assert!(
1176+
err.contains("[llm.stt].provider") && err.contains("ghost"),
1177+
"expected stt-provider-mismatch error, got: {err}"
1178+
);
1179+
}
1180+
1181+
#[test]
1182+
fn validate_accepts_stt_provider_matching_existing_provider() {
1183+
let mut cfg = Config::default();
1184+
cfg.llm.stt = Some(crate::providers::SttConfig {
1185+
provider: crate::providers::ProviderName::new("ollama"),
1186+
language: crate::providers::default_stt_language(),
1187+
});
1188+
assert!(cfg.validate().is_ok());
1189+
}
1190+
1191+
#[test]
1192+
fn validate_rejects_trajectory_sentinel_inverted_thresholds() {
1193+
let mut cfg = Config::default();
1194+
cfg.security.trajectory.elevated_at = 0.9;
1195+
cfg.security.trajectory.high_at = 0.5;
1196+
let err = cfg.validate().unwrap_err().to_string();
1197+
assert!(
1198+
err.contains("elevated_at") && err.contains("high_at"),
1199+
"expected trajectory threshold-ordering error, got: {err}"
1200+
);
1201+
}
1202+
1203+
#[test]
1204+
fn validate_rejects_gateway_invalid_webhook_timeout() {
1205+
// `rate_limit` and `max_body_size` are already covered by `validate_scalar_bounds`
1206+
// (runs earlier in the pipeline), so testing those wouldn't prove
1207+
// `GatewayConfig::validate()` is actually wired in — it would pass identically on
1208+
// pre-#5932 code (critic-flagged shadowing, S2). `webhook_send_timeout_secs` is the
1209+
// one field uniquely reachable only through the new call.
1210+
let mut cfg = Config::default();
1211+
cfg.gateway.webhook_send_timeout_secs = 0;
1212+
let err = cfg.validate().unwrap_err().to_string();
1213+
assert!(
1214+
err.contains("webhook_send_timeout_secs"),
1215+
"expected gateway webhook_send_timeout_secs error, got: {err}"
1216+
);
1217+
}
1218+
1219+
#[test]
1220+
fn validate_rejects_negative_utility_scoring_weight() {
1221+
let mut cfg = Config::default();
1222+
cfg.tools.utility.gain_weight = -1.0;
1223+
let err = cfg.validate().unwrap_err().to_string();
1224+
assert!(
1225+
err.contains("gain_weight"),
1226+
"expected utility-scoring weight error, got: {err}"
1227+
);
1228+
}
1229+
1230+
#[test]
1231+
fn validate_rejects_fidelity_threshold_ordering() {
1232+
let mut cfg = Config::default();
1233+
cfg.memory.fidelity = Some(crate::fidelity::FidelityConfig {
1234+
full_threshold: 0.2,
1235+
compressed_threshold: 0.5,
1236+
..Default::default()
1237+
});
1238+
let err = cfg.validate().unwrap_err().to_string();
1239+
assert!(
1240+
err.contains("full_threshold") && err.contains("compressed_threshold"),
1241+
"expected fidelity threshold-ordering error, got: {err}"
1242+
);
1243+
}
1244+
1245+
#[test]
1246+
fn validate_accepts_absent_fidelity_config() {
1247+
let cfg = Config::default();
1248+
assert!(cfg.memory.fidelity.is_none());
1249+
assert!(cfg.validate().is_ok());
1250+
}
1251+
1252+
#[test]
1253+
fn validate_rejects_acon_inverted_thresholds() {
1254+
let mut cfg = Config::default();
1255+
cfg.memory.compression.acon.passthrough_threshold = 5000;
1256+
cfg.memory.compression.acon.summarize_threshold = 1000;
1257+
let err = cfg.validate().unwrap_err().to_string();
1258+
assert!(
1259+
err.contains("passthrough_threshold") && err.contains("summarize_threshold"),
1260+
"expected acon threshold-ordering error, got: {err}"
1261+
);
1262+
}
1263+
1264+
/// Regression test (critic S1): `Config::default()` must itself satisfy `validate_pool`
1265+
/// so `--dump-config-defaults` (which serializes `Config::default()` verbatim,
1266+
/// `src/runner.rs`) emits a config that `zeph --config <dump>` can actually load and
1267+
/// validate, rather than a self-inconsistent onboarding trap.
1268+
#[test]
1269+
fn dump_defaults_output_is_self_consistent_and_validates() {
1270+
assert!(Config::default().validate().is_ok());
1271+
1272+
let dumped = Config::dump_defaults().expect("dump defaults");
1273+
assert!(
1274+
dumped.contains("[[llm.providers]]"),
1275+
"dumped defaults must include an active provider entry, got:\n{dumped}"
1276+
);
1277+
let reparsed: Config = toml::from_str(&dumped).expect("reparse dumped defaults");
1278+
assert!(reparsed.validate().is_ok());
1279+
}
10941280
}

crates/zeph-config/src/migrate/mcp.rs

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
//! the [`Migration`](super::Migration) trait, and the [`MIGRATIONS`](super::MIGRATIONS)
88
//! registry remain in the parent module.
99
10-
use super::{MigrateError, MigrationResult};
10+
use super::{MigrateError, MigrationResult, section_header_present};
1111

1212
/// Migrate `[[mcp.servers]]` entries to add `trust_level = "trusted"` for any entry
1313
/// that lacks an explicit `trust_level`.
@@ -79,30 +79,31 @@ pub fn migrate_mcp_trust_levels(toml_src: &str) -> Result<MigrationResult, Migra
7979
///
8080
/// All elicitation fields have `#[serde(default)]` so existing configs parse without changes.
8181
///
82+
/// Idempotent: the guard for the anchor section checks for an *active* `[mcp]` header via
83+
/// [`section_header_present`], not a naive substring match on `"[mcp]\n"` — the latter also
84+
/// matches inside a commented `# [mcp]` header (e.g. the stub the `ConfigMigrator` catch-all
85+
/// pass writes when the whole section is absent), which would splice this step's advisory
86+
/// comment into the middle of that commented block on a later run instead of staying a no-op
87+
/// (#6018).
88+
///
8289
/// # Errors
8390
///
8491
/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
8592
pub fn migrate_mcp_elicitation_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
86-
// Idempotency: check for any elicitation key presence.
87-
if toml_src.contains("elicitation_enabled") || toml_src.contains("# elicitation_enabled") {
88-
return Ok(MigrationResult {
89-
output: toml_src.to_owned(),
90-
changed_count: 0,
91-
sections_changed: Vec::new(),
92-
});
93-
}
94-
95-
// Only inject under an existing [mcp] section.
96-
if !toml_src.contains("[mcp]") {
93+
// Idempotency: the written form is always commented, so a plain substring check catches
94+
// both the active and commented form of the key.
95+
if toml_src.contains("elicitation_enabled") {
9796
return Ok(MigrationResult {
9897
output: toml_src.to_owned(),
9998
changed_count: 0,
10099
sections_changed: Vec::new(),
101100
});
102101
}
103102

104-
// Guard against configs that have `[mcp]` but with Windows line endings or at EOF.
105-
if !toml_src.contains("[mcp]\n") {
103+
// Only inject under an active [mcp] section. Also guards against Windows line endings
104+
// or `[mcp]` at EOF, where the literal `"[mcp]\n"` anchor used by `replacen` below
105+
// would not match.
106+
if !section_header_present(toml_src, "mcp") || !toml_src.contains("[mcp]\n") {
106107
return Ok(MigrationResult {
107108
output: toml_src.to_owned(),
108109
changed_count: 0,
@@ -130,6 +131,11 @@ pub fn migrate_mcp_elicitation_config(toml_src: &str) -> Result<MigrationResult,
130131
/// configs omit it and get the default value of `3`. This migration surfaces the key
131132
/// as a comment so users can discover and tune it.
132133
///
134+
/// Idempotent: the anchor guard requires an *active* `[mcp]` header via
135+
/// [`section_header_present`] rather than a naive substring match on `"[mcp]\n"`, which
136+
/// would also match inside a commented `# [mcp]` header (#6018, same defect shape as
137+
/// [`migrate_mcp_elicitation_config`]).
138+
///
133139
/// # Errors
134140
///
135141
/// Returns `Ok` with unchanged output when the key is already present or `[mcp]` is absent.
@@ -142,7 +148,7 @@ pub fn migrate_mcp_max_connect_attempts(toml_src: &str) -> Result<MigrationResul
142148
});
143149
}
144150

145-
if !toml_src.contains("[mcp]\n") {
151+
if !section_header_present(toml_src, "mcp") || !toml_src.contains("[mcp]\n") {
146152
return Ok(MigrationResult {
147153
output: toml_src.to_owned(),
148154
changed_count: 0,
@@ -167,14 +173,22 @@ pub fn migrate_mcp_max_connect_attempts(toml_src: &str) -> Result<MigrationResul
167173
/// Both keys have `#[serde(default)]` and require no user action; this migration surfaces them
168174
/// so operators can discover and tune the new retry and per-call timeout settings.
169175
///
176+
/// Idempotent: the anchor guard requires an *active* `[mcp]` header via
177+
/// [`section_header_present`] rather than a naive substring match on `"[mcp]\n"`, which
178+
/// would also match inside a commented `# [mcp]` header (#6018, same defect shape as
179+
/// [`migrate_mcp_elicitation_config`]).
180+
///
170181
/// # Errors
171182
///
172183
/// Returns `Ok` with unchanged output when either key is already present or `[mcp]` is absent.
173184
pub fn migrate_mcp_retry_and_tool_timeout(toml_src: &str) -> Result<MigrationResult, MigrateError> {
174185
let has_backoff = toml_src.contains("startup_retry_backoff_ms");
175186
let has_timeout = toml_src.contains("tool_timeout_secs");
176187

177-
if (has_backoff && has_timeout) || !toml_src.contains("[mcp]\n") {
188+
if (has_backoff && has_timeout)
189+
|| !section_header_present(toml_src, "mcp")
190+
|| !toml_src.contains("[mcp]\n")
191+
{
178192
return Ok(MigrationResult {
179193
output: toml_src.to_owned(),
180194
changed_count: 0,

crates/zeph-config/src/migrate/memory.rs

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -507,7 +507,11 @@ pub fn migrate_focus_auto_consolidate_min_window(
507507
/// (MM-F3, #3341) and is verified working in CI-604/CI-605. It is a no-op when the persona
508508
/// table is empty, so enabling it by default is safe.
509509
///
510-
/// Idempotent: the section header (live or commented) suppresses re-injection.
510+
/// Idempotent: the step only ever writes `query_bias_correction` as a commented advisory
511+
/// line, so the guard checks for the key as a raw substring (matching both the active and
512+
/// commented form) rather than `starts_with` on trimmed lines, which never matches a
513+
/// `#`-prefixed line and would re-append the block on every run (#6018, same defect shape
514+
/// fixed for the `[memory.hebbian]` steps in #5945).
511515
///
512516
/// # Errors
513517
///
@@ -516,20 +520,21 @@ pub fn migrate_memory_retrieval_query_bias(
516520
toml_src: &str,
517521
) -> Result<MigrationResult, MigrateError> {
518522
// Already handled by migrate_memory_retrieval_config if the whole section is absent.
519-
// This step only splices the key into an existing [memory.retrieval] section.
520-
if !toml_src.lines().any(|l| l.trim() == "[memory.retrieval]") {
523+
// This step only splices the key into an existing [memory.retrieval] section. Uses
524+
// `section_header_present` (not a bare exact-line match) so an inline-commented header
525+
// (`[memory.retrieval] # note`) is still recognized as active, matching the pattern
526+
// used by the sibling `[memory.hebbian]` steps (critic M3, #5945 lesson).
527+
if !section_header_present(toml_src, "memory.retrieval") {
521528
return Ok(MigrationResult {
522529
output: toml_src.to_owned(),
523530
changed_count: 0,
524531
sections_changed: Vec::new(),
525532
});
526533
}
527534

528-
// Idempotent: key already present (active or as comment).
529-
if toml_src
530-
.lines()
531-
.any(|l| l.trim().starts_with("query_bias_correction"))
532-
{
535+
// Idempotent: key already present, active or commented — the step only ever writes
536+
// the commented form, so a plain substring check catches both.
537+
if toml_src.contains("query_bias_correction") {
533538
return Ok(MigrationResult {
534539
output: toml_src.to_owned(),
535540
changed_count: 0,

0 commit comments

Comments
 (0)