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 §ions_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;