Skip to main content

agentmux_srv/backend/storage/
agent_tokens.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Per-agent local identity token, keyed by the agent's UID (`db_agents.id`).
5//!
6//! Identity M1 of `docs/specs/SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md`
7//! (§6, §9.1). See `db_agent_tokens` in migrations.rs (OBJECT_SCHEMA_VERSION
8//! v37).
9//!
10//! **What it is.** A secret the server generates for one agent and hands to
11//! that agent's process environment at spawn (`AGENTMUX_AGENT_TOKEN`, set by
12//! `build_persistent_spawn_env`). Only the server and the agent it spawned
13//! ever hold the value, which is what will let a later phase (M4) map
14//! token → UID on a request and treat *that* as the caller's identity,
15//! instead of trusting an `agent_id` the request body asserts. Spec §2
16//! rule 2: identity is proven, not asserted.
17//!
18//! **What it is not.** Not the cloud Cognito M2M credential in
19//! `muxbus/agent_credentials.rs` — that one needs a prior human login and
20//! falls back to a shared token by design, both of which disqualify it
21//! (spec §0.3). This is minted locally, with no network and no fallback.
22//!
23//! **Lifetime.** Long-lived for the agent's lifetime and revoked when the
24//! agent is deleted (`purge_agent_dependents`), never renewed on a timer:
25//! spec §6.3 — expiry mid-task turns a healthy agent silent, and renewal is a
26//! liveness dependency on the very path being secured. Persisted here, in
27//! the server's own store, so an srv restart does not strand every running
28//! agent with a token nothing can verify (same section). This is the
29//! server persisting its own secret where only the server reads it — not a
30//! name-keyed, agent-readable file in shared space, which §6.3 rules out.
31//!
32//! **Keyed by UID, not name.** `db_agent_jekt_keys` and the registry entry
33//! files are keyed by display name, so two agents sharing a name share one
34//! credential (spec §6.3). This table cannot have that defect: `db_agents.id`
35//! is a `PRIMARY KEY`, and every non-template row is a UUID.
36//!
37//! **Read since M4a** through [`TokenIndex`]: a request carrying
38//! `X-Agent-Token` is attributed to the UID the token was minted for
39//! (spec §6.5.3). Attribution only — an unknown token is treated as absent,
40//! never refused.
41
42use std::collections::HashMap;
43use std::sync::{Arc, RwLock};
44
45use rusqlite::params;
46
47use super::agent_lan_keys::random_seed_bytes;
48use super::error::StoreError;
49use super::store::Store;
50
51use agentmux_common::time::now_secs;
52
53/// 32 bytes of CSPRNG randomness from `random_seed_bytes`, hex-encoded
54/// (64 chars); hex rather than base64 so the value is safe in any
55/// environment-variable or shell context without quoting.
56fn random_token() -> String {
57    hex::encode(random_seed_bytes())
58}
59
60/// token → UID, in memory, so attributing a request never reads the store
61/// (spec §10.1). Attached to the channel's object store only
62/// ([`Store::attach_token_index`]) and kept in step with `db_agent_tokens`
63/// by the two statements that change it — mint (`agent_token_ensure`) and
64/// revoke (`purge_agent_dependents`) — each under the store's connection
65/// lock, so the index never disagrees with a committed row (spec §6.5.3:
66/// purge is a sequence of autocommit statements, not a transaction, so
67/// the index follows the token statement itself, not the purge's result).
68#[derive(Default)]
69pub struct TokenIndex {
70    maps: RwLock<TokenMaps>,
71}
72
73#[derive(Default)]
74struct TokenMaps {
75    uid_by_token: HashMap<String, String>,
76    token_by_uid: HashMap<String, String>,
77}
78
79impl TokenIndex {
80    /// The UID `token` was minted for, if any.
81    pub fn uid_for(&self, token: &str) -> Option<String> {
82        let maps = self.maps.read().unwrap_or_else(|e| e.into_inner());
83        maps.uid_by_token.get(token).cloned()
84    }
85
86    fn insert(&self, uid: &str, token: &str) {
87        let mut maps = self.maps.write().unwrap_or_else(|e| e.into_inner());
88        if let Some(old) = maps.token_by_uid.insert(uid.to_string(), token.to_string()) {
89            if old != token {
90                maps.uid_by_token.remove(&old);
91            }
92        }
93        maps.uid_by_token.insert(token.to_string(), uid.to_string());
94    }
95
96    /// Drop `uid`'s token. Called by `purge_agent_dependents` right after
97    /// the row's delete, under the same connection lock.
98    pub(super) fn forget_uid(&self, uid: &str) {
99        let mut maps = self.maps.write().unwrap_or_else(|e| e.into_inner());
100        if let Some(token) = maps.token_by_uid.remove(uid) {
101            maps.uid_by_token.remove(&token);
102        }
103    }
104
105    #[cfg(test)]
106    pub fn len(&self) -> usize {
107        self.maps
108            .read()
109            .unwrap_or_else(|e| e.into_inner())
110            .uid_by_token
111            .len()
112    }
113}
114
115impl Store {
116    /// Build this store's [`TokenIndex`] from `db_agent_tokens` and attach
117    /// it, so mint and revoke keep it current from here on. Called once, on
118    /// the channel's object store at boot — never on the shared, identity or
119    /// migration-time stores, which must not grow an index of their own.
120    pub fn attach_token_index(&self) -> Result<Arc<TokenIndex>, StoreError> {
121        let conn = self.conn.lock().unwrap();
122        let index = Arc::new(TokenIndex::default());
123        {
124            let mut stmt = conn.prepare("SELECT agent_id, token FROM db_agent_tokens")?;
125            let rows =
126                stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
127            for row in rows {
128                let (uid, token) = row?;
129                index.insert(&uid, &token);
130            }
131        }
132        *self.token_index.lock().unwrap_or_else(|e| e.into_inner()) = Some(index.clone());
133        Ok(index)
134    }
135
136    /// The attached [`TokenIndex`], if this store has one.
137    pub fn token_index(&self) -> Option<Arc<TokenIndex>> {
138        self.token_index
139            .lock()
140            .unwrap_or_else(|e| e.into_inner())
141            .clone()
142    }
143
144    /// This agent's token if one has been minted, without minting.
145    ///
146    /// The verification read (M4) is the production caller; until it lands
147    /// only tests read this, hence the `dead_code` allowance outside test
148    /// builds. Deletion does not go through here — `purge_agent_dependents`
149    /// covers `db_agent_tokens` directly.
150    #[cfg_attr(not(test), allow(dead_code))]
151    pub fn agent_token_load(&self, uid: &str) -> Result<Option<String>, StoreError> {
152        let conn = self.conn.lock().unwrap();
153        let mut stmt = conn.prepare("SELECT token FROM db_agent_tokens WHERE agent_id = ?1")?;
154        match stmt.query_row(params![uid], |row| row.get::<_, String>(0)) {
155            Ok(v) => Ok(Some(v)),
156            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
157            Err(e) => Err(e.into()),
158        }
159    }
160
161    /// This agent's token, minting and persisting one on first use.
162    ///
163    /// Race-safe under concurrent first use, the same way
164    /// `agent_jekt_key_ensure` is: the insert is `OR IGNORE` and the row is
165    /// re-read regardless of whether this call's insert won, so two spawns
166    /// of a never-before-seen agent agree on one token instead of each
167    /// holding a different one. An empty `uid` is refused rather than given
168    /// a token: a token keyed by `""` would be shared by every caller that
169    /// failed to resolve an identity, which is the shared-credential defect
170    /// this table exists to remove.
171    pub fn agent_token_ensure(&self, uid: &str) -> Result<String, StoreError> {
172        if uid.trim().is_empty() {
173            return Err(StoreError::Other(
174                "agent_token_ensure: refusing to mint a token for an empty uid".to_string(),
175            ));
176        }
177        let conn = self.conn.lock().unwrap();
178        conn.execute(
179            "INSERT OR IGNORE INTO db_agent_tokens (agent_id, token, created_at) VALUES (?1, ?2, ?3)",
180            params![uid, random_token(), now_secs()],
181        )?;
182        let mut stmt = conn.prepare("SELECT token FROM db_agent_tokens WHERE agent_id = ?1")?;
183        let token: String = stmt.query_row(params![uid], |row| row.get(0))?;
184        // Still under the connection lock: the index follows the row.
185        if let Some(index) = self.token_index() {
186            index.insert(uid, &token);
187        }
188        Ok(token)
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    fn object_store() -> Store {
197        Store::open_in_memory().unwrap()
198    }
199
200    #[test]
201    fn load_is_none_until_minted_then_ensure_is_stable() {
202        let store = object_store();
203        assert_eq!(store.agent_token_load("uid-a").unwrap(), None);
204
205        let first = store.agent_token_ensure("uid-a").unwrap();
206        assert_eq!(first.len(), 64, "32 random bytes, hex-encoded");
207        assert!(first.chars().all(|c| c.is_ascii_hexdigit()));
208
209        // Second ensure returns the persisted value, not a fresh mint.
210        assert_eq!(store.agent_token_ensure("uid-a").unwrap(), first);
211        assert_eq!(
212            store.agent_token_load("uid-a").unwrap().as_deref(),
213            Some(first.as_str())
214        );
215    }
216
217    /// The property M4 will rely on: two agents never share a token, even
218    /// when everything a human can see about them (their names) collides.
219    /// The table is keyed by UID, so the names never enter into it.
220    #[test]
221    fn distinct_uids_get_distinct_tokens() {
222        let store = object_store();
223        let a = store.agent_token_ensure("4f3c-agenty").unwrap();
224        let b = store.agent_token_ensure("9b2e-agenty").unwrap();
225        assert_ne!(a, b);
226    }
227
228    #[test]
229    fn empty_uid_is_refused_not_given_a_shared_token() {
230        let store = object_store();
231        assert!(store.agent_token_ensure("").is_err());
232        assert!(store.agent_token_ensure("   ").is_err());
233        assert_eq!(store.agent_token_load("").unwrap(), None);
234    }
235
236    // ---- identity M4a: the index follows the token rows ----------------
237
238    /// Mint puts the token in the index, and attaching to a store that
239    /// already holds tokens loads them — an srv restart attributes a running
240    /// agent's requests without it respawning.
241    #[test]
242    fn the_index_follows_mint_and_is_rebuilt_on_attach() {
243        let store = object_store();
244        let before = store.agent_token_ensure("uid-a").unwrap();
245        let index = store.attach_token_index().unwrap();
246        assert_eq!(
247            index.uid_for(&before).as_deref(),
248            Some("uid-a"),
249            "loaded on attach"
250        );
251        let minted = store.agent_token_ensure("uid-b").unwrap();
252        assert_eq!(
253            index.uid_for(&minted).as_deref(),
254            Some("uid-b"),
255            "added on mint"
256        );
257        assert_eq!(index.len(), 2);
258    }
259
260    /// Purge revokes the token from the index as well as the table.
261    #[test]
262    fn purge_removes_the_token_from_the_index() {
263        let store = object_store();
264        let index = store.attach_token_index().unwrap();
265        let token = store.agent_token_ensure("uid-a").unwrap();
266        {
267            let conn = store.conn.lock().unwrap();
268            super::super::agents::purge_agent_dependents_for_tests(
269                &conn,
270                "uid-a",
271                store.token_index().as_deref(),
272            )
273            .unwrap();
274        }
275        assert_eq!(index.uid_for(&token), None);
276        assert_eq!(store.agent_token_load("uid-a").unwrap(), None);
277    }
278
279    /// Through the real deletion paths: deleting an agent revokes its token
280    /// from the index and tombstones every name its legacy signing keys may
281    /// sit under (spec §6.5.4) — for both names one deletion arrives by.
282    #[test]
283    fn deleting_an_agent_revokes_its_token_and_tombstones_its_names() {
284        use crate::backend::storage::agents::test_agent_def;
285        for via_instance_delete in [false, true] {
286            let store = object_store();
287            let index = store.attach_token_index().unwrap();
288            let mut def = test_agent_def("uid-agenty", "AgentY", "claude", "agent", 1, "");
289            def.slug = "agenty-2".to_string();
290            store.agent_def_insert(&mut def).unwrap();
291            let token = store.agent_token_ensure("uid-agenty").unwrap();
292            assert_eq!(index.uid_for(&token).as_deref(), Some("uid-agenty"));
293
294            let deleted = if via_instance_delete {
295                store.instance_delete("uid-agenty").unwrap()
296            } else {
297                store.agent_def_delete("uid-agenty").unwrap()
298            };
299            assert!(deleted, "instance_delete={via_instance_delete}");
300            assert_eq!(
301                index.uid_for(&token),
302                None,
303                "instance_delete={via_instance_delete}"
304            );
305            let tombstones = {
306                let conn = store.conn.lock().unwrap();
307                super::super::agents::key_tombstones_for_tests(&conn)
308            };
309            let names: Vec<&str> = tombstones.iter().map(|(n, _)| n.as_str()).collect();
310            assert!(
311                names.contains(&"agenty") && names.contains(&"agenty-2"),
312                "{tombstones:?}"
313            );
314            assert!(tombstones.iter().all(|(_, uid)| uid == "uid-agenty"));
315        }
316    }
317
318    /// The colliding-names fixture: deleting the second "AgentY" (slug
319    /// agenty-2) must not tombstone `agenty`, the first one's slug — its
320    /// signing keys sit under that name, and a tombstone would cost the
321    /// rightful, live owner its key at M4d (adversarial review of #3571).
322    #[test]
323    fn deleting_one_of_two_same_named_agents_spares_the_others_slug() {
324        use crate::backend::storage::agents::test_agent_def;
325        let store = object_store();
326        for (id, name, slug) in [
327            ("uid-first", "AgentY", "agenty"),
328            ("uid-second", "AGENTY", "agenty-2"),
329        ] {
330            let mut def = test_agent_def(id, name, "claude", "agent", 1, "");
331            def.slug = slug.to_string();
332            store.agent_def_insert(&mut def).unwrap();
333        }
334        assert!(store.agent_def_delete("uid-second").unwrap());
335        let tombstones = {
336            let conn = store.conn.lock().unwrap();
337            super::super::agents::key_tombstones_for_tests(&conn)
338        };
339        assert_eq!(
340            tombstones,
341            vec![("agenty-2".to_string(), "uid-second".to_string())],
342            "only the deleted agent's own slug"
343        );
344    }
345
346    /// Identity M4a-3 (spec §6.5.4): deleting an agent deletes the signing
347    /// keys filed under its slug, so a later agent taking the name gets
348    /// fresh ones instead of the dead agent's — through both deletion paths.
349    /// The colliding-names fixture: deleting the second "AgentY" (agenty-2)
350    /// leaves the first one's `agenty` keys alone.
351    #[test]
352    fn deleting_an_agent_deletes_the_keys_filed_under_its_slug() {
353        use crate::backend::storage::agents::test_agent_def;
354        for via_instance_delete in [false, true] {
355            let store = object_store();
356            for (id, name, slug) in [
357                ("uid-first", "AgentY", "agenty"),
358                ("uid-second", "AGENTY", "agenty-2"),
359            ] {
360                let mut def = test_agent_def(id, name, "claude", "agent", 1, "");
361                def.slug = slug.to_string();
362                store.agent_def_insert(&mut def).unwrap();
363            }
364            for name in ["agenty", "agenty-2"] {
365                store.agent_jekt_key_ensure(name).unwrap();
366                store.agent_lan_key_ensure(name).unwrap();
367                store.agent_wan_key_ensure(name).unwrap();
368            }
369            let old_lan = store.agent_lan_key_load("agenty-2").unwrap().unwrap();
370
371            let deleted = if via_instance_delete {
372                store.instance_delete("uid-second").unwrap()
373            } else {
374                store.agent_def_delete("uid-second").unwrap()
375            };
376            assert!(deleted, "instance_delete={via_instance_delete}");
377            assert!(store.agent_jekt_key_load("agenty-2").unwrap().is_none());
378            assert!(store.agent_lan_key_load("agenty-2").unwrap().is_none());
379            assert!(store.agent_wan_key_load("agenty-2").unwrap().is_none());
380            assert!(
381                store.agent_jekt_key_load("agenty").unwrap().is_some(),
382                "the live agent's keys stay"
383            );
384            assert!(store.agent_lan_key_load("agenty").unwrap().is_some());
385            assert!(store.agent_wan_key_load("agenty").unwrap().is_some());
386
387            // A reuser of the name is minted a fresh keypair, not handed the
388            // dead agent's.
389            let reused = store.agent_lan_key_ensure("agenty-2").unwrap();
390            assert_ne!(
391                reused.public_key, old_lan.public_key,
392                "instance_delete={via_instance_delete}"
393            );
394        }
395    }
396
397    /// W3-S (`SPEC_WAN_JEKT_VERIFICATION_2026_09_24.md` §2.2): agent WAN keys
398    /// live in the channel-wide `wan.db` now, and the same purge reaches it
399    /// through both deletion paths — the name's reuser gets a new key, and
400    /// the colliding live agent keeps its own.
401    #[test]
402    fn deleting_an_agent_deletes_its_wan_key_from_the_channel_wide_store() {
403        use crate::backend::storage::agents::test_agent_def;
404        for via_instance_delete in [false, true] {
405            let store = object_store();
406            let (_wan_dir, wan) = crate::backend::storage::wan_identity::attach_temp_wan_identity(&store);
407            for (id, name, slug) in [
408                ("uid-first", "AgentY", "agenty"),
409                ("uid-second", "AGENTY", "agenty-2"),
410            ] {
411                let mut def = test_agent_def(id, name, "claude", "agent", 1, "");
412                def.slug = slug.to_string();
413                store.agent_def_insert(&mut def).unwrap();
414            }
415            for name in ["agenty", "agenty-2"] {
416                wan.agent_key_ensure(name, None).unwrap();
417            }
418            let old = wan.agent_key_load("agenty-2").unwrap().unwrap();
419
420            let deleted = if via_instance_delete {
421                store.instance_delete("uid-second").unwrap()
422            } else {
423                store.agent_def_delete("uid-second").unwrap()
424            };
425            assert!(deleted, "instance_delete={via_instance_delete}");
426            assert!(wan.agent_key_load("agenty-2").unwrap().is_none(), "instance_delete={via_instance_delete}");
427            assert!(wan.agent_key_load("agenty").unwrap().is_some(), "the live agent's WAN key stays");
428            assert_ne!(wan.agent_key_ensure("agenty-2", None).unwrap().public_key, old.public_key);
429        }
430    }
431
432    /// Identity M4d-1 (spec §6.5.10): deleting an agent also deletes keys
433    /// filed under the other names it answered to — display name,
434    /// `instance_name`, and `agent.open`'s fallback id — except a name another
435    /// agent signs under (here: another row holds `zed-bot` as its slug).
436    #[test]
437    fn deleting_an_agent_deletes_the_keys_under_all_its_names() {
438        use crate::backend::storage::agents::test_agent_def;
439        let store = object_store();
440        let mut zed = test_agent_def("uid-zed", "Zed Bot", "claude", "agent", 1, "");
441        zed.slug = "zb".to_string();
442        store.agent_def_insert(&mut zed).unwrap();
443        store
444            .conn()
445            .lock()
446            .unwrap()
447            .execute("UPDATE db_agents SET instance_name = 'Zeddy' WHERE id = 'uid-zed'", [])
448            .unwrap();
449        let mut other = test_agent_def("uid-other", "Other", "claude", "agent", 1, "");
450        other.slug = "zed-bot".to_string();
451        store.agent_def_insert(&mut other).unwrap();
452        for name in ["zb", "zed bot", "zeddy", "zed-bot"] {
453            store.agent_lan_key_ensure(name).unwrap();
454            store.agent_jekt_key_ensure(name).unwrap();
455        }
456
457        assert!(store.agent_def_delete("uid-zed").unwrap());
458        for gone in ["zb", "zed bot", "zeddy"] {
459            assert!(store.agent_lan_key_load(gone).unwrap().is_none(), "{gone}");
460            assert!(store.agent_jekt_key_load(gone).unwrap().is_none(), "{gone}");
461        }
462        assert!(
463            store.agent_lan_key_load("zed-bot").unwrap().is_some(),
464            "the fallback id another agent holds as its slug stays"
465        );
466        assert!(store.agent_jekt_key_load("zed-bot").unwrap().is_some());
467
468        // Two template-created agents named "Agent (v2)" (slugs agent-v2,
469        // agent-v2-2) both sign their first session as the fallback id
470        // `agent--v2-`: deleting one leaves it for the other (review of #3633).
471        for (id, slug) in [("uid-v2a", "agent-v2"), ("uid-v2b", "agent-v2-2")] {
472            let mut def = test_agent_def(id, "Agent (v2)", "claude", "agent", 1, "");
473            def.slug = slug.to_string();
474            store.agent_def_insert(&mut def).unwrap();
475        }
476        store.agent_lan_key_ensure("agent--v2-").unwrap();
477        assert!(store.agent_def_delete("uid-v2a").unwrap());
478        assert!(
479            store.agent_lan_key_load("agent--v2-").unwrap().is_some(),
480            "the same-named sibling still signs under it"
481        );
482
483        // Renamed after its first launch: the launch name, kept as
484        // `instance_name`, keyed its first session's fallback — purged too
485        // (Codex P1 on #3633).
486        let mut renamed = test_agent_def("uid-renamed", "New Name", "claude", "agent", 1, "");
487        renamed.slug = "new-name".to_string();
488        store.agent_def_insert(&mut renamed).unwrap();
489        store
490            .conn()
491            .lock()
492            .unwrap()
493            .execute("UPDATE db_agents SET instance_name = 'Old (launch)' WHERE id = 'uid-renamed'", [])
494            .unwrap();
495        store.agent_lan_key_ensure("old--launch-").unwrap();
496        assert!(store.agent_def_delete("uid-renamed").unwrap());
497        assert!(store.agent_lan_key_load("old--launch-").unwrap().is_none());
498
499        // The picker's rename overwrites both the display name and
500        // instance_name; the old name is remembered, so its fallback key
501        // is still purged at delete (Codex P1 on #3633).
502        let mut v2 = test_agent_def("uid-v2-renamed", "Agent (v2)", "claude", "agent", 1, "");
503        v2.slug = "agent-v2-r".to_string();
504        store.agent_def_insert(&mut v2).unwrap();
505        store
506            .conn()
507            .lock()
508            .unwrap()
509            .execute("UPDATE db_agents SET instance_name = 'Agent (v2)' WHERE id = 'uid-v2-renamed'", [])
510            .unwrap();
511        // The earlier "Agent (v2)" pair is gone, so nobody else signs as
512        // agent--v2- now except this row's first session.
513        assert!(store.agent_def_delete("uid-v2b").unwrap());
514        store.agent_lan_key_ensure("agent--v2-").unwrap();
515        v2.name = "New Name".to_string();
516        store.agent_def_update(&mut v2).unwrap();
517        assert!(store.instance_rename("uid-v2-renamed", "New Name").unwrap());
518        assert!(store.agent_def_delete("uid-v2-renamed").unwrap());
519        assert!(
520            store.agent_lan_key_load("agent--v2-").unwrap().is_none(),
521            "the pre-rename fallback key is purged"
522        );
523
524        // Any write path is recorded — a raw UPDATE stands in for the
525        // continuation fold — and never evicted, however many renames
526        // (Codex P1/P2 on #3633).
527        let mut many = test_agent_def("uid-many", "First Name", "claude", "agent", 1, "");
528        many.slug = "many".to_string();
529        store.agent_def_insert(&mut many).unwrap();
530        store.agent_lan_key_ensure("first-name").unwrap();
531        for i in 0..25 {
532            store
533                .conn()
534                .lock()
535                .unwrap()
536                .execute(
537                    "UPDATE db_agents SET name = ?1, instance_name = ?1 WHERE id = 'uid-many'",
538                    rusqlite::params![format!("Name {i}")],
539                )
540                .unwrap();
541        }
542        assert!(store.agent_def_delete("uid-many").unwrap());
543        assert!(store.agent_lan_key_load("first-name").unwrap().is_none(), "the first name, 25 renames ago");
544    }
545
546    /// Slug ownership is folded as the key tables fold (`to_lowercase`), not
547    /// with SQLite's ASCII-only `lower()`: agents with slugs `Ä` and `ä`
548    /// share the key filed under `ä`, so deleting one must leave it for the
549    /// other (Codex P2 on #3575).
550    #[test]
551    fn a_non_ascii_slug_held_by_another_agent_keeps_its_keys() {
552        let store = store_with_slugs(&[("uid-upper", "Ä", false), ("uid-lower", "ä", false)]);
553        store.agent_lan_key_ensure("ä").unwrap();
554        assert!(store.agent_def_delete("uid-lower").unwrap());
555        assert!(
556            store.agent_lan_key_load("ä").unwrap().is_some(),
557            "the live `Ä` agent's key stays"
558        );
559    }
560
561    /// The guard, through the real deletion path: a slug another row holds —
562    /// an ordinary row carrying the same slug (the old consolidation left
563    /// such duplicates), or a template, whose `agent.open` launches sign
564    /// under it — keeps its keys (adversarial review of #3575).
565    #[test]
566    fn a_slug_another_row_or_a_template_holds_keeps_its_keys() {
567        for other_is_template in [false, true] {
568            let store = store_with_slugs(&[
569                ("uid-dup-a", "claude", other_is_template),
570                ("uid-dup-b", "claude", false),
571            ]);
572            store.agent_jekt_key_ensure("claude").unwrap();
573            store.agent_lan_key_ensure("claude").unwrap();
574            assert!(store.agent_def_delete("uid-dup-b").unwrap());
575            assert!(
576                store.agent_lan_key_load("claude").unwrap().is_some()
577                    && store.agent_jekt_key_load("claude").unwrap().is_some(),
578                "template={other_is_template}"
579            );
580        }
581    }
582
583    /// Rows with exactly these slugs — forced, since insertion would
584    /// suffix a collision away.
585    fn store_with_slugs(rows: &[(&str, &str, bool)]) -> Store {
586        use crate::backend::storage::agents::test_agent_def;
587        let store = object_store();
588        for (id, slug, template) in rows {
589            let mut def = test_agent_def(id, slug, "claude", "agent", 1, "");
590            if *template {
591                def.is_seeded = 1;
592            }
593            store.agent_def_insert(&mut def).unwrap();
594            store.test_force_slug(id, slug).unwrap();
595        }
596        store
597    }
598}