Skip to main content

agentmux_srv/migrations/
m0033_narrow_skill_global_uniqueness_index.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Narrow the global skill uniqueness index from `(name, skill_type)` to
5//! `name` alone, matching the invariant `skill_upsert_unique_global`'s own
6//! dup-check has always enforced — with a defensive dedup pass for any
7//! existing same-name/different-type pair the OLD, wider index let through.
8//!
9//! Part B of Phase 2's application-layer redirect,
10//! `SPEC_DURABLE_BINDINGS_2026_09_10.md` §5.4 (Codex P2, PR #3182).
11//! `idx_ids_skills_global_name_type` (`IDENTITY_STORE_SCHEMA_VERSION` v7) is
12//! narrower than what the application actually promises ("a global skill's
13//! name is unique, full stop, regardless of type" — see
14//! `managed_upsert_unique_global`'s error message). MCP servers don't have
15//! this mismatch (`idx_ids_mcp_servers_global_name` is already name-only) —
16//! this migration is skills-specific.
17//!
18//! Because `m0031_carry_skills_and_mcp_servers_to_identity_store`'s own dedup
19//! key is `(name, skill_type)`, a store that already ran that migration (in
20//! an earlier release) may already hold two global `db_skills` rows sharing
21//! a name with different types — exactly the pair the OLD index allowed and
22//! the NEW one would reject outright. This migration detects any such
23//! group (not assuming exactly two), collapses to the earliest-`created_at`
24//! row (ties broken by id), rewrites refs, deletes the loser(s), and only
25//! THEN creates the new index — in that order, since creating a UNIQUE
26//! index over still-colliding data would fail.
27//!
28//! ## Cross-channel ref cleanup is intentionally partial
29//!
30//! `db_agent_skills_ref`/`db_bundle_skills_ref` live in EACH CHANNEL's own
31//! `mstore`, not in the identity store this migration (Global-scoped) opens.
32//! A single global migration run cannot reach every channel's ref rows the
33//! way `m0022_identity_store_links_backfill` reads multiple sources —
34//! reaching in and WRITING to a sibling channel's live `objects.db` from a
35//! process that isn't running that channel is the same unnecessary risk
36//! `m0031`'s own doc comment already declines to take. So this migration
37//! rewrites only the CURRENT channel's refs (the one whose `objects.db` is
38//! in `MigrationContext`); any other channel's ref rows still pointing at a
39//! collapsed-away loser id are left dangling until that channel's own next
40//! boot runs this same migration and would have to reconcile them itself —
41//! except this migration is Global-scoped and runs exactly ONCE across every
42//! channel, so a sibling channel never gets a second pass. This mirrors the
43//! already-accepted "cross-channel dangling refs on catalog delete" gap
44//! §5.4 documents elsewhere (a dangling ref degrades gracefully: `skill_get`
45//! against a superseded id returns `None`, which every read path already
46//! handles as "this bound skill no longer exists"), not a new regression —
47//! but it is a real, acknowledged incompleteness worth a second look.
48
49use rusqlite::Connection;
50
51use crate::backend::storage::store::Store;
52use crate::registry;
53
54use super::{Migration, MigrationContext, MigrationError, MigrationScope, VerifyOutcome};
55
56pub struct M0033NarrowSkillGlobalUniquenessIndex;
57
58/// One global skill row's identity, for grouping/collapsing.
59struct GlobalSkillRow {
60    id: String,
61    name: String,
62    created_at: i64,
63}
64
65/// Collapse every group of global `db_skills` rows sharing a `name` (however
66/// large) to the earliest-`created_at` row (ties broken by `id`, for
67/// determinism), rewriting `channel_mstore`'s own ref rows (if given) and
68/// deleting the loser(s) from `identity_conn`'s `db_skills` — then swaps the
69/// uniqueness index. Returns the number of rows collapsed away.
70///
71/// Takes explicit connections/stores rather than resolving paths itself so
72/// it can be exercised directly in tests without depending on
73/// `registry::resolve_identity_store_path`'s env-var-driven resolution (the
74/// same reason `m0031`'s `carry_skills`/`carry_mcp_servers` take explicit
75/// `Store` params instead of opening their own).
76fn collapse_and_reindex(identity_conn: &Connection, channel_mstore: Option<&Store>) -> Result<usize, String> {
77    let mut stmt = identity_conn
78        .prepare("SELECT id, name, created_at FROM db_skills WHERE is_global = 1 ORDER BY name, created_at ASC, id ASC")
79        .map_err(|e| format!("prepare global skills scan: {e}"))?;
80    let rows: Vec<GlobalSkillRow> = stmt
81        .query_map([], |row| {
82            Ok(GlobalSkillRow {
83                id: row.get(0)?,
84                name: row.get(1)?,
85                created_at: row.get(2)?,
86            })
87        })
88        .and_then(|rows| rows.collect::<Result<Vec<_>, _>>())
89        .map_err(|e| format!("read global skills: {e}"))?;
90    drop(stmt);
91
92    // Group by name, preserving the query's own (name, created_at, id)
93    // order within each group — first element is already the survivor.
94    let mut groups: std::collections::BTreeMap<String, Vec<GlobalSkillRow>> = std::collections::BTreeMap::new();
95    for row in rows {
96        groups.entry(row.name.clone()).or_default().push(row);
97    }
98
99    let mut collapsed = 0usize;
100    for (_, mut members) in groups {
101        if members.len() < 2 {
102            continue;
103        }
104        // Deterministic survivor: earliest created_at, ties broken by id.
105        // The SELECT's ORDER BY already sorted this way, but sort again
106        // explicitly so this function's correctness doesn't depend on the
107        // query staying in sync with this comment.
108        members.sort_by(|a, b| a.created_at.cmp(&b.created_at).then_with(|| a.id.cmp(&b.id)));
109        let survivor_id = members[0].id.clone();
110        for loser in &members[1..] {
111            if let Some(mstore) = channel_mstore {
112                mstore
113                    .skill_rewrite_ref_id(&loser.id, &survivor_id)
114                    .map_err(|e| format!("rewrite refs for collapsed skill {}: {e}", loser.id))?;
115            }
116            identity_conn
117                .execute("DELETE FROM db_skills WHERE id = ?1", [&loser.id])
118                .map_err(|e| format!("delete collapsed skill {}: {e}", loser.id))?;
119            collapsed += 1;
120        }
121    }
122
123    // Only safe to create the narrower index AFTER every collision has been
124    // collapsed above — a UNIQUE index over still-colliding data fails.
125    identity_conn
126        .execute_batch(
127            "CREATE UNIQUE INDEX IF NOT EXISTS idx_ids_skills_global_name
128                ON db_skills(name) WHERE is_global = 1;
129             DROP INDEX IF EXISTS idx_ids_skills_global_name_type;",
130        )
131        .map_err(|e| format!("swap uniqueness index: {e}"))?;
132
133    Ok(collapsed)
134}
135
136impl Migration for M0033NarrowSkillGlobalUniquenessIndex {
137    fn id(&self) -> &'static str {
138        "0033_narrow_skill_global_uniqueness_index"
139    }
140
141    fn scope(&self) -> MigrationScope {
142        MigrationScope::Global
143    }
144
145    fn description(&self) -> &'static str {
146        "Collapse any global skill rows sharing a name across skill_type, then narrow the global-uniqueness index to name alone"
147    }
148
149    fn up(&self, ctx: &MigrationContext) -> Result<(), MigrationError> {
150        let Some(path) = registry::resolve_identity_store_path() else {
151            // No resolvable identity store (CI / unusual env, same
152            // treat-as-no-op posture as m0031's own resolution failures).
153            return Ok(());
154        };
155        if !path.exists() {
156            // Nothing carried yet — a fresh store gets the new index
157            // directly from run_identity_store_schema, no dedup needed.
158            return Ok(());
159        }
160        if let Some(parent) = path.parent() {
161            std::fs::create_dir_all(parent)
162                .map_err(|e| MigrationError(format!("narrow_skill_global_uniqueness_index: create dir: {e}")))?;
163        }
164        let conn = Connection::open(&path)
165            .map_err(|e| MigrationError(format!("narrow_skill_global_uniqueness_index: open identity store: {e}")))?;
166        let has_table: bool = conn
167            .query_row(
168                "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'db_skills')",
169                [],
170                |row| row.get(0),
171            )
172            .map_err(|e| MigrationError(format!("narrow_skill_global_uniqueness_index: probe db_skills: {e}")))?;
173        if !has_table {
174            // Older-than-v6 store: db_skills doesn't exist yet. Nothing to
175            // dedup or reindex; a later boot creates it fresh (new index
176            // included) once it's actually populated.
177            return Ok(());
178        }
179
180        let channel_mstore = if ctx.channel_store_path.exists() {
181            Some(
182                Store::open(&ctx.channel_store_path)
183                    .map_err(|e| MigrationError(format!("narrow_skill_global_uniqueness_index: open mstore: {e}")))?,
184            )
185        } else {
186            None
187        };
188
189        let collapsed = collapse_and_reindex(&conn, channel_mstore.as_ref())
190            .map_err(|e| MigrationError(format!("narrow_skill_global_uniqueness_index: {e}")))?;
191        tracing::info!(collapsed, "m0033_narrow_skill_global_uniqueness_index: complete");
192        Ok(())
193    }
194
195    fn verify(&self, _ctx: &MigrationContext) -> VerifyOutcome {
196        let Some(path) = registry::resolve_identity_store_path() else {
197            return VerifyOutcome::Ok("identity store path unresolved".to_string());
198        };
199        let conn = match super::runner::open_readonly(&path) {
200            Ok(Some(c)) => c,
201            Ok(None) => return VerifyOutcome::Ok("identity store not yet created".to_string()),
202            Err(e) => return VerifyOutcome::Error(e),
203        };
204        let dupes: i64 = match conn.query_row(
205            "SELECT COUNT(*) FROM (
206                SELECT name FROM db_skills WHERE is_global = 1 GROUP BY name HAVING COUNT(*) > 1
207             )",
208            [],
209            |row| row.get(0),
210        ) {
211            Ok(n) => n,
212            Err(rusqlite::Error::SqliteFailure(_, Some(msg))) if msg.contains("no such table") => 0,
213            Err(e) => return VerifyOutcome::Error(format!("count duplicate global skill names: {e}")),
214        };
215        if dupes > 0 {
216            return VerifyOutcome::Mismatch(format!("{dupes} global skill name(s) still have more than one row"));
217        }
218        VerifyOutcome::Ok("no duplicate global skill names remain".to_string())
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    /// A pre-v8 identity store shape: `db_skills` with the OLD
227    /// `(name, skill_type)` index, not the new name-only one — the state a
228    /// real upgrading store is in before this migration runs. Deliberately
229    /// NOT built via `Store::open_identity_store` (which would already lay
230    /// down the NEW index from this binary's own `run_identity_store_schema`
231    /// and refuse to create a colliding fixture in the first place).
232    fn legacy_identity_conn() -> (tempfile::TempDir, Connection) {
233        let dir = tempfile::tempdir().unwrap();
234        let path = dir.path().join("identity-store.db");
235        let conn = Connection::open(&path).unwrap();
236        conn.execute_batch(
237            "CREATE TABLE db_skills (
238                id          TEXT PRIMARY KEY,
239                name        TEXT NOT NULL,
240                trigger     TEXT NOT NULL DEFAULT '',
241                skill_type  TEXT NOT NULL DEFAULT 'prompt',
242                description TEXT NOT NULL DEFAULT '',
243                content     TEXT NOT NULL DEFAULT '',
244                is_global   INTEGER NOT NULL DEFAULT 0,
245                created_at  INTEGER NOT NULL DEFAULT 0,
246                updated_at  INTEGER NOT NULL DEFAULT 0
247             );
248             CREATE UNIQUE INDEX idx_ids_skills_global_name_type
249                ON db_skills(name, skill_type) WHERE is_global = 1;",
250        )
251        .unwrap();
252        (dir, conn)
253    }
254
255    fn insert_skill(conn: &Connection, id: &str, name: &str, skill_type: &str, created_at: i64) {
256        conn.execute(
257            "INSERT INTO db_skills (id, name, skill_type, is_global, created_at, updated_at)
258             VALUES (?1, ?2, ?3, 1, ?4, ?4)",
259            rusqlite::params![id, name, skill_type, created_at],
260        )
261        .unwrap();
262    }
263
264    #[test]
265    fn two_colliding_global_skills_are_collapsed_to_the_earliest_created_at_row() {
266        let (_dir, conn) = legacy_identity_conn();
267        insert_skill(&conn, "newer", "Deploy", "prompt", 200);
268        insert_skill(&conn, "older", "Deploy", "agent-skill", 100);
269
270        let collapsed = collapse_and_reindex(&conn, None).unwrap();
271        assert_eq!(collapsed, 1);
272
273        let remaining: Vec<String> = {
274            let mut stmt = conn.prepare("SELECT id FROM db_skills").unwrap();
275            stmt.query_map([], |r| r.get::<_, String>(0)).unwrap().map(|r| r.unwrap()).collect()
276        };
277        assert_eq!(remaining, vec!["older".to_string()], "the earliest created_at row must survive");
278    }
279
280    #[test]
281    fn a_larger_group_of_collisions_collapses_to_one_survivor() {
282        let (_dir, conn) = legacy_identity_conn();
283        insert_skill(&conn, "c", "Deploy", "prompt", 300);
284        insert_skill(&conn, "a", "Deploy", "agent-skill", 100);
285        insert_skill(&conn, "b", "Deploy", "shell", 200);
286
287        let collapsed = collapse_and_reindex(&conn, None).unwrap();
288        assert_eq!(collapsed, 2, "must not assume exactly two colliding rows");
289
290        let remaining: i64 = conn.query_row("SELECT COUNT(*) FROM db_skills", [], |r| r.get(0)).unwrap();
291        assert_eq!(remaining, 1);
292        let survivor: String = conn.query_row("SELECT id FROM db_skills", [], |r| r.get(0)).unwrap();
293        assert_eq!(survivor, "a");
294    }
295
296    #[test]
297    fn ties_in_created_at_are_broken_deterministically_by_id() {
298        let (_dir, conn) = legacy_identity_conn();
299        insert_skill(&conn, "zzz", "Deploy", "prompt", 100);
300        insert_skill(&conn, "aaa", "Deploy", "agent-skill", 100);
301
302        collapse_and_reindex(&conn, None).unwrap();
303        let survivor: String = conn.query_row("SELECT id FROM db_skills", [], |r| r.get(0)).unwrap();
304        assert_eq!(survivor, "aaa", "the lexicographically smaller id must win a created_at tie");
305    }
306
307    #[test]
308    fn the_current_channels_ref_rows_are_rewritten_to_the_survivor() {
309        let (_dir, conn) = legacy_identity_conn();
310        insert_skill(&conn, "loser", "Deploy", "prompt", 200);
311        insert_skill(&conn, "winner", "Deploy", "agent-skill", 100);
312
313        let mstore = Store::open_in_memory().unwrap();
314        // Insert the ref row directly rather than through `skill_bind` — the
315        // catalog row lives only in the raw identity `conn` above (not
316        // wrapped as a `Store`), and `skill_bind` now checks catalog
317        // existence (Part C of SPEC_DURABLE_BINDINGS_2026_09_10.md). This
318        // test is only exercising `skill_rewrite_ref_id`'s effect on the ref
319        // table, same as m0031's own tests seed ref rows directly.
320        {
321            let raw = mstore.conn().lock().unwrap();
322            raw.execute("INSERT INTO db_agents (id, name, provider) VALUES ('agent-1', 'A', 'claude')", [])
323                .unwrap();
324            raw.execute("INSERT INTO db_agent_skills_ref (agent_id, skill_id) VALUES ('agent-1', 'loser')", [])
325                .unwrap();
326        }
327
328        collapse_and_reindex(&conn, Some(&mstore)).unwrap();
329
330        assert!(mstore.skill_is_bound_to("agent-1", "winner").unwrap(), "the ref must now point at the survivor");
331        assert!(!mstore.skill_is_bound_to("agent-1", "loser").unwrap());
332    }
333
334    #[test]
335    fn the_new_index_rejects_a_fresh_same_name_insert_after_the_swap() {
336        let (_dir, conn) = legacy_identity_conn();
337        insert_skill(&conn, "existing", "Deploy", "prompt", 100);
338
339        collapse_and_reindex(&conn, None).unwrap();
340
341        let result = conn.execute(
342            "INSERT INTO db_skills (id, name, skill_type, is_global, created_at, updated_at)
343             VALUES ('new-one', 'Deploy', 'agent-skill', 1, 200, 200)",
344            [],
345        );
346        assert!(
347            result.is_err(),
348            "the new name-only index must reject a same-name insert even with a different skill_type"
349        );
350    }
351
352    #[test]
353    fn a_store_with_no_collisions_is_a_clean_no_op() {
354        let (_dir, conn) = legacy_identity_conn();
355        insert_skill(&conn, "s1", "Deploy", "prompt", 100);
356        insert_skill(&conn, "s2", "Review", "prompt", 100);
357
358        let collapsed = collapse_and_reindex(&conn, None).unwrap();
359        assert_eq!(collapsed, 0);
360
361        let remaining: i64 = conn.query_row("SELECT COUNT(*) FROM db_skills", [], |r| r.get(0)).unwrap();
362        assert_eq!(remaining, 2, "no-collision rows must be untouched");
363
364        // The index swap must still have happened even with nothing to
365        // collapse — a fresh-enough store still needs the new index.
366        let has_new_index: bool = conn
367            .query_row(
368                "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='index' AND name='idx_ids_skills_global_name')",
369                [],
370                |r| r.get(0),
371            )
372            .unwrap();
373        assert!(has_new_index);
374    }
375
376    // ── up()-level tests: these resolve a REAL identity store path via
377    // registry::resolve_identity_store_path(), so — same as
378    // m0022_identity_store_links_backfill's test module — they must pin
379    // AGENTMUX_HOME_OVERRIDE to an isolated temp dir under the shared
380    // process-wide env lock, never run against whatever the test process's
381    // ambient environment happens to resolve to.
382    use crate::test_support::ISOLATED_AUTH_ENV_LOCK as ENV_LOCK;
383
384    fn clear_env() {
385        std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
386        std::env::remove_var("AGENTMUX_SHARED_DIR");
387        std::env::remove_var("AGENTMUX_ISOLATED_AUTH");
388        std::env::remove_var("AGENTMUX_CHANNEL");
389        std::env::remove_var("AGENTMUX_INSTANCE_DIR");
390    }
391
392    #[test]
393    fn a_non_existent_identity_store_path_is_a_no_op() {
394        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
395        clear_env();
396        let tmp = tempfile::tempdir().unwrap();
397        std::env::set_var("AGENTMUX_HOME_OVERRIDE", tmp.path());
398
399        let ctx = MigrationContext {
400            home: tmp.path().to_path_buf(),
401            data_dir: tmp.path().join("data"),
402            shared_store_path: tmp.path().join("shared").join("store.db"),
403            channel_store_path: tmp.path().join("data").join("db").join("does-not-exist.db"),
404        };
405        // Nothing has ever created identity-store.db under this fresh home.
406        assert!(M0033NarrowSkillGlobalUniquenessIndex.up(&ctx).is_ok());
407        clear_env();
408    }
409
410    /// End-to-end through `up()` itself: a real identity store (pre-v8
411    /// shape, built the same way `legacy_identity_conn` does but at the
412    /// resolved production path) with a real colliding pair, plus a real
413    /// current-channel `mstore` holding a ref row pointing at the loser —
414    /// after `up()`, the identity store has one row, the ref points at the
415    /// survivor, and the new index is in place.
416    #[test]
417    fn up_end_to_end_collapses_rewrites_refs_and_swaps_the_index() {
418        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
419        clear_env();
420        let tmp = tempfile::tempdir().unwrap();
421        std::env::set_var("AGENTMUX_HOME_OVERRIDE", tmp.path());
422
423        let identity_path = registry::resolve_identity_store_path().unwrap();
424        std::fs::create_dir_all(identity_path.parent().unwrap()).unwrap();
425        {
426            let conn = Connection::open(&identity_path).unwrap();
427            conn.execute_batch(
428                "CREATE TABLE db_skills (
429                    id TEXT PRIMARY KEY, name TEXT NOT NULL, trigger TEXT NOT NULL DEFAULT '',
430                    skill_type TEXT NOT NULL DEFAULT 'prompt', description TEXT NOT NULL DEFAULT '',
431                    content TEXT NOT NULL DEFAULT '', is_global INTEGER NOT NULL DEFAULT 0,
432                    created_at INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0
433                 );
434                 CREATE UNIQUE INDEX idx_ids_skills_global_name_type
435                    ON db_skills(name, skill_type) WHERE is_global = 1;",
436            )
437            .unwrap();
438            insert_skill(&conn, "loser", "Deploy", "prompt", 200);
439            insert_skill(&conn, "winner", "Deploy", "agent-skill", 100);
440        }
441
442        let channel_store_path = tmp.path().join("data").join("db").join("objects.db");
443        std::fs::create_dir_all(channel_store_path.parent().unwrap()).unwrap();
444        {
445            let mstore = Store::open(&channel_store_path).unwrap();
446            // Direct ref-row insert, not `skill_bind` — see the identical
447            // note on `the_current_channels_ref_rows_are_rewritten_to_the_survivor`
448            // above.
449            let raw = mstore.conn().lock().unwrap();
450            raw.execute("INSERT INTO db_agents (id, name, provider) VALUES ('agent-1', 'A', 'claude')", [])
451                .unwrap();
452            raw.execute("INSERT INTO db_agent_skills_ref (agent_id, skill_id) VALUES ('agent-1', 'loser')", [])
453                .unwrap();
454        }
455
456        let ctx = MigrationContext {
457            home: tmp.path().to_path_buf(),
458            data_dir: tmp.path().join("data"),
459            shared_store_path: tmp.path().join("shared").join("store.db"),
460            channel_store_path,
461        };
462        M0033NarrowSkillGlobalUniquenessIndex.up(&ctx).unwrap();
463
464        let identity_conn = Connection::open(&identity_path).unwrap();
465        let remaining: i64 = identity_conn.query_row("SELECT COUNT(*) FROM db_skills", [], |r| r.get(0)).unwrap();
466        assert_eq!(remaining, 1, "the loser row must be gone from the identity store");
467        let survivor: String = identity_conn.query_row("SELECT id FROM db_skills", [], |r| r.get(0)).unwrap();
468        assert_eq!(survivor, "winner");
469
470        let mstore = Store::open(&ctx.channel_store_path).unwrap();
471        assert!(mstore.skill_is_bound_to("agent-1", "winner").unwrap(), "the ref must now point at the survivor");
472        assert!(!mstore.skill_is_bound_to("agent-1", "loser").unwrap());
473
474        assert!(
475            matches!(M0033NarrowSkillGlobalUniquenessIndex.verify(&ctx), VerifyOutcome::Ok(_)),
476            "no duplicate names remain, so verify() must pass"
477        );
478
479        clear_env();
480    }
481}