Skip to main content

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