Skip to main content

agentmux_srv/migrations/
m0022_identity_store_links_backfill.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Backfill `db_agent_identity_links`, plus a `db_accounts` fallback mirror
5//! (reagentx P0 review, added after this migration's first version — see
6//! `identity::resolver::inject::resolve_account`'s own doc comment for why
7//! the mirror is needed even though `db_accounts` itself stays on `id_store`
8//! as the authoritative store), from every reachable store into the new,
9//! permanently-global identity store — see
10//! `docs/specs/SPEC_IDENTITY_STORE_SPLIT_2026_08_17.md`.
11//!
12//! Before this migration, an agent's account link lived wherever `id_store`
13//! (`registry::resolve_shared_store_path`) happened to resolve at write time:
14//! the true global `shared/store.db` on the `"stable"` channel, or a
15//! throwaway `<instance_dir>/identity-store.db` on every local/dev/portable
16//! channel (isolated by default since PR #2431,
17//! `docs/specs/SPEC_ISOLATED_AUTH_DEFAULT_BY_CHANNEL_2026_08_06.md`) — the
18//! exact fragmentation
19//! `docs/specs/REPORT_HISTORY_CONTINUITY_ACROSS_VERSION_UPGRADE_2026_08_17.md`
20//! traces. Without this migration, every link written since 2026-08-06 would
21//! be invisible to the new always-global lookup — shipping the store split
22//! alone would REGRESS every existing non-stable-channel install (a working
23//! agent would suddenly fail its credential gate) instead of fixing it.
24//!
25//! Sources scanned, read-only (never mutates a source):
26//! 1. The current channel's `objects.db` (links can live there if `id_store`
27//!    ever fell back to `mstore`, e.g. before `0011_shared_store_backfill`
28//!    applied).
29//! 2. The true global `shared/store.db`, resolved via
30//!    `registry::resolve_global_shared_root()` — NOT `MigrationContext`'s own
31//!    `shared_store_path` field, which is `resolve_shared_store_path()`'s
32//!    result and therefore DOES redirect to a per-channel path under
33//!    isolation (reagentx P1 review on this PR — the first version of this
34//!    migration used `ctx.shared_store_path` here, silently skipping the
35//!    real global store on every non-`"stable"` channel, exactly the
36//!    isolation-unaware bug this migration exists to route around). A
37//!    `"stable"`-channel run (or any run with `AGENTMUX_ISOLATED_AUTH=0`)
38//!    may have written real links there.
39//! 3. Every sibling per-(channel,version) and per-dev-branch `objects.db`
40//!    (`registry::enumerate_objects_dbs`, the same enumerator
41//!    `0011_shared_store_backfill` uses).
42//! 4. For each sibling `objects.db`, its sibling isolated identity store
43//!    (`<instance_dir>/identity-store.db` — same `instance_dir` the
44//!    `objects.db` lives under, derived by stripping `data/db/objects.db`)
45//!    — this is the one that actually holds the fragmented, post-2026-08-06
46//!    links the whole redesign exists to reunify.
47//!
48//! Deliberately UNCONDITIONAL on `isolated_auth_enabled()` (unlike
49//! `0011_shared_store_backfill`'s sibling-scan, which skips other channels
50//! when isolated so a disposable test account store starts empty): this
51//! migration's destination has no isolation concept at all — see the design
52//! doc §2.3 — so there is no "keep this run's view empty" case to preserve.
53
54use crate::backend::storage::store::Store;
55use crate::registry;
56use super::{Migration, MigrationContext, MigrationError, MigrationScope};
57
58pub struct M0022IdentityStoreLinksBackfill;
59
60impl Migration for M0022IdentityStoreLinksBackfill {
61    fn id(&self) -> &'static str { "0022_identity_store_links_backfill" }
62    fn scope(&self) -> MigrationScope { MigrationScope::Global }
63    fn description(&self) -> &'static str {
64        "Backfill agent<->account links (and an account fallback mirror) into the permanently-global identity store"
65    }
66
67    fn up(&self, ctx: &MigrationContext) -> Result<(), MigrationError> {
68        let identity_store_path = registry::resolve_identity_store_path()
69            .ok_or_else(|| MigrationError("identity_store_links_backfill: could not resolve identity store path".to_string()))?;
70        if let Some(parent) = identity_store_path.parent() {
71            std::fs::create_dir_all(parent)
72                .map_err(|e| MigrationError(format!("identity_store_links_backfill: create dir: {e}")))?;
73        }
74        let dest = Store::open_identity_store(&identity_store_path)
75            .map_err(|e| MigrationError(format!("identity_store_links_backfill: open destination: {e}")))?;
76
77        // Idempotent re-run: once the destination has ANY link, later boots
78        // skip the (relatively expensive) multi-source scan. A link written
79        // normally after this migration first ran is not lost by skipping —
80        // it already goes straight to `dest` via the live application code.
81        // The account mirror is a one-time snapshot from THIS migration
82        // only — unlike links, nothing in the live application code writes
83        // new accounts into `dest` afterwards (identity::resolver::inject's
84        // resolve_account only READS the mirror as a fallback; the OAuth
85        // status-update write-back only reaches it for an account already
86        // found there). An account created entirely after this migration
87        // ran, on a channel that's isolated when the agent is later reopened
88        // on a DIFFERENT channel, is the one residual gap this PR
89        // explicitly discloses (see the PR description) — the full fix is
90        // the deferred per-account `disposable_test` scope split
91        // (SPEC_IDENTITY_STORE_SPLIT_2026_08_17.md §3.2).
92        let already_seeded = !dest.agent_identity_list_all().unwrap_or_default().is_empty();
93        if already_seeded {
94            return Ok(());
95        }
96
97        // Collect source PATHS first (not yet opened), so they can be sorted
98        // into a deterministic order before any reads happen — reagentx P1
99        // review on this PR: `agent_identity_link`'s upsert is last-write-
100        // wins, and `enumerate_objects_dbs`'s `std::fs::read_dir`-based
101        // traversal has no ordering guarantee, so without a defined order an
102        // agent linked to different accounts in different channels would
103        // resolve non-deterministically across runs/machines — contradicting
104        // this migration's own stated "first-seen-wins" intent
105        // (SPEC_IDENTITY_STORE_SPLIT_2026_08_17.md §6). Sorting by path
106        // string plus the explicit `seen` guard below (belt-and-suspenders:
107        // the guard alone would already make ordering irrelevant, but a
108        // stable order also makes the resulting choice reproducible/
109        // explainable, not just deterministic).
110        let mut source_paths: Vec<std::path::PathBuf> = Vec::new();
111
112        // 1. Current channel's objects.db.
113        if ctx.channel_store_path.exists() {
114            source_paths.push(ctx.channel_store_path.clone());
115        }
116
117        // 2. The TRUE global shared/store.db, independent of this process's
118        // current isolation state. reagentx P1 review: `ctx.shared_store_path`
119        // is NOT this — it's `registry::resolve_shared_store_path()`'s
120        // result, which itself redirects to a per-channel path under
121        // isolation (`registry/paths.rs`'s own doc comment: "only
122        // ctx.shared_store_path itself is meant to vary"). Using it here
123        // silently skipped the real global store on every non-`"stable"`
124        // channel — exactly the isolation-unaware bug this migration exists
125        // to route around. `resolve_global_shared_root()` is the
126        // unconditional root every other always-global resolver in this
127        // crate uses (`identity_store_path` itself, `resolve_shared_registry_dir`,
128        // etc.) — never redirected, per its own doc comment.
129        if let Some(global_root) = registry::resolve_global_shared_root() {
130            let global_store_path = global_root.join("store.db");
131            if global_store_path.exists() {
132                source_paths.push(global_store_path);
133            }
134        }
135
136        // 3 + 4. Every sibling objects.db, plus each one's sibling isolated
137        // identity-store.db (same instance_dir — objects.db lives at
138        // <instance_dir>/data/db/objects.db, identity-store.db lives
139        // directly at <instance_dir>/identity-store.db).
140        for objects_db in registry::enumerate_objects_dbs(&ctx.home) {
141            if objects_db != ctx.channel_store_path {
142                source_paths.push(objects_db.clone());
143            }
144            let instance_dir = objects_db
145                .parent() // .../data/db
146                .and_then(|p| p.parent()) // .../data
147                .and_then(|p| p.parent()); // instance_dir
148            if let Some(instance_dir) = instance_dir {
149                let isolated_store = instance_dir.join("identity-store.db");
150                if isolated_store.is_file() && !source_paths.contains(&isolated_store) {
151                    source_paths.push(isolated_store);
152                }
153            }
154        }
155
156        source_paths.sort();
157        source_paths.dedup();
158
159        let mut sources: Vec<Store> = Vec::new();
160        for path in &source_paths {
161            match Store::open_source_readonly(path) {
162                Ok(s) => sources.push(s),
163                Err(e) => tracing::debug!(path = %path.display(), error = %e, "identity_store_links_backfill: skip unreadable source"),
164            }
165        }
166
167        // First-seen-wins across the now-deterministically-ordered sources:
168        // track which (agent_id, provider) pairs already got a link written
169        // this run so a later source can never silently overwrite an
170        // earlier one's choice, regardless of what agent_identity_link's own
171        // ON CONFLICT clause would otherwise do.
172        let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
173        let mut linked = 0usize;
174        for src in &sources {
175            for link in src.agent_identity_list_all().unwrap_or_default() {
176                let key = (link.agent_id.clone(), link.provider.clone());
177                if !seen.insert(key) {
178                    continue;
179                }
180                if dest.agent_identity_link(&link.agent_id, &link.account_id, &link.provider).is_ok() {
181                    linked += 1;
182                }
183            }
184        }
185
186        // Account mirror (reagentx P0 review on this PR): without this, a
187        // link resolved via `dest` still dead-ends the very next step —
188        // resolving the ACCOUNT the link points at — whenever that account
189        // only ever existed in a per-channel-isolated store, which is the
190        // common case on a version/channel switch (see
191        // identity::resolver::inject::resolve_account). Same deterministic,
192        // first-seen-wins treatment as links, keyed by account id.
193        let mut seen_accounts: std::collections::HashSet<String> = std::collections::HashSet::new();
194        let mut mirrored = 0usize;
195        for src in &sources {
196            for account in src.identity_list(None).unwrap_or_default() {
197                if !seen_accounts.insert(account.id.clone()) {
198                    continue;
199                }
200                if dest.identity_upsert(&account).is_ok() {
201                    mirrored += 1;
202                }
203            }
204        }
205
206        tracing::info!(
207            sources = sources.len(),
208            links_written = linked,
209            accounts_mirrored = mirrored,
210            "identity_store_links_backfill: complete"
211        );
212
213        Ok(())
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use crate::test_support::ISOLATED_AUTH_ENV_LOCK as ENV_LOCK;
221
222    fn clear() {
223        std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
224        std::env::remove_var("AGENTMUX_SHARED_DIR");
225        std::env::remove_var("AGENTMUX_ISOLATED_AUTH");
226        std::env::remove_var("AGENTMUX_CHANNEL");
227        std::env::remove_var("AGENTMUX_INSTANCE_DIR");
228    }
229
230    /// Sets up the exact real-world broken state: an EMPTY global
231    /// `shared/store.db`, an empty current-channel `objects.db`, and one
232    /// sibling dev-branch instance dir carrying a link ONLY in its
233    /// isolated, per-instance `identity-store.db` (never in an
234    /// `objects.db` at all) — precisely how a link written under
235    /// `SPEC_ISOLATED_AUTH_DEFAULT_BY_CHANNEL_2026_08_06.md`'s default
236    /// looks on disk today.
237    fn setup() -> (tempfile::TempDir, MigrationContext) {
238        let tmp = tempfile::TempDir::new().unwrap();
239        let home = tmp.path().to_path_buf();
240
241        let sibling_instance_dir = home.join("dev").join("other-branch");
242        let sibling_objects_db = sibling_instance_dir.join("data").join("db").join("objects.db");
243        std::fs::create_dir_all(sibling_objects_db.parent().unwrap()).unwrap();
244        Store::open(&sibling_objects_db).unwrap(); // exists, but carries no link — it never does in this scenario
245
246        let sibling_isolated_store = sibling_instance_dir.join("identity-store.db");
247        let isolated = Store::open_identity_store(&sibling_isolated_store).unwrap();
248        isolated.agent_identity_link("agent-continuing", "acct-real", "claude").unwrap();
249        drop(isolated);
250
251        let channel_store_path = home.join("this-channel").join("data").join("db").join("objects.db");
252        std::fs::create_dir_all(channel_store_path.parent().unwrap()).unwrap();
253        Store::open(&channel_store_path).unwrap();
254
255        let shared_store_path = home.join("shared").join("store.db");
256        std::fs::create_dir_all(shared_store_path.parent().unwrap()).unwrap();
257
258        let ctx = MigrationContext {
259            home,
260            data_dir: tmp.path().join("this-channel").join("data"),
261            shared_store_path,
262            channel_store_path,
263        };
264        (tmp, ctx)
265    }
266
267    #[test]
268    fn backfills_a_link_from_a_sibling_channels_isolated_identity_store() {
269        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
270        clear();
271        let (_tmp, ctx) = setup();
272        std::env::set_var("AGENTMUX_HOME_OVERRIDE", &ctx.home);
273
274        M0022IdentityStoreLinksBackfill.up(&ctx).unwrap();
275
276        let dest = Store::open_identity_store(&registry::resolve_identity_store_path().unwrap()).unwrap();
277        let links = dest.agent_identity_list_for_agent("agent-continuing").unwrap();
278        assert_eq!(
279            links.len(), 1,
280            "the link written to the sibling channel's ISOLATED identity-store.db \
281             must be found — this is the exact real-world broken state, not a \
282             hypothetical"
283        );
284        assert_eq!(links[0].account_id, "acct-real");
285        clear();
286    }
287
288    /// reagentx P0 review on PR #2632: a link alone isn't enough — the
289    /// account row it points at must also be reachable, or the spawn dead-
290    /// ends resolving it. This test seeds an account in the SAME sibling
291    /// isolated store the link test above uses, and confirms it's mirrored
292    /// into the destination's db_accounts fallback table.
293    #[test]
294    fn backfills_the_account_row_alongside_its_link() {
295        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
296        clear();
297        let (_tmp, ctx) = setup();
298        std::env::set_var("AGENTMUX_HOME_OVERRIDE", &ctx.home);
299
300        // Seed the account in the same sibling isolated store setup()
301        // already links "agent-continuing" to.
302        let sibling_isolated_store = ctx.home.join("dev").join("other-branch").join("identity-store.db");
303        let sibling = Store::open_identity_store(&sibling_isolated_store).unwrap();
304        sibling.identity_upsert(&crate::backend::storage::store::IdentityAccount {
305            id: "acct-real".to_string(),
306            name: "Real Account".to_string(),
307            provider: "claude".to_string(),
308            kind: "oauth".to_string(),
309            display_name: String::new(),
310            secret_ref: crate::backend::storage::store::SecretRef::OAuthConfigDir { dir: "/tmp/acct-real".to_string() },
311            context: serde_json::json!({}),
312            status: "valid".to_string(),
313            created_at: 0,
314            updated_at: 0,
315        }).unwrap();
316        drop(sibling);
317
318        M0022IdentityStoreLinksBackfill.up(&ctx).unwrap();
319
320        let dest = Store::open_identity_store(&registry::resolve_identity_store_path().unwrap()).unwrap();
321        let account = dest.identity_get("acct-real").unwrap();
322        assert!(
323            account.is_some(),
324            "the account row must be mirrored into the destination, not just its link — \
325             a link with no resolvable account still fails the spawn"
326        );
327        assert_eq!(account.unwrap().provider, "claude");
328        clear();
329    }
330
331    #[test]
332    fn is_idempotent_on_a_second_run() {
333        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
334        clear();
335        let (_tmp, ctx) = setup();
336        std::env::set_var("AGENTMUX_HOME_OVERRIDE", &ctx.home);
337
338        M0022IdentityStoreLinksBackfill.up(&ctx).unwrap();
339        // Second run must not error, and must not duplicate/lose the row —
340        // the already_seeded fast-path should skip the whole scan.
341        M0022IdentityStoreLinksBackfill.up(&ctx).unwrap();
342
343        let dest = Store::open_identity_store(&registry::resolve_identity_store_path().unwrap()).unwrap();
344        let links = dest.agent_identity_list_for_agent("agent-continuing").unwrap();
345        assert_eq!(links.len(), 1);
346        clear();
347    }
348
349    #[test]
350    fn does_not_touch_source_stores() {
351        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
352        clear();
353        let (_tmp, ctx) = setup();
354        std::env::set_var("AGENTMUX_HOME_OVERRIDE", &ctx.home);
355
356        M0022IdentityStoreLinksBackfill.up(&ctx).unwrap();
357
358        // The sibling's own isolated store must still have its original
359        // link — read-only means read-only.
360        let sibling_isolated_store = ctx.home.join("dev").join("other-branch").join("identity-store.db");
361        let sibling = Store::open_identity_store(&sibling_isolated_store).unwrap();
362        let links = sibling.agent_identity_list_for_agent("agent-continuing").unwrap();
363        assert_eq!(links.len(), 1, "source store must be untouched, not drained");
364        clear();
365    }
366
367    /// reagentx P1 review on this PR: the original version of this
368    /// migration used `ctx.shared_store_path` to find "the true global
369    /// store," but that field is `resolve_shared_store_path()`'s result —
370    /// which itself redirects to a per-channel path whenever THIS process
371    /// is currently isolated. The previous test suite never caught this
372    /// because `setup()` hand-built `MigrationContext.shared_store_path`
373    /// pointing directly at the real global path, bypassing the exact
374    /// function whose isolation-awareness caused the bug. This test drives
375    /// the migration with isolation genuinely ON (`AGENTMUX_ISOLATED_AUTH=1`,
376    /// a non-`"stable"` channel, a real `AGENTMUX_INSTANCE_DIR`) — the same
377    /// environment shape `open_stores_and_migrate` actually runs under on
378    /// every local/dev/portable build — with a link seeded ONLY in the true
379    /// global `shared/store.db`, and confirms the migration still finds it.
380    #[test]
381    fn finds_a_link_in_the_true_global_store_even_when_this_process_is_currently_isolated() {
382        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
383        clear();
384        let tmp = tempfile::TempDir::new().unwrap();
385        let home = tmp.path().to_path_buf();
386
387        // Seed a link directly in the TRUE global store — e.g. written on
388        // the "stable" channel, or with AGENTMUX_ISOLATED_AUTH=0, at some
389        // point before this run.
390        let global_store_path = home.join("shared").join("store.db");
391        std::fs::create_dir_all(global_store_path.parent().unwrap()).unwrap();
392        let global = Store::open_shared(&global_store_path).unwrap();
393        // open_shared's schema (unlike open_identity_store's) still has a
394        // real account_id FK, so the link write needs a matching row first.
395        global.identity_upsert(&crate::backend::storage::store::IdentityAccount {
396            id: "acct-global".to_string(),
397            name: "Global Acct".to_string(),
398            provider: "claude".to_string(),
399            kind: "oauth".to_string(),
400            display_name: String::new(),
401            secret_ref: crate::backend::storage::store::SecretRef::OAuthConfigDir { dir: "/tmp/acct-global".to_string() },
402            context: serde_json::json!({}),
403            status: "valid".to_string(),
404            created_at: 0,
405            updated_at: 0,
406        }).unwrap();
407        global.agent_identity_link("agent-from-stable", "acct-global", "claude").unwrap();
408        drop(global);
409
410        let instance_dir = home.join("channels").join("local-somebranch-abcd1234").join("versions").join("0.55.0");
411        let channel_store_path = instance_dir.join("data").join("db").join("objects.db");
412        std::fs::create_dir_all(channel_store_path.parent().unwrap()).unwrap();
413        Store::open(&channel_store_path).unwrap();
414
415        let ctx = MigrationContext {
416            home: home.clone(),
417            data_dir: instance_dir.join("data"),
418            // Deliberately WRONG in exactly the way the bug was: this is
419            // what resolve_shared_store_path() would actually return under
420            // isolation (the per-channel path), NOT the global store. If
421            // the migration used this field for "the global store" the way
422            // it did before the fix, this test would fail.
423            shared_store_path: instance_dir.join("identity-store.db"),
424            channel_store_path,
425        };
426
427        std::env::set_var("AGENTMUX_HOME_OVERRIDE", &home);
428        std::env::set_var("AGENTMUX_ISOLATED_AUTH", "1");
429        std::env::set_var("AGENTMUX_CHANNEL", "local-somebranch-abcd1234");
430        std::env::set_var("AGENTMUX_INSTANCE_DIR", &instance_dir);
431
432        M0022IdentityStoreLinksBackfill.up(&ctx).unwrap();
433
434        let dest = Store::open_identity_store(&registry::resolve_identity_store_path().unwrap()).unwrap();
435        let links = dest.agent_identity_list_for_agent("agent-from-stable").unwrap();
436        assert_eq!(
437            links.len(), 1,
438            "a link that only ever existed in the TRUE global store must still be \
439             found even when the CURRENT process is isolated — this is the exact \
440             gap reagentx's review caught"
441        );
442        assert_eq!(links[0].account_id, "acct-global");
443        clear();
444    }
445
446    /// reagentx P2 review on this PR: two sources disagreeing about which
447    /// account an agent should use must resolve deterministically
448    /// (first-seen-wins by sorted source path), not by whatever order
449    /// `std::fs::read_dir` happens to return.
450    #[test]
451    fn conflicting_links_across_sources_resolve_deterministically_not_last_write_wins() {
452        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
453        clear();
454        let tmp = tempfile::TempDir::new().unwrap();
455        let home = tmp.path().to_path_buf();
456
457        // Two sibling dev branches, alphabetically "a-branch" then
458        // "z-branch", each linking the SAME agent to a DIFFERENT account.
459        // Sorted-path order means "a-branch" must win, regardless of
460        // whichever happened to be enumerated or opened first.
461        for (branch, account) in [("a-branch", "acct-from-a"), ("z-branch", "acct-from-z")] {
462            let instance_dir = home.join("dev").join(branch);
463            let objects_db = instance_dir.join("data").join("db").join("objects.db");
464            std::fs::create_dir_all(objects_db.parent().unwrap()).unwrap();
465            Store::open(&objects_db).unwrap();
466            let isolated_store = instance_dir.join("identity-store.db");
467            let s = Store::open_identity_store(&isolated_store).unwrap();
468            s.agent_identity_link("agent-conflicted", account, "claude").unwrap();
469        }
470
471        let channel_store_path = home.join("this-channel").join("data").join("db").join("objects.db");
472        std::fs::create_dir_all(channel_store_path.parent().unwrap()).unwrap();
473        Store::open(&channel_store_path).unwrap();
474        let shared_store_path = home.join("shared").join("store.db");
475        std::fs::create_dir_all(shared_store_path.parent().unwrap()).unwrap();
476
477        let ctx = MigrationContext {
478            home: home.clone(),
479            data_dir: tmp.path().join("this-channel").join("data"),
480            shared_store_path,
481            channel_store_path,
482        };
483        std::env::set_var("AGENTMUX_HOME_OVERRIDE", &home);
484
485        M0022IdentityStoreLinksBackfill.up(&ctx).unwrap();
486
487        let dest = Store::open_identity_store(&registry::resolve_identity_store_path().unwrap()).unwrap();
488        let links = dest.agent_identity_list_for_agent("agent-conflicted").unwrap();
489        assert_eq!(links.len(), 1);
490        assert_eq!(
491            links[0].account_id, "acct-from-a",
492            "the alphabetically-first source path must win, deterministically, \
493             on every run — not whichever source happened to be read last"
494        );
495        clear();
496    }
497}