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 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 &sections_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;