Skip to main content

zeroclaw_config/schema/
v2.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4/// V1/V2 supported a "colon-URL" provider string form (e.g.
5/// `"anthropic-custom:https://api.z.ai/api/anthropic"`) where the URL was
6/// embedded inline. V3 uses a typed `uri` field on the per-provider
7/// alias entry. This helper splits the colon-URL form into `(type, url)`
8/// so the migration can use `type` as the V3 provider key and store the
9/// URL in `uri` on the alias entry. Returns `(type_key, Some(url))`
10/// for colon-URL forms; otherwise `(raw.to_string(), None)`.
11fn split_colon_url_provider(raw: &str) -> (String, Option<String>) {
12    if let Some(colon_idx) = raw.find(':') {
13        let (prefix, rest) = raw.split_at(colon_idx);
14        let url = &rest[1..];
15        if (prefix == "custom" || prefix == "anthropic-custom")
16            && (url.starts_with("https://") || url.starts_with("http://"))
17        {
18            return (prefix.to_string(), Some(url.to_string()));
19        }
20    }
21    (raw.to_string(), None)
22}
23
24/// V2 partial typed lens. Everything not explicitly named flows through
25/// `passthrough` unchanged.
26#[derive(Debug, Default, Deserialize, Serialize)]
27pub struct V2Config {
28    #[serde(default = "default_v2_schema_version")]
29    pub schema_version: u32,
30
31    /// V3 synthesizes `risk_profiles` from this block.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub autonomy: Option<toml::Value>,
34
35    /// V3 synthesizes `runtime_profiles` from this block.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub agent: Option<toml::Value>,
38
39    /// V3 dropped swarms entirely.
40    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
41    pub swarms: HashMap<String, toml::Value>,
42
43    /// V3 restructures cron: `[cron.<alias>] = CronJobDecl`; subsystem knobs
44    /// (`enabled`, `catch_up_on_startup`, `max_run_history`) move to `[scheduler]`.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub cron: Option<toml::Value>,
47
48    /// V3 restructures providers: drops `fallback`, aliases `models`, adds `tts`.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub providers: Option<toml::Value>,
51
52    /// V3 drops `cost.prices`; pricing moves inline onto each model provider.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub cost: Option<toml::Value>,
55
56    /// V3 wraps each channel section in `HashMap<String, T>` (alias-keyed) and
57    /// folds `discord_history` into `discord.<alias>.archive = true`.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub channels: Option<toml::Value>,
60
61    /// V3 replaces inline brain fields on each agent with model-provider
62    /// alias references; brain fields surface as new entries under
63    /// `model_providers.<provider>.agent_<id>`.
64    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
65    pub agents: HashMap<String, toml::Value>,
66
67    /// Everything else passes through unchanged.
68    #[serde(flatten)]
69    pub passthrough: toml::Table,
70}
71
72fn default_v2_schema_version() -> u32 {
73    2
74}
75
76/// Channel section keys subject to V3 alias-wrapping. A missing entry
77/// here sends its V2 `[channels.<type>]` block through the passthrough
78/// branch, which leaves it flat instead of `<type>.default`-shaped and
79/// the V3 deserializer then chokes on the typed `HashMap<String, T>`
80/// slot. Tests cross-check this list against the typed channel slots
81/// on `ChannelsConfig` to catch silent drift.
82pub const V3_CHANNEL_TYPES: &[&str] = &[
83    "telegram",
84    "discord",
85    "slack",
86    "mattermost",
87    "webhook",
88    "imessage",
89    "matrix",
90    "signal",
91    "whatsapp",
92    "linq",
93    "wati",
94    "nextcloud_talk",
95    "email",
96    "gmail_push",
97    "irc",
98    "lark",
99    "line",
100    "dingtalk",
101    "wecom",
102    "wecom_ws",
103    "wechat",
104    "qq",
105    "twitter",
106    "mochat",
107    "nostr",
108    "clawdtalk",
109    "reddit",
110    "bluesky",
111    "voice_call",
112    "voice_wake",
113    "voice_duplex",
114    "mqtt",
115];
116
117impl V2Config {
118    /// Returns a V3-shaped `toml::Value`. The caller deserializes it
119    /// into `Config` — that round-trip is the gate that catches any
120    /// structural mismatch.
121    pub fn migrate(self) -> anyhow::Result<toml::Value> {
122        let V2Config {
123            schema_version: _,
124            autonomy,
125            agent,
126            swarms,
127            cron,
128            providers,
129            cost,
130            channels,
131            agents,
132            mut passthrough,
133        } = self;
134
135        // autonomy → risk_profiles.default + runtime_profiles.default.
136        //
137        // Authorization fields (allowlists, sandbox, approval gates,
138        // env passthrough) land on the risk profile. Budget caps
139        // (`max_actions_per_hour`, `max_cost_per_day_cents`,
140        // `shell_timeout_secs`) and recursion/timeout fields
141        // (`max_delegation_depth`, `delegation_timeout_secs`,
142        // `agentic_timeout_secs`) land on the runtime profile because
143        // they are operational tuning enforced with subagent
144        // parent-subset discipline, not authorization decisions.
145        //
146        // V2 `non_cli_excluded_tools` renames to V3 `excluded_tools`
147        // (broader scope, same shape).
148        if let Some(autonomy_value) = autonomy {
149            let renamed = rename_table_keys(
150                autonomy_value,
151                &[("non_cli_excluded_tools", "excluded_tools")],
152            );
153            let (risk_fields, runtime_fields) = split_autonomy_into_profile_buckets(renamed);
154            if let Some(risk_table) = risk_fields {
155                let mut risk_profiles = passthrough
156                    .remove("risk_profiles")
157                    .and_then(|v| v.try_into::<toml::Table>().ok())
158                    .unwrap_or_default();
159                merge_into_profile_default(&mut risk_profiles, risk_table);
160                passthrough.insert(
161                    "risk_profiles".to_string(),
162                    toml::Value::Table(risk_profiles),
163                );
164                ::zeroclaw_log::record!(
165                    INFO,
166                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
167                    "[autonomy] authorization fields → [risk_profiles.default]"
168                );
169            }
170            if let Some(runtime_table) = runtime_fields {
171                let mut runtime_profiles = passthrough
172                    .remove("runtime_profiles")
173                    .and_then(|v| v.try_into::<toml::Table>().ok())
174                    .unwrap_or_default();
175                merge_into_profile_default(&mut runtime_profiles, runtime_table);
176                passthrough.insert(
177                    "runtime_profiles".to_string(),
178                    toml::Value::Table(runtime_profiles),
179                );
180                ::zeroclaw_log::record!(
181                    INFO,
182                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
183                    "[autonomy] budget/timeout fields → [runtime_profiles.default]"
184                );
185            }
186        }
187
188        // V3 RiskProfileConfig absorbed [security.sandbox]; the
189        // [security.resources] block is dropped (max_memory_mb,
190        // max_cpu_time_seconds, max_subprocesses, memory_monitoring
191        // were never wired to any enforcement codepath; sandbox
192        // backends carry their own resource budgets).
193        fold_security_into_risk_profile(&mut passthrough);
194
195        // agent → runtime_profiles.default + risk_profiles.default.
196        //
197        // Most agent-section fields are operational tuning and land on
198        // the runtime profile. `allowed_tools` is the one authorization
199        // field on V2's `[agent]` block (which tools may the agent
200        // call), so it moves to `[risk_profiles.default.allowed_tools]`
201        // alongside `allowed_commands`.
202        if let Some(toml::Value::Table(mut agent_table)) = agent {
203            let allowed_tools = agent_table.remove("allowed_tools");
204            if let Some(at_value) = allowed_tools {
205                let mut risk_profiles = passthrough
206                    .remove("risk_profiles")
207                    .and_then(|v| v.try_into::<toml::Table>().ok())
208                    .unwrap_or_default();
209                let mut risk_default = toml::Table::new();
210                risk_default.insert("allowed_tools".to_string(), at_value);
211                merge_into_profile_default(&mut risk_profiles, risk_default);
212                passthrough.insert(
213                    "risk_profiles".to_string(),
214                    toml::Value::Table(risk_profiles),
215                );
216                ::zeroclaw_log::record!(
217                    INFO,
218                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
219                    "[agent.allowed_tools] → [risk_profiles.default.allowed_tools]"
220                );
221            }
222            if !agent_table.is_empty() {
223                let mut runtime_profiles = passthrough
224                    .remove("runtime_profiles")
225                    .and_then(|v| v.try_into::<toml::Table>().ok())
226                    .unwrap_or_default();
227                merge_into_profile_default(&mut runtime_profiles, agent_table);
228                passthrough.insert(
229                    "runtime_profiles".to_string(),
230                    toml::Value::Table(runtime_profiles),
231                );
232                ::zeroclaw_log::record!(
233                    INFO,
234                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
235                    "[agent] → [runtime_profiles.default]"
236                );
237            }
238        }
239
240        // V3 dropped swarms.
241        if !swarms.is_empty() {
242            ::zeroclaw_log::record!(
243                INFO,
244                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
245                &format!("[swarms] dropped ({} entries)", swarms.len())
246            );
247        }
248
249        // V3 eradicated provider fallback. Strip the V2 reliability
250        // fields that referenced it; the rest of [reliability] stays.
251        if let Some(toml::Value::Table(reliability_table)) = passthrough.get_mut("reliability") {
252            let dropped_fb = reliability_table.remove("fallback_providers").is_some();
253            let dropped_mf = reliability_table.remove("model_fallbacks").is_some();
254            if dropped_fb || dropped_mf {
255                ::zeroclaw_log::record!(
256                    INFO,
257                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
258                    "[reliability] {{fallback_providers, model_fallbacks}} dropped (provider fallback eradicated in V3)"
259                );
260            }
261        }
262
263        // Restructure providers: drop fallback, alias-wrap models,
264        // fold V2 [providers] globals down to per-provider entries.
265        let mut new_providers = providers
266            .and_then(|v| match v {
267                toml::Value::Table(t) => Some(t),
268                _ => None,
269            })
270            .unwrap_or_default();
271        if new_providers.remove("fallback").is_some() {
272            ::zeroclaw_log::record!(
273                INFO,
274                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
275                "providers.fallback eradicated"
276            );
277        }
278        let mut aliased_models = alias_provider_models(new_providers.remove("models"));
279
280        // V3 ModelProviderConfig absorbed the V2 [providers] globals
281        // (api_key, default_model, etc.) inline; fold them down.
282        fold_providers_globals_into_models(&mut new_providers, &mut aliased_models);
283
284        // V3 dropped cost.prices: the V2 keys ("<provider>/<model>")
285        // don't carry the V3 alias path, so remapping is fragile.
286        // Log each entry's last-known rates for manual reinstatement.
287        let cost_passthrough = if let Some(cost_value) = cost {
288            let (cost_remaining, prices) = strip_cost_prices(cost_value);
289            if !prices.is_empty() {
290                drop_cost_prices_with_logs(&prices);
291            }
292            cost_remaining
293        } else {
294            None
295        };
296        if !aliased_models.is_empty() {
297            new_providers.insert("models".to_string(), toml::Value::Table(aliased_models));
298        }
299
300        // V3 renamed the route field `provider` → `model_provider` to
301        // disambiguate from TTS/transcription providers. Apply to both
302        // the [providers.<routes>] nested form and the bare top-level
303        // [[model_routes]] / [[embedding_routes]] arrays.
304        rename_route_provider_field(&mut new_providers, "model_routes");
305        rename_route_provider_field(&mut new_providers, "embedding_routes");
306        rename_route_provider_field(&mut passthrough, "model_routes");
307        rename_route_provider_field(&mut passthrough, "embedding_routes");
308
309        // Promote V2 [tts.<type>] / [transcription.<family>] sub-blocks
310        // into V3 [<kind>_providers.<type>.default]. Global
311        // default_provider keys are dropped — V3 has no such concept;
312        // each agent declares its own provider.
313        fold_v2_tts_into_providers(&mut passthrough, &mut new_providers);
314        fold_v2_transcription_into_providers(&mut passthrough, &mut new_providers);
315
316        // V3 collapses model/tts/transcription providers under a single
317        // top-level `[providers]` table, with one sub-key per category.
318        // Hoist providers.{models,tts,transcription} into a shared
319        // `providers` table; *_routes stay top-level.
320        let mut v3_providers = toml::Table::new();
321        if let Some(models) = new_providers.remove("models") {
322            v3_providers.insert("models".to_string(), models);
323        }
324        if let Some(tts) = new_providers.remove("tts") {
325            v3_providers.insert("tts".to_string(), tts);
326        }
327        if let Some(transcription) = new_providers.remove("transcription") {
328            v3_providers.insert("transcription".to_string(), transcription);
329        }
330        if !v3_providers.is_empty() {
331            passthrough.insert("providers".to_string(), toml::Value::Table(v3_providers));
332        }
333        if let Some(routes) = new_providers.remove("model_routes") {
334            passthrough.insert("model_routes".to_string(), routes);
335        }
336        if let Some(routes) = new_providers.remove("embedding_routes") {
337            passthrough.insert("embedding_routes".to_string(), routes);
338        }
339        if !new_providers.is_empty() {
340            ::zeroclaw_log::record!(
341                WARN,
342                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
343                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
344                &format!(
345                    "[providers] residual keys dropped during V3 hoist: {:?}",
346                    new_providers.keys().collect::<Vec<_>>()
347                )
348            );
349        }
350        if let Some(remaining_cost) = cost_passthrough {
351            passthrough.insert("cost".to_string(), remaining_cost);
352        }
353
354        // V2 [memory.qdrant], [memory.postgres], and [storage.provider.config]
355        // all collapse into V3 [storage.<backend>.<alias>].
356        fold_v2_storage_subsystems(&mut passthrough);
357
358        // Alias-wrap each [channels.<type>], fold discord_history into
359        // [channels.discord.<alias>].archive, and lift per-channel
360        // inbound peer-auth fields (allowed_users, allowed_contacts,
361        // allowed_from, allowed_numbers, allowed_senders, allowed_pubkeys)
362        // into synthesized [peer_groups.<type>_default] entries. The
363        // peer_groups sink is additive — operator entries survive.
364        if let Some(channels_value) = channels {
365            let mut peer_groups_for_fold = match passthrough.remove("peer_groups") {
366                Some(toml::Value::Table(t)) => t,
367                _ => toml::Table::new(),
368            };
369            let new_channels = alias_wrap_channels(channels_value, &mut peer_groups_for_fold);
370            passthrough.insert("channels".to_string(), toml::Value::Table(new_channels));
371            if !peer_groups_for_fold.is_empty() {
372                passthrough.insert(
373                    "peer_groups".to_string(),
374                    toml::Value::Table(peer_groups_for_fold),
375                );
376            }
377            ::zeroclaw_log::record!(
378                INFO,
379                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
380                "[channels] sections alias-wrapped, discord_history folded, inbound peer-auth folded into [peer_groups.*]"
381            );
382        }
383
384        if let Some(cron_value) = cron {
385            let (new_cron, scheduler_extras) = restructure_cron(cron_value);
386            if !new_cron.is_empty() {
387                passthrough.insert("cron".to_string(), toml::Value::Table(new_cron));
388            }
389            if !scheduler_extras.is_empty() {
390                merge_into_table(&mut passthrough, "scheduler", scheduler_extras);
391            }
392            ::zeroclaw_log::record!(
393                INFO,
394                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
395                "[cron] restructured into [cron.<alias>] + [scheduler]"
396            );
397        }
398
399        // V3 makes agents explicit — V1/V2 had an implicit single-agent
400        // model. Strip inline brain fields onto provider aliases; if no
401        // [agents] blocks but brain config exists, synthesize a default
402        // agent (with the profile entries it references) so the upgrade
403        // has at least one runnable agent.
404        let new_agents = if !agents.is_empty() {
405            synthesize_agent_brains(agents, &mut passthrough)
406        } else {
407            let synthesized = synthesize_default_agent_if_needed(&passthrough);
408            if !synthesized.is_empty() {
409                ensure_profile_entry(&mut passthrough, "risk_profiles", "default");
410                ensure_profile_entry(&mut passthrough, "runtime_profiles", "default");
411            }
412            synthesized
413        };
414        if !new_agents.is_empty() {
415            passthrough.insert("agents".to_string(), toml::Value::Table(new_agents));
416        }
417
418        // V3 demoted [identity] to per-agent. Lift the V2 top-level block
419        // into each declared [agents.<alias>.identity]. Runs after the
420        // agents fold so synthesized and pre-existing agents both get it.
421        lift_top_level_identity_into_agents(&mut passthrough);
422
423        // V3 requires heartbeat.agent to be set when enabled=true.
424        // V2 fell through to the implicit single agent; point this at
425        // the synthesized (or first preserved) agent.
426        backfill_heartbeat_agent(&mut passthrough);
427
428        // peer_groups synthesized in the channels step used the bridge
429        // alias "default". If named agents won out (no agents.default),
430        // rewrite each peer_groups.<X>.agents = ["default"] to the
431        // surviving agent alias.
432        rewrite_dangling_peer_group_agents(&mut passthrough);
433
434        // V3 renamed `provider` to a domain-qualified noun on a few
435        // tables. Without this rewrite V3 errors with `missing field
436        // <noun>_provider`.
437        rename_subkey(&mut passthrough, "tunnel", "provider", "tunnel_provider");
438        rename_subkey(
439            &mut passthrough,
440            "web_search",
441            "provider",
442            "search_provider",
443        );
444
445        passthrough.insert("schema_version".to_string(), toml::Value::Integer(3));
446
447        Ok(toml::Value::Table(passthrough))
448    }
449}
450
451/// Rename `inner` to `replacement` inside the `[<parent>]` table when both
452/// the parent and the inner key are present. No-op if either is absent or
453/// if `replacement` already exists (operator wins; their explicit V3 key is
454/// the source of truth). Used for V3 schema field renames where the
455/// migration just needs to rewrite a flat scalar in place.
456fn rename_subkey(table: &mut toml::Table, parent: &str, inner: &str, replacement: &str) {
457    let Some(toml::Value::Table(parent_tbl)) = table.get_mut(parent) else {
458        return;
459    };
460    if parent_tbl.contains_key(replacement) {
461        // Operator already wrote the V3 key; nothing to do. If they ALSO
462        // wrote the V2 key, drop the stale one so the deserializer doesn't
463        // see a stray field on a `#[serde(deny_unknown_fields)]` struct.
464        let _ = parent_tbl.remove(inner);
465        return;
466    }
467    if let Some(value) = parent_tbl.remove(inner) {
468        parent_tbl.insert(replacement.to_string(), value);
469        ::zeroclaw_log::record!(
470            INFO,
471            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
472                ::serde_json::json!({"parent": parent, "inner": inner, "replacement": replacement})
473            ),
474            &format!(
475                "[{parent}].{inner} renamed to [{parent}].{replacement} (V3 qualified-noun rename)"
476            )
477        );
478    }
479}
480
481/// Split V2 `[cron]` into V3 `[cron.<alias>]` and `[scheduler]` extras.
482fn restructure_cron(cron_value: toml::Value) -> (toml::Table, toml::Table) {
483    let mut new_cron = toml::Table::new();
484    let mut scheduler_extras = toml::Table::new();
485    let mut cron_table = match cron_value {
486        toml::Value::Table(t) => t,
487        _ => return (new_cron, scheduler_extras),
488    };
489
490    // V2 had `[[cron.jobs]]` array; V3 keys each job by its HashMap
491    // alias, which makes the V2 `id: String` field redundant. Strip it.
492    if let Some(toml::Value::Array(jobs)) = cron_table.remove("jobs") {
493        for (i, job) in jobs.into_iter().enumerate() {
494            // Pick alias key: name slug → id → fallback `job_N`.
495            let key = job
496                .get("name")
497                .and_then(toml::Value::as_str)
498                .map(slugify)
499                .or_else(|| {
500                    job.get("id")
501                        .and_then(toml::Value::as_str)
502                        .map(ToString::to_string)
503                })
504                .unwrap_or_else(|| format!("job_{}", i + 1));
505            let key = ensure_unique_key(&new_cron, key);
506            let stripped = match job {
507                toml::Value::Table(mut t) => {
508                    t.remove("id");
509                    dot_delivery_channel(&mut t);
510                    toml::Value::Table(t)
511                }
512                other => other,
513            };
514            new_cron.insert(key, stripped);
515        }
516    }
517
518    // Subsystem knobs move to [scheduler].
519    for knob in ["enabled", "catch_up_on_startup", "max_run_history"] {
520        if let Some(v) = cron_table.remove(knob) {
521            scheduler_extras.insert(knob.to_string(), v);
522        }
523    }
524
525    // Anything left was unknown to V2 cron; surface but don't drop silently —
526    // dropped fields are visible in INFO logs instead.
527    if !cron_table.is_empty() {
528        ::zeroclaw_log::record!(
529            INFO,
530            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
531            &format!(
532                "[cron] had unmodeled keys: {:?}",
533                cron_table.keys().collect::<Vec<_>>()
534            )
535        );
536    }
537
538    (new_cron, scheduler_extras)
539}
540
541fn dot_delivery_channel(job: &mut toml::Table) {
542    let Some(toml::Value::Table(delivery)) = job.get_mut("delivery") else {
543        return;
544    };
545    let Some(toml::Value::String(channel)) = delivery.get_mut("channel") else {
546        return;
547    };
548    if !channel.contains('.') {
549        *channel = format!("{channel}.default");
550    }
551}
552
553/// Normalize a V2 provider type string to its V3 canonical name plus the
554/// extras that the typed family config requires (region endpoint, auth_mode,
555/// alias rename, family-specific fields).
556///
557/// Returns `(canonical_type, alias_key, extras_to_inject)`. `extras_to_inject`
558/// is a vec of `(field_name, toml::Value)` pairs that the migration writes
559/// onto the alias entry table — typically `endpoint = "cn"` for regional
560/// collapses, `auth_mode = "oauth"` for oauth-mode collapses, `wire_api =
561/// "responses"` + `requires_openai_auth = true` for the openai_codex fold.
562///
563/// The alias spellings here mirror the V2 registry's match arms in
564/// `crates/zeroclaw-providers/src/lib.rs` (`is_<vendor>_alias` functions).
565fn normalize_provider_type(
566    raw: &str,
567    incoming_alias: &str,
568) -> (String, String, Vec<(&'static str, toml::Value)>) {
569    let mut extras: Vec<(&'static str, toml::Value)> = Vec::new();
570
571    // Vendor-canonical collapses (synonym kills only; alias unchanged).
572    let synonym_canonical = match raw {
573        // Azure: vendor name; was azure_openai|azure-openai|azure
574        "azure_openai" | "azure-openai" | "azure" => Some("azure"),
575        // xAI: was xai|grok
576        "xai" | "grok" => Some("xai"),
577        // Gemini: vendor product name; was gemini|google|google-gemini
578        "gemini" | "google" | "google-gemini" => Some("gemini"),
579        // Together: was together|together-ai
580        "together" | "together-ai" => Some("together"),
581        // Fireworks: was fireworks|fireworks-ai
582        "fireworks" | "fireworks-ai" => Some("fireworks"),
583        // Vercel AI Gateway: was vercel|vercel-ai
584        "vercel" | "vercel-ai" => Some("vercel"),
585        // Cloudflare AI Gateway: was cloudflare|cloudflare-ai
586        "cloudflare" | "cloudflare-ai" => Some("cloudflare"),
587        // NVIDIA: was nvidia|nvidia-nim|build.nvidia.com
588        "nvidia" | "nvidia-nim" | "build.nvidia.com" => Some("nvidia"),
589        // Bedrock: was bedrock|aws-bedrock
590        "bedrock" | "aws-bedrock" => Some("bedrock"),
591        // LMStudio: was lmstudio|lm-studio
592        "lmstudio" | "lm-studio" => Some("lmstudio"),
593        // LiteLLM: was litellm|lite-llm
594        "litellm" | "lite-llm" => Some("litellm"),
595        // HuggingFace: was huggingface|hf
596        "huggingface" | "hf" => Some("huggingface"),
597        // Yi: was yi|01ai|lingyiwanwu
598        "yi" | "01ai" | "lingyiwanwu" => Some("yi"),
599        // Hunyuan: was hunyuan|tencent
600        "hunyuan" | "tencent" => Some("hunyuan"),
601        // Qianfan/Baidu: was qianfan|baidu
602        "qianfan" | "baidu" => Some("qianfan"),
603        // Copilot: was copilot|github-copilot
604        "copilot" | "github-copilot" => Some("copilot"),
605        // OVH: was ovhcloud|ovh
606        "ovhcloud" | "ovh" => Some("ovh"),
607        // OpenCode: was opencode|opencode-zen, opencode-go folded as alias=go
608        "opencode" | "opencode-zen" => Some("opencode"),
609        // llama.cpp: was llamacpp|llama.cpp (dot in key drops)
610        "llamacpp" | "llama.cpp" => Some("llamacpp"),
611        // DeepMyst: was deepmyst|deep-myst
612        "deepmyst" | "deep-myst" => Some("deepmyst"),
613        // SiliconFlow: was siliconflow|silicon-flow
614        "siliconflow" | "silicon-flow" => Some("siliconflow"),
615        // DeepInfra: was deepinfra|deep-infra
616        "deepinfra" | "deep-infra" => Some("deepinfra"),
617        // AI21: was ai21|ai21-labs
618        "ai21" | "ai21-labs" => Some("ai21"),
619        // Friendli: was friendli|friendliai
620        "friendli" | "friendliai" => Some("friendli"),
621        // Lepton: was lepton|lepton-ai
622        "lepton" | "lepton-ai" => Some("lepton"),
623        // Stepfun: was stepfun|step (stepfun-intl handled below as variant)
624        "stepfun" | "step" => Some("stepfun"),
625        // KiloCli: was kilocli|kilo
626        "kilocli" | "kilo" => Some("kilocli"),
627        _ => None,
628    };
629
630    if let Some(canonical) = synonym_canonical {
631        return (canonical.to_string(), incoming_alias.to_string(), extras);
632    }
633
634    // opencode-go folds under opencode as alias=go
635    if raw == "opencode-go" {
636        return ("opencode".to_string(), "go".to_string(), extras);
637    }
638
639    // OpenAI Codex folds under openai with wire_api=responses + requires_openai_auth=true
640    if matches!(raw, "openai-codex" | "openai_codex" | "codex") {
641        extras.push(("wire_api", toml::Value::String("responses".to_string())));
642        extras.push(("requires_openai_auth", toml::Value::Boolean(true)));
643        return ("openai".to_string(), "codex".to_string(), extras);
644    }
645
646    // claude-code folds under anthropic.claude-code (preserved from prior
647    // migration; the canonical name for Anthropic's CLI variant).
648    if raw == "claude-code" {
649        return ("anthropic".to_string(), "claude-code".to_string(), extras);
650    }
651
652    // anthropic-custom is the V1/V2 colon-URL form for "Anthropic-API at
653    // a custom URL" (the URL was already split out into `uri` above by
654    // `alias_provider_models`). Folds under anthropic with alias "custom"
655    // so a stock `anthropic.default` entry and an `anthropic-custom:URL`
656    // entry both migrate cleanly without clobbering each other.
657    if raw == "anthropic-custom" {
658        return ("anthropic".to_string(), "custom".to_string(), extras);
659    }
660
661    // `custom` (the bare V2 placeholder for "user-supplied URL") folds
662    // under the dedicated `custom` typed slot. Preserves the colon-URL
663    // form's URI on the alias entry.
664    if raw == "custom" {
665        return ("custom".to_string(), incoming_alias.to_string(), extras);
666    }
667
668    // Regional + OAuth collapse for Chinese-vendor families. Each block
669    // mirrors the upstream/master V2 alias-detector functions verbatim.
670
671    // Moonshot/Kimi
672    if matches!(
673        raw,
674        "moonshot-intl" | "moonshot-global" | "kimi-intl" | "kimi-global"
675    ) {
676        extras.push(("endpoint", toml::Value::String("intl".to_string())));
677        return ("moonshot".to_string(), incoming_alias.to_string(), extras);
678    }
679    if matches!(raw, "moonshot" | "kimi" | "moonshot-cn" | "kimi-cn") {
680        extras.push(("endpoint", toml::Value::String("cn".to_string())));
681        return ("moonshot".to_string(), incoming_alias.to_string(), extras);
682    }
683    if matches!(raw, "kimi-code" | "kimi_coding" | "kimi_for_coding") {
684        extras.push(("endpoint", toml::Value::String("code".to_string())));
685        return ("moonshot".to_string(), incoming_alias.to_string(), extras);
686    }
687
688    // Qwen / DashScope / Bailian
689    if matches!(raw, "qwen-cn" | "dashscope" | "qwen" | "dashscope-cn") {
690        extras.push(("endpoint", toml::Value::String("cn".to_string())));
691        return ("qwen".to_string(), incoming_alias.to_string(), extras);
692    }
693    if matches!(
694        raw,
695        "qwen-intl" | "dashscope-intl" | "qwen-international" | "dashscope-international"
696    ) {
697        extras.push(("endpoint", toml::Value::String("intl".to_string())));
698        return ("qwen".to_string(), incoming_alias.to_string(), extras);
699    }
700    if matches!(raw, "qwen-us" | "dashscope-us") {
701        extras.push(("endpoint", toml::Value::String("us".to_string())));
702        return ("qwen".to_string(), incoming_alias.to_string(), extras);
703    }
704    if matches!(raw, "qwen-code" | "qwen-oauth" | "qwen_oauth") {
705        extras.push(("endpoint", toml::Value::String("code".to_string())));
706        extras.push(("auth_mode", toml::Value::String("oauth".to_string())));
707        return ("qwen".to_string(), incoming_alias.to_string(), extras);
708    }
709    if matches!(raw, "bailian" | "aliyun-bailian" | "aliyun") {
710        extras.push(("endpoint", toml::Value::String("cn".to_string())));
711        return ("qwen".to_string(), incoming_alias.to_string(), extras);
712    }
713
714    // GLM / Zhipu
715    if matches!(raw, "glm" | "zhipu" | "glm-global" | "zhipu-global") {
716        extras.push(("endpoint", toml::Value::String("global".to_string())));
717        return ("glm".to_string(), incoming_alias.to_string(), extras);
718    }
719    if matches!(raw, "glm-cn" | "zhipu-cn" | "bigmodel") {
720        extras.push(("endpoint", toml::Value::String("cn".to_string())));
721        return ("glm".to_string(), incoming_alias.to_string(), extras);
722    }
723
724    // Z.AI
725    if matches!(raw, "zai" | "z.ai" | "zai-global" | "z.ai-global") {
726        extras.push(("endpoint", toml::Value::String("global".to_string())));
727        return ("zai".to_string(), incoming_alias.to_string(), extras);
728    }
729    if matches!(raw, "zai-cn" | "z.ai-cn") {
730        extras.push(("endpoint", toml::Value::String("cn".to_string())));
731        return ("zai".to_string(), incoming_alias.to_string(), extras);
732    }
733
734    // Minimax (cn/intl + oauth)
735    if matches!(
736        raw,
737        "minimax"
738            | "minimax-intl"
739            | "minimax-io"
740            | "minimax-global"
741            | "minimax-portal"
742            | "minimax-portal-global"
743    ) {
744        extras.push(("endpoint", toml::Value::String("intl".to_string())));
745        return ("minimax".to_string(), incoming_alias.to_string(), extras);
746    }
747    if matches!(raw, "minimax-oauth" | "minimax-oauth-global") {
748        extras.push(("endpoint", toml::Value::String("intl".to_string())));
749        extras.push(("auth_mode", toml::Value::String("oauth".to_string())));
750        return ("minimax".to_string(), incoming_alias.to_string(), extras);
751    }
752    if matches!(raw, "minimax-cn" | "minimaxi" | "minimax-portal-cn") {
753        extras.push(("endpoint", toml::Value::String("cn".to_string())));
754        return ("minimax".to_string(), incoming_alias.to_string(), extras);
755    }
756    if matches!(raw, "minimax-oauth-cn") {
757        extras.push(("endpoint", toml::Value::String("cn".to_string())));
758        extras.push(("auth_mode", toml::Value::String("oauth".to_string())));
759        return ("minimax".to_string(), incoming_alias.to_string(), extras);
760    }
761
762    // Doubao / Volcengine
763    if matches!(raw, "doubao" | "volcengine" | "ark" | "doubao-cn") {
764        return ("doubao".to_string(), incoming_alias.to_string(), extras);
765    }
766
767    // gemini-cli stays as a separate slot (subprocess runtime, not a synonym)
768    if raw == "gemini-cli" {
769        return ("gemini_cli".to_string(), incoming_alias.to_string(), extras);
770    }
771
772    // stepfun-intl folds into stepfun with a different uri
773    if matches!(raw, "stepfun-intl" | "step-intl") {
774        extras.push((
775            "uri",
776            toml::Value::String("https://api.stepfun.com/intl/v1".to_string()),
777        ));
778        return ("stepfun".to_string(), incoming_alias.to_string(), extras);
779    }
780
781    // Unknown/passthrough: keep the raw key. Silent drop will happen at V3
782    // deserialize if it doesn't match any typed slot — that's the migration's
783    // accountability gap, intentional per #6273. Operators with truly novel
784    // names (a forked custom backend) need a slot defined for it.
785    (raw.to_string(), incoming_alias.to_string(), extras)
786}
787
788fn alias_provider_models(models: Option<toml::Value>) -> toml::Table {
789    let flat = match models {
790        Some(toml::Value::Table(t)) => t,
791        _ => return toml::Table::new(),
792    };
793    let mut aliased = toml::Table::new();
794    for (provider_id, mut config) in flat {
795        // Colon-URL form like `"anthropic-custom:https://..."`: split the URL
796        // out into `uri` and use only the prefix as the seed for normalization.
797        let (raw_type, url) = split_colon_url_provider(&provider_id);
798        if let Some(url) = url
799            && let toml::Value::Table(t) = &mut config
800        {
801            t.entry("uri".to_string())
802                .or_insert(toml::Value::String(url));
803        }
804
805        // V2 per-block `base_url` + optional `api_path` → V3 `uri` (full
806        // endpoint URL). Matches the same concatenation
807        // `fold_providers_globals_into_models` applies to V2 top-level
808        // globals — without this, per-block [model_providers.<id>] entries
809        // would survive into V3 with the unknown `base_url`/`api_path`
810        // keys, and V3 deserialize silently drops them.
811        if let toml::Value::Table(t) = &mut config {
812            fold_base_url_api_path_into_uri(t);
813        }
814
815        let (provider_type, alias, extras) = normalize_provider_type(&raw_type, "default");
816
817        // Inject family-specific extras (endpoint, auth_mode, wire_api,
818        // requires_openai_auth, uri) onto the alias entry table — overrides
819        // by the operator's own config win via .or_insert.
820        if let toml::Value::Table(t) = &mut config {
821            for (field, value) in extras {
822                t.entry(field.to_string()).or_insert(value);
823            }
824        }
825
826        let entry = aliased
827            .entry(provider_type)
828            .or_insert_with(|| toml::Value::Table(toml::Table::new()));
829        if let toml::Value::Table(entry_table) = entry {
830            entry_table.insert(alias, config);
831        }
832    }
833    aliased
834}
835
836/// Fold V2 `[providers]` global fields (which lived directly on `ProvidersConfig`)
837/// onto the V3 per-provider `ModelProviderConfig` entry.
838///
839/// Field renames applied during the fold:
840/// - `api_url` (+ optional `api_path` suffix) → `uri` (matches V3 `ModelProviderConfig.uri`)
841/// - `default_model` → `model`
842/// - `default_temperature` → `temperature`
843/// - `provider_timeout_secs` → `timeout_secs`
844/// - `provider_max_tokens` → `max_tokens`
845///
846/// Target entry resolution:
847/// - If `default_provider` is a string and matches a key in `aliased_models`, fold there.
848/// - Otherwise, if `aliased_models` already has at least one entry, fold onto its
849///   first entry's `default` alias (this matches V1 `[model_providers.<id>]` blocks
850///   that had no separate `default_provider` declaration).
851/// - Otherwise, synthesize a fresh `<default_provider | "openrouter">.default`
852///   entry to hold the globals (matches V1's documented default provider).
853///
854/// `claude-code` continues to map under `anthropic.claude-code` per the V3 fold.
855///
856/// Per-provider explicit fields take precedence: globals only fill in missing slots.
857fn fold_providers_globals_into_models(
858    new_providers: &mut toml::Table,
859    aliased_models: &mut toml::Table,
860) {
861    let g_api_key = new_providers.remove("api_key");
862    let g_api_url = new_providers.remove("api_url");
863    let g_api_path = new_providers.remove("api_path");
864    let g_default_provider = new_providers.remove("default_provider");
865    let g_default_model = new_providers.remove("default_model");
866    let g_default_temperature = new_providers.remove("default_temperature");
867    let g_provider_timeout_secs = new_providers.remove("provider_timeout_secs");
868    let g_provider_max_tokens = new_providers.remove("provider_max_tokens");
869    let g_extra_headers = new_providers.remove("extra_headers");
870
871    let any_value_globals = g_api_key.is_some()
872        || g_api_url.is_some()
873        || g_api_path.is_some()
874        || g_default_model.is_some()
875        || g_default_temperature.is_some()
876        || g_provider_timeout_secs.is_some()
877        || g_provider_max_tokens.is_some()
878        || g_extra_headers.is_some();
879
880    if !any_value_globals && g_default_provider.is_none() {
881        return;
882    }
883
884    // Determine target (provider_type, alias). For colon-URL forms like
885    // `"anthropic-custom:https://..."`, split the URL out of the type key so
886    // the V3 reference grammar (`<type>.<alias>`) doesn't tokenize at a URL
887    // dot. The URL is folded into `uri` below.
888    //
889    // Then run the V2-EOL provider name through `normalize_provider_type` so
890    // synonym kills + regional/oauth collapses + claude_code/openai_codex
891    // folds happen here too — same canonical-naming gate as
892    // `alias_provider_models`. Without this, an operator with
893    // `default_provider = "grok"` would land in a `grok` slot that doesn't
894    // exist on V3 ModelProviders and silently disappear.
895    let (target_type, target_alias, colon_url, normalized_extras) =
896        match g_default_provider.as_ref().and_then(toml::Value::as_str) {
897            Some(s) => {
898                let (raw_type, url) = split_colon_url_provider(s);
899                let (canonical, alias, extras) = normalize_provider_type(&raw_type, "default");
900                (canonical, alias, url, extras)
901            }
902            None => match aliased_models.keys().next() {
903                Some(k) => (k.clone(), "default".to_string(), None, Vec::new()),
904                None => (
905                    "openrouter".to_string(),
906                    "default".to_string(),
907                    None,
908                    Vec::new(),
909                ),
910            },
911        };
912
913    let provider_value = aliased_models
914        .entry(target_type.clone())
915        .or_insert_with(|| toml::Value::Table(toml::Table::new()));
916    let provider_table = match provider_value.as_table_mut() {
917        Some(t) => t,
918        None => return,
919    };
920    let alias_value = provider_table
921        .entry(target_alias.clone())
922        .or_insert_with(|| toml::Value::Table(toml::Table::new()));
923    let alias_table = match alias_value.as_table_mut() {
924        Some(t) => t,
925        None => return,
926    };
927
928    // The colon-URL form's URL portion (split from default_provider) takes
929    // precedence over the global `api_url` field — both originate from V2's
930    // top-level providers block, but the colon-URL form was the more specific
931    // hint when the user wrote `default_provider = "anthropic-custom:<url>"`.
932    // V3's `uri` field is the full endpoint URL — concatenate any V2 `api_path`
933    // suffix onto it, since `api_path` no longer exists separately.
934    let base_url_source = colon_url.map(toml::Value::String).or(g_api_url);
935    let uri_source = match (base_url_source, g_api_path) {
936        (Some(toml::Value::String(b)), Some(toml::Value::String(p))) => {
937            let trimmed_b = b.trim_end_matches('/');
938            let suffix = if p.starts_with('/') {
939                p
940            } else {
941                format!("/{p}")
942            };
943            Some(toml::Value::String(format!("{trimmed_b}{suffix}")))
944        }
945        (Some(b), _) => Some(b),
946        // api_path alone, without a base, has nowhere to live in V3 — drop.
947        (None, _) => None,
948    };
949
950    // Per-provider entries take precedence: only fill missing slots.
951    for (target_key, source) in [
952        ("api_key", g_api_key),
953        ("uri", uri_source),
954        ("model", g_default_model),
955        ("temperature", g_default_temperature),
956        ("timeout_secs", g_provider_timeout_secs),
957        ("max_tokens", g_provider_max_tokens),
958        ("extra_headers", g_extra_headers),
959    ] {
960        if let Some(value) = source
961            && !alias_table.contains_key(target_key)
962        {
963            alias_table.insert(target_key.to_string(), value);
964        }
965    }
966
967    // Inject family-specific extras (endpoint, auth_mode, wire_api,
968    // requires_openai_auth, uri) from the normalize_provider_type call
969    // above. Operator-set fields win — only fill missing slots.
970    for (field, value) in normalized_extras {
971        if !alias_table.contains_key(field) {
972            alias_table.insert(field.to_string(), value);
973        }
974    }
975
976    if any_value_globals {
977        ::zeroclaw_log::record!(
978            INFO,
979            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
980                ::serde_json::json!({"target_type": target_type, "target_alias": target_alias})
981            ),
982            "[providers] globals folded onto model_providers.."
983        );
984    }
985}
986
987/// Pull `prices` (a per-model HashMap) out of a V2 `[cost]` block.
988/// Returns `(cost_passthrough, prices)`. `prices` keys are model identifiers;
989/// values are `ModelPricing` tables.
990fn strip_cost_prices(cost_value: toml::Value) -> (Option<toml::Value>, toml::Table) {
991    let mut cost_table = match cost_value {
992        toml::Value::Table(t) => t,
993        other => return (Some(other), toml::Table::new()),
994    };
995    let prices = match cost_table.remove("prices") {
996        Some(toml::Value::Table(p)) => p,
997        Some(other) => {
998            // Unexpected shape — reinsert and skip the fold.
999            cost_table.insert("prices".to_string(), other);
1000            return (Some(toml::Value::Table(cost_table)), toml::Table::new());
1001        }
1002        None => toml::Table::new(),
1003    };
1004    let cost_passthrough = if cost_table.is_empty() {
1005        None
1006    } else {
1007        Some(toml::Value::Table(cost_table))
1008    };
1009    (cost_passthrough, prices)
1010}
1011
1012/// Drop V2 `[cost.prices.*]` entries. V2 keyed pricing by composite
1013/// `"<provider>/<model>"` identifiers that don't carry the V3
1014/// `<provider_type>.<alias>` path, so any automatic remap is fragile.
1015/// Operators paste the rates manually under the right V3
1016/// `[model_providers.<type>.<alias>].pricing` block; the INFO log per
1017/// entry names the model id and last-known input/output rates.
1018fn drop_cost_prices_with_logs(prices: &toml::Table) {
1019    for (model_id, price) in prices {
1020        let (input, output) = match price.as_table() {
1021            Some(t) => (
1022                t.get("input").and_then(toml::Value::as_float),
1023                t.get("output").and_then(toml::Value::as_float),
1024            ),
1025            None => (None, None),
1026        };
1027        ::zeroclaw_log::record!(
1028            INFO,
1029            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
1030                ::serde_json::json!({"model_id": model_id, "input": format!("{:?}", input), "output": format!("{:?}", output)})
1031            ),
1032            &format!(
1033                "[cost.prices.{model_id}] dropped (V3 puts pricing on each \
1034                 [model_providers.<type>.<alias>] block); last-known rates: \
1035                 input={input:?} output={output:?}"
1036            )
1037        );
1038    }
1039}
1040
1041/// Synthesize one `[peer_groups.<channel_type>_<alias>]` entry from a
1042/// V2 channel's inbound peer-auth allow-list, and emit an INFO log.
1043/// The per-channel arms in [`apply_v2_to_v3_channel_folds`] each:
1044///
1045///   1. `instance.remove("<field>")` (V3 has no slot for the field —
1046///      strip regardless of whether the fold synthesizes a group).
1047///   2. Call this helper with the removed array and the channel's V3
1048///      `<type>.<alias>` ref so the synthesized group lands in
1049///      `peer_groups`.
1050///
1051/// Skip rules: empty arrays and any list containing `"*"` produce no
1052/// group (a peer group can't express "anyone"). Collisions with an
1053/// operator-authored `[peer_groups.<type>_<alias>]` are left
1054/// untouched.
1055///
1056/// V1/V2 had implicit single-agent semantics, so the synthesized
1057/// group always binds the migration-bridge `default` agent. That is
1058/// the *only* legitimate `default` usage in the V2→V3 fold path —
1059/// post-migration the operator owns peer_group membership.
1060fn synthesize_peer_group_from_allowlist(
1061    peer_groups: &mut toml::Table,
1062    channel_type: &str,
1063    channel_alias: &str,
1064    field_name: &str,
1065    allowed: toml::Value,
1066) {
1067    let toml::Value::Array(allowed) = allowed else {
1068        return;
1069    };
1070    let usernames: Vec<String> = allowed
1071        .iter()
1072        .filter_map(|v| v.as_str())
1073        .map(str::trim)
1074        .filter(|s| !s.is_empty() && *s != "*")
1075        .map(str::to_string)
1076        .collect();
1077    if usernames.is_empty() {
1078        return;
1079    }
1080    let group_name = format!("{channel_type}_{channel_alias}");
1081    if peer_groups.contains_key(&group_name) {
1082        // Operator-authored group with the synthesized name wins.
1083        return;
1084    }
1085    let mut group_entry = toml::Table::new();
1086    // Channel type only (peer-groups bind to the type, not an alias).
1087    group_entry.insert(
1088        "channel".to_string(),
1089        toml::Value::String(channel_type.to_string()),
1090    );
1091    // V1/V2 single-agent semantics — bridge alias `default`.
1092    group_entry.insert(
1093        "agents".to_string(),
1094        toml::Value::Array(vec![toml::Value::String("default".to_string())]),
1095    );
1096    let external_peers: Vec<toml::Value> = usernames.into_iter().map(toml::Value::String).collect();
1097    group_entry.insert(
1098        "external_peers".to_string(),
1099        toml::Value::Array(external_peers),
1100    );
1101    peer_groups.insert(group_name, toml::Value::Table(group_entry));
1102    ::zeroclaw_log::record!(
1103        INFO,
1104        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
1105            ::serde_json::json!({"channel_type": channel_type, "channel_alias": channel_alias, "field_name": field_name})
1106        ),
1107        &format!(
1108            "channels.{channel_type}.{channel_alias}.{field_name} folded into [peer_groups.{channel_type}_{channel_alias}]"
1109        )
1110    );
1111}
1112
1113/// Wrap V2 `Option<T>` channel sections into V3 `HashMap<String, T>` keyed
1114/// by `"default"`. Applies, per channel instance:
1115///
1116/// - **discord_history fold**: `[channels.discord_history]` →
1117///   `[channels.discord]` with `archive = true`. Effective `enabled` is
1118///   the OR of both sides so a user with only
1119///   `discord_history.enabled = true` still ends up with an enabled
1120///   merged discord block.
1121/// - Singular→plural fold per channel type (`discord.guild_id` →
1122///   `guild_ids[]`, `mattermost.channel_id` → `channel_ids[]`,
1123///   `reddit.subreddit` → `subreddits[]`, `signal.group_id` →
1124///   `group_ids[]` or `dm_only=true` for the `"dm"` sentinel).
1125///
1126/// `cli: bool` is preserved at the top-level `channels.cli`, not aliased.
1127fn alias_wrap_channels(channels_value: toml::Value, peer_groups: &mut toml::Table) -> toml::Table {
1128    let mut channels_table = match channels_value {
1129        toml::Value::Table(t) => t,
1130        _ => return toml::Table::new(),
1131    };
1132    let mut new_channels = toml::Table::new();
1133
1134    // CLI is a top-level bool, not aliased.
1135    if let Some(cli) = channels_table.remove("cli") {
1136        new_channels.insert("cli".to_string(), cli);
1137    }
1138
1139    // Fold discord_history into discord BEFORE the enabled filter so a
1140    // discord_history-only user with `enabled=true` survives into V3.
1141    fold_discord_history(&mut channels_table);
1142
1143    // V3 collapses Feishu and Lark to one channel type — they share the same
1144    // bot framework, only the API endpoint differs (Feishu = open.feishu.cn
1145    // for China, Lark = open.larksuite.com for international). Stash the V2
1146    // [channels.feishu] block here so the alias-wrap loop processes the V2
1147    // [channels.lark] block normally; the stash is re-injected after the loop
1148    // as [channels.lark.feishu] (NOT lark.default) so two-bot deployments
1149    // survive without operator intervention.
1150    let stashed_feishu_v2 = strip_feishu_block(&mut channels_table);
1151
1152    // Per-channel-type: singular→plural fold, peer-auth lift into
1153    // [peer_groups.<type>_default], then alias-wrap as <type>.default.
1154    for ct in V3_CHANNEL_TYPES {
1155        let Some(value) = channels_table.remove(*ct) else {
1156            continue;
1157        };
1158        let mut instance = match value {
1159            toml::Value::Table(t) => t,
1160            other => {
1161                // Unexpected shape — wrap raw value under "default" without
1162                // any of the V3 transforms. This preserves data; V3
1163                // deserialize will surface the type error.
1164                let mut wrapped = toml::Table::new();
1165                wrapped.insert("default".to_string(), other);
1166                new_channels.insert((*ct).to_string(), toml::Value::Table(wrapped));
1167                continue;
1168            }
1169        };
1170        apply_v2_to_v3_channel_folds(ct, &mut instance);
1171        fold_channel_peer_auth_into_peer_groups(ct, &mut instance, peer_groups);
1172        // V3 keeps the `enabled` field on every channel config — V2's
1173        // boolean ports through verbatim and the orchestrator gates on
1174        // it at registration time. Missing `enabled` deserializes to
1175        // `false` via `#[serde(default)]`, matching V2 semantics.
1176        let mut wrapped = toml::Table::new();
1177        wrapped.insert("default".to_string(), toml::Value::Table(instance));
1178        new_channels.insert((*ct).to_string(), toml::Value::Table(wrapped));
1179    }
1180
1181    // Unmodeled channel-section keys: pass through under their original key.
1182    if !channels_table.is_empty() {
1183        let leftover_keys: Vec<String> = channels_table.keys().cloned().collect();
1184        ::zeroclaw_log::record!(
1185            INFO,
1186            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1187            &format!(
1188                "[channels] passthrough for unmodeled keys: {:?}",
1189                leftover_keys
1190            )
1191        );
1192        for (k, v) in channels_table {
1193            new_channels.insert(k, v);
1194        }
1195    }
1196
1197    // Re-inject the stashed V2 [channels.feishu] block as [channels.lark.feishu]
1198    // with use_feishu = true. The alias name is "feishu" — not "default" — so a
1199    // two-bot deployment with both [channels.lark] (international) AND
1200    // [channels.feishu] (CN) survives as [channels.lark.default] +
1201    // [channels.lark.feishu]; both bots remain reachable post-migration.
1202    inject_feishu_as_lark_alias(&mut new_channels, stashed_feishu_v2);
1203
1204    new_channels
1205}
1206
1207/// Pre-alias-wrap: remove the V2 `[channels.feishu]` block from `channels`
1208/// (so the alias-wrap loop doesn't process it) and return its body for
1209/// post-wrap injection as `[channels.lark.feishu]`.
1210fn strip_feishu_block(channels: &mut toml::Table) -> Option<toml::Table> {
1211    let feishu_value = channels.remove("feishu")?;
1212    match feishu_value {
1213        toml::Value::Table(t) => Some(t),
1214        _ => {
1215            ::zeroclaw_log::record!(
1216                WARN,
1217                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1218                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1219                "[channels.feishu] is not a table; dropping during fold to lark"
1220            );
1221            None
1222        }
1223    }
1224}
1225
1226/// Post-alias-wrap: insert the stashed V2 feishu block as
1227/// `[channels.lark.feishu]` with `use_feishu = true`. The alias name is
1228/// `feishu` (not `default`) so a two-bot V2 deployment with both
1229/// `[channels.lark]` (international) AND `[channels.feishu]` (CN) survives as
1230/// two distinct V3 aliases — `lark.default` and `lark.feishu` — without
1231/// losing data or requiring operator intervention.
1232///
1233/// If a `lark.feishu` alias already exists in `new_channels` (impossible
1234/// from V2 input but cheap to defend), we do not overwrite — the existing
1235/// entry wins and a WARN names the dropped source.
1236fn inject_feishu_as_lark_alias(new_channels: &mut toml::Table, feishu_table: Option<toml::Table>) {
1237    let Some(mut feishu_table) = feishu_table else {
1238        return;
1239    };
1240
1241    feishu_table.insert("use_feishu".to_string(), toml::Value::Boolean(true));
1242
1243    let lark_entry = new_channels
1244        .entry("lark".to_string())
1245        .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1246    let Some(lark_aliases) = lark_entry.as_table_mut() else {
1247        ::zeroclaw_log::record!(
1248            WARN,
1249            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1250                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1251            "[channels.lark] is not a table; cannot inject feishu alias"
1252        );
1253        return;
1254    };
1255
1256    if lark_aliases.contains_key("feishu") {
1257        ::zeroclaw_log::record!(
1258            WARN,
1259            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1260                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1261            "[channels.lark.feishu] already exists; the V2 [channels.feishu] \
1262             block was dropped to avoid clobbering it. Recover the dropped \
1263             value from the pre-migration <config>.backup if needed."
1264        );
1265        return;
1266    }
1267
1268    lark_aliases.insert("feishu".to_string(), toml::Value::Table(feishu_table));
1269    ::zeroclaw_log::record!(
1270        INFO,
1271        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1272        "[channels.feishu] folded into [channels.lark.feishu] (use_feishu=true)"
1273    );
1274}
1275
1276/// Fold V2 `[channels.discord_history]` into `[channels.discord]` in place.
1277/// Sets `archive = true`. Effective `enabled` = `discord.enabled` OR
1278/// `discord_history.enabled`. Existing discord keys win over history keys
1279/// for non-`enabled` fields (so a user-set discord.bot_token isn't
1280/// overwritten by history's bot_token).
1281///
1282/// When both blocks have a `bot_token` and the values **differ**, emit
1283/// one `WARN` line naming the source block whose token was dropped
1284/// (`[channels.discord_history].bot_token`) and the surviving block
1285/// (`[channels.discord]`). The dropped value itself is **not** logged
1286/// — operators recover from the pre-migration `<config>.backup`.
1287/// Two-bot deployments must reconfigure manually.
1288fn fold_discord_history(channels: &mut toml::Table) {
1289    let history_value = match channels.remove("discord_history") {
1290        Some(v) => v,
1291        None => return,
1292    };
1293
1294    // Capture the conflict signal BEFORE the merge mutates either side.
1295    let discord_bot_token = channels
1296        .get("discord")
1297        .and_then(toml::Value::as_table)
1298        .and_then(|t| t.get("bot_token"))
1299        .and_then(toml::Value::as_str)
1300        .map(ToString::to_string);
1301    let history_bot_token = history_value
1302        .as_table()
1303        .and_then(|t| t.get("bot_token"))
1304        .and_then(toml::Value::as_str)
1305        .map(ToString::to_string);
1306    let bot_token_conflict = match (&discord_bot_token, &history_bot_token) {
1307        (Some(d), Some(h)) => d != h,
1308        _ => false,
1309    };
1310
1311    let history_enabled = history_value
1312        .as_table()
1313        .and_then(|t| t.get("enabled"))
1314        .and_then(toml::Value::as_bool)
1315        .unwrap_or(false);
1316    let discord_enabled = channels
1317        .get("discord")
1318        .and_then(toml::Value::as_table)
1319        .and_then(|t| t.get("enabled"))
1320        .and_then(toml::Value::as_bool)
1321        .unwrap_or(false);
1322    let effective_enabled = discord_enabled || history_enabled;
1323
1324    let discord_entry = channels
1325        .entry("discord".to_string())
1326        .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1327    if let Some(discord_table) = discord_entry.as_table_mut() {
1328        discord_table.insert("archive".to_string(), toml::Value::Boolean(true));
1329        if let toml::Value::Table(history_table) = history_value {
1330            for (k, v) in history_table {
1331                if k == "enabled" {
1332                    // Handled explicitly via effective_enabled below.
1333                    continue;
1334                }
1335                discord_table.entry(k).or_insert(v);
1336            }
1337        }
1338        discord_table.insert(
1339            "enabled".to_string(),
1340            toml::Value::Boolean(effective_enabled),
1341        );
1342    }
1343    ::zeroclaw_log::record!(
1344        INFO,
1345        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1346            .with_attrs(::serde_json::json!({"effective_enabled": effective_enabled})),
1347        "[channels.discord_history] folded into [channels.discord] (archive=true, effective enabled=)"
1348    );
1349    if bot_token_conflict {
1350        ::zeroclaw_log::record!(
1351            WARN,
1352            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1353                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1354            "[channels.discord_history].bot_token differed from [channels.discord].bot_token; \
1355             the discord_history token was dropped and the discord token survives. \
1356             Two-bot deployments must reconfigure manually — recover the dropped value \
1357             from the pre-migration <config>.backup file adjacent to the migrated config."
1358        );
1359    }
1360}
1361
1362/// Apply V2→V3 singular→plural folds:
1363/// `discord.guild_id` → `guild_ids[]`, `mattermost.channel_id` → `channel_ids[]`,
1364/// `reddit.subreddit` → `subreddits[]`, and `signal.group_id` → `group_ids[]`
1365/// (with the `"dm"` sentinel mapped to `dm_only=true` instead).
1366fn apply_v2_to_v3_channel_folds(channel_type: &str, instance: &mut toml::Table) {
1367    use crate::migration::fold_string_into_array;
1368    match channel_type {
1369        "discord" if fold_string_into_array(instance, "guild_id", "guild_ids") => {
1370            ::zeroclaw_log::record!(
1371                INFO,
1372                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1373                "channels.discord.guild_id folded into channels.discord.guild_ids[]"
1374            );
1375        }
1376        "mattermost" if fold_string_into_array(instance, "channel_id", "channel_ids") => {
1377            ::zeroclaw_log::record!(
1378                INFO,
1379                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1380                "channels.mattermost.channel_id folded into channels.mattermost.channel_ids[]"
1381            );
1382        }
1383        "reddit" if fold_string_into_array(instance, "subreddit", "subreddits") => {
1384            ::zeroclaw_log::record!(
1385                INFO,
1386                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1387                "channels.reddit.subreddit folded into channels.reddit.subreddits[]"
1388            );
1389        }
1390        "signal" => {
1391            // Special: V2 group_id="dm" was a sentinel meaning "DMs only".
1392            // V3 splits that into a typed dm_only bool. Other group_id
1393            // values fold into group_ids[] like the simpler renames.
1394            if let Some(toml::Value::String(group_id)) = instance.remove("group_id")
1395                && !group_id.is_empty()
1396            {
1397                if group_id == "dm" {
1398                    instance.insert("dm_only".to_string(), toml::Value::Boolean(true));
1399                    ::zeroclaw_log::record!(
1400                        INFO,
1401                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1402                        "channels.signal.group_id=\"dm\" → channels.signal.dm_only=true"
1403                    );
1404                } else {
1405                    let entry = instance
1406                        .entry("group_ids".to_string())
1407                        .or_insert_with(|| toml::Value::Array(Vec::new()));
1408                    if let Some(arr) = entry.as_array_mut() {
1409                        let already = arr.iter().any(|v| v.as_str() == Some(group_id.as_str()));
1410                        if !already {
1411                            arr.push(toml::Value::String(group_id));
1412                        }
1413                    }
1414                    ::zeroclaw_log::record!(
1415                        INFO,
1416                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1417                        "channels.signal.group_id folded into channels.signal.group_ids[]"
1418                    );
1419                }
1420            }
1421        }
1422        _ => {}
1423    }
1424}
1425
1426/// V2 → V3 inbound peer-auth fold per channel. Each channel that had
1427/// a user-allowlist field in V2 strips it from the instance and
1428/// synthesizes the V3 peer_group binding `default` agent to this
1429/// channel. Field name varies per platform; helper handles wildcard
1430/// / empty / collision skip rules uniformly.
1431///
1432/// Field-name table (the only place this list lives):
1433///
1434/// - Most channels: `allowed_users`
1435/// - iMessage:      `allowed_contacts`
1436/// - Signal:        `allowed_from`
1437/// - WhatsApp/Wati: `allowed_numbers`
1438/// - Linq/Email/GmailPush: `allowed_senders`
1439/// - Nostr:         `allowed_pubkeys`
1440///
1441/// Channels with no inbound peer-auth concept (Webhook, Reddit,
1442/// Bluesky, MQTT, voice_*, ClawdTalk, CLI) return `None` and the
1443/// function is a no-op.
1444fn fold_channel_peer_auth_into_peer_groups(
1445    channel_type: &str,
1446    instance: &mut toml::Table,
1447    peer_groups: &mut toml::Table,
1448) {
1449    let Some(field_name) = (match channel_type {
1450        "telegram" | "discord" | "slack" | "mattermost" | "matrix" | "nextcloud_talk" | "irc"
1451        | "lark" | "line" | "feishu" | "dingtalk" | "wecom" | "wechat" | "qq" | "twitter"
1452        | "mochat" => Some("allowed_users"),
1453        "imessage" => Some("allowed_contacts"),
1454        "signal" => Some("allowed_from"),
1455        "whatsapp" | "wati" => Some("allowed_numbers"),
1456        "linq" | "email" | "gmail_push" => Some("allowed_senders"),
1457        "nostr" => Some("allowed_pubkeys"),
1458        _ => None,
1459    }) else {
1460        return;
1461    };
1462    if let Some(allowed) = instance.remove(field_name) {
1463        synthesize_peer_group_from_allowlist(
1464            peer_groups,
1465            channel_type,
1466            "default",
1467            field_name,
1468            allowed,
1469        );
1470    }
1471}
1472
1473/// Strip V2-specific fields from each agent and synthesize the V3 alias
1474/// references / per-agent profile overrides. Specifically:
1475///
1476/// - Inline brain fields (`provider`/`model`/`api_key`/`temperature`)
1477///   fold into a synthesized `model_providers.<provider>.agent_<id>`
1478///   entry; the agent gets `model_provider = "<provider>.agent_<id>"`.
1479/// - `max_iterations` is renamed to `max_tool_iterations` inline.
1480/// - `agentic` / `allowed_tools` / `timeout_secs` / `agentic_timeout_secs`
1481///   lift into a synthesized `runtime_profiles.agent_<id>`.
1482/// - `max_depth` lifts into a synthesized
1483///   `risk_profiles.agent_<id>.max_delegation_depth`.
1484/// - `skills_directory` lifts into a synthesized
1485///   `skill_bundles.agent_<id>.directory` and the alias is appended
1486///   to the agent's `skill_bundles` array.
1487/// - `memory_namespace` is dropped — V3 isolates memory under
1488///   `[agents.<alias>.memory]` instead.
1489/// - Every agent ends with `risk_profile` and `runtime_profile` set
1490///   to either a synthesized `agent_<id>` alias or `default`, with
1491///   the referenced profile entries guaranteed to exist (V3
1492///   validation rejects dangling profile refs).
1493fn synthesize_agent_brains(
1494    agents: HashMap<String, toml::Value>,
1495    passthrough: &mut toml::Table,
1496) -> toml::Table {
1497    let mut new_agents = toml::Table::new();
1498    for (alias, agent_value) in agents {
1499        let mut agent_table = match agent_value {
1500            toml::Value::Table(t) => t,
1501            other => {
1502                new_agents.insert(alias, other);
1503                continue;
1504            }
1505        };
1506
1507        // Brain fold: provider/model/api_key/temperature/timeout_secs →
1508        // model-provider alias. V2's per-agent `timeout_secs` was the HTTP
1509        // timeout for LLM calls; V3 hangs it off the model_provider entry,
1510        // not the agent.
1511        let provider = agent_table.remove("provider");
1512        let model = agent_table.remove("model");
1513        let api_key = agent_table.remove("api_key");
1514        let temperature = agent_table.remove("temperature");
1515        let provider_timeout_secs = extract_provider_timeout_secs(&mut agent_table);
1516        if let Some(toml::Value::String(raw_provider)) = provider {
1517            // Colon-URL form: split the URL out so the V3 outer key stays
1518            // dot-free and the URL lands in `uri`. Without this,
1519            // `split_once('.')` would tokenize at a URL dot like the one
1520            // inside `api.z.ai`.
1521            let (provider_type, colon_url) = split_colon_url_provider(&raw_provider);
1522            let provider_alias = format!("agent_{}", alias);
1523            let mut entry = toml::Table::new();
1524            if let Some(url) = colon_url {
1525                entry.insert("uri".to_string(), toml::Value::String(url));
1526            }
1527            if let Some(m) = model {
1528                entry.insert("model".to_string(), m);
1529            }
1530            if let Some(k) = api_key {
1531                entry.insert("api_key".to_string(), k);
1532            }
1533            if let Some(t) = temperature {
1534                entry.insert("temperature".to_string(), t);
1535            }
1536            if let Some(t) = provider_timeout_secs {
1537                entry.insert("timeout_secs".to_string(), t);
1538            }
1539            // V3 keeps every provider category under `[providers]`:
1540            // `[providers.models.<type>.<alias>]` is the destination.
1541            let providers_value = passthrough
1542                .entry("providers".to_string())
1543                .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1544            if let Some(providers_table) = providers_value.as_table_mut() {
1545                let models_value = providers_table
1546                    .entry("models".to_string())
1547                    .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1548                if let Some(models_table) = models_value.as_table_mut() {
1549                    let provider_value = models_table
1550                        .entry(provider_type.clone())
1551                        .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1552                    if let Some(provider_table) = provider_value.as_table_mut() {
1553                        provider_table.insert(provider_alias.clone(), toml::Value::Table(entry));
1554                    }
1555                }
1556            }
1557            agent_table.insert(
1558                "model_provider".to_string(),
1559                toml::Value::String(format!("{provider_type}.{provider_alias}")),
1560            );
1561            ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"alias": alias, "provider_type": provider_type, "provider_alias": provider_alias})), "agents.: inline brain → providers.models..");
1562        } else {
1563            // No provider declared but operator still set timeout_secs;
1564            // drop it rather than silently storing on the agent block,
1565            // since V3 has no agent-level slot for it.
1566            if provider_timeout_secs.is_some() {
1567                ::zeroclaw_log::record!(
1568                    WARN,
1569                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1570                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1571                        .with_attrs(::serde_json::json!({"alias": alias})),
1572                    "agents..timeout_secs dropped: V3 stores it on \
1573                     [model_providers.<type>.<alias>] and this agent has no \
1574                     inline provider to fold it onto. Set it manually after \
1575                     migration."
1576                );
1577            }
1578            if let Some(other) = provider {
1579                agent_table.insert("provider".to_string(), other);
1580            }
1581        }
1582
1583        // max_iterations → max_tool_iterations (V3 inline rename).
1584        if let Some(v) = agent_table.remove("max_iterations") {
1585            agent_table
1586                .entry("max_tool_iterations".to_string())
1587                .or_insert(v);
1588            ::zeroclaw_log::record!(
1589                INFO,
1590                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1591                    .with_attrs(::serde_json::json!({"alias": alias})),
1592                "agents..max_iterations → agents..max_tool_iterations"
1593            );
1594        }
1595
1596        // V2 per-agent overrides split into authorization (risk) and
1597        // operational (runtime) buckets, matching the V3 profile shape:
1598        //   risk: allowed_tools
1599        //   runtime: agentic, max_delegation_depth (from V2 max_depth),
1600        //            agentic_timeout_secs
1601        let allowed_tools = agent_table.remove("allowed_tools");
1602        let agentic_flag = agent_table.remove("agentic");
1603        let max_depth = agent_table.remove("max_depth");
1604        let agentic_timeout_secs = extract_agentic_timeout_secs(&mut agent_table);
1605
1606        let profile_alias = format!("agent_{}", alias);
1607
1608        if let Some(at_value) = allowed_tools {
1609            let mut overrides = toml::Table::new();
1610            overrides.insert("allowed_tools".to_string(), at_value);
1611            install_profile_entry(passthrough, "risk_profiles", &profile_alias, overrides);
1612            agent_table
1613                .entry("risk_profile".to_string())
1614                .or_insert_with(|| toml::Value::String(profile_alias.clone()));
1615            ::zeroclaw_log::record!(
1616                INFO,
1617                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1618                    .with_attrs(
1619                        ::serde_json::json!({"alias": alias, "profile_alias": profile_alias})
1620                    ),
1621                "agents..allowed_tools → risk_profiles..allowed_tools"
1622            );
1623        }
1624
1625        if agentic_flag.is_some() || max_depth.is_some() || agentic_timeout_secs.is_some() {
1626            let mut overrides = toml::Table::new();
1627            if let Some(v) = agentic_flag {
1628                overrides.insert("agentic".to_string(), v);
1629            }
1630            if let Some(d) = max_depth {
1631                overrides.insert("max_delegation_depth".to_string(), d);
1632            }
1633            if let Some(t) = agentic_timeout_secs {
1634                overrides.insert("agentic_timeout_secs".to_string(), t);
1635            }
1636            install_profile_entry(passthrough, "runtime_profiles", &profile_alias, overrides);
1637            agent_table
1638                .entry("runtime_profile".to_string())
1639                .or_insert_with(|| toml::Value::String(profile_alias.clone()));
1640            ::zeroclaw_log::record!(
1641                INFO,
1642                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1643                    .with_attrs(
1644                        ::serde_json::json!({"alias": alias, "profile_alias": profile_alias})
1645                    ),
1646                "agents.: agentic/max_depth/agentic_timeout_secs → runtime_profiles."
1647            );
1648        }
1649
1650        // skills_directory → synthesize a per-agent skill_bundle and
1651        // append its alias to agent.skill_bundles. V3 confines bundle
1652        // directories to `<install>/shared/skills/<bundle_alias>/`, so
1653        // V2 paths inside `shared/` survive verbatim; everything else
1654        // (absolute paths, paths above `shared/`) drops the explicit
1655        // directory and falls back to the default. The operator's
1656        // V2 skills need to be copied into the new location after
1657        // migration — surface a warning naming what was dropped.
1658        if let Some(toml::Value::String(skills_dir)) = agent_table.remove("skills_directory")
1659            && !skills_dir.is_empty()
1660        {
1661            let bundle_alias = format!("agent_{}", alias);
1662            let mut bundle_entry = toml::Table::new();
1663            let trimmed = skills_dir.trim().trim_start_matches("./");
1664            let stays_inside_shared = !std::path::Path::new(trimmed).is_absolute()
1665                && (trimmed == "shared" || trimmed.starts_with("shared/"));
1666            if stays_inside_shared {
1667                bundle_entry.insert("directory".to_string(), toml::Value::String(skills_dir));
1668            } else {
1669                ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"alias": alias, "skills_dir": skills_dir, "bundle_alias": bundle_alias})), "agents..skills_directory = \"\" lies outside \
1670                     <install>/shared/. V3 confines skill-bundles to \
1671                     <install>/shared/skills/<alias>/; the path was dropped and the bundle \
1672                     falls back to the default. Copy the V2 skill files into \
1673                     <install>/shared/skills// to restore them.");
1674            }
1675            install_profile_entry(passthrough, "skill_bundles", &bundle_alias, bundle_entry);
1676            // V3 AliasedAgentConfig.skill_bundles is Vec<String> of aliases.
1677            // Append our synthesized bundle alias (preserve any user-set list).
1678            let existing = agent_table
1679                .remove("skill_bundles")
1680                .and_then(|v| match v {
1681                    toml::Value::Array(a) => Some(a),
1682                    _ => None,
1683                })
1684                .unwrap_or_default();
1685            let mut new_list = existing;
1686            let already = new_list
1687                .iter()
1688                .any(|v| v.as_str() == Some(bundle_alias.as_str()));
1689            if !already {
1690                new_list.push(toml::Value::String(bundle_alias.clone()));
1691            }
1692            agent_table.insert("skill_bundles".to_string(), toml::Value::Array(new_list));
1693            ::zeroclaw_log::record!(
1694                INFO,
1695                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1696                    .with_attrs(
1697                        ::serde_json::json!({"alias": alias, "bundle_alias": bundle_alias})
1698                    ),
1699                "agents..skills_directory → [skill_bundles.] (referenced \
1700                 from agents..skill_bundles)"
1701            );
1702        }
1703
1704        // Every V3 agent must reference a configured risk_profile and
1705        // runtime_profile. For agents that didn't trigger the
1706        // per-agent synthesis above, fall back to "default" and ensure
1707        // both entries exist (V3 rejects dangling profile refs).
1708        let agent_risk = agent_table
1709            .get("risk_profile")
1710            .and_then(toml::Value::as_str)
1711            .map(ToString::to_string)
1712            .filter(|s| !s.is_empty());
1713        let risk_alias = agent_risk.unwrap_or_else(|| "default".to_string());
1714        ensure_profile_entry(passthrough, "risk_profiles", &risk_alias);
1715        agent_table.insert("risk_profile".to_string(), toml::Value::String(risk_alias));
1716
1717        let agent_runtime = agent_table
1718            .get("runtime_profile")
1719            .and_then(toml::Value::as_str)
1720            .map(ToString::to_string)
1721            .filter(|s| !s.is_empty());
1722        let runtime_alias = agent_runtime.unwrap_or_else(|| "default".to_string());
1723        ensure_profile_entry(passthrough, "runtime_profiles", &runtime_alias);
1724        agent_table.insert(
1725            "runtime_profile".to_string(),
1726            toml::Value::String(runtime_alias),
1727        );
1728
1729        // V3 retired the V2 `memory_namespace` field on agents (and the
1730        // top-level [memory_namespaces.<alias>] section it referenced)
1731        // when per-agent memory backends landed under
1732        // [agents.<alias>.memory]. Drop the V2 key so it doesn't carry
1733        // through to the V3 deserialization step.
1734        agent_table.remove("memory_namespace");
1735
1736        new_agents.insert(alias, toml::Value::Table(agent_table));
1737    }
1738    new_agents
1739}
1740
1741/// Pull V2 `[agents.<alias>].agentic_timeout_secs` off the agent table
1742/// and hand it to the caller for routing onto the synthesized
1743/// `runtime_profiles.agent_<alias>.agentic_timeout_secs`.
1744fn extract_agentic_timeout_secs(agent: &mut toml::Table) -> Option<toml::Value> {
1745    agent.remove("agentic_timeout_secs")
1746}
1747
1748/// Pull V2 `[agents.<alias>].timeout_secs` off the agent table; the
1749/// caller folds this into the agent's resolved model_provider entry.
1750fn extract_provider_timeout_secs(agent: &mut toml::Table) -> Option<toml::Value> {
1751    agent.remove("timeout_secs")
1752}
1753
1754/// Insert (or merge) a profile entry at `passthrough.<section>.<alias>`.
1755/// Existing keys win — `fields` only fills in missing slots.
1756fn install_profile_entry(
1757    passthrough: &mut toml::Table,
1758    section: &str,
1759    alias: &str,
1760    fields: toml::Table,
1761) {
1762    let section_value = passthrough
1763        .entry(section.to_string())
1764        .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1765    if let Some(section_table) = section_value.as_table_mut() {
1766        let alias_value = section_table
1767            .entry(alias.to_string())
1768            .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1769        if let Some(alias_table) = alias_value.as_table_mut() {
1770            for (k, v) in fields {
1771                alias_table.entry(k).or_insert(v);
1772            }
1773        }
1774    }
1775}
1776
1777/// Insert `(key, value)` pairs from `extras` into a sub-table at `top.<section>`.
1778/// Creates the sub-table if missing; overwrites individual keys but preserves
1779/// other existing keys in the section.
1780fn merge_into_table(top: &mut toml::Table, section: &str, extras: toml::Table) {
1781    let entry = top
1782        .entry(section.to_string())
1783        .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1784    if let Some(section_table) = entry.as_table_mut() {
1785        for (k, v) in extras {
1786            section_table.insert(k, v);
1787        }
1788    }
1789}
1790
1791/// Fold V2 `base_url` (+ optional `api_path`) into V3 `uri` on a single
1792/// `[model_providers.<type>.<alias>]` entry table. No-op when `uri` is
1793/// already set (operator wins) or when `base_url` is absent. Matches the
1794/// top-level-globals fold so both V1/V2 entry points produce the same
1795/// V3 shape.
1796fn fold_base_url_api_path_into_uri(entry: &mut toml::Table) {
1797    if entry.contains_key("uri") {
1798        // Operator-set V3 key wins; drop stale V2 spellings so V3
1799        // deserialize doesn't see unknown fields.
1800        entry.remove("base_url");
1801        entry.remove("api_path");
1802        return;
1803    }
1804    let base = match entry.remove("base_url") {
1805        Some(toml::Value::String(s)) if !s.is_empty() => s,
1806        _ => {
1807            // No base_url to fold. api_path alone has nowhere to live.
1808            entry.remove("api_path");
1809            return;
1810        }
1811    };
1812    let path = match entry.remove("api_path") {
1813        Some(toml::Value::String(p)) if !p.is_empty() => Some(p),
1814        _ => None,
1815    };
1816    let uri = match path {
1817        Some(p) => {
1818            let trimmed = base.trim_end_matches('/');
1819            let suffix = if p.starts_with('/') {
1820                p
1821            } else {
1822                format!("/{p}")
1823            };
1824            format!("{trimmed}{suffix}")
1825        }
1826        None => base,
1827    };
1828    entry.insert("uri".to_string(), toml::Value::String(uri));
1829}
1830
1831/// Rewrite any `peer_groups.<X>.agents = ["default"]` entries to point at
1832/// a real agent alias when `agents.default` doesn't exist. Step 7
1833/// synthesizes peer_groups with the bridge alias `"default"` before
1834/// step 8 decides what the actual agent map looks like; this post-pass
1835/// patches up the dangling reference in the multi-agent V2 case where
1836/// `agents.default` is never created.
1837///
1838/// Also injects the peer_group's channel ref into the chosen agent's
1839/// `channels` list. V3 validation rejects an agent listed in a peer_group
1840/// for a channel it doesn't own (`agents.<X>.channels` must contain the
1841/// peer_group's channel); V2 had no per-agent channel binding, so the
1842/// migration extends the chosen agent's reach to cover what V2's implicit
1843/// single-agent semantics expected.
1844///
1845/// No-op when `agents.default` exists (the bridge alias is valid) or
1846/// when the agents map is empty (no fix possible — the operator will
1847/// hit a different validation error). Operator-authored peer_groups
1848/// whose agents list isn't exactly `["default"]` are left untouched.
1849fn rewrite_dangling_peer_group_agents(passthrough: &mut toml::Table) {
1850    let replacement_alias = {
1851        let Some(agents_table) = passthrough.get("agents").and_then(toml::Value::as_table) else {
1852            return;
1853        };
1854        if agents_table.is_empty() || agents_table.contains_key("default") {
1855            return;
1856        }
1857        let Some(alias) = agents_table.keys().next().cloned() else {
1858            return;
1859        };
1860        alias
1861    };
1862
1863    let mut rewritten_channel_types: Vec<String> = Vec::new();
1864    {
1865        let Some(toml::Value::Table(peer_groups)) = passthrough.get_mut("peer_groups") else {
1866            return;
1867        };
1868        for (group_name, group_value) in peer_groups.iter_mut() {
1869            let Some(group_table) = group_value.as_table_mut() else {
1870                continue;
1871            };
1872            let Some(toml::Value::Array(agents_arr)) = group_table.get("agents") else {
1873                continue;
1874            };
1875            let only_default = agents_arr.len() == 1 && agents_arr[0].as_str() == Some("default");
1876            if !only_default {
1877                continue;
1878            }
1879            group_table.insert(
1880                "agents".to_string(),
1881                toml::Value::Array(vec![toml::Value::String(replacement_alias.clone())]),
1882            );
1883            if let Some(toml::Value::String(channel_ref)) = group_table.get("channel") {
1884                rewritten_channel_types.push(channel_ref.clone());
1885            }
1886            ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"group_name": group_name, "replacement_alias": format!("{:?}", replacement_alias)})), "peer_groups..agents rewritten from [\"default\"] to [] (no agents.default exists)");
1887        }
1888    }
1889
1890    if rewritten_channel_types.is_empty() {
1891        return;
1892    }
1893
1894    // Resolve each bare channel type back to the full set of
1895    // `<type>.<alias>` ChannelRefs that exist in `[channels.<type>.*]`.
1896    // peer_groups now bind to a type only, but agents.<X>.channels
1897    // requires dotted form. The V1/V2 single-agent fold assigned every
1898    // alias of that type to the bridge agent.
1899    let mut resolved_refs: Vec<String> = Vec::new();
1900    if let Some(toml::Value::Table(channels_table)) = passthrough.get("channels") {
1901        for channel_type in &rewritten_channel_types {
1902            let aliases = channels_table
1903                .get(channel_type)
1904                .and_then(toml::Value::as_table)
1905                .map(|t| t.keys().cloned().collect::<Vec<_>>())
1906                .unwrap_or_default();
1907            for alias in aliases {
1908                let dotted = format!("{channel_type}.{alias}");
1909                if !resolved_refs.contains(&dotted) {
1910                    resolved_refs.push(dotted);
1911                }
1912            }
1913        }
1914    }
1915    if resolved_refs.is_empty() {
1916        return;
1917    }
1918
1919    let Some(toml::Value::Table(agents_table)) = passthrough.get_mut("agents") else {
1920        return;
1921    };
1922    let Some(toml::Value::Table(agent_entry)) = agents_table.get_mut(&replacement_alias) else {
1923        return;
1924    };
1925    let channels_array = agent_entry
1926        .entry("channels".to_string())
1927        .or_insert_with(|| toml::Value::Array(Vec::new()));
1928    let Some(channels_arr) = channels_array.as_array_mut() else {
1929        return;
1930    };
1931    let mut added: Vec<String> = Vec::new();
1932    for ch in &resolved_refs {
1933        let present = channels_arr.iter().any(|v| v.as_str() == Some(ch.as_str()));
1934        if !present {
1935            channels_arr.push(toml::Value::String(ch.clone()));
1936            added.push(ch.clone());
1937        }
1938    }
1939    if !added.is_empty() {
1940        ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"replacement_alias": replacement_alias, "added": format!("{:?}", added)})), "agents..channels extended with  so the rewritten peer_groups resolve");
1941    }
1942}
1943
1944/// V2 → V3 backfill: when `[heartbeat] enabled = true` and `agent` is
1945/// unset/empty, set `agent` to a configured agent alias. Picks `"default"`
1946/// when present (matching the synthesized-default-agent path), otherwise
1947/// the first agent in the table. No-op when `agents` is empty or
1948/// `heartbeat.agent` is already set (operator wins).
1949fn backfill_heartbeat_agent(passthrough: &mut toml::Table) {
1950    let needs_backfill = passthrough
1951        .get("heartbeat")
1952        .and_then(toml::Value::as_table)
1953        .is_some_and(|hb| {
1954            let enabled = hb
1955                .get("enabled")
1956                .and_then(toml::Value::as_bool)
1957                .unwrap_or(false);
1958            let agent_set = hb
1959                .get("agent")
1960                .and_then(toml::Value::as_str)
1961                .is_some_and(|s| !s.trim().is_empty());
1962            enabled && !agent_set
1963        });
1964    if !needs_backfill {
1965        return;
1966    }
1967    let alias = passthrough
1968        .get("agents")
1969        .and_then(toml::Value::as_table)
1970        .and_then(|agents| {
1971            if agents.contains_key("default") {
1972                Some("default".to_string())
1973            } else {
1974                agents.keys().next().cloned()
1975            }
1976        });
1977    let Some(alias) = alias else {
1978        return;
1979    };
1980    if let Some(toml::Value::Table(hb)) = passthrough.get_mut("heartbeat") {
1981        hb.insert("agent".to_string(), toml::Value::String(alias.clone()));
1982        ::zeroclaw_log::record!(
1983            INFO,
1984            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1985                .with_attrs(::serde_json::json!({"alias": format!("{:?}", alias)})),
1986            &format!(
1987                "heartbeat.agent unset with heartbeat.enabled = true → backfilled to {alias:?}"
1988            )
1989        );
1990    }
1991}
1992
1993/// Ensure `[<section>.<alias>]` exists in `passthrough` as at least an
1994/// empty table. Used when synthesizing the default agent so the agent's
1995/// alias references resolve under V3 dangling-reference validation.
1996fn ensure_profile_entry(passthrough: &mut toml::Table, section: &str, alias: &str) {
1997    let entry = passthrough
1998        .entry(section.to_string())
1999        .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2000    if let Some(section_table) = entry.as_table_mut() {
2001        section_table
2002            .entry(alias.to_string())
2003            .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2004    }
2005}
2006
2007/// Lift the top-level `[identity]` table into each `[agents.<alias>.identity]`
2008/// during V2 → V3. V3 demoted identity to a per-agent block; leaving the
2009/// V2 top-level key intact would surface as an unknown field on the V3
2010/// deserializer. Operators who already wrote a per-agent identity block
2011/// keep it (no clobber). If no agents are present after the fold, the
2012/// top-level block is dropped with a warn (lossy but intentional — V3
2013/// has no other slot for it).
2014fn lift_top_level_identity_into_agents(passthrough: &mut toml::Table) {
2015    let Some(identity_value) = passthrough.remove("identity") else {
2016        return;
2017    };
2018    let Some(agents_value) = passthrough.get_mut("agents") else {
2019        ::zeroclaw_log::record!(
2020            WARN,
2021            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2022                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
2023            "[identity] dropped during V2->V3 (no [agents] table to attach to)"
2024        );
2025        return;
2026    };
2027    let Some(agents_table) = agents_value.as_table_mut() else {
2028        return;
2029    };
2030    if agents_table.is_empty() {
2031        ::zeroclaw_log::record!(
2032            WARN,
2033            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2034                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
2035            "[identity] dropped during V2->V3 (agents map empty after fold)"
2036        );
2037        return;
2038    }
2039    let aliases: Vec<String> = agents_table.keys().cloned().collect();
2040    let mut folded = 0usize;
2041    for alias in &aliases {
2042        let Some(agent_table) = agents_table
2043            .get_mut(alias)
2044            .and_then(toml::Value::as_table_mut)
2045        else {
2046            continue;
2047        };
2048        if agent_table.contains_key("identity") {
2049            continue;
2050        }
2051        agent_table.insert("identity".to_string(), identity_value.clone());
2052        folded += 1;
2053    }
2054    ::zeroclaw_log::record!(
2055        INFO,
2056        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2057            .with_attrs(::serde_json::json!({"folded": folded})),
2058        &format!("[identity] lifted into [agents.<alias>.identity] ({folded} agent(s))")
2059    );
2060}
2061
2062/// If no agents were declared in V2 input but the V2→V3 fold synthesized at
2063/// least one provider model entry, emit a single `agents.default` referencing
2064/// the first provider-alias. This preserves V1/V2 implicit single-agent
2065/// semantics: the V1 user with `default_provider = "openai"` and a brain
2066/// configured globally gets a working V3 default agent automatically.
2067///
2068/// `passthrough` is read (not mutated) — the synthesized agent is returned so
2069/// the caller decides whether to install it under `agents`.
2070fn synthesize_default_agent_if_needed(passthrough: &toml::Table) -> toml::Table {
2071    // V3 keeps every provider category under `[providers]`:
2072    // `[providers.models.<type>.<alias>]`. Walk in via the new path.
2073    let models = match passthrough
2074        .get("providers")
2075        .and_then(toml::Value::as_table)
2076        .and_then(|providers| providers.get("models"))
2077        .and_then(toml::Value::as_table)
2078    {
2079        Some(t) => t,
2080        None => return toml::Table::new(),
2081    };
2082    let first_alias = models.iter().find_map(|(provider_type, value)| {
2083        let inner = value.as_table()?;
2084        let alias = inner.keys().next()?;
2085        Some(format!("{provider_type}.{alias}"))
2086    });
2087    let alias_ref = match first_alias {
2088        Some(s) => s,
2089        None => return toml::Table::new(),
2090    };
2091
2092    let mut default_agent = toml::Table::new();
2093    default_agent.insert("model_provider".to_string(), toml::Value::String(alias_ref));
2094    default_agent.insert(
2095        "risk_profile".to_string(),
2096        toml::Value::String("default".into()),
2097    );
2098    default_agent.insert(
2099        "runtime_profile".to_string(),
2100        toml::Value::String("default".into()),
2101    );
2102
2103    let mut agents = toml::Table::new();
2104    agents.insert("default".to_string(), toml::Value::Table(default_agent));
2105    ::zeroclaw_log::record!(
2106        INFO,
2107        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2108        "synthesized [agents.default] from V1/V2 implicit single-agent semantics"
2109    );
2110    agents
2111}
2112
2113/// V3 TTS provider type keys. Matches the V2 `TtsConfig` per-provider
2114/// option fields.
2115const V3_TTS_TYPES: &[&str] = &["openai", "elevenlabs", "google", "edge", "piper"];
2116
2117/// Promote V2 `[tts.<type>]` per-provider sub-blocks into V3's unified
2118/// `[tts_providers.<type>.default]` alias map.
2119///
2120/// V2 `TtsConfig` had a separate `Option<*TtsConfig>` field per provider
2121/// (`openai`, `elevenlabs`, `google`, `edge`, `piper`); V3 keys them all
2122/// by `<type>.<alias>` like the model providers. `[tts]` top-level
2123/// scalars (`enabled`, `default_voice`, `default_format`,
2124/// `max_text_length`) stay on `[tts]`; `default_provider` is dropped —
2125/// V3 has no global default TTS provider.
2126fn fold_v2_tts_into_providers(passthrough: &mut toml::Table, new_providers: &mut toml::Table) {
2127    let Some(toml::Value::Table(tts_table)) = passthrough.get_mut("tts") else {
2128        return;
2129    };
2130
2131    let mut tts_aliased = toml::Table::new();
2132    for ty in V3_TTS_TYPES {
2133        if let Some(mut value) = tts_table.remove(*ty) {
2134            // V2 ElevenLabsTtsConfig.model_id → V3 TtsProviderConfig.model.
2135            // Other V2 sub-types (OpenAi, Google, Edge, Piper) used field
2136            // names that survive into V3's unified TtsProviderConfig as-is.
2137            if *ty == "elevenlabs"
2138                && let Some(t) = value.as_table_mut()
2139                && let Some(v) = t.remove("model_id")
2140            {
2141                t.entry("model".to_string()).or_insert(v);
2142                ::zeroclaw_log::record!(
2143                    INFO,
2144                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2145                    "tts.elevenlabs.model_id renamed to tts.elevenlabs.model"
2146                );
2147            }
2148            let mut wrapped = toml::Table::new();
2149            wrapped.insert("default".to_string(), value);
2150            tts_aliased.insert((*ty).to_string(), toml::Value::Table(wrapped));
2151        }
2152    }
2153
2154    if tts_table.remove("default_provider").is_some() {
2155        ::zeroclaw_log::record!(
2156            INFO,
2157            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2158            "[tts].default_provider dropped (V3 has no global default-provider; set agent.<X>.tts_provider instead)"
2159        );
2160    }
2161
2162    if !tts_aliased.is_empty() {
2163        new_providers.insert("tts".to_string(), toml::Value::Table(tts_aliased));
2164        ::zeroclaw_log::record!(
2165            INFO,
2166            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2167            "[tts.<type>] sub-blocks promoted to [tts_providers.<type>.default]"
2168        );
2169    }
2170}
2171
2172/// Fold V2 `[transcription]` flat block + per-family sub-blocks into V3's
2173/// typed `[transcription_providers.<family>.<alias>]` shape. The Groq
2174/// fields lived directly on `[transcription]` in V2 (api_key, api_url,
2175/// model, language, initial_prompt) — they migrate to
2176/// `[transcription_providers.groq.default]`. Per-family sub-blocks
2177/// (`[transcription.openai]`, etc.) migrate to
2178/// `[transcription_providers.<family>.default]`.
2179///
2180/// Behavior fields (`enabled`, `transcribe_non_ptt_audio`,
2181/// `max_duration_secs`) stay on `[transcription]`. Legacy default-provider
2182/// keys (`default_provider`, `default_model_provider`,
2183/// `default_transcription_provider`) are dropped — V3 has no global
2184/// default; per-agent `transcription_provider` is the only selector.
2185fn fold_v2_transcription_into_providers(
2186    passthrough: &mut toml::Table,
2187    new_providers: &mut toml::Table,
2188) {
2189    let Some(toml::Value::Table(transcription_table)) = passthrough.get_mut("transcription") else {
2190        return;
2191    };
2192
2193    let mut transcription_aliased = toml::Table::new();
2194
2195    // Per-family sub-blocks: move to transcription_providers.<family>.default.
2196    const V3_TRANSCRIPTION_FAMILIES: &[&str] = &[
2197        "openai",
2198        "deepgram",
2199        "assemblyai",
2200        "google",
2201        "local_whisper",
2202    ];
2203    for family in V3_TRANSCRIPTION_FAMILIES {
2204        if let Some(value) = transcription_table.remove(*family) {
2205            let mut wrapped = toml::Table::new();
2206            wrapped.insert("default".to_string(), value);
2207            transcription_aliased.insert((*family).to_string(), toml::Value::Table(wrapped));
2208        }
2209    }
2210
2211    // Groq lived directly on [transcription] in V2. Extract its fields into
2212    // [transcription_providers.groq.default] so V3 can find it via the typed
2213    // family slot. Pulled fields: api_key, api_url, model, language,
2214    // initial_prompt. Behavior fields (enabled, transcribe_non_ptt_audio,
2215    // max_duration_secs) stay on [transcription].
2216    let mut groq_entry = toml::Table::new();
2217    for groq_field in &["api_key", "api_url", "model", "language", "initial_prompt"] {
2218        if let Some(v) = transcription_table.remove(*groq_field) {
2219            groq_entry.insert((*groq_field).to_string(), v);
2220        }
2221    }
2222    if !groq_entry.is_empty() {
2223        let mut wrapped = toml::Table::new();
2224        wrapped.insert("default".to_string(), toml::Value::Table(groq_entry));
2225        transcription_aliased.insert("groq".to_string(), toml::Value::Table(wrapped));
2226        ::zeroclaw_log::record!(
2227            INFO,
2228            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2229            "[transcription] Groq fields promoted to [transcription_providers.groq.default]"
2230        );
2231    }
2232
2233    // Drop legacy default-provider keys — V3 has no global default-provider
2234    // field. Operators select transcription per agent
2235    // (`agent.<X>.transcription_provider`).
2236    for legacy_default in &[
2237        "default_provider",
2238        "default_model_provider",
2239        "default_transcription_provider",
2240    ] {
2241        if transcription_table.remove(*legacy_default).is_some() {
2242            ::zeroclaw_log::record!(
2243                INFO,
2244                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2245                    .with_attrs(::serde_json::json!({"legacy_default": legacy_default})),
2246                &format!(
2247                    "[transcription].{legacy_default} dropped (V3 has no global default-provider; set agent.<X>.transcription_provider instead)"
2248                )
2249            );
2250        }
2251    }
2252
2253    if !transcription_aliased.is_empty() {
2254        // Merge into existing providers.transcription if any (operator may
2255        // have written V3-style entries already).
2256        let providers_transcription = new_providers
2257            .entry("transcription".to_string())
2258            .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2259        if let Some(existing) = providers_transcription.as_table_mut() {
2260            for (family, value) in transcription_aliased {
2261                existing.entry(family).or_insert(value);
2262            }
2263        }
2264        ::zeroclaw_log::record!(
2265            INFO,
2266            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2267            "[transcription.<family>] sub-blocks promoted to [transcription_providers.<family>.default]"
2268        );
2269    }
2270}
2271
2272/// Rename each route entry's V2 `provider` field to V3 `model_provider`.
2273/// Applies to `[providers.<routes_key>]` for `model_routes` and
2274/// `embedding_routes`. Bare provider names get promoted to the V3 dotted
2275/// form (`"openai"` → `"openai.default"`) so the dangling-reference
2276/// validator sees a real `model_providers.<type>.<alias>` reference.
2277fn rename_route_provider_field(new_providers: &mut toml::Table, routes_key: &str) {
2278    let Some(toml::Value::Array(routes)) = new_providers.get_mut(routes_key) else {
2279        return;
2280    };
2281    let mut renamed = 0usize;
2282    let mut promoted = 0usize;
2283    for entry in routes.iter_mut() {
2284        let toml::Value::Table(t) = entry else {
2285            continue;
2286        };
2287        if t.contains_key("model_provider") {
2288            // Already V3-shaped (operator wrote `model_provider` directly,
2289            // or migration ran twice). Drop a stray `provider` if present
2290            // so downstream serde doesn't trip on an unknown field.
2291            t.remove("provider");
2292        } else if let Some(value) = t.remove("provider") {
2293            t.insert("model_provider".to_string(), value);
2294            renamed += 1;
2295        }
2296        // V3's `model_provider` is a dotted alias (`<type>.<alias>`). V2
2297        // wrote a bare provider type (e.g. `"openai"`); promote it to
2298        // `"openai.default"` so V3 deserialize and the dangling-reference
2299        // validator both see a real `model_providers.<type>.<alias>` ref.
2300        if let Some(toml::Value::String(s)) = t.get_mut("model_provider")
2301            && !s.is_empty()
2302            && !s.contains('.')
2303        {
2304            *s = format!("{s}.default");
2305            promoted += 1;
2306        }
2307    }
2308    if renamed > 0 {
2309        ::zeroclaw_log::record!(
2310            INFO,
2311            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2312                .with_attrs(::serde_json::json!({"routes_key": routes_key, "renamed": renamed})),
2313            "[providers.]  entry/entries: `provider` field renamed to `model_provider`"
2314        );
2315    }
2316    if promoted > 0 {
2317        ::zeroclaw_log::record!(
2318            INFO,
2319            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2320                .with_attrs(::serde_json::json!({"routes_key": routes_key, "promoted": promoted})),
2321            "[providers.]  entry/entries: bare `model_provider` promoted to dotted `<type>.default` form"
2322        );
2323    }
2324}
2325
2326/// Fold V2 `[memory.qdrant]`, `[memory.postgres]`, and
2327/// `[storage.provider.config]` into V3 `[storage.<backend>.<alias>]`. V3
2328/// unified V2's three storage sources under one typed map per backend:
2329///
2330/// - `[memory.qdrant]` → `[storage.qdrant.default]` (same field names).
2331/// - `[memory.postgres]` contributes only `vector_enabled` and
2332///   `vector_dimensions`; the remaining `db_url`, `schema`, `table`
2333///   come from `[storage.provider.config]` when the operator set
2334///   `provider = "postgres"` there.
2335/// - `[storage.provider.config]`'s `provider` field selects the V3
2336///   backend; remaining fields are adapted per-backend (sqlite extracts
2337///   path from a `sqlite://...` URL; qdrant maps `db_url` → `url`;
2338///   postgres maps directly).
2339/// - `[memory].sqlite_open_timeout_secs` lifts onto
2340///   `[storage.sqlite.default].open_timeout_secs`.
2341///
2342/// Operator-authored V3-shaped entries take precedence over the fold.
2343fn fold_v2_storage_subsystems(passthrough: &mut toml::Table) {
2344    let (memory_qdrant, memory_postgres, memory_sqlite_timeout) = match passthrough
2345        .get_mut("memory")
2346        .and_then(toml::Value::as_table_mut)
2347    {
2348        Some(memory) => (
2349            memory.remove("qdrant"),
2350            memory.remove("postgres"),
2351            memory.remove("sqlite_open_timeout_secs"),
2352        ),
2353        None => (None, None, None),
2354    };
2355
2356    let storage_provider = match passthrough
2357        .get_mut("storage")
2358        .and_then(toml::Value::as_table_mut)
2359    {
2360        Some(storage) => storage.remove("provider"),
2361        None => None,
2362    };
2363
2364    if memory_qdrant.is_none()
2365        && memory_postgres.is_none()
2366        && memory_sqlite_timeout.is_none()
2367        && storage_provider.is_none()
2368    {
2369        return;
2370    }
2371
2372    let storage_entry = passthrough
2373        .entry("storage".to_string())
2374        .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2375    let Some(storage_table) = storage_entry.as_table_mut() else {
2376        return;
2377    };
2378
2379    if let Some(toml::Value::Table(qdrant_data)) = memory_qdrant {
2380        merge_storage_default(storage_table, "qdrant", qdrant_data);
2381        ::zeroclaw_log::record!(
2382            INFO,
2383            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2384            "[memory.qdrant] promoted to [storage.qdrant.default]"
2385        );
2386    }
2387    if let Some(timeout_value) = memory_sqlite_timeout {
2388        let mut sqlite_fields = toml::Table::new();
2389        sqlite_fields.insert("open_timeout_secs".to_string(), timeout_value);
2390        merge_storage_default(storage_table, "sqlite", sqlite_fields);
2391        ::zeroclaw_log::record!(
2392            INFO,
2393            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2394            "memory.sqlite_open_timeout_secs → [storage.sqlite.default].open_timeout_secs"
2395        );
2396    }
2397    if let Some(toml::Value::Table(postgres_vector_data)) = memory_postgres {
2398        merge_storage_default(storage_table, "postgres", postgres_vector_data);
2399        ::zeroclaw_log::record!(
2400            INFO,
2401            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2402            "[memory.postgres] vector fields promoted to [storage.postgres.default]"
2403        );
2404    }
2405
2406    if let Some(provider_section_value) = storage_provider {
2407        // V2 had two layouts: `[storage.provider.config]` (nested) or
2408        // `storage.provider = { provider = "...", db_url = "..." }` (inline).
2409        // Both produce the same parsed structure: a Table with a `config`
2410        // sub-table. Flatten that here.
2411        let config_table = match provider_section_value {
2412            toml::Value::Table(mut section) => {
2413                if let Some(toml::Value::Table(inner)) = section.remove("config") {
2414                    inner
2415                } else {
2416                    section
2417                }
2418            }
2419            _ => {
2420                drop_empty_subsystem_blocks(passthrough);
2421                return;
2422            }
2423        };
2424        if config_table.is_empty() {
2425            drop_empty_subsystem_blocks(passthrough);
2426            return;
2427        }
2428
2429        let (provider_type, mut adapted_fields) = adapt_storage_provider_config(config_table);
2430        if !adapted_fields.is_empty() {
2431            // sqlite_open_timeout_secs from [memory] (already removed above)
2432            // wasn't re-injected, but we previously moved memory.qdrant /
2433            // memory.postgres in here, so fields stay separate per backend.
2434            merge_storage_default(
2435                storage_table,
2436                &provider_type,
2437                std::mem::take(&mut adapted_fields),
2438            );
2439            ::zeroclaw_log::record!(
2440                INFO,
2441                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2442                    .with_attrs(::serde_json::json!({"provider_type": provider_type})),
2443                "[storage.provider.config provider=] promoted to [storage..default]"
2444            );
2445        }
2446    }
2447
2448    drop_empty_subsystem_blocks(passthrough);
2449}
2450
2451/// Drop top-level blocks that the storage fold emptied. `[memory]` requires
2452/// `backend` and `[storage]` requires at least one backend instance, so an
2453/// empty table at either path would fail V3 schema validation. The default
2454/// at parse time (struct Default for both) is the correct fallback when the
2455/// operator hadn't authored anything beyond the now-lifted subentries.
2456fn drop_empty_subsystem_blocks(passthrough: &mut toml::Table) {
2457    for key in ["memory", "storage"] {
2458        if let Some(toml::Value::Table(t)) = passthrough.get(key)
2459            && t.is_empty()
2460        {
2461            passthrough.remove(key);
2462        }
2463    }
2464}
2465
2466/// Adapt a V2 `StorageProviderConfig` (flat `{provider, db_url, schema,
2467/// table, connect_timeout_secs}`) to the V3 backend-specific shape. Returns
2468/// the chosen backend type and the adapted field table.
2469fn adapt_storage_provider_config(mut config: toml::Table) -> (String, toml::Table) {
2470    let provider_type = config
2471        .remove("provider")
2472        .and_then(|v| match v {
2473            toml::Value::String(s) if !s.is_empty() => Some(s),
2474            _ => None,
2475        })
2476        .unwrap_or_else(|| "sqlite".to_string());
2477
2478    match provider_type.as_str() {
2479        "sqlite" => {
2480            let mut out = toml::Table::new();
2481            // V2 db_url for sqlite was typically "sqlite:///path" — extract path.
2482            if let Some(toml::Value::String(db_url)) = config.remove("db_url") {
2483                let path = db_url
2484                    .strip_prefix("sqlite://")
2485                    .or_else(|| db_url.strip_prefix("sqlite:"))
2486                    .map(ToString::to_string)
2487                    .unwrap_or(db_url);
2488                if !path.is_empty() {
2489                    out.insert("path".to_string(), toml::Value::String(path));
2490                }
2491            }
2492            // V2 connect_timeout_secs maps to V3 SqliteStorageConfig.open_timeout_secs.
2493            if let Some(v) = config.remove("connect_timeout_secs") {
2494                out.insert("open_timeout_secs".to_string(), v);
2495            }
2496            // schema/table not applicable to sqlite — drop.
2497            (provider_type, out)
2498        }
2499        "postgres" => {
2500            // db_url, schema, table, connect_timeout_secs all map directly.
2501            (provider_type, config)
2502        }
2503        "qdrant" => {
2504            let mut out = toml::Table::new();
2505            if let Some(v) = config.remove("db_url") {
2506                out.insert("url".to_string(), v);
2507            }
2508            // schema/table not applicable to qdrant — drop.
2509            (provider_type, out)
2510        }
2511        _ => {
2512            ::zeroclaw_log::record!(
2513                INFO,
2514                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2515                    .with_attrs(
2516                        ::serde_json::json!({"provider_type": format!("{:?}", provider_type)})
2517                    ),
2518                "[storage.provider.config] unknown provider type ; passthrough as-is"
2519            );
2520            (provider_type, config)
2521        }
2522    }
2523}
2524
2525/// Merge `fields` into `storage_table.<backend>.default`, creating the
2526/// nested tables if missing. Existing keys win — `fields` only fills gaps.
2527fn merge_storage_default(storage_table: &mut toml::Table, backend_type: &str, fields: toml::Table) {
2528    let backend_entry = storage_table
2529        .entry(backend_type.to_string())
2530        .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2531    if let Some(backend_table) = backend_entry.as_table_mut() {
2532        let default_entry = backend_table
2533            .entry("default".to_string())
2534            .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2535        if let Some(default_table) = default_entry.as_table_mut() {
2536            for (k, v) in fields {
2537                default_table.entry(k).or_insert(v);
2538            }
2539        }
2540    }
2541}
2542
2543/// Fold V2 `[security.sandbox]` into `risk_profiles.default` and drop
2544/// `[security.resources]`.
2545///
2546/// Field renames during the sandbox fold:
2547/// - `security.sandbox.enabled` → `risk_profiles.default.sandbox_enabled`
2548/// - `security.sandbox.backend` → `risk_profiles.default.sandbox_backend`
2549/// - `security.sandbox.firejail_args` → `risk_profiles.default.firejail_args`
2550///
2551/// `[security.resources]` (max_memory_mb, max_cpu_time_seconds,
2552/// max_subprocesses, memory_monitoring) is dropped: V2 carried the fields
2553/// but no enforcement codepath ever consumed them. Sandbox backends
2554/// (firejail/landlock) own the actual resource budgets they enforce.
2555/// A WARN-level log names the dropped values so an operator who set
2556/// them can reconfigure the equivalent in their sandbox backend.
2557///
2558/// Existing values on the V3 profile take precedence — sandbox globals
2559/// only fill in missing slots.
2560fn fold_security_into_risk_profile(passthrough: &mut toml::Table) {
2561    let (sandbox, resources) = {
2562        let security_table = match passthrough
2563            .get_mut("security")
2564            .and_then(toml::Value::as_table_mut)
2565        {
2566            Some(t) => t,
2567            None => return,
2568        };
2569        (
2570            security_table.remove("sandbox"),
2571            security_table.remove("resources"),
2572        )
2573    };
2574    if sandbox.is_none() && resources.is_none() {
2575        return;
2576    }
2577
2578    if let Some(toml::Value::Table(resources_table)) = resources
2579        && !resources_table.is_empty()
2580    {
2581        let dropped: Vec<String> = resources_table
2582            .iter()
2583            .map(|(k, v)| format!("{k}={v}"))
2584            .collect();
2585        ::zeroclaw_log::record!(
2586            WARN,
2587            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2588                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
2589            &format!(
2590                "[security.resources] dropped during V2→V3 migration (no V3 enforcement \
2591             codepath existed; sandbox backends own resource budgets): {}",
2592                dropped.join(", ")
2593            )
2594        );
2595    }
2596
2597    let Some(toml::Value::Table(sandbox_table)) = sandbox else {
2598        return;
2599    };
2600    if sandbox_table.is_empty() {
2601        return;
2602    }
2603
2604    let risk_profiles = passthrough
2605        .entry("risk_profiles".to_string())
2606        .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2607    let Some(risk_profiles_table) = risk_profiles.as_table_mut() else {
2608        return;
2609    };
2610    let default_entry = risk_profiles_table
2611        .entry("default".to_string())
2612        .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2613    let Some(default_profile) = default_entry.as_table_mut() else {
2614        return;
2615    };
2616
2617    for (k, v) in sandbox_table {
2618        let target_key = match k.as_str() {
2619            "enabled" => "sandbox_enabled",
2620            "backend" => "sandbox_backend",
2621            "firejail_args" => "firejail_args",
2622            _ => continue,
2623        };
2624        default_profile.entry(target_key.to_string()).or_insert(v);
2625    }
2626    ::zeroclaw_log::record!(
2627        INFO,
2628        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2629        "[security.sandbox] folded into [risk_profiles.default]"
2630    );
2631}
2632
2633/// Split a V2 `[autonomy]` block (already key-renamed where applicable)
2634/// into the V3 risk-profile and runtime-profile field sets. The risk
2635/// bucket holds authorization fields; the runtime bucket holds budget
2636/// caps and other operational tuning that the V3 `RuntimeProfileConfig`
2637/// now owns.
2638///
2639/// Returns `(risk_fields, runtime_fields)` as optional tables — `None`
2640/// when the bucket is empty so callers can skip the destination block.
2641fn split_autonomy_into_profile_buckets(
2642    value: toml::Value,
2643) -> (Option<toml::Table>, Option<toml::Table>) {
2644    let Ok(table) = value.try_into::<toml::Table>() else {
2645        return (None, None);
2646    };
2647    const RUNTIME_FIELDS: &[&str] = &[
2648        "max_actions_per_hour",
2649        "max_cost_per_day_cents",
2650        "shell_timeout_secs",
2651        "max_delegation_depth",
2652        "delegation_timeout_secs",
2653        "agentic_timeout_secs",
2654    ];
2655    let mut risk = toml::Table::new();
2656    let mut runtime = toml::Table::new();
2657    for (k, v) in table {
2658        if RUNTIME_FIELDS.contains(&k.as_str()) {
2659            runtime.insert(k, v);
2660        } else {
2661            risk.insert(k, v);
2662        }
2663    }
2664    let risk = (!risk.is_empty()).then_some(risk);
2665    let runtime = (!runtime.is_empty()).then_some(runtime);
2666    (risk, runtime)
2667}
2668
2669/// Merge a field set into `<profile_kind>.default`, preserving values
2670/// that already exist on the destination (`entry().or_insert`).
2671fn merge_into_profile_default(profiles: &mut toml::Table, fields: toml::Table) {
2672    let default_entry = profiles
2673        .entry("default".to_string())
2674        .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2675    let Some(default_table) = default_entry.as_table_mut() else {
2676        return;
2677    };
2678    for (k, v) in fields {
2679        default_table.entry(k).or_insert(v);
2680    }
2681}
2682
2683/// Rename top-level keys inside a `toml::Value::Table` according to a list of
2684/// `(old, new)` pairs. Non-tables are returned unchanged. Existing values at
2685/// the new key are not overwritten — the rename is best-effort.
2686fn rename_table_keys(value: toml::Value, renames: &[(&str, &str)]) -> toml::Value {
2687    let mut table = match value {
2688        toml::Value::Table(t) => t,
2689        other => return other,
2690    };
2691    for (old, new) in renames {
2692        if let Some(v) = table.remove(*old)
2693            && !table.contains_key(*new)
2694        {
2695            table.insert((*new).to_string(), v);
2696        }
2697    }
2698    toml::Value::Table(table)
2699}
2700
2701/// Lowercase, replace non-alphanumeric runs with underscores, trim underscores.
2702fn slugify(s: &str) -> String {
2703    let mut out = String::with_capacity(s.len());
2704    let mut prev_underscore = false;
2705    for c in s.chars() {
2706        if c.is_alphanumeric() {
2707            out.push(c.to_ascii_lowercase());
2708            prev_underscore = false;
2709        } else if !prev_underscore {
2710            out.push('_');
2711            prev_underscore = true;
2712        }
2713    }
2714    out.trim_matches('_').to_string()
2715}
2716
2717/// If `key` already exists in `existing`, suffix `_2`, `_3`, … until unique.
2718fn ensure_unique_key(existing: &toml::Table, key: String) -> String {
2719    if !existing.contains_key(&key) {
2720        return key;
2721    }
2722    let mut n = 2;
2723    loop {
2724        let candidate = format!("{key}_{n}");
2725        if !existing.contains_key(&candidate) {
2726            return candidate;
2727        }
2728        n += 1;
2729    }
2730}
2731
2732// =============================================================================
2733// V2 → V3 filesystem & memory-backend migration
2734// =============================================================================
2735//
2736// One source of truth for every V2→V3 disk move and backend agent_id backfill.
2737// The dispatch tables below drive both production migration and the e2e test;
2738// adding a new legacy entry is one row, picked up by both sides without further
2739// edits.
2740
2741use anyhow::{Context as MigContext, Result as MigResult};
2742use rusqlite::{Connection, OptionalExtension, params};
2743use std::path::{Path, PathBuf};
2744
2745/// Destination class for a top-level entry under the legacy
2746/// `<install>/workspace/` directory.
2747#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2748pub enum V2WorkspaceDest {
2749    /// Wholesale relocation into `<install>/data/<name>`.
2750    DataDir,
2751    /// Wholesale relocation into `<install>/shared/<name>`.
2752    SharedDir,
2753    /// Wholesale relocation into `<install>/agents/default/workspace/<name>`.
2754    AgentDefault,
2755    /// `workspace/memory/` has mixed contents: shared DBs / archive /
2756    /// snapshot stay in `data/memory/`; markdown daily files belong to
2757    /// the agent. The orchestrator iterates subentries and dispatches
2758    /// via [`V2_MEMORY_DATA_NAMES`].
2759    MemorySubentryDispatch,
2760}
2761
2762/// Single canonical V2 → V3 top-level workspace dispatch.
2763///
2764/// Anything not in this list falls through to
2765/// [`V2WorkspaceDest::AgentDefault`].
2766///
2767/// Adding a new entry here is the ONLY edit needed to extend coverage —
2768/// the orchestrator and the e2e test both iterate this table.
2769pub const V2_WORKSPACE_TOPLEVEL_DISPATCH: &[(&str, V2WorkspaceDest)] = &[
2770    ("memory", V2WorkspaceDest::MemorySubentryDispatch),
2771    ("sessions", V2WorkspaceDest::DataDir),
2772    ("state", V2WorkspaceDest::DataDir),
2773    ("skills", V2WorkspaceDest::SharedDir),
2774    // Top-level instance-state file. The DeviceRegistry reader at
2775    // `api_pairing.rs:40` opens `<data_dir>/devices.db`, so unlike
2776    // per-agent files this has to land in `data/`, not in the agent
2777    // workspace where the default-branch would otherwise send it.
2778    ("devices.db", V2WorkspaceDest::DataDir),
2779];
2780
2781/// Subentries of legacy `<install>/workspace/memory/` that belong to the
2782/// shared instance memory dir (`<install>/data/memory/`).
2783///
2784/// Anything else under `workspace/memory/` (notably markdown daily files
2785/// like `2025-04-12.md`) goes to
2786/// `<install>/agents/default/workspace/memory/<name>` so the per-agent
2787/// markdown backend (which reads from the agent workspace) can find them.
2788pub const V2_MEMORY_DATA_NAMES: &[&str] = &[
2789    "brain.db",
2790    "audit.db",
2791    "response_cache.db",
2792    "MEMORY_SNAPSHOT.md",
2793    "archive",
2794];
2795
2796/// V3 root directories that should exist after a successful migration.
2797/// The e2e test asserts every entry under `<install>` is either one of
2798/// these, the post-migration `config.toml(.backup)?`, or a `backup-*/`.
2799pub const V3_INSTALL_ROOT_NAMES: &[&str] = &["data", "shared", "agents"];
2800
2801/// Dispatch a top-level legacy entry name to its V2WorkspaceDest class.
2802pub fn v2_workspace_toplevel_dest(name: &str) -> V2WorkspaceDest {
2803    V2_WORKSPACE_TOPLEVEL_DISPATCH
2804        .iter()
2805        .copied()
2806        .find(|(n, _)| *n == name)
2807        .map(|(_, d)| d)
2808        .unwrap_or(V2WorkspaceDest::AgentDefault)
2809}
2810
2811/// V3 destination path for a top-level entry under legacy `workspace/`.
2812///
2813/// For `MemorySubentryDispatch` entries the returned path is the
2814/// `data/<name>` prefix; the caller iterates the entry's subdir and uses
2815/// [`memory_subentry_v3_path`] per subentry.
2816pub fn workspace_toplevel_v3_path(install: &Path, name: &str) -> PathBuf {
2817    match v2_workspace_toplevel_dest(name) {
2818        V2WorkspaceDest::DataDir | V2WorkspaceDest::MemorySubentryDispatch => {
2819            install.join("data").join(name)
2820        }
2821        V2WorkspaceDest::SharedDir => install.join("shared").join(name),
2822        V2WorkspaceDest::AgentDefault => install
2823            .join("agents")
2824            .join("default")
2825            .join("workspace")
2826            .join(name),
2827    }
2828}
2829
2830/// V3 destination path for a subentry under legacy `workspace/memory/`.
2831pub fn memory_subentry_v3_path(install: &Path, sub_name: &str) -> PathBuf {
2832    if V2_MEMORY_DATA_NAMES.contains(&sub_name) {
2833        install.join("data").join("memory").join(sub_name)
2834    } else {
2835        install
2836            .join("agents")
2837            .join("default")
2838            .join("workspace")
2839            .join("memory")
2840            .join(sub_name)
2841    }
2842}
2843
2844/// Result of a successful filesystem migration.
2845#[derive(Debug, Clone)]
2846pub struct FilesystemMigrationReport {
2847    /// Timestamped backup directory (e.g. `<install>/backup-20260516T140530`).
2848    /// Empty when no migration ran.
2849    pub backup_dir: Option<PathBuf>,
2850    /// Number of top-level entries relocated.
2851    pub entries_relocated: usize,
2852}
2853
2854/// V2 → V3 install-root filesystem migration.
2855///
2856/// 1. Back up the entire legacy `<install>/workspace/` tree under
2857///    `<install>/backup-<ts>/legacy-workspace/` (copy-not-rename so a
2858///    partial failure leaves the legacy data untouched).
2859/// 2. Iterate legacy top-level entries; for each, look up the V3
2860///    destination via [`workspace_toplevel_v3_path`] (or the
2861///    [`memory_subentry_v3_path`] sub-dispatch for `memory/`) and move it.
2862/// 3. Heal intermediate v0.8.0-pre installs by relocating
2863///    `agents/default/workspace/skills/` to `shared/skills/`.
2864///
2865/// Idempotent: on a fresh install or an already-migrated install the
2866/// function is a no-op. Refuses to clobber an existing target —
2867/// surfacing a WARN and leaving the legacy entry in place rather than
2868/// overwriting operator data.
2869pub fn migrate_v2_to_v3_install_filesystem(
2870    install_root: &Path,
2871) -> MigResult<FilesystemMigrationReport> {
2872    let legacy = install_root.join("workspace");
2873    let agent_default = install_root
2874        .join("agents")
2875        .join("default")
2876        .join("workspace");
2877
2878    if !legacy.is_dir() {
2879        relocate_default_agent_skills_to_shared(install_root)?;
2880        return Ok(FilesystemMigrationReport {
2881            backup_dir: None,
2882            entries_relocated: 0,
2883        });
2884    }
2885
2886    let data_target = install_root.join("data");
2887    let data_populated = data_target
2888        .is_dir()
2889        .then(|| std::fs::read_dir(&data_target).ok())
2890        .flatten()
2891        .is_some_and(|mut it| it.next().is_some());
2892    let agent_populated = agent_default
2893        .is_dir()
2894        .then(|| std::fs::read_dir(&agent_default).ok())
2895        .flatten()
2896        .is_some_and(|mut it| it.next().is_some());
2897    if data_populated && agent_populated {
2898        ::zeroclaw_log::record!(
2899            INFO,
2900            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
2901                ::serde_json::json!({
2902                    "data_target": data_target.display().to_string(),
2903                    "agent_target": agent_default.display().to_string(),
2904                    "legacy": legacy.display().to_string(),
2905                })
2906            ),
2907            "[system] filesystem migration: targets already populated; skipping split"
2908        );
2909        relocate_default_agent_skills_to_shared(install_root)?;
2910        return Ok(FilesystemMigrationReport {
2911            backup_dir: None,
2912            entries_relocated: 0,
2913        });
2914    }
2915
2916    let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%S").to_string();
2917    let backup_dir = install_root
2918        .join(format!("backup-{timestamp}"))
2919        .join("legacy-workspace");
2920    std::fs::create_dir_all(&backup_dir).with_context(|| {
2921        format!(
2922            "[system] failed to create migration backup dir at {}",
2923            backup_dir.display()
2924        )
2925    })?;
2926    copy_dir_recursive(&legacy, &backup_dir).with_context(|| {
2927        format!(
2928            "[system] failed to back up legacy workspace from {} to {}",
2929            legacy.display(),
2930            backup_dir.display()
2931        )
2932    })?;
2933    ::zeroclaw_log::record!(
2934        INFO,
2935        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
2936            ::serde_json::json!({
2937                "backup": backup_dir.display().to_string(),
2938            })
2939        ),
2940        "[system] filesystem migration: legacy workspace backed up"
2941    );
2942
2943    let entries_relocated = relocate_workspace_toplevel(&legacy, install_root, &backup_dir)
2944        .with_context(|| {
2945            format!(
2946                "[system] failed during workspace top-level relocation under {}",
2947                install_root.display()
2948            )
2949        })?;
2950
2951    if std::fs::read_dir(&legacy)
2952        .map(|mut it| it.next().is_none())
2953        .unwrap_or(false)
2954    {
2955        let _ = std::fs::remove_dir(&legacy);
2956    }
2957
2958    relocate_default_agent_skills_to_shared(install_root)?;
2959
2960    ::zeroclaw_log::record!(
2961        INFO,
2962        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
2963            ::serde_json::json!({
2964                "backup": backup_dir.display().to_string(),
2965                "entries_relocated": entries_relocated,
2966            })
2967        ),
2968        "[system] filesystem migration: legacy workspace split into V3 layout"
2969    );
2970
2971    Ok(FilesystemMigrationReport {
2972        backup_dir: Some(backup_dir.parent().unwrap_or(&backup_dir).to_path_buf()),
2973        entries_relocated,
2974    })
2975}
2976
2977/// Iterate `legacy/` top-level entries and relocate each via the
2978/// dispatch tables. Returns the count of entries successfully moved
2979/// (entries already at the target are counted as moved).
2980fn relocate_workspace_toplevel(
2981    legacy: &Path,
2982    install_root: &Path,
2983    backup_dir: &Path,
2984) -> MigResult<usize> {
2985    let mut count = 0usize;
2986    for entry in std::fs::read_dir(legacy).with_context(|| {
2987        format!(
2988            "[system] failed to enumerate legacy workspace at {}",
2989            legacy.display()
2990        )
2991    })? {
2992        let entry = entry?;
2993        let name = entry.file_name();
2994        let Some(name_str) = name.to_str() else {
2995            ::zeroclaw_log::record!(
2996                WARN,
2997                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2998                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2999                    .with_attrs(::serde_json::json!({
3000                        "legacy": legacy.display().to_string(),
3001                    })),
3002                "[system] filesystem migration: skipping non-UTF-8 entry"
3003            );
3004            continue;
3005        };
3006        let src = entry.path();
3007
3008        match v2_workspace_toplevel_dest(name_str) {
3009            V2WorkspaceDest::MemorySubentryDispatch => {
3010                count += relocate_memory_subentries(&src, install_root, backup_dir)?;
3011            }
3012            _ => {
3013                let dst = workspace_toplevel_v3_path(install_root, name_str);
3014                if move_with_refuse_to_clobber(&src, &dst)? {
3015                    count += 1;
3016                }
3017            }
3018        }
3019    }
3020    Ok(count)
3021}
3022
3023/// Iterate `legacy/memory/`'s subentries and route each per
3024/// [`memory_subentry_v3_path`].
3025fn relocate_memory_subentries(
3026    legacy_memory_dir: &Path,
3027    install_root: &Path,
3028    _backup_dir: &Path,
3029) -> MigResult<usize> {
3030    if !legacy_memory_dir.is_dir() {
3031        return Ok(0);
3032    }
3033    let mut count = 0usize;
3034    for entry in std::fs::read_dir(legacy_memory_dir).with_context(|| {
3035        format!(
3036            "[system] failed to enumerate {} during memory sub-dispatch",
3037            legacy_memory_dir.display()
3038        )
3039    })? {
3040        let entry = entry?;
3041        let name = entry.file_name();
3042        let Some(name_str) = name.to_str() else {
3043            ::zeroclaw_log::record!(
3044                WARN,
3045                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3046                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
3047                    .with_attrs(::serde_json::json!({
3048                        "legacy_memory_dir": legacy_memory_dir.display().to_string(),
3049                    })),
3050                "[system] filesystem migration: skipping non-UTF-8 entry under memory"
3051            );
3052            continue;
3053        };
3054        let src = entry.path();
3055        let dst = memory_subentry_v3_path(install_root, name_str);
3056        if move_with_refuse_to_clobber(&src, &dst)? {
3057            count += 1;
3058        }
3059    }
3060    // Remove the now-empty memory dir (best-effort).
3061    if std::fs::read_dir(legacy_memory_dir)
3062        .map(|mut it| it.next().is_none())
3063        .unwrap_or(false)
3064    {
3065        let _ = std::fs::remove_dir(legacy_memory_dir);
3066    }
3067    Ok(count)
3068}
3069
3070/// Move `src` to `dst`, creating intermediate dirs and falling back to
3071/// copy+remove for cross-filesystem moves. Returns `Ok(true)` if the
3072/// move ran, `Ok(false)` if the destination already existed (operator
3073/// data preserved, WARN logged, caller continues with the rest of the
3074/// split).
3075fn move_with_refuse_to_clobber(src: &Path, dst: &Path) -> MigResult<bool> {
3076    if dst.exists() {
3077        ::zeroclaw_log::record!(
3078            WARN,
3079            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3080                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
3081                .with_attrs(::serde_json::json!({
3082                    "source": src.display().to_string(),
3083                    "target": dst.display().to_string(),
3084                })),
3085            "[system] filesystem migration: target already exists; refusing to clobber"
3086        );
3087        return Ok(false);
3088    }
3089    if let Some(parent) = dst.parent() {
3090        std::fs::create_dir_all(parent).with_context(|| {
3091            format!("[system] failed to create parent dir {}", parent.display())
3092        })?;
3093    }
3094    if std::fs::rename(src, dst).is_ok() {
3095        return Ok(true);
3096    }
3097    // Cross-filesystem fallback.
3098    if src.is_dir() {
3099        copy_dir_recursive(src, dst).with_context(|| {
3100            format!(
3101                "[system] failed to copy {} to {}",
3102                src.display(),
3103                dst.display()
3104            )
3105        })?;
3106        std::fs::remove_dir_all(src)
3107            .with_context(|| format!("[system] failed to remove {} after copy", src.display()))?;
3108    } else {
3109        std::fs::copy(src, dst).with_context(|| {
3110            format!(
3111                "[system] failed to copy {} to {}",
3112                src.display(),
3113                dst.display()
3114            )
3115        })?;
3116        std::fs::remove_file(src)
3117            .with_context(|| format!("[system] failed to remove {} after copy", src.display()))?;
3118    }
3119    Ok(true)
3120}
3121
3122/// Heal intermediate v0.8.0-pre installs that landed skills under
3123/// `agents/default/workspace/skills/` before the host-wide
3124/// `shared/skills/` layout was introduced. Idempotent.
3125pub fn relocate_default_agent_skills_to_shared(install_root: &Path) -> MigResult<bool> {
3126    let src = install_root
3127        .join("agents")
3128        .join("default")
3129        .join("workspace")
3130        .join("skills");
3131    let dst = install_root.join("shared").join("skills");
3132    if !src.is_dir() {
3133        return Ok(false);
3134    }
3135    let dst_populated = dst
3136        .is_dir()
3137        .then(|| std::fs::read_dir(&dst).ok())
3138        .flatten()
3139        .is_some_and(|mut it| it.next().is_some());
3140    if dst_populated {
3141        return Ok(false);
3142    }
3143    if let Some(parent) = dst.parent() {
3144        std::fs::create_dir_all(parent).with_context(|| {
3145            format!(
3146                "[system] failed to create shared workspace parent {}",
3147                parent.display()
3148            )
3149        })?;
3150    }
3151    if std::fs::rename(&src, &dst).is_err() {
3152        copy_dir_recursive(&src, &dst).with_context(|| {
3153            format!(
3154                "[system] failed to copy {} to {}",
3155                src.display(),
3156                dst.display()
3157            )
3158        })?;
3159        std::fs::remove_dir_all(&src)
3160            .with_context(|| format!("[system] failed to remove {} after copy", src.display()))?;
3161    }
3162    ::zeroclaw_log::record!(
3163        INFO,
3164        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
3165            ::serde_json::json!({
3166                "from": src.display().to_string(),
3167                "to": dst.display().to_string(),
3168            })
3169        ),
3170        "[system] filesystem migration: lifted default-agent skills into shared/"
3171    );
3172    Ok(true)
3173}
3174
3175fn copy_dir_recursive(src: &Path, dst: &Path) -> MigResult<()> {
3176    std::fs::create_dir_all(dst)?;
3177    for entry in std::fs::read_dir(src)? {
3178        let entry = entry?;
3179        let from = entry.path();
3180        let to = dst.join(entry.file_name());
3181        let ft = entry.file_type()?;
3182        if ft.is_dir() {
3183            copy_dir_recursive(&from, &to)?;
3184        } else if ft.is_symlink() {
3185            #[cfg(unix)]
3186            {
3187                let target = std::fs::read_link(&from)?;
3188                std::os::unix::fs::symlink(&target, &to)?;
3189            }
3190            #[cfg(not(unix))]
3191            {
3192                std::fs::copy(&from, &to)?;
3193            }
3194        } else {
3195            std::fs::copy(&from, &to)?;
3196        }
3197    }
3198    Ok(())
3199}
3200
3201// -----------------------------------------------------------------------------
3202// SQLite agent_id backfill.
3203// -----------------------------------------------------------------------------
3204
3205/// On-disk schema version stamped after a successful SQLite memory
3206/// migration. Future migrations consult this rather than re-running
3207/// PRAGMA detection.
3208pub const SQLITE_MEMORY_SCHEMA_VERSION: i64 = 1;
3209
3210/// Migrate a SQLite memory database to the V3 multi-agent shape.
3211///
3212/// Adds the `agents` table, the `agent_id` column on `memories`,
3213/// backfills existing rows to a synthesized `default` agent, and
3214/// promotes the column to `NOT NULL REFERENCES agents(id)` via a table
3215/// rebuild. Idempotent: re-running on an already-migrated DB is a
3216/// no-op. Before any destructive step the file is backed up at
3217/// `<db_path>.backup-<ts>` when there are rows that would be touched.
3218///
3219/// The caller is responsible for opening the connection with
3220/// `PRAGMA foreign_keys = ON` (and any other backend-specific PRAGMA
3221/// tuning); this function operates on the open connection.
3222pub fn migrate_sqlite_memory_to_v3(db_path: &Path, conn: &Connection) -> MigResult<()> {
3223    if sqlite_memories_agent_id_is_not_null(conn)? {
3224        return Ok(());
3225    }
3226
3227    if sqlite_memories_row_count(conn)? > 0 && db_path.exists() {
3228        backup_sqlite_for_multi_agent_migration(db_path)?;
3229    }
3230
3231    conn.execute_batch("BEGIN IMMEDIATE; PRAGMA defer_foreign_keys = ON;")?;
3232    let result = (|| -> MigResult<()> {
3233        conn.execute_batch(
3234            "CREATE TABLE IF NOT EXISTS agents (
3235                id          TEXT PRIMARY KEY,
3236                alias       TEXT NOT NULL UNIQUE,
3237                created_at  TEXT NOT NULL
3238             );",
3239        )?;
3240        let default_uuid = sqlite_ensure_default_agent_uuid(conn)?;
3241
3242        if !sqlite_memories_has_agent_id_column(conn)? {
3243            conn.execute_batch("ALTER TABLE memories ADD COLUMN agent_id TEXT;")?;
3244        }
3245        conn.execute(
3246            "UPDATE memories SET agent_id = ?1 WHERE agent_id IS NULL",
3247            params![default_uuid],
3248        )?;
3249
3250        conn.execute_batch(
3251            "DROP TRIGGER IF EXISTS memories_ai;
3252             DROP TRIGGER IF EXISTS memories_ad;
3253             DROP TRIGGER IF EXISTS memories_au;
3254             DROP TABLE IF EXISTS memories_fts;
3255
3256             CREATE TABLE memories_new (
3257                id            TEXT PRIMARY KEY,
3258                key           TEXT NOT NULL,
3259                content       TEXT NOT NULL,
3260                category      TEXT NOT NULL DEFAULT 'core',
3261                embedding     BLOB,
3262                created_at    TEXT NOT NULL,
3263                updated_at    TEXT NOT NULL,
3264                session_id    TEXT,
3265                namespace     TEXT DEFAULT 'default',
3266                importance    REAL DEFAULT 0.5,
3267                superseded_by TEXT,
3268                agent_id      TEXT NOT NULL REFERENCES agents(id),
3269                UNIQUE (agent_id, key)
3270             );
3271
3272             INSERT INTO memories_new (
3273                id, key, content, category, embedding, created_at, updated_at,
3274                session_id, namespace, importance, superseded_by, agent_id
3275             )
3276             SELECT
3277                id, key, content, category, embedding, created_at, updated_at,
3278                session_id, namespace, importance, superseded_by, agent_id
3279             FROM memories;
3280
3281             DROP TABLE memories;
3282             ALTER TABLE memories_new RENAME TO memories;
3283
3284             CREATE INDEX IF NOT EXISTS idx_memories_category  ON memories(category);
3285             CREATE INDEX IF NOT EXISTS idx_memories_key       ON memories(key);
3286             CREATE INDEX IF NOT EXISTS idx_memories_session   ON memories(session_id);
3287             CREATE INDEX IF NOT EXISTS idx_memories_namespace ON memories(namespace);
3288             CREATE INDEX IF NOT EXISTS idx_memories_agent_id  ON memories(agent_id);
3289
3290             CREATE VIRTUAL TABLE memories_fts USING fts5(
3291                key, content, content=memories, content_rowid=rowid
3292             );
3293             INSERT INTO memories_fts(memories_fts) VALUES('rebuild');
3294
3295             CREATE TRIGGER memories_ai AFTER INSERT ON memories BEGIN
3296                INSERT INTO memories_fts(rowid, key, content)
3297                VALUES (new.rowid, new.key, new.content);
3298             END;
3299             CREATE TRIGGER memories_ad AFTER DELETE ON memories BEGIN
3300                INSERT INTO memories_fts(memories_fts, rowid, key, content)
3301                VALUES ('delete', old.rowid, old.key, old.content);
3302             END;
3303             CREATE TRIGGER memories_au AFTER UPDATE ON memories BEGIN
3304                INSERT INTO memories_fts(memories_fts, rowid, key, content)
3305                VALUES ('delete', old.rowid, old.key, old.content);
3306                INSERT INTO memories_fts(rowid, key, content)
3307                VALUES (new.rowid, new.key, new.content);
3308             END;",
3309        )?;
3310
3311        sqlite_ensure_schema_version_table(conn)?;
3312        conn.execute(
3313            "INSERT OR REPLACE INTO schema_version (component, version, applied_at) \
3314             VALUES ('memories', ?1, ?2)",
3315            params![
3316                SQLITE_MEMORY_SCHEMA_VERSION,
3317                chrono::Utc::now().to_rfc3339()
3318            ],
3319        )?;
3320        Ok(())
3321    })();
3322
3323    match result {
3324        Ok(()) => {
3325            conn.execute_batch("COMMIT;")?;
3326            Ok(())
3327        }
3328        Err(e) => {
3329            let _ = conn.execute_batch("ROLLBACK;");
3330            Err(e)
3331        }
3332    }
3333}
3334
3335fn sqlite_ensure_schema_version_table(conn: &Connection) -> MigResult<()> {
3336    conn.execute_batch(
3337        "CREATE TABLE IF NOT EXISTS schema_version (
3338            component  TEXT PRIMARY KEY,
3339            version    INTEGER NOT NULL,
3340            applied_at TEXT NOT NULL
3341         );",
3342    )?;
3343    Ok(())
3344}
3345
3346fn sqlite_memories_agent_id_is_not_null(conn: &Connection) -> MigResult<bool> {
3347    let mut stmt = conn.prepare("PRAGMA table_info(memories)")?;
3348    let agent_id_notnull: Option<bool> = stmt
3349        .query_map([], |row| {
3350            let name: String = row.get(1)?;
3351            let notnull: i64 = row.get(3)?;
3352            Ok((name, notnull != 0))
3353        })?
3354        .filter_map(Result::ok)
3355        .find(|(name, _)| name == "agent_id")
3356        .map(|(_, notnull)| notnull);
3357
3358    let Some(true) = agent_id_notnull else {
3359        return Ok(false);
3360    };
3361
3362    let mut fk_stmt = conn.prepare("PRAGMA foreign_key_list(memories)")?;
3363    let has_fk = fk_stmt
3364        .query_map([], |row| {
3365            let target_table: String = row.get(2)?;
3366            let from_col: String = row.get(3)?;
3367            Ok((target_table, from_col))
3368        })?
3369        .filter_map(Result::ok)
3370        .any(|(target, from)| target == "agents" && from == "agent_id");
3371    Ok(has_fk)
3372}
3373
3374fn sqlite_memories_has_agent_id_column(conn: &Connection) -> MigResult<bool> {
3375    let mut stmt = conn.prepare("PRAGMA table_info(memories)")?;
3376    Ok(stmt
3377        .query_map([], |row| row.get::<_, String>(1))?
3378        .filter_map(Result::ok)
3379        .any(|name| name == "agent_id"))
3380}
3381
3382fn sqlite_memories_row_count(conn: &Connection) -> MigResult<i64> {
3383    let table_exists: bool = conn
3384        .query_row(
3385            "SELECT 1 FROM sqlite_master WHERE type='table' AND name='memories' LIMIT 1",
3386            [],
3387            |_| Ok(()),
3388        )
3389        .optional()?
3390        .is_some();
3391    if !table_exists {
3392        return Ok(0);
3393    }
3394    let count: i64 = conn.query_row("SELECT COUNT(*) FROM memories", [], |row| row.get(0))?;
3395    Ok(count)
3396}
3397
3398/// Mint or query the `default` agent's row. Idempotent on concurrent
3399/// first-init: the returned UUID is the row that actually persisted,
3400/// not the candidate we attempted to insert.
3401pub fn sqlite_ensure_default_agent_uuid(conn: &Connection) -> MigResult<String> {
3402    sqlite_ensure_agent_uuid(conn, "default")
3403}
3404
3405/// Mint-or-query a single agent row keyed by alias. Used by the
3406/// SQLite migration's default-agent backfill and by the `ensure_agent_uuid`
3407/// trait impl on the memory backend (alias resolution at agent-loop entry).
3408pub fn sqlite_ensure_agent_uuid(conn: &Connection, alias: &str) -> MigResult<String> {
3409    let new_id = uuid::Uuid::new_v4().to_string();
3410    let now = chrono::Utc::now().to_rfc3339();
3411    conn.execute(
3412        "INSERT OR IGNORE INTO agents (id, alias, created_at) VALUES (?1, ?2, ?3)",
3413        params![new_id, alias, now],
3414    )?;
3415    let final_id: String = conn.query_row(
3416        "SELECT id FROM agents WHERE alias = ?1 LIMIT 1",
3417        params![alias],
3418        |row| row.get(0),
3419    )?;
3420    Ok(final_id)
3421}
3422
3423fn backup_sqlite_for_multi_agent_migration(db_path: &Path) -> MigResult<()> {
3424    let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%S").to_string();
3425    let backup_path = db_path.with_file_name(format!(
3426        "{}.backup-{timestamp}",
3427        db_path
3428            .file_name()
3429            .map(|n| n.to_string_lossy().into_owned())
3430            .unwrap_or_else(|| "brain.db".to_string()),
3431    ));
3432    std::fs::copy(db_path, &backup_path).with_context(|| {
3433        format!(
3434            "failed to copy {} to {} before multi-agent migration",
3435            db_path.display(),
3436            backup_path.display(),
3437        )
3438    })?;
3439    ::zeroclaw_log::record!(
3440        INFO,
3441        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
3442            ::serde_json::json!({
3443                "backup": backup_path.display().to_string(),
3444            })
3445        ),
3446        "multi-agent migration: backed up SQLite memory DB before adding agents table"
3447    );
3448    Ok(())
3449}
3450
3451// -----------------------------------------------------------------------------
3452// Postgres agent_id backfill.
3453// -----------------------------------------------------------------------------
3454
3455/// Migrate a Postgres memory schema to the V3 multi-agent shape.
3456///
3457/// Adds the `agents` table and the `agent_id` column on the qualified
3458/// memories table, with a default-agent backfill. Idempotent: every
3459/// step uses `IF NOT EXISTS` / `ON CONFLICT DO NOTHING` so re-runs are
3460/// no-ops. Uses the low-lock NOT VALID → VALIDATE pattern so the
3461/// upgrade does not take ACCESS EXCLUSIVE on a populated table.
3462///
3463/// Backups are the operator's responsibility for Postgres (documented
3464/// in the release notes); reaching across the network to dump a
3465/// managed cluster from inside the binary is out of scope.
3466#[cfg(feature = "memory-postgres")]
3467pub fn migrate_postgres_memory_to_v3(
3468    client: &mut postgres::Client,
3469    schema_ident: &str,
3470    qualified_table: &str,
3471) -> MigResult<()> {
3472    let qualified_agents = format!("{schema_ident}.agents");
3473
3474    client.batch_execute(&format!(
3475        "CREATE TABLE IF NOT EXISTS {qualified_agents} (
3476            id          TEXT PRIMARY KEY,
3477            alias       TEXT NOT NULL UNIQUE,
3478            created_at  TIMESTAMPTZ NOT NULL
3479        );"
3480    ))?;
3481
3482    let candidate_uuid = uuid::Uuid::new_v4().to_string();
3483    client.execute(
3484        &format!(
3485            "INSERT INTO {qualified_agents} (id, alias, created_at)
3486             VALUES ($1, 'default', NOW())
3487             ON CONFLICT (alias) DO NOTHING"
3488        ),
3489        &[&candidate_uuid],
3490    )?;
3491    let default_uuid: String = client
3492        .query_one(
3493            &format!("SELECT id FROM {qualified_agents} WHERE alias = 'default' LIMIT 1"),
3494            &[],
3495        )?
3496        .get(0);
3497
3498    client.batch_execute(&format!(
3499        "ALTER TABLE {qualified_table} ADD COLUMN IF NOT EXISTS agent_id TEXT;
3500         CREATE INDEX IF NOT EXISTS idx_memories_agent_id ON {qualified_table}(agent_id);"
3501    ))?;
3502    client.execute(
3503        &format!("UPDATE {qualified_table} SET agent_id = $1 WHERE agent_id IS NULL"),
3504        &[&default_uuid],
3505    )?;
3506
3507    client.batch_execute(&format!(
3508        "
3509        DO $$
3510        BEGIN
3511            IF NOT EXISTS (
3512                SELECT 1 FROM pg_constraint
3513                WHERE conname = 'memories_agent_id_notnull_chk'
3514            ) THEN
3515                ALTER TABLE {qualified_table}
3516                    ADD CONSTRAINT memories_agent_id_notnull_chk
3517                    CHECK (agent_id IS NOT NULL) NOT VALID;
3518            END IF;
3519        END$$;
3520        ALTER TABLE {qualified_table} VALIDATE CONSTRAINT memories_agent_id_notnull_chk;
3521        ALTER TABLE {qualified_table} ALTER COLUMN agent_id SET NOT NULL;
3522        DO $$
3523        BEGIN
3524            IF NOT EXISTS (
3525                SELECT 1 FROM pg_constraint
3526                WHERE conname = 'memories_agent_id_fk'
3527            ) THEN
3528                ALTER TABLE {qualified_table}
3529                    ADD CONSTRAINT memories_agent_id_fk
3530                    FOREIGN KEY (agent_id) REFERENCES {qualified_agents}(id) NOT VALID;
3531            END IF;
3532        END$$;
3533        ALTER TABLE {qualified_table} VALIDATE CONSTRAINT memories_agent_id_fk;
3534        -- Swap the legacy key-only uniqueness for composite (agent_id, key)
3535        -- so two agents may hold rows under the same caller-chosen key.
3536        ALTER TABLE {qualified_table} DROP CONSTRAINT IF EXISTS memories_key_key;
3537        DO $$
3538        BEGIN
3539            IF NOT EXISTS (
3540                SELECT 1 FROM pg_constraint
3541                WHERE conname = 'memories_agent_key_uniq'
3542            ) THEN
3543                ALTER TABLE {qualified_table}
3544                    ADD CONSTRAINT memories_agent_key_uniq UNIQUE (agent_id, key);
3545            END IF;
3546        END$$;
3547        "
3548    ))?;
3549
3550    client.batch_execute(&format!(
3551        "CREATE TABLE IF NOT EXISTS {schema_ident}.schema_version (
3552            component  TEXT PRIMARY KEY,
3553            version    INTEGER NOT NULL,
3554            applied_at TIMESTAMPTZ NOT NULL
3555        );"
3556    ))?;
3557    client.execute(
3558        &format!(
3559            "INSERT INTO {schema_ident}.schema_version (component, version, applied_at) \
3560             VALUES ('memories', $1, NOW()) \
3561             ON CONFLICT (component) DO UPDATE SET version = EXCLUDED.version, applied_at = EXCLUDED.applied_at"
3562        ),
3563        &[&SQLITE_MEMORY_SCHEMA_VERSION],
3564    )?;
3565    Ok(())
3566}
3567
3568// -----------------------------------------------------------------------------
3569// Qdrant agent_id backfill (NEW for V3; closes the gap where pre-V3 points
3570// without `agent_id` payload would be silently filtered out by the
3571// AgentScopedMemory `must` clause).
3572// -----------------------------------------------------------------------------
3573
3574/// V3 default agent_id payload value on Qdrant collections.
3575///
3576/// Qdrant does not maintain an `agents` table; it stores the agent
3577/// alias directly as the `agent_id` payload field. The
3578/// `AgentScopedMemory` wrapper's `must` filter expects `agent_id ==
3579/// "default"` for the V1/V2 single-agent bridge.
3580pub const QDRANT_DEFAULT_AGENT_ID: &str = "default";
3581
3582/// Migrate a Qdrant collection to the V3 multi-agent shape.
3583///
3584/// Scrolls the collection in pages of 1000 points; for any point whose
3585/// payload lacks `agent_id`, issues a `set payload` to add
3586/// `agent_id = "default"`. Idempotent: subsequent runs skip points
3587/// that already carry the field.
3588///
3589/// Backups are the operator's responsibility (documented in the
3590/// release notes); we cannot snapshot a remote Qdrant cluster from
3591/// inside the binary.
3592pub async fn migrate_qdrant_collection_to_v3(
3593    client: &reqwest::Client,
3594    base_url: &str,
3595    collection: &str,
3596    api_key: Option<&str>,
3597) -> MigResult<usize> {
3598    let base_url = base_url.trim_end_matches('/');
3599    let mut next_offset: Option<serde_json::Value> = None;
3600    let mut updated = 0usize;
3601
3602    loop {
3603        let mut scroll_body = serde_json::json!({
3604            "limit": 1000,
3605            "with_payload": true,
3606            "with_vector": false,
3607            // Match only points that lack agent_id. is_empty supports
3608            // the missing-key case (the filter matches a point whose
3609            // payload key is absent or whose stored value is null).
3610            "filter": {
3611                "must": [{ "is_empty": { "key": "agent_id" } }]
3612            }
3613        });
3614        if let Some(ref offset) = next_offset {
3615            scroll_body["offset"] = offset.clone();
3616        }
3617
3618        let url = format!("{base_url}/collections/{collection}/points/scroll");
3619        let mut req = client.request(reqwest::Method::POST, &url);
3620        if let Some(key) = api_key {
3621            req = req.header("api-key", key);
3622        }
3623        let resp = req
3624            .header("Content-Type", "application/json")
3625            .json(&scroll_body)
3626            .send()
3627            .await
3628            .context("[system] Qdrant V3 migration: scroll request failed")?;
3629        if !resp.status().is_success() {
3630            let status = resp.status();
3631            let text = resp.text().await.unwrap_or_default();
3632            anyhow::bail!("Qdrant scroll failed ({status}): {text}");
3633        }
3634
3635        #[derive(serde::Deserialize)]
3636        struct ScrollPage {
3637            result: ScrollResult,
3638        }
3639        #[derive(serde::Deserialize)]
3640        struct ScrollResult {
3641            points: Vec<ScrollPoint>,
3642            #[serde(default)]
3643            next_page_offset: Option<serde_json::Value>,
3644        }
3645        #[derive(serde::Deserialize)]
3646        struct ScrollPoint {
3647            id: serde_json::Value,
3648        }
3649
3650        let page: ScrollPage = resp
3651            .json()
3652            .await
3653            .context("[system] Qdrant V3 migration: scroll page parse failed")?;
3654        let ids: Vec<serde_json::Value> = page.result.points.into_iter().map(|p| p.id).collect();
3655        if !ids.is_empty() {
3656            let set_url = format!("{base_url}/collections/{collection}/points/payload");
3657            let body = serde_json::json!({
3658                "payload": { "agent_id": QDRANT_DEFAULT_AGENT_ID },
3659                "points": ids,
3660            });
3661            let mut req = client.request(reqwest::Method::POST, &set_url);
3662            if let Some(key) = api_key {
3663                req = req.header("api-key", key);
3664            }
3665            let resp = req
3666                .header("Content-Type", "application/json")
3667                .query(&[("wait", "true")])
3668                .json(&body)
3669                .send()
3670                .await
3671                .context("[system] Qdrant V3 migration: set payload request failed")?;
3672            if !resp.status().is_success() {
3673                let status = resp.status();
3674                let text = resp.text().await.unwrap_or_default();
3675                anyhow::bail!("Qdrant set payload failed ({status}): {text}");
3676            }
3677            let batch_count = body["points"].as_array().map(|a| a.len()).unwrap_or(0);
3678            updated += batch_count;
3679        }
3680
3681        match page.result.next_page_offset {
3682            Some(offset) if !offset.is_null() => next_offset = Some(offset),
3683            _ => break,
3684        }
3685    }
3686
3687    if updated > 0 {
3688        ::zeroclaw_log::record!(
3689            INFO,
3690            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
3691                ::serde_json::json!({
3692                    "collection": collection,
3693                    "updated": updated,
3694                })
3695            ),
3696            "[system] Qdrant V3 migration: backfilled agent_id payload"
3697        );
3698    }
3699    Ok(updated)
3700}
3701
3702#[cfg(test)]
3703mod fs_db_migration_tests {
3704    //! End-to-end V2 → V3 filesystem & DB migration test.
3705    //!
3706    //! Lays down a V2 install in a `TempDir` (real disk), drives the
3707    //! orchestrator, and asserts every relocated path matches the
3708    //! shared dispatch fns (`workspace_toplevel_v3_path`,
3709    //! `memory_subentry_v3_path`). The test loops over the canonical
3710    //! dispatch tables — adding a new entry there auto-extends test
3711    //! coverage with no companion edit here.
3712    use super::*;
3713    use rusqlite::Connection;
3714    use std::collections::BTreeSet;
3715    use std::fs;
3716    use tempfile::TempDir;
3717
3718    /// Walk a directory tree and return a sorted list of (relative
3719    /// path, file contents) pairs. Used to diff pre-migration backup
3720    /// against the legacy snapshot for byte-equal verification.
3721    fn snapshot_tree(root: &Path) -> BTreeSet<(PathBuf, Vec<u8>)> {
3722        fn walk(root: &Path, dir: &Path, out: &mut BTreeSet<(PathBuf, Vec<u8>)>) {
3723            let Ok(rd) = fs::read_dir(dir) else { return };
3724            for entry in rd.flatten() {
3725                let path = entry.path();
3726                if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
3727                    walk(root, &path, out);
3728                } else if let Ok(bytes) = fs::read(&path)
3729                    && let Ok(rel) = path.strip_prefix(root)
3730                {
3731                    out.insert((rel.to_path_buf(), bytes));
3732                }
3733            }
3734        }
3735        let mut out = BTreeSet::new();
3736        walk(root, root, &mut out);
3737        out
3738    }
3739
3740    /// Lay down a V2 install rooted at `install`: a `workspace/` tree
3741    /// hitting every dispatch branch, plus a populated `brain.db` in
3742    /// pre-multi-agent shape so the SQLite migration has rows to
3743    /// backfill.
3744    fn seed_v2_install(install: &Path) {
3745        // Top-level instance file (devices.db bug fix lands this in
3746        // data/ post-migration).
3747        fs::create_dir_all(install.join("workspace")).unwrap();
3748        fs::write(
3749            install.join("workspace/devices.db"),
3750            b"pretend-paired-devices-blob",
3751        )
3752        .unwrap();
3753
3754        // Per-agent identity files (default branch → agent workspace).
3755        for fname in [
3756            "MEMORY.md",
3757            "IDENTITY.md",
3758            "SOUL.md",
3759            "USER.md",
3760            "AGENTS.md",
3761        ] {
3762            fs::write(install.join("workspace").join(fname), format!("# {fname}")).unwrap();
3763        }
3764
3765        // workspace/sessions/ (wholesale → data/sessions/).
3766        fs::create_dir_all(install.join("workspace/sessions")).unwrap();
3767        fs::write(install.join("workspace/sessions/sessions.db"), b"sessions").unwrap();
3768
3769        // workspace/state/ (wholesale → data/state/).
3770        fs::create_dir_all(install.join("workspace/state")).unwrap();
3771        fs::write(
3772            install.join("workspace/state/runtime-trace.jsonl"),
3773            b"trace",
3774        )
3775        .unwrap();
3776
3777        // workspace/skills/ (wholesale → shared/skills/).
3778        fs::create_dir_all(install.join("workspace/skills/my-skill")).unwrap();
3779        fs::write(install.join("workspace/skills/my-skill/SKILL.md"), b"skill").unwrap();
3780
3781        // workspace/memory/ subentries: split between data/memory/ and
3782        // agents/default/workspace/memory/ per V2_MEMORY_DATA_NAMES.
3783        let mem_dir = install.join("workspace/memory");
3784        fs::create_dir_all(&mem_dir).unwrap();
3785        for sub in V2_MEMORY_DATA_NAMES {
3786            let p = mem_dir.join(sub);
3787            if *sub == "archive" {
3788                fs::create_dir_all(&p).unwrap();
3789                fs::write(p.join("old-recall.jsonl"), b"archived").unwrap();
3790            } else if (*sub).ends_with(".db") {
3791                // Real SQLite file so the in-DB migration has something
3792                // to migrate after the FS move.
3793                let conn = Connection::open(&p).unwrap();
3794                if *sub == "brain.db" {
3795                    conn.execute_batch(
3796                        "PRAGMA foreign_keys = ON;
3797                         CREATE TABLE memories (
3798                            id TEXT PRIMARY KEY,
3799                            key TEXT NOT NULL UNIQUE,
3800                            content TEXT NOT NULL,
3801                            category TEXT NOT NULL DEFAULT 'core',
3802                            embedding BLOB,
3803                            created_at TEXT NOT NULL,
3804                            updated_at TEXT NOT NULL,
3805                            session_id TEXT,
3806                            namespace TEXT DEFAULT 'default',
3807                            importance REAL DEFAULT 0.5,
3808                            superseded_by TEXT
3809                         );
3810                         INSERT INTO memories (id, key, content, created_at, updated_at)
3811                         VALUES ('m1', 'hello', 'world', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'),
3812                                ('m2', 'foo',   'bar',   '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z');",
3813                    )
3814                    .unwrap();
3815                }
3816            } else {
3817                fs::write(&p, format!("{sub} payload").as_bytes()).unwrap();
3818            }
3819        }
3820        // Markdown daily files (must land in agents/default/workspace/memory/).
3821        fs::write(
3822            mem_dir.join("2025-04-12.md"),
3823            b"# daily 2025-04-12\nhello world\n",
3824        )
3825        .unwrap();
3826        fs::write(
3827            mem_dir.join("2025-04-13.md"),
3828            b"# daily 2025-04-13\nstill here\n",
3829        )
3830        .unwrap();
3831    }
3832
3833    #[test]
3834    fn migrate_v2_install_into_v3_layout_with_real_filesystem() {
3835        let tmp = TempDir::new().unwrap();
3836        let install = tmp.path();
3837        seed_v2_install(install);
3838
3839        let legacy_snapshot = snapshot_tree(&install.join("workspace"));
3840        assert!(
3841            !legacy_snapshot.is_empty(),
3842            "fixture seed must produce content under workspace/"
3843        );
3844
3845        let report = migrate_v2_to_v3_install_filesystem(install).expect("migration must succeed");
3846        assert!(report.entries_relocated > 0);
3847        let backup_root = report.backup_dir.expect("backup dir present");
3848
3849        // Backup is byte-equal to the pre-migration workspace snapshot.
3850        let backup_snapshot = snapshot_tree(&backup_root.join("legacy-workspace"));
3851        assert_eq!(
3852            backup_snapshot, legacy_snapshot,
3853            "backup must be a byte-equal copy of the pre-migration workspace"
3854        );
3855
3856        // Every legacy file lives at exactly one V3 location, predicted
3857        // by the shared dispatch fns. We never name V3 paths here —
3858        // the same fns the migration uses produce them.
3859        for (rel, expected_bytes) in &legacy_snapshot {
3860            let v3_path = predict_v3_path(install, rel);
3861            assert!(
3862                v3_path.exists(),
3863                "file {} should exist at predicted V3 path {}",
3864                rel.display(),
3865                v3_path.display(),
3866            );
3867            let actual_bytes = fs::read(&v3_path).unwrap_or_else(|e| {
3868                panic!(
3869                    "failed to read predicted V3 path {} for legacy {}: {e}",
3870                    v3_path.display(),
3871                    rel.display(),
3872                )
3873            });
3874            assert_eq!(
3875                actual_bytes,
3876                *expected_bytes,
3877                "byte mismatch at {}",
3878                v3_path.display()
3879            );
3880        }
3881
3882        // Legacy workspace/ is gone (it was empty after the relocation).
3883        assert!(
3884            !install.join("workspace").exists(),
3885            "legacy workspace must be removed after a clean split"
3886        );
3887
3888        // Nothing outside the V3 root names + backup + (no config.toml in
3889        // this test) lives at install root.
3890        let mut roots = BTreeSet::new();
3891        for entry in fs::read_dir(install).unwrap() {
3892            let entry = entry.unwrap();
3893            let name = entry.file_name().to_string_lossy().to_string();
3894            roots.insert(name);
3895        }
3896        for name in &roots {
3897            let allowed =
3898                V3_INSTALL_ROOT_NAMES.contains(&name.as_str()) || name.starts_with("backup-");
3899            assert!(
3900                allowed,
3901                "unexpected install-root entry {name:?}; allowed: {V3_INSTALL_ROOT_NAMES:?} + backup-*"
3902            );
3903        }
3904
3905        // Idempotent re-run is a no-op: same on-disk state afterward.
3906        let post_first = snapshot_tree(install);
3907        let report2 =
3908            migrate_v2_to_v3_install_filesystem(install).expect("second run must be a no-op");
3909        assert_eq!(report2.entries_relocated, 0);
3910        let post_second = snapshot_tree(install);
3911        assert_eq!(
3912            post_first, post_second,
3913            "idempotent re-run must not modify disk"
3914        );
3915
3916        // In-DB migration: open the now-moved brain.db and run
3917        // migrate_sqlite_memory_to_v3. Should backfill agent_id on the
3918        // 2 seeded rows and stamp schema_version.
3919        let brain_path = install.join("data/memory/brain.db");
3920        assert!(
3921            brain_path.is_file(),
3922            "brain.db must have moved to data/memory/"
3923        );
3924        let conn = Connection::open(&brain_path).unwrap();
3925        conn.execute_batch("PRAGMA foreign_keys = ON;").unwrap();
3926        migrate_sqlite_memory_to_v3(&brain_path, &conn).expect("SQLite migration must succeed");
3927
3928        let null_count: i64 = conn
3929            .query_row(
3930                "SELECT COUNT(*) FROM memories WHERE agent_id IS NULL",
3931                [],
3932                |r| r.get(0),
3933            )
3934            .unwrap();
3935        assert_eq!(
3936            null_count, 0,
3937            "all memories must have agent_id post-migration"
3938        );
3939
3940        let agent_row_count: i64 = conn
3941            .query_row(
3942                "SELECT COUNT(*) FROM agents WHERE alias = 'default'",
3943                [],
3944                |r| r.get(0),
3945            )
3946            .unwrap();
3947        assert_eq!(agent_row_count, 1, "default agent row must exist");
3948
3949        // SQLite migration is idempotent.
3950        migrate_sqlite_memory_to_v3(&brain_path, &conn)
3951            .expect("SQLite migration second run must be a no-op");
3952
3953        // SQLite backup file from the in-DB migration is present.
3954        let backup_glob: Vec<_> = fs::read_dir(install.join("data/memory"))
3955            .unwrap()
3956            .flatten()
3957            .filter(|e| {
3958                e.file_name()
3959                    .to_string_lossy()
3960                    .starts_with("brain.db.backup-")
3961            })
3962            .collect();
3963        assert_eq!(
3964            backup_glob.len(),
3965            1,
3966            "in-DB SQLite migration must write exactly one backup file"
3967        );
3968    }
3969
3970    /// Predict the V3 absolute path for a legacy path relative to
3971    /// `<install>/workspace/`. Uses the same dispatch fns the migration
3972    /// uses; never names a V3 path literally.
3973    fn predict_v3_path(install: &Path, rel: &Path) -> PathBuf {
3974        let mut parts = rel.components();
3975        let top = parts
3976            .next()
3977            .expect("legacy snapshot paths have at least one component");
3978        let top_name = top.as_os_str().to_string_lossy().to_string();
3979
3980        // Sub-dispatch for memory/<x>: first segment is the subentry name.
3981        if v2_workspace_toplevel_dest(&top_name) == V2WorkspaceDest::MemorySubentryDispatch {
3982            let sub = parts.next();
3983            let Some(sub) = sub else {
3984                return workspace_toplevel_v3_path(install, &top_name);
3985            };
3986            let sub_name = sub.as_os_str().to_string_lossy().to_string();
3987            let base = memory_subentry_v3_path(install, &sub_name);
3988            let rest: PathBuf = parts.as_path().to_path_buf();
3989            if rest.as_os_str().is_empty() {
3990                base
3991            } else {
3992                base.join(rest)
3993            }
3994        } else {
3995            let top_v3 = workspace_toplevel_v3_path(install, &top_name);
3996            let rest: PathBuf = parts.as_path().to_path_buf();
3997            if rest.as_os_str().is_empty() {
3998                top_v3
3999            } else {
4000                top_v3.join(rest)
4001            }
4002        }
4003    }
4004
4005    #[test]
4006    fn fresh_install_is_noop() {
4007        let tmp = TempDir::new().unwrap();
4008        let report =
4009            migrate_v2_to_v3_install_filesystem(tmp.path()).expect("fresh install must be a no-op");
4010        assert_eq!(report.entries_relocated, 0);
4011        assert!(report.backup_dir.is_none());
4012    }
4013
4014    #[test]
4015    fn refuse_to_clobber_existing_v3_target() {
4016        let tmp = TempDir::new().unwrap();
4017        let install = tmp.path();
4018        seed_v2_install(install);
4019
4020        // Pre-seed an operator-authored file at the V3 destination for
4021        // devices.db. Migration must NOT overwrite it.
4022        fs::create_dir_all(install.join("data")).unwrap();
4023        let v3_devices = workspace_toplevel_v3_path(install, "devices.db");
4024        fs::write(&v3_devices, b"operator-owned").unwrap();
4025
4026        let _ = migrate_v2_to_v3_install_filesystem(install).expect("migration must not fail");
4027
4028        // Operator file untouched.
4029        let after = fs::read(&v3_devices).unwrap();
4030        assert_eq!(
4031            after, b"operator-owned",
4032            "refuse-to-clobber: operator file must survive"
4033        );
4034
4035        // Legacy devices.db is still in place (left for operator inspection)
4036        // OR moved to backup; in either case the file is not lost.
4037        let legacy_still = install.join("workspace/devices.db").exists();
4038        let in_backup = fs::read_dir(install).unwrap().flatten().any(|e| {
4039            let n = e.file_name().to_string_lossy().to_string();
4040            n.starts_with("backup-") && e.path().join("legacy-workspace/devices.db").exists()
4041        });
4042        assert!(
4043            legacy_still || in_backup,
4044            "legacy devices.db must be preserved (in legacy/ or backup/)"
4045        );
4046    }
4047}