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(®istry::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(®istry::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(®istry::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(®istry::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(®istry::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}