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