zeph_config/migrate/mod.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Config migration: add missing parameters from the canonical reference as commented-out entries.
5//!
6//! The canonical reference is the checked-in `config/default.toml` file embedded at compile time.
7//! Missing sections and keys are added as `# key = default_value` comments so users can discover
8//! and enable them without hunting through documentation.
9
10use regex::Regex;
11use toml_edit::{Array, DocumentMut, Item, Table, Value};
12
13// ── Submodules: migration steps grouped by subsystem (#4874) ─────────────────────────────────────
14mod features;
15mod infra;
16mod integrity;
17mod llm;
18mod mcp;
19mod memory;
20mod plugins;
21mod serve;
22mod session;
23mod subagent;
24mod tools;
25
26pub use features::{
27 migrate_autodream_config, migrate_caveman_config, migrate_compression_predictor_config,
28 migrate_deep_link_config, migrate_five_signal_config, migrate_goals_config,
29 migrate_knowledge_config, migrate_magic_docs_config, migrate_microcompact_config,
30 migrate_orchestration_asset_sensitivity, migrate_orchestration_command_config,
31 migrate_orchestration_ensemble, migrate_orchestration_idle_timeout,
32 migrate_orchestration_persistence, migrate_orchestration_whole_plan_verifier_timeout,
33 migrate_rate_limit_advisory, migrate_skill_trust_require_check, migrate_skills_registry,
34 migrate_telegram_expandable_blockquote_config, migrate_tui_delights, migrate_tui_mouse,
35 migrate_tui_panel_sizing, migrate_tui_theme_config, migrate_tui_theme_defaults,
36};
37pub use infra::*;
38pub use integrity::migrate_integrity_config;
39/// Advisory `GonkaGate` migration is crate-internal (registered via the [`MIGRATIONS`] registry).
40pub(crate) use llm::migrate_gonkagate_to_gonka;
41pub use llm::*;
42pub use mcp::*;
43pub use memory::*;
44pub use plugins::migrate_plugins_reputation_config;
45pub use serve::migrate_serve_config;
46pub use session::*;
47pub use subagent::{migrate_agents_delegation_mode, migrate_agents_max_spawns_per_session};
48pub use tools::*;
49
50/// Returns `true` when `name` is an active (non-commented) TOML section header in `src`.
51///
52/// Correctly handles:
53/// - Exact bare header: `[name]` on its own line.
54/// - Inline comment: `[name] # remark` — header is active.
55/// - Implicit subtable parent: `[name.foo]` implies `[name]` is active.
56/// - Commented header: `# [name]` — returns `false`.
57///
58/// # Panics
59///
60/// Never panics in practice — [`regex::escape`] always produces a valid pattern.
61#[must_use]
62pub fn section_header_present(src: &str, name: &str) -> bool {
63 // Escape the name for use in a regex pattern.
64 let escaped = regex::escape(name);
65 // Matches `[name]` or `[name.anything]`, optionally followed by whitespace/comment.
66 // Applied to trimmed lines after filtering out lines starting with `#`.
67 let pattern = format!(r"^\[{escaped}(?:\.[^\]]+)?\](?:\s*#.*)?$");
68 let re = Regex::new(&pattern).expect("regex::escape always produces a valid pattern");
69 src.lines()
70 .filter(|line| !line.trim_start().starts_with('#'))
71 .any(|line| re.is_match(line.trim()))
72}
73
74/// Canonical section ordering for top-level keys in the output document.
75static CANONICAL_ORDER: &[&str] = &[
76 "agent",
77 "llm",
78 "skills",
79 "memory",
80 "index",
81 "tools",
82 "mcp",
83 "telegram",
84 "discord",
85 "slack",
86 "a2a",
87 "acp",
88 "gateway",
89 "metrics",
90 "daemon",
91 "scheduler",
92 "orchestration",
93 "classifiers",
94 "security",
95 "vault",
96 "timeouts",
97 "cost",
98 "debug",
99 "logging",
100 "notifications",
101 "tui",
102 "agents",
103 "experiments",
104 "lsp",
105 "telemetry",
106 "session",
107 "deep_link",
108];
109
110/// Error type for migration failures.
111#[derive(Debug, thiserror::Error)]
112#[non_exhaustive]
113pub enum MigrateError {
114 /// Failed to parse the user's config.
115 #[error("failed to parse input config: {0}")]
116 Parse(#[from] toml_edit::TomlError),
117 /// Failed to parse the embedded reference config (should never happen in practice).
118 #[error("failed to parse reference config: {0}")]
119 Reference(toml_edit::TomlError),
120 /// The document structure is inconsistent (e.g. `[llm.stt].model` exists but `[llm]` table
121 /// cannot be obtained as a mutable table — can happen when `[llm]` is absent or not a table).
122 #[error("migration failed: invalid TOML structure — {0}")]
123 InvalidStructure(&'static str),
124}
125
126/// Result of a migration operation.
127#[derive(Debug)]
128pub struct MigrationResult {
129 /// The migrated TOML document as a string.
130 pub output: String,
131 /// Number of top-level keys or sub-keys modified (added or removed) during migration.
132 pub changed_count: usize,
133 /// Names of top-level sections that were modified (added or removed).
134 pub sections_changed: Vec<String>,
135}
136
137/// Migrates a user config by adding missing parameters as commented-out entries.
138///
139/// The canonical reference is embedded from `config/default.toml` at compile time.
140/// User values are never modified; only missing keys are appended as comments.
141pub struct ConfigMigrator {
142 reference_src: &'static str,
143}
144
145impl Default for ConfigMigrator {
146 fn default() -> Self {
147 Self::new()
148 }
149}
150
151impl ConfigMigrator {
152 /// Create a new migrator using the embedded canonical reference config.
153 #[must_use]
154 pub fn new() -> Self {
155 Self {
156 reference_src: include_str!("../../config/default.toml"),
157 }
158 }
159
160 /// Migrate `user_toml`: add missing parameters from the reference as commented-out entries.
161 ///
162 /// # Errors
163 ///
164 /// Returns `MigrateError::Parse` if the user's TOML is invalid.
165 /// Returns `MigrateError::Reference` if the embedded reference TOML cannot be parsed.
166 ///
167 /// # Panics
168 ///
169 /// Never panics in practice; `.expect("checked")` is unreachable because `is_table()` is
170 /// verified on the same `ref_item` immediately before calling `as_table()`.
171 pub fn migrate(&self, user_toml: &str) -> Result<MigrationResult, MigrateError> {
172 let reference_doc = self
173 .reference_src
174 .parse::<DocumentMut>()
175 .map_err(MigrateError::Reference)?;
176 let mut user_doc = user_toml.parse::<DocumentMut>()?;
177
178 let mut changed_count = 0usize;
179 let mut sections_changed: Vec<String> = Vec::new();
180 // Collected scalar/sub-table comment lines to insert after rendering.
181 // Each entry: (section_key, comment_line).
182 let mut pending_comments: Vec<(String, String)> = Vec::new();
183
184 // Walk the reference top-level keys.
185 for (key, ref_item) in reference_doc.as_table() {
186 if ref_item.is_table() {
187 let ref_table = ref_item.as_table().expect("is_table checked above");
188 if user_doc.contains_key(key) {
189 // Section exists — merge missing sub-keys.
190 if let Some(user_table) = user_doc.get_mut(key).and_then(Item::as_table_mut) {
191 let (n, comments) =
192 merge_table_commented(user_table, ref_table, key, user_toml);
193 changed_count += n;
194 pending_comments.extend(comments);
195 }
196 } else {
197 // Entire section is missing — record for textual append after rendering.
198 // Idempotency: skip if a commented block for this section was already appended.
199 if user_toml.contains(&format!("# [{key}]")) {
200 continue;
201 }
202 let commented = commented_table_block(key, ref_table);
203 if !commented.is_empty() {
204 sections_changed.push(key.to_owned());
205 }
206 changed_count += 1;
207 }
208 } else {
209 // Top-level scalar/array key.
210 if !user_doc.contains_key(key) {
211 let raw = format_commented_item(key, ref_item);
212 if !raw.is_empty() {
213 sections_changed.push(format!("__scalar__{key}"));
214 changed_count += 1;
215 }
216 }
217 }
218 }
219
220 // Render the user doc as-is first.
221 let user_str = user_doc.to_string();
222
223 // Insert collected scalar/sub-table comment lines via raw text operations.
224 // This avoids toml_edit decor roundtrip loss — guards check the rendered string.
225 let mut output = user_str;
226 for (section_key, comment_line) in &pending_comments {
227 if !section_body(&output, section_key).contains(comment_line.trim()) {
228 output = insert_after_section(&output, section_key, comment_line);
229 }
230 }
231
232 // Append missing sections as raw commented text at the end.
233 for key in §ions_changed {
234 if let Some(scalar_key) = key.strip_prefix("__scalar__") {
235 if let Some(ref_item) = reference_doc.get(scalar_key) {
236 let raw = format_commented_item(scalar_key, ref_item);
237 if !raw.is_empty() {
238 output.push('\n');
239 output.push_str(&raw);
240 output.push('\n');
241 }
242 }
243 } else if let Some(ref_table) = reference_doc.get(key.as_str()).and_then(Item::as_table)
244 {
245 let block = commented_table_block(key, ref_table);
246 if !block.is_empty() {
247 output.push('\n');
248 output.push_str(&block);
249 }
250 }
251 }
252
253 // Reorder top-level sections by canonical order.
254 output = reorder_sections(&output, CANONICAL_ORDER);
255
256 // Resolve sections_changed to only real section names (not scalars).
257 let sections_changed_clean: Vec<String> = sections_changed
258 .into_iter()
259 .filter(|k| !k.starts_with("__scalar__"))
260 .collect();
261
262 Ok(MigrationResult {
263 output,
264 changed_count,
265 sections_changed: sections_changed_clean,
266 })
267 }
268}
269
270/// Merge missing keys from `ref_table` into `user_table` as commented-out entries.
271///
272/// Returns `(count, comment_lines)` where `comment_lines` is a list of
273/// `(section_key, comment_line)` pairs to be inserted into the rendered output.
274/// Using raw-string insertion avoids `toml_edit` decor roundtrip loss.
275fn merge_table_commented(
276 user_table: &mut Table,
277 ref_table: &Table,
278 section_key: &str,
279 user_toml: &str,
280) -> (usize, Vec<(String, String)>) {
281 let mut count = 0usize;
282 let mut comments: Vec<(String, String)> = Vec::new();
283 for (key, ref_item) in ref_table {
284 if ref_item.is_table() {
285 if user_table.contains_key(key) {
286 let pair = (
287 user_table.get_mut(key).and_then(Item::as_table_mut),
288 ref_item.as_table(),
289 );
290 if let (Some(user_sub_table), Some(ref_sub_table)) = pair {
291 let sub_key = format!("{section_key}.{key}");
292 let (n, c) =
293 merge_table_commented(user_sub_table, ref_sub_table, &sub_key, user_toml);
294 count += n;
295 comments.extend(c);
296 }
297 } else if let Some(ref_sub_table) = ref_item.as_table() {
298 // Sub-table missing from user config — collect as raw commented block.
299 let dotted = format!("{section_key}.{key}");
300 let marker = format!("# [{dotted}]");
301 if !user_toml.contains(&marker) {
302 let block = commented_table_block(&dotted, ref_sub_table);
303 if !block.is_empty() {
304 comments.push((section_key.to_owned(), format!("\n{block}")));
305 count += 1;
306 }
307 }
308 }
309 } else if ref_item.is_array_of_tables() {
310 // Never inject array-of-tables entries — they are user-defined.
311 } else {
312 // Scalar/array value — check if already present (as value or as comment).
313 if !user_table.contains_key(key) {
314 let raw_value = ref_item
315 .as_value()
316 .map(value_to_toml_string)
317 .unwrap_or_default();
318 if !raw_value.is_empty() {
319 let comment_line = format!("# {key} = {raw_value}\n");
320 // Scope the guard to the target section body so that an identical key
321 // name in another section does not suppress this insertion.
322 if !section_body(user_toml, section_key).contains(comment_line.trim()) {
323 comments.push((section_key.to_owned(), comment_line));
324 count += 1;
325 }
326 }
327 }
328 }
329 }
330 (count, comments)
331}
332
333/// Return the body of `[section]` in `doc` — the text between the section header line
334/// and the next top-level `[...]` header (or end of document).
335///
336/// Used to scope idempotency guards to a single section so that a comment present in
337/// one section does not suppress insertion into a different section with the same key name.
338fn section_body<'a>(doc: &'a str, section: &str) -> &'a str {
339 let header = format!("[{section}]");
340 let Some(section_start) = doc.find(&header) else {
341 return "";
342 };
343 let body_start = section_start + header.len();
344 let body_end = doc[body_start..]
345 .find("\n[")
346 .map_or(doc.len(), |r| body_start + r);
347 &doc[body_start..body_end]
348}
349
350/// Insert `text` after the last line belonging to `[section_name]` and before the next
351/// top-level `[section]` header (or at the end of the file if no such header follows).
352///
353/// This is a purely textual operation: it does not parse TOML, making it immune to
354/// `toml_edit` decor round-trip loss.
355fn insert_after_section(raw: &str, section_name: &str, text: &str) -> String {
356 let header = format!("[{section_name}]");
357 let Some(section_start) = raw.find(&header) else {
358 return format!("{raw}{text}");
359 };
360 // Find the next top-level section `[...]` after `section_start`.
361 let search_from = section_start + header.len();
362 // Look for `\n[` which signals a new top-level section.
363 let insert_pos = raw[search_from..]
364 .find("\n[")
365 .map_or(raw.len(), |rel| search_from + rel + 1);
366 let mut out = String::with_capacity(raw.len() + text.len());
367 out.push_str(&raw[..insert_pos]);
368 out.push_str(text);
369 out.push_str(&raw[insert_pos..]);
370 out
371}
372
373/// Format a reference item as a commented TOML line: `# key = value`.
374fn format_commented_item(key: &str, item: &Item) -> String {
375 if let Some(val) = item.as_value() {
376 let raw = value_to_toml_string(val);
377 if !raw.is_empty() {
378 return format!("# {key} = {raw}\n");
379 }
380 }
381 String::new()
382}
383
384/// Render a table as a commented-out TOML block with arbitrary nesting depth.
385///
386/// `section_name` is the full dotted path (e.g. `security.content_isolation`).
387/// Returns an empty string if the table has no renderable content.
388fn commented_table_block(section_name: &str, table: &Table) -> String {
389 use std::fmt::Write as _;
390
391 let mut lines = format!("# [{section_name}]\n");
392
393 for (key, item) in table {
394 if item.is_table() {
395 if let Some(sub_table) = item.as_table() {
396 let sub_name = format!("{section_name}.{key}");
397 let sub_block = commented_table_block(&sub_name, sub_table);
398 if !sub_block.is_empty() {
399 lines.push('\n');
400 lines.push_str(&sub_block);
401 }
402 }
403 } else if item.is_array_of_tables() {
404 // Skip — user configures these manually (e.g. `[[mcp.servers]]`).
405 } else if let Some(val) = item.as_value() {
406 let raw = value_to_toml_string(val);
407 if !raw.is_empty() {
408 let _ = writeln!(lines, "# {key} = {raw}");
409 }
410 }
411 }
412
413 // Return empty if we only wrote the section header with no content.
414 if lines.trim() == format!("[{section_name}]") {
415 return String::new();
416 }
417 lines
418}
419
420/// Convert a `toml_edit::Value` to its TOML string representation.
421fn value_to_toml_string(val: &Value) -> String {
422 match val {
423 Value::String(s) => {
424 let inner = s.value();
425 format!("\"{inner}\"")
426 }
427 Value::Integer(i) => i.value().to_string(),
428 Value::Float(f) => {
429 let v = f.value();
430 // Use representation that round-trips exactly.
431 if v.fract() == 0.0 {
432 format!("{v:.1}")
433 } else {
434 format!("{v}")
435 }
436 }
437 Value::Boolean(b) => b.value().to_string(),
438 Value::Array(arr) => format_array(arr),
439 Value::InlineTable(t) => {
440 let pairs: Vec<String> = t
441 .iter()
442 .map(|(k, v)| format!("{k} = {}", value_to_toml_string(v)))
443 .collect();
444 format!("{{ {} }}", pairs.join(", "))
445 }
446 Value::Datetime(dt) => dt.value().to_string(),
447 }
448}
449
450fn format_array(arr: &Array) -> String {
451 if arr.is_empty() {
452 return "[]".to_owned();
453 }
454 let items: Vec<String> = arr.iter().map(value_to_toml_string).collect();
455 format!("[{}]", items.join(", "))
456}
457
458/// Reorder top-level sections of a TOML document string by the canonical order.
459///
460/// Sections not in the canonical list are placed at the end, preserving their relative order.
461/// This operates on the raw string rather than the parsed document to preserve comments that
462/// would otherwise be dropped by `toml_edit`'s round-trip.
463fn reorder_sections(toml_str: &str, canonical_order: &[&str]) -> String {
464 let sections = split_into_sections(toml_str);
465 if sections.is_empty() {
466 return toml_str.to_owned();
467 }
468
469 // Each entry is (header, content). Empty header = preamble block.
470 let preamble_block = sections
471 .iter()
472 .find(|(h, _)| h.is_empty())
473 .map_or("", |(_, c)| c.as_str());
474
475 let section_map: Vec<(&str, &str)> = sections
476 .iter()
477 .filter(|(h, _)| !h.is_empty())
478 .map(|(h, c)| (h.as_str(), c.as_str()))
479 .collect();
480
481 let mut out = String::new();
482 if !preamble_block.is_empty() {
483 out.push_str(preamble_block);
484 }
485
486 let mut emitted: Vec<bool> = vec![false; section_map.len()];
487
488 for &canon in canonical_order {
489 for (idx, &(header, content)) in section_map.iter().enumerate() {
490 let section_name = extract_section_name(header);
491 let top_level = section_name
492 .split('.')
493 .next()
494 .unwrap_or("")
495 .trim_start_matches('#')
496 .trim();
497 if top_level == canon && !emitted[idx] {
498 out.push_str(content);
499 emitted[idx] = true;
500 }
501 }
502 }
503
504 // Append sections not in canonical order.
505 for (idx, &(_, content)) in section_map.iter().enumerate() {
506 if !emitted[idx] {
507 out.push_str(content);
508 }
509 }
510
511 out
512}
513
514/// Extract the section name from a section header line (e.g. `[agent]` → `agent`).
515fn extract_section_name(header: &str) -> &str {
516 // Strip leading `# ` for commented headers.
517 let trimmed = header.trim().trim_start_matches("# ");
518 // Strip `[` and `]`.
519 if trimmed.starts_with('[') && trimmed.contains(']') {
520 let inner = &trimmed[1..];
521 if let Some(end) = inner.find(']') {
522 return &inner[..end];
523 }
524 }
525 trimmed
526}
527
528/// Split a TOML string into `(header_line, full_block)` pairs.
529///
530/// The first element may have an empty header representing the preamble.
531fn split_into_sections(toml_str: &str) -> Vec<(String, String)> {
532 let mut sections: Vec<(String, String)> = Vec::new();
533 let mut current_header = String::new();
534 let mut current_content = String::new();
535
536 for line in toml_str.lines() {
537 let trimmed = line.trim();
538 if is_top_level_section_header(trimmed) {
539 sections.push((current_header.clone(), current_content.clone()));
540 trimmed.clone_into(&mut current_header);
541 line.clone_into(&mut current_content);
542 current_content.push('\n');
543 } else {
544 current_content.push_str(line);
545 current_content.push('\n');
546 }
547 }
548
549 // Push the last section.
550 if !current_header.is_empty() || !current_content.is_empty() {
551 sections.push((current_header, current_content));
552 }
553
554 sections
555}
556
557/// Determine if a line is a real (non-commented) top-level section header.
558///
559/// Top-level means `[name]` with no dots. Commented headers like `# [name]`
560/// are NOT treated as section boundaries — they are migrator-generated hints.
561fn is_top_level_section_header(line: &str) -> bool {
562 if line.starts_with('[')
563 && !line.starts_with("[[")
564 && let Some(end) = line.find(']')
565 {
566 return !line[1..end].contains('.');
567 }
568 false
569}
570
571/// A single idempotent config migration step.
572///
573/// Each impl wraps one of the free-standing `migrate_*` functions and gives it a stable
574/// name used in logs and test assertions. The trait is object-safe so that steps can be
575/// stored in a `Vec<Box<dyn Migration + Send + Sync>>`.
576///
577/// # Contract for implementors
578///
579/// - `apply` **must** be idempotent: calling it twice on the same source must return the
580/// same output as calling it once.
581/// - On a no-op (nothing to migrate), `apply` returns a [`MigrationResult`] with
582/// `changed_count == 0`.
583///
584/// # Examples
585///
586/// ```rust
587/// use zeph_config::migrate::{Migration, MIGRATIONS};
588///
589/// // The registry is ordered chronologically; apply each step in sequence.
590/// let mut toml = "[agent]\nname = \"zeph\"\n".to_owned();
591/// for m in MIGRATIONS.iter() {
592/// toml = m.apply(&toml).expect("migration failed").output;
593/// }
594/// ```
595pub trait Migration: Send + Sync {
596 /// Human-readable identifier used in diagnostics and ordering assertions.
597 fn name(&self) -> &'static str;
598
599 /// Apply this migration step to `toml_src`.
600 ///
601 /// # Errors
602 ///
603 /// Propagates any [`MigrateError`] from the underlying free function.
604 fn apply(&self, toml_src: &str) -> Result<MigrationResult, MigrateError>;
605}
606
607mod steps;
608use steps::{
609 MigrateA2aCardTrustConfig, MigrateA2aServerRemoveInertFields, MigrateAcpAuthClientsConfig,
610 MigrateAcpSubagentsConfig, MigrateAgentBudgetHint, MigrateAgentRetryToToolsRetry,
611 MigrateAgentTimeReminder, MigrateAgentsDelegationMode, MigrateAgentsMaxSpawnsPerSession,
612 MigrateAutodreamConfig, MigrateCavemanConfig, MigrateCocoonProviderNotice,
613 MigrateCocoonShowBalance, MigrateCompressionPredictorConfig, MigrateDatabaseUrl,
614 MigrateDeepLinkConfig, MigrateDurableConfig, MigrateDurableHwmAdvisory,
615 MigrateDurableKeyRotation, MigrateDurableSharedDb, MigrateDurableStaleRunningAfterSecs,
616 MigrateEgressConfig, MigrateEmbedProviderRename, MigrateEvalModelToProvider,
617 MigrateFidelityTimeoutDefaults, MigrateFiveSignalConfig, MigrateFocusAutoConsolidateMinWindow,
618 MigrateForgettingConfig, MigrateGoalsConfig, MigrateGonkagateToGonka,
619 MigrateHooksPermissionDeniedConfig, MigrateHooksTurnComplete, MigrateIntegrityConfig,
620 MigrateKnowledgeConfig, MigrateLlmStreamLimits, MigrateMagicDocsConfig,
621 MigrateMcpElicitationConfig, MigrateMcpMaxConnectAttempts, MigrateMcpMediaConfig,
622 MigrateMcpRetryAndToolTimeout, MigrateMcpTrustLevels, MigrateMemoryConsentGateConfig,
623 MigrateMemoryGraph, MigrateMemoryGraphRecallIncludeImported, MigrateMemoryHebbian,
624 MigrateMemoryHebbianConsolidation, MigrateMemoryHebbianSpread, MigrateMemoryPersonaConfig,
625 MigrateMemoryReasoning, MigrateMemoryReasoningJudge, MigrateMemoryRetrieval,
626 MigrateMemoryRetrievalQueryBias, MigrateMemoryStoreConfig, MigrateMemoryTypeAwareCompose,
627 MigrateMicrocompactConfig, MigrateNliConfig, MigrateOrchestrationAssetSensitivity,
628 MigrateOrchestrationCommandConfig, MigrateOrchestrationEnsemble,
629 MigrateOrchestrationIdleTimeout, MigrateOrchestrationPersistence,
630 MigrateOrchestrationWholePlanVerifierTimeout, MigrateOrchestratorProvider, MigrateOtelFilter,
631 MigrateOverflowMaxPerCallOverride, MigratePiiFilterNames, MigratePlannerModelToProvider,
632 MigratePluginsReputationConfig, MigratePolicyProviderAndUtilityWindow,
633 MigrateProviderMaxConcurrent, MigrateQdrantApiKey, MigrateQdrantTimeoutSecs,
634 MigrateQualityConfig, MigrateRateLimitAdvisory, MigrateSandboxConfig,
635 MigrateSandboxEgressFilter, MigrateSchedulerDaemon, MigrateSearchConfig,
636 MigrateSecretMaskingConfig, MigrateServeConfig, MigrateSessionPersistProviderOverrides,
637 MigrateSessionPersistenceConfig, MigrateSessionProviderPersistence, MigrateSessionRecapConfig,
638 MigrateSessionResumeConfig, MigrateShadowSentinelConfig, MigrateShellCheckpointsConfig,
639 MigrateShellRiskChainWindowTurns, MigrateShellTransactional, MigrateSkillTrustRequireCheck,
640 MigrateSkillsRegistry, MigrateSttToProvider, MigrateSupervisorConfig,
641 MigrateTelegramExpandableBlockquoteConfig, MigrateTelemetryConfig,
642 MigrateToolsCompressionConfig, MigrateTraceMetadata, MigrateTuiDelights, MigrateTuiMouse,
643 MigrateTuiPanelSizing, MigrateTuiThemeConfig, MigrateTuiThemeDefaults,
644 MigrateUtilityHighGainTools, MigrateVigilConfig, MigrateWorktreeConfig,
645 MigrateWorktreeGitTimeout, MigrateWorktreeQuotaFields,
646};
647
648/// Ordered registry of all sequential migration steps (steps 1–99).
649///
650/// Each entry wraps the corresponding free function and is evaluated lazily at first access.
651/// The ordering is chronological; the dispatch loop in `src/commands/migrate.rs` iterates
652/// this registry rather than calling free functions individually.
653///
654/// # Examples
655///
656/// ```rust
657/// use zeph_config::migrate::MIGRATIONS;
658///
659/// // Every step in the registry has a non-empty name.
660/// for m in MIGRATIONS.iter() {
661/// assert!(!m.name().is_empty());
662/// }
663/// ```
664pub static MIGRATIONS: std::sync::LazyLock<Vec<Box<dyn Migration + Send + Sync>>> =
665 std::sync::LazyLock::new(|| {
666 vec![
667 // Steps 1–25 (pre-existing migrations)
668 Box::new(MigrateSttToProvider) as Box<dyn Migration + Send + Sync>,
669 Box::new(MigratePlannerModelToProvider),
670 Box::new(MigrateMcpTrustLevels),
671 Box::new(MigrateAgentRetryToToolsRetry),
672 Box::new(MigrateDatabaseUrl),
673 Box::new(MigrateShellTransactional),
674 Box::new(MigrateAgentBudgetHint),
675 Box::new(MigrateForgettingConfig),
676 Box::new(MigrateCompressionPredictorConfig),
677 Box::new(MigrateMicrocompactConfig),
678 Box::new(MigrateAutodreamConfig),
679 Box::new(MigrateMagicDocsConfig),
680 Box::new(MigrateTelemetryConfig),
681 Box::new(MigrateSupervisorConfig),
682 Box::new(MigrateOtelFilter),
683 Box::new(MigrateEgressConfig),
684 Box::new(MigrateVigilConfig),
685 Box::new(MigrateSandboxConfig),
686 Box::new(MigrateSandboxEgressFilter),
687 Box::new(MigrateOrchestrationPersistence),
688 Box::new(MigrateSessionRecapConfig),
689 Box::new(MigrateMcpElicitationConfig),
690 Box::new(MigrateQualityConfig),
691 Box::new(MigrateAcpSubagentsConfig),
692 Box::new(MigrateHooksPermissionDeniedConfig),
693 // Steps 26–35 (most recent migrations, pre-stable-defaults)
694 Box::new(MigrateMemoryGraph),
695 Box::new(MigrateSchedulerDaemon),
696 Box::new(MigrateMemoryRetrieval),
697 Box::new(MigrateMemoryReasoning),
698 Box::new(MigrateMemoryReasoningJudge),
699 Box::new(MigrateMemoryHebbian),
700 Box::new(MigrateMemoryHebbianConsolidation),
701 Box::new(MigrateMemoryHebbianSpread),
702 Box::new(MigrateHooksTurnComplete),
703 Box::new(MigrateFocusAutoConsolidateMinWindow),
704 // Steps 36–38 (stable-defaults: flip verified-stable config keys to on)
705 Box::new(MigrateSessionProviderPersistence),
706 Box::new(MigrateMemoryRetrievalQueryBias),
707 Box::new(MigrateMemoryPersonaConfig),
708 // Step 39 — optional Qdrant API key (#3543)
709 Box::new(MigrateQdrantApiKey),
710 // Step 40 — MCP startup auto-retry max_connect_attempts (#3568)
711 Box::new(MigrateMcpMaxConnectAttempts),
712 // Steps 41–42 — goal lifecycle and TACO compression (#3567, #3306)
713 Box::new(MigrateGoalsConfig),
714 Box::new(MigrateToolsCompressionConfig),
715 // Step 43 — orchestrator_provider for scheduling-tier LLM calls (#3300)
716 Box::new(MigrateOrchestratorProvider),
717 // Step 44 — max_concurrent per-provider admission control hint (#3299)
718 Box::new(MigrateProviderMaxConcurrent),
719 // Step 45 — advisory notice for GonkaGate → native Gonka upgrade path (#3613)
720 Box::new(MigrateGonkagateToGonka),
721 // Step 46 — advisory notice for Cocoon decentralized inference provider (#3671)
722 Box::new(MigrateCocoonProviderNotice),
723 // Step 47 — telemetry.trace_metadata OTEL resource attributes (#4160)
724 Box::new(MigrateTraceMetadata),
725 // Step 48 — five-signal SYNAPSE retrieval advisory (#4374)
726 Box::new(MigrateFiveSignalConfig),
727 // Step 49 — rename embed_provider → embedding_provider (#4480)
728 Box::new(MigrateEmbedProviderRename),
729 // Step 50 — add mcp startup_retry_backoff_ms and tool_timeout_secs (#4004)
730 Box::new(MigrateMcpRetryAndToolTimeout),
731 // Step 51 — add embed_timeout_secs and compress_timeout_secs to [memory.fidelity] (#4645, #4651)
732 Box::new(MigrateFidelityTimeoutDefaults),
733 // Step 52 — add persist_provider_overrides to [session] (#4654)
734 Box::new(MigrateSessionPersistProviderOverrides),
735 // Step 53 — add [cocoon] show_balance advisory notice (#4649)
736 Box::new(MigrateCocoonShowBalance),
737 // Step 54 — add [worktree] section with defaults (#4679)
738 Box::new(MigrateWorktreeConfig),
739 // Step 55 — add git_timeout_secs to [worktree] (#4704)
740 Box::new(MigrateWorktreeGitTimeout),
741 // Step 56 — add [llm.stream_limits] commented advisory notice (#4750)
742 Box::new(MigrateLlmStreamLimits),
743 // Step 57 — add [durable] execution-layer section, default-off (spec-064, #4949)
744 Box::new(MigrateDurableConfig),
745 // Step 58 — rename [experiments] eval_model → eval_provider (#4987)
746 Box::new(MigrateEvalModelToProvider),
747 // Step 59 — add [caveman] ultra-compressed output section (#4985)
748 Box::new(MigrateCavemanConfig),
749 // Step 60 — add [tools.shell] checkpoints_enabled and max_checkpoints (#4990)
750 Box::new(MigrateShellCheckpointsConfig),
751 // Step 61 — add [knowledge] section advisory notice (spec-067, #5017)
752 Box::new(MigrateKnowledgeConfig),
753 // Step 62 — add [deep_link] section advisory notice (spec-066, #5011)
754 Box::new(MigrateDeepLinkConfig),
755 // Step 63 — add recall_include_imported to [memory.graph] (#5015)
756 Box::new(MigrateMemoryGraphRecallIncludeImported),
757 // Step 64 — add policy_provider and utility_window advisory comments (#5067)
758 Box::new(MigratePolicyProviderAndUtilityWindow),
759 // Step 65 — add [tui.theme] advisory block (Theme System 2.0, #5087)
760 Box::new(MigrateTuiThemeConfig),
761 // Step 66 — insert active name/color_mode defaults into [tui.theme] (#5091)
762 Box::new(MigrateTuiThemeDefaults),
763 // Step 67 — add [tui.delights] advisory block (#5104)
764 Box::new(MigrateTuiDelights),
765 // Step 68 — add mouse = false advisory comment under [tui] (#5103)
766 Box::new(MigrateTuiMouse),
767 // Step 69 — add default_asset_sensitivity advisory comment under [orchestration] (spec-068, #3934)
768 Box::new(MigrateOrchestrationAssetSensitivity),
769 // Step 70 — add session-persistence keys + [session.condense] advisory block (#5343)
770 Box::new(MigrateSessionPersistenceConfig),
771 // Step 71 — add [serve] advisory block for `zeph serve` (spec-068 §9, #5343)
772 Box::new(MigrateServeConfig),
773 // Step 72 — add [security.content_isolation.nli] advisory block (#5438)
774 Box::new(MigrateNliConfig),
775 // Step 73 — add [security.content_isolation.secret_masking] advisory block (#5437)
776 Box::new(MigrateSecretMaskingConfig),
777 // Step 74 — add a commented `filter_names = false` advisory to an existing
778 // [security.pii_filter] table (#5530)
779 Box::new(MigratePiiFilterNames),
780 // Step 75 — add qdrant_timeout_secs advisory under [memory]
781 Box::new(MigrateQdrantTimeoutSecs),
782 // Step 76 — add high_gain_tools advisory under [tools.utility] (#5659)
783 Box::new(MigrateUtilityHighGainTools),
784 // Step 77 — add [[acp.auth_clients]] advisory block (#5868)
785 Box::new(MigrateAcpAuthClientsConfig),
786 // Step 78 — add [skills.registry] advisory block (spec-045, #5869)
787 Box::new(MigrateSkillsRegistry),
788 // Step 79 — add shared_db = false advisory to an existing active [durable] table (#5996)
789 Box::new(MigrateDurableSharedDb),
790 // Step 80 — add require_integrity_check_on_promote advisory to an existing active
791 // [skills.trust] table (#6087)
792 Box::new(MigrateSkillTrustRequireCheck),
793 // Step 81 — add [security.shadow_sentinel] advisory block (spec 050, #5934)
794 Box::new(MigrateShadowSentinelConfig),
795 // Step 82 — add [a2a_client] card_trust_policy/trusted_agent_keys advisory block (#5928)
796 Box::new(MigrateA2aCardTrustConfig),
797 // Step 83 — add max_worktrees/disk_quota_mb/auto_reconcile_secs/
798 // reconcile_on_startup advisory comments to an existing active [worktree]
799 // table (#5924)
800 Box::new(MigrateWorktreeQuotaFields),
801 // Step 84 — drop the inert require_tls/ssrf_protection keys from an existing
802 // active [a2a] table (#5885)
803 Box::new(MigrateA2aServerRemoveInertFields),
804 // Step 85 — add [memory.type_aware_compose] advisory block for MemGuard
805 // type-aware retrieval composition (spec 004-16, #6086)
806 Box::new(MigrateMemoryTypeAwareCompose),
807 // Step 86 — add [orchestration.ensemble] advisory block for ORCH-style
808 // deterministic verifier ensemble-merge (spec 073, #6232)
809 Box::new(MigrateOrchestrationEnsemble),
810 // Step 87 — add stale_running_after_secs advisory to an existing active
811 // [durable.retention] table for the crash-orphan sweep (spec-064, #6254)
812 Box::new(MigrateDurableStaleRunningAfterSecs),
813 // Step 88 — add default_idle_timeout_secs advisory comment under [orchestration]
814 // (spec-075-orchestration-node-control-parity, #6021)
815 Box::new(MigrateOrchestrationIdleTimeout),
816 // Step 89 — add media_passthrough = false to existing [[mcp.servers]] entries and
817 // a commented [mcp.media] advisory block (spec-072, #6241)
818 Box::new(MigrateMcpMediaConfig),
819 // Step 90 — add max_per_call_override advisory comment under [tools.overflow]
820 // (#3079)
821 Box::new(MigrateOverflowMaxPerCallOverride),
822 // Step 91 — add [orchestration.command] advisory block for Command-style
823 // dynamic task handoff (spec-080, #6363)
824 Box::new(MigrateOrchestrationCommandConfig),
825 // Step 92 — add [memory.store] advisory block for the generic cross-thread
826 // key-value store (spec-080, #6363)
827 Box::new(MigrateMemoryStoreConfig),
828 // Step 93 — add whole_plan_verifier_timeout_secs advisory comment under
829 // [orchestration] (#6379)
830 Box::new(MigrateOrchestrationWholePlanVerifierTimeout),
831 // Step 94 — add [session.resume] advisory block for the resume-visibility
832 // banner and /history bound (spec-068 §13, §18, #6420)
833 Box::new(MigrateSessionResumeConfig),
834 // Step 95 — add time_reminder_enabled/time_reminder_interval_requests advisory
835 // comments under [agent] (#6361)
836 Box::new(MigrateAgentTimeReminder),
837 // Step 96 — add [tools.search] advisory block for the native query-based
838 // web_search tool (spec 006-1-web-search, #6358)
839 Box::new(MigrateSearchConfig),
840 // Step 97 — insert active key_id = 0 into an existing [durable] table lacking it
841 // (AEAD payload-key rotation, #6447)
842 Box::new(MigrateDurableKeyRotation),
843 // Step 98 — add [plugins.reputation] advisory block for the install-time
844 // name-similarity/typosquat check (spec-043, #5864)
845 Box::new(MigratePluginsReputationConfig),
846 // Step 99 — add a documentation-only advisory comment to an existing active
847 // [durable] table noting that row-HMAC + high-water-mark tamper-evidence
848 // (issue #6360) is unconditional, not a new opt-in toggle
849 Box::new(MigrateDurableHwmAdvisory),
850 // Step 100 — add [integrity] advisory block for vault-anchor downgrade-resistance
851 // (issue #6449)
852 Box::new(MigrateIntegrityConfig),
853 // Step 101 — advisory notice for [security.rate_limit] default-on posture (#6469)
854 Box::new(MigrateRateLimitAdvisory),
855 // Step 102 — insert active delegation_mode = "proactive" into an existing
856 // [agents] table with enabled = true and no delegation_mode key (#5857)
857 Box::new(MigrateAgentsDelegationMode),
858 // Step 103 — add [memory.consent_gate] advisory block for the write-time
859 // memory-consent gate (issue #6490, MemGhost)
860 Box::new(MigrateMemoryConsentGateConfig),
861 // Step 104 — add expandable_blockquote_min_lines advisory comment to an
862 // existing active [telegram] table (spec 007-3-telegram-rich-text, #6541)
863 Box::new(MigrateTelegramExpandableBlockquoteConfig),
864 // Step 105 — insert active max_spawns_per_session = 100 into an existing
865 // [agents] table with enabled = true and no max_spawns_per_session key (#6545)
866 Box::new(MigrateAgentsMaxSpawnsPerSession),
867 // Step 106 — add risk_chain_window_turns advisory comment to [tools.shell]
868 // for RiskChainAccumulator's cross-turn multi-step chain detection (#6603)
869 Box::new(MigrateShellRiskChainWindowTurns),
870 // Step 107 — add panel_sizing = "auto" advisory comment under [tui] (#6675)
871 Box::new(MigrateTuiPanelSizing),
872 ]
873 });
874
875// Helper to create a formatted value (used in tests).
876#[cfg(test)]
877fn make_formatted_str(s: &str) -> Value {
878 use toml_edit::Formatted;
879 Value::String(Formatted::new(s.to_owned()))
880}
881
882#[cfg(test)]
883mod tests;