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