Skip to main content

agentmux_srv/backend/storage/agents/
definition.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `agent_def_*`: the agent-definition catalog's CRUD on `db_agents`.
5
6use super::*;
7
8impl Store {
9    /// Fetch a single agent definition by primary key. Reads `db_agents`
10    /// directly (the same source `agent_def_list` reads, via the shared
11    /// private `agent_row_get`) — as of the definition flip this is no longer
12    /// scoped to definitions/templates only: any `db_agents` row, including
13    /// a bare template launch with no `db_agent_definitions` counterpart,
14    /// resolves here, matching what `agent_def_list` already returns for
15    /// it. Falls back to the global cross-channel registry when the row
16    /// doesn't exist locally at all.
17    ///
18    /// Used by the `template_promote` migration's deterministic-id
19    /// idempotency check (see
20    /// `migrate_promote_template_sessions_v1`): every retry asks
21    /// "does the promote-target clone for this template already
22    /// exist?" and either reuses it or inserts it.
23    pub fn agent_def_get(&self, id: &str) -> Result<Option<AgentDefinition>, StoreError> {
24        let local = self.agent_row_get(id)?;
25        if local.is_some() {
26            return Ok(local);
27        }
28        // Not in local SQLite — check the global cross-channel registry.
29        // agent_def_list() already overlays this; keep agent_def_get consistent.
30        match self.shared_def_get(id) {
31            Ok(def) => Ok(def),
32            Err(e) => {
33                tracing::warn!(agent_id = %id, error = %e, "agent_def_get: global registry lookup failed; returning not-found");
34                Ok(None)
35            }
36        }
37    }
38
39    /// `agent_def_get`, but a failed read of the global definition registry
40    /// is an error rather than "not found". For callers that must refuse
41    /// instead of acting on a partial answer — the name resolver treats "no
42    /// such UID" as "send the bare name", which a hidden fault must not
43    /// trigger (Codex P2 on #3568).
44    pub(crate) fn agent_def_get_strict(&self, id: &str) -> Result<Option<AgentDefinition>, String> {
45        if let Some(local) = self.agent_row_get(id).map_err(|e| e.to_string())? {
46            return Ok(Some(local));
47        }
48        self.shared_def_get(id)
49            .map_err(|e| format!("global definition registry: {e}"))
50    }
51
52    /// The global cross-channel definition for `id`, errors preserved.
53    fn shared_def_get(&self, id: &str) -> Result<Option<AgentDefinition>, crate::registry::DefStoreError> {
54        let Some(reg) = self.shared_def_registry() else {
55            return Ok(None);
56        };
57        Ok(reg
58            .get(id)?
59            .map(|record| super::super::def_registry_mirror::record_to_agent_definition(&record)))
60    }
61
62    /// List all agent definitions, **most-recently-used first**.
63    ///
64    /// Reads from the consolidated `db_agents` table, ordered by
65    /// `updated_at DESC` then `created_at ASC`. Every definition mutation
66    /// AND every instance lifecycle touch bumps `db_agents.updated_at`, so
67    /// recency on a row tracks the last time the agent was either edited or
68    /// launched.
69    ///
70    /// Result-set shape: every `db_agents` row is returned — templates
71    /// (`is_template = 1`) and user-clone projections (`is_template = 0`)
72    /// each appear once. `parent_id` is sourced from
73    /// `db_agents.parent_template_id`.
74    pub fn agent_def_list(&self) -> Result<Vec<AgentDefinition>, StoreError> {
75        // Local SQLite: this channel's templates (seeded) + its own user agents.
76        let local: Vec<AgentDefinition> = {
77            let conn = self.conn.lock().unwrap();
78            let mut stmt = conn.prepare(&format!("{AGENT_DEFINITION_SELECT} ORDER BY updated_at DESC, created_at ASC"))?;
79            let rows = stmt.query_map([], map_agent_definition_row)?;
80            let mut agents = Vec::new();
81            for row in rows {
82                agents.push(row?);
83            }
84            agents
85        };
86        // conn dropped. Overlay the GLOBAL cross-channel user-agent roster so
87        // an agent created in another channel appears here too. The global
88        // store wins for user rows (it's authoritative and holds cross-channel
89        // agents); templates (seeded — never in the global store) come from
90        // local SQLite. Falls back to SQLite-only when the global store is
91        // absent or unreadable. (P0.2c.)
92        let Some(reg) = self.shared_def_registry() else {
93            return Ok(local);
94        };
95        let global = match reg.list_active() {
96            Ok(recs) => recs,
97            Err(e) => {
98                tracing::warn!(error = %e, "def registry: global list failed, using SQLite only");
99                return Ok(local);
100            }
101        };
102        let mut by_id: std::collections::HashMap<String, AgentDefinition> =
103            local.into_iter().map(|d| (d.id.clone(), d)).collect();
104        for rec in &global {
105            let mut def = super::super::def_registry_mirror::record_to_agent_definition(rec);
106            // model_vendor_base_url is deliberately channel-local only —
107            // DefinitionRecordV1 doesn't carry it, so record_to_agent_definition
108            // always returns "" for it. Without this, the global overlay
109            // silently wiped a same-channel agent's override on every read
110            // (including agent.open's spawn-time resolution), making the
111            // whole feature a no-op in default single-instance operation —
112            // not just the genuinely-cross-channel case this limitation is
113            // documented for. Preserve the local row's value when one
114            // exists; only a truly cross-channel agent (no local row) sees
115            // the empty default. (reagent P0 on PR #2505.)
116            //
117            // memory_id needs the IDENTICAL treatment, for the identical
118            // reason (P0 fix, ReAgent review on PR #2587 round 6):
119            // DefinitionRecordV1 doesn't carry memory_id either (same
120            // documented gap, def_registry_mirror.rs), so
121            // record_to_agent_definition always returns "" for it too.
122            // Every local write auto-mirrors into the global registry
123            // (agent_def_insert -> registry_def_upsert), so this overlay
124            // fires for virtually every agent whenever a shared store is
125            // configured — the "normal case," not an edge case. Without
126            // this fix, agent_def_list() would silently zero memory_id on
127            // every read, defeating the bundle lookups that read it
128            // (bundle-scoped skills, the empty-provider
129            // fallback in resolve_effective_provider_id) and m0021's own
130            // memory_id-empty backfill filter (which would re-process
131            // every already-bound agent on every migration run, since it
132            // reads through this same function).
133            if let Some(existing) = by_id.get(&def.id) {
134                def.model_vendor_base_url = existing.model_vendor_base_url.clone();
135                def.memory_id = existing.memory_id.clone();
136                // conversation_visibility is the identical channel-local-only
137                // case (SPEC_MUXSPECT_CROSS_TIER_CONVERSATION_VISIBILITY_2026_08_21.md
138                // Phase B/C, jekt rules SPEC_JEKT_TRANSCRIPT_REQUEST_TIER_RULES_2026_08_22.md)
139                // — DefinitionRecordV1 doesn't carry it, so without this the
140                // global overlay would silently reset every agent's
141                // disclosure policy back to "private" on every list read,
142                // even though "private" is a safe fail-closed default (this
143                // preserves the ADMINISTRATOR'S actual configured choice,
144                // not just avoiding a crash).
145                def.conversation_visibility = existing.conversation_visibility.clone();
146            }
147            by_id.insert(def.id.clone(), def);
148        }
149        let mut result: Vec<AgentDefinition> = by_id.into_values().collect();
150        // Match the SQL ORDER BY: updated_at DESC, then created_at ASC.
151        result.sort_by(|a, b| {
152            b.updated_at
153                .cmp(&a.updated_at)
154                .then(a.created_at.cmp(&b.created_at))
155        });
156        Ok(result)
157    }
158
159    /// Count agent rows (used by seed engine to check if seeding is needed).
160    /// Reads from the consolidated `db_agents` table. The seed engine only
161    /// cares about `== 0` to decide "fresh database, seed templates".
162    pub fn agent_def_count(&self) -> Result<i64, StoreError> {
163        let conn = self.conn.lock().unwrap();
164        let count: i64 = conn.query_row(
165            "SELECT COUNT(*) FROM db_agents",
166            [],
167            |row| row.get(0),
168        )?;
169        Ok(count)
170    }
171
172    /// Whether a definition with `id` exists in the LOCAL channel's SQLite
173    /// (`db_agents`). Gates the cross-channel content/skills fallback: a
174    /// locally-known agent with genuinely empty content/skills must NOT
175    /// resurrect them from the global record. (reagent P1 on #1385.)
176    pub(in crate::backend::storage) fn agent_def_exists_local(&self, id: &str) -> Result<bool, StoreError> {
177        let conn = self.conn.lock().unwrap();
178        let exists: bool = conn.query_row(
179            "SELECT EXISTS(SELECT 1 FROM db_agents WHERE id = ?1)",
180            params![id],
181            |row| row.get(0),
182        )?;
183        Ok(exists)
184    }
185
186    /// Delete all seeded agents (is_seeded=1). Used by reseed to clear built-in agents.
187    ///
188    /// No project-instruction cleanup here, unlike `agent_def_delete`: this
189    /// removes template rows, and a template is never launched — observations
190    /// are recorded by `agent.open`, so a template has none to forget (Codex,
191    /// PR #3162).
192    pub fn agent_def_delete_seeded(&self) -> Result<usize, StoreError> {
193        // Consolidation Phase 3b (PR 2): only the template rows go. Every
194        // `is_template = 0` row — a user clone OR an agent launched from a
195        // template — is an agent in its own right and persists across a
196        // reseed, keeping its `parent_template_id` (templates have stable
197        // ids, so the lineage survives the re-insert).
198        let conn = self.conn.lock().unwrap();
199        Ok(conn.execute("DELETE FROM db_agents WHERE is_template=1", [])?)
200    }
201
202    /// Insert a new agent definition. Auto-derives slug from name if empty,
203    /// resolves collisions by appending `-2`, `-3`, etc., and mutates
204    /// `agent.slug` so the caller sees the resolved value (important
205    /// for handlers that serialize the struct back to the frontend
206    /// after insert).
207    ///
208    /// The collision check + insert run under a single mutex lock,
209    /// so this is race-safe against concurrent inserts on the same
210    /// connection.
211    pub fn agent_def_insert(&self, agent: &mut AgentDefinition) -> Result<(), StoreError> {
212        self.agent_def_insert_local_only(agent, None)?;
213        // Mirror into the global cross-channel definition store. Content +
214        // skills are inserted after the definition (separate calls), so this
215        // initial record is content-less; agent_content_set / agent_skill_*
216        // re-mirror with the full payload. (P0.2b.)
217        self.registry_def_upsert(&agent.id);
218        Ok(())
219    }
220
221    /// Insert into `db_agents` only — does NOT mirror to the global
222    /// registry. Used directly by `agent_def_insert` (which mirrors right
223    /// after) and by `agent_def_backfill_local_from_registry` (which must
224    /// NOT mirror — see that function's doc comment for why re-mirroring
225    /// immediately after this call would wipe the registry's real
226    /// content/skills).
227    ///
228    /// `updated_at_override`: `None` stamps `updated_at = created_at`
229    /// (the original, unchanged behavior for genuinely new definitions).
230    /// `Some(ts)` stamps `updated_at = ts` instead — used by the backfill
231    /// path to preserve the registry record's real `updated_at` rather
232    /// than resetting it.
233    ///
234    /// `is_seeded = 1` rows become `is_template = 1` (a template; bindings
235    /// stay empty). `is_seeded = 0` rows become `is_template = 0` with
236    /// `parent_template_id = parent_id` — same is_template/parent_template_id
237    /// derivation the old dual-write mirror used (frozen intent, no longer
238    /// a second write).
239    pub(in crate::backend::storage) fn agent_def_insert_local_only(
240        &self,
241        agent: &mut AgentDefinition,
242        updated_at_override: Option<i64>,
243    ) -> Result<(), StoreError> {
244        let conn = self.conn.lock().unwrap();
245        let base = if agent.slug.is_empty() {
246            derive_slug(&agent.name)
247        } else {
248            agent.slug.clone()
249        };
250        // Collision-resolve against every `db_agents` slug — the consolidated
251        // table surfaces definition slugs and template-instance projections
252        // alike, so a collision against an instance-derived row is caught here
253        // too. Shared with `instance_create` so the two cannot drift.
254        agent.slug = resolve_slug_collision(&conn, &base)?;
255        let stamped_updated_at = updated_at_override.unwrap_or(agent.created_at);
256        let is_template = if agent.is_seeded == 1 { 1_i64 } else { 0_i64 };
257        let parent_template_id = if agent.is_seeded == 1 { String::new() } else { agent.parent_id.clone() };
258        conn.execute(
259            "INSERT INTO db_agents (
260                id, name, icon, description,
261                is_template, parent_template_id,
262                provider, provider_flags, shell, environment,
263                agent_type, agent_bus_id, accounts,
264                auto_start, restart_on_crash, idle_timeout_minutes,
265                slug, branch_label, working_directory,
266                created_at, updated_at, is_seeded, user_hidden,
267                container_image, container_volumes, container_name,
268                use_ambient_login, model_vendor_base_url, auto_continue_enabled,
269                default_memory_id, conversation_visibility
270             ) VALUES (
271                ?1, ?2, ?3, ?4,
272                ?5, ?6,
273                ?7, ?8, ?9, ?10,
274                ?11, ?12, ?13,
275                ?14, ?15, ?16,
276                ?17, ?18, ?19,
277                ?20, ?21, ?22, ?23,
278                ?24, ?25, ?26,
279                ?27, ?28, ?29,
280                ?30, ?31
281             )",
282            params![
283                agent.id,
284                agent.name,
285                agent.icon,
286                agent.description,
287                is_template,
288                parent_template_id,
289                agent.provider,
290                agent.provider_flags,
291                agent.shell,
292                agent.environment,
293                agent.agent_type,
294                agent.agent_bus_id,
295                agent.accounts,
296                agent.auto_start,
297                agent.restart_on_crash,
298                agent.idle_timeout_minutes,
299                agent.slug,
300                agent.branch_label,
301                agent.working_directory,
302                agent.created_at,
303                // New definitions: updated_at == created_at, unless the
304                // caller supplied an override (backfill path preserving
305                // the registry record's real updated_at).
306                stamped_updated_at,
307                agent.is_seeded,
308                // Phase 2 (hide templates): new rows start visible. The
309                // user only hides via the explicit `agentdefhide` RPC,
310                // and the agent-seed re-sync forces user_hidden = 0 on
311                // any newly-added template id anyway, so honouring the
312                // caller-supplied value here is safe even when a stray
313                // 1 sneaks through.
314                agent.user_hidden,
315                agent.container_image,
316                agent.container_volumes,
317                agent.container_name,
318                agent.use_ambient_login,
319                agent.model_vendor_base_url,
320                agent.auto_continue_enabled,
321                agent.memory_id,
322                agent.conversation_visibility,
323            ],
324        )?;
325        // Reagent P1 on #1013 round 2: do NOT mutate the caller's
326        // `&mut AgentDefinition` here. The PR is supposed to be
327        // zero-behaviour-change; the previous version reflected
328        // `stamped_updated_at` back onto the caller, which is an
329        // observable mutation downstream callers may rely on remaining
330        // untouched. Callers that need the freshly-stamped value can
331        // re-fetch the row via the normal read path.
332        Ok(())
333    }
334
335    /// Materialize a local shadow of a registry-only definition — this
336    /// channel's `db_agents` row plus a best-effort local copy of
337    /// content/skills — so `instance_create`'s FK can succeed for an agent
338    /// whose definition exists cross-channel but was never created in THIS
339    /// channel's SQLite.
340    ///
341    /// Deliberately does NOT call `registry_def_upsert` (unlike
342    /// `agent_def_insert`, which does): the registry is already
343    /// authoritative for this record. `registry_def_upsert` rebuilds
344    /// the registry record from `agent_content_get_all_local`/
345    /// `agent_skill_list_local` — LOCAL-only reads, by design (see that
346    /// function's own comment) — so calling it immediately after this
347    /// insert-with-no-content-yet would overwrite the registry's real
348    /// content/skills with empty arrays. Content/skill rows are instead
349    /// copied here via direct INSERTs (bypassing `agent_content_set`/
350    /// `agent_skill_insert`, which each call `registry_def_refresh_if_mirrored`
351    /// themselves) so nothing re-mirrors mid-backfill.
352    ///
353    /// Copy failures for content/skills are logged and otherwise
354    /// ignored — the definition row alone satisfies the FK, and reads
355    /// still resolve content/skills correctly via the existing
356    /// cross-channel fallback even if this best-effort local copy is
357    /// incomplete. A slug collision against an unrelated local agent
358    /// will rename the local slug (existing `agent_def_insert_local_only`
359    /// behavior) — low-probability and non-fatal, since slug isn't the
360    /// FK target.
361    pub(in crate::backend::storage) fn agent_def_backfill_local_from_registry(
362        &self,
363        record: &crate::registry::DefinitionRecord,
364    ) -> Result<(), StoreError> {
365        let mut def = super::super::def_registry_mirror::record_to_agent_definition(record);
366        self.agent_def_insert_local_only(&mut def, Some(record.data.updated_at))?;
367
368        let conn = self.conn.lock().unwrap();
369        for c in &record.data.content {
370            if let Err(e) = conn.execute(
371                "INSERT INTO db_agent_content (agent_id, content_type, content, updated_at)
372                 VALUES (?1, ?2, ?3, ?4)
373                 ON CONFLICT(agent_id, content_type) DO UPDATE SET content=?3, updated_at=?4",
374                params![def.id, c.content_type, c.content, record.data.updated_at],
375            ) {
376                tracing::warn!(
377                    def_id = %def.id,
378                    content_type = %c.content_type,
379                    error = %e,
380                    "instance_create backfill: local content copy failed (non-fatal)"
381                );
382            }
383        }
384        for s in &record.data.skills {
385            if let Err(e) = conn.execute(
386                "INSERT INTO db_agent_skills (id, agent_id, name, trigger, skill_type, description, content, created_at)
387                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
388                params![
389                    s.id,
390                    def.id,
391                    s.name,
392                    s.trigger,
393                    s.skill_type,
394                    s.description,
395                    s.content,
396                    record.data.updated_at,
397                ],
398            ) {
399                tracing::warn!(
400                    def_id = %def.id,
401                    skill = %s.name,
402                    error = %e,
403                    "instance_create backfill: local skill copy failed (non-fatal)"
404                );
405            }
406        }
407        Ok(())
408    }
409
410    /// Atomic check-then-insert for `agent.define`.
411    ///
412    /// Looks up an existing user-owned definition by name (case-insensitive)
413    /// or derived slug under the SAME mutex guard that protects the INSERT —
414    /// preventing TOCTOU when two concurrent `agent.define` calls arrive for
415    /// the same name.
416    ///
417    /// Returns:
418    /// - `Ok(Some(def))` — an existing row matched; `agent` was NOT inserted.
419    /// - `Ok(None)` — no match; `agent` was inserted and `agent.slug` now
420    ///   holds the collision-resolved slug.
421    pub fn agent_def_find_or_insert(
422        &self,
423        agent: &mut AgentDefinition,
424    ) -> Result<Option<AgentDefinition>, StoreError> {
425        let name_lower = agent.name.trim().to_lowercase();
426        let derived_slug = derive_slug(agent.name.trim());
427
428        {
429            let conn = self.conn.lock().unwrap();
430
431            // Check under the same lock to close the TOCTOU window.
432            // `is_seeded = 0` already excludes templates; it does NOT
433            // exclude a template-launch row (also `is_seeded = 0`, no
434            // `db_agent_definitions` counterpart) — same as before this
435            // read moved to `db_agents`, since the legacy table never held
436            // launch rows to exclude in the first place.
437            let mut stmt = conn.prepare(&format!(
438                "{AGENT_DEFINITION_SELECT}
439                 WHERE (lower(trim(name)) = ?1 OR slug = ?2)
440                   AND is_seeded = 0
441                 ORDER BY CASE WHEN lower(trim(name)) = ?1 THEN 0 ELSE 1 END
442                 LIMIT 1"
443            ))?;
444            let mut rows = stmt.query_map(
445                params![name_lower, derived_slug],
446                map_agent_definition_row,
447            )?;
448            if let Some(row) = rows.next() {
449                return Ok(Some(row?));
450            }
451            // Drop borrows on `conn` before proceeding to the insert.
452            drop(rows);
453            drop(stmt);
454
455            // Not found — insert under the same lock.
456            let base = if agent.slug.is_empty() {
457                derive_slug(&agent.name)
458            } else {
459                agent.slug.clone()
460            };
461            let mut candidate = base.clone();
462            let mut n: u32 = 2;
463            loop {
464                let count: i64 = conn.query_row(
465                    "SELECT COUNT(*) FROM db_agents WHERE slug = ?1",
466                    params![candidate],
467                    |row| row.get(0),
468                )?;
469                if count == 0 {
470                    break;
471                }
472                candidate = format!("{}-{}", base, n);
473                n += 1;
474            }
475            agent.slug = candidate;
476            let is_template = if agent.is_seeded == 1 { 1_i64 } else { 0_i64 };
477            let parent_template_id = if agent.is_seeded == 1 { String::new() } else { agent.parent_id.clone() };
478            conn.execute(
479                "INSERT INTO db_agents (
480                    id, name, icon, description,
481                    is_template, parent_template_id,
482                    provider, provider_flags, shell, environment,
483                    agent_type, agent_bus_id, accounts,
484                    auto_start, restart_on_crash, idle_timeout_minutes,
485                    slug, branch_label, working_directory,
486                    created_at, updated_at, is_seeded, user_hidden,
487                    container_image, container_volumes, container_name,
488                    use_ambient_login, model_vendor_base_url, auto_continue_enabled,
489                    default_memory_id, conversation_visibility
490                 ) VALUES (
491                    ?1, ?2, ?3, ?4,
492                    ?5, ?6,
493                    ?7, ?8, ?9, ?10,
494                    ?11, ?12, ?13,
495                    ?14, ?15, ?16,
496                    ?17, ?18, ?19,
497                    ?20, ?21, ?22, ?23,
498                    ?24, ?25, ?26,
499                    ?27, ?28, ?29,
500                    ?30, ?31
501                 )",
502                params![
503                    agent.id,
504                    agent.name,
505                    agent.icon,
506                    agent.description,
507                    is_template,
508                    parent_template_id,
509                    agent.provider,
510                    agent.provider_flags,
511                    agent.shell,
512                    agent.environment,
513                    agent.agent_type,
514                    agent.agent_bus_id,
515                    agent.accounts,
516                    agent.auto_start,
517                    agent.restart_on_crash,
518                    agent.idle_timeout_minutes,
519                    agent.slug,
520                    agent.branch_label,
521                    agent.working_directory,
522                    agent.created_at,
523                    agent.created_at, // updated_at = created_at for new rows
524                    agent.is_seeded,
525                    agent.user_hidden,
526                    agent.container_image,
527                    agent.container_volumes,
528                    agent.container_name,
529                    agent.use_ambient_login,
530                    agent.model_vendor_base_url,
531                    agent.auto_continue_enabled,
532                    agent.memory_id,
533                    agent.conversation_visibility,
534                ],
535            )?;
536        }
537        // Do NOT mutate `agent.updated_at` here — matches
538        // `agent_def_insert_local_only`'s own "zero-behaviour-change"
539        // posture (reagent P1 on #1013 round 2): callers that need the
540        // freshly-stamped value re-fetch the row via the normal read path.
541        // Mirror the freshly-defined agent into the global store (agent.define
542        // path). Without this a define-created agent stays channel-local until
543        // a later edit. (codex P2 on #1385.)
544        self.registry_def_upsert(&agent.id);
545
546        Ok(None)
547    }
548
549    /// Set the `user_hidden` flag on a single agent definition. Phase 2
550    /// of the two-tier picker (Q2 Decision Y). Returns:
551    ///   `Ok(true)`  — row updated.
552    ///   `Ok(false)` — no row with that id exists.
553    ///   `Err(...)`  — the row exists but is NOT a seeded template
554    ///                 (`is_seeded != 1`). User-owned definitions go
555    ///                 through `agent_def_delete`, not hide.
556    ///
557    /// Does NOT bump `updated_at`: hide is a per-user view-state flag,
558    /// not a definition-content edit. Keeps `updated_at` faithful to the
559    /// agent's payload (the manifest re-sync compares `description` etc.
560    /// against the canonical row).
561    pub fn agent_def_set_hidden(&self, id: &str, hidden: bool) -> Result<bool, StoreError> {
562        let conn = self.conn.lock().unwrap();
563        // Only seeded templates (`is_template = 1`) may flip the hide flag.
564        // Templates carry `is_template = 1` and are the only rows allowed
565        // to flip the hide flag; folded user-clone-def projections and
566        // template-instance projections (both `is_template = 0`) reject.
567        let is_template: i64 = match conn.query_row(
568            "SELECT is_template FROM db_agents WHERE id = ?1",
569            params![id],
570            |row| row.get(0),
571        ) {
572            Ok(v) => v,
573            Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(false),
574            Err(e) => return Err(StoreError::Sqlite(e)),
575        };
576        if is_template != 1 {
577            return Err(StoreError::Other(format!(
578                "agent_def_set_hidden: {id} is not a seeded template (is_template={is_template}); \
579                 user-owned definitions must use delete/archive paths, not hide"
580            )));
581        }
582        // Templates are seeded; they do NOT go to the global cross-channel
583        // def store, so no registry mirror here.
584        let rows = conn.execute(
585            "UPDATE db_agents SET user_hidden = ?1 WHERE id = ?2 AND is_template = 1",
586            params![if hidden { 1_i64 } else { 0_i64 }, id],
587        )?;
588        Ok(rows > 0)
589    }
590
591    /// Whether this channel's store has its own row for definition `id`, as
592    /// opposed to the definition only resolving through the global registry
593    /// overlay. Only a local row can have its `default_memory_id` set.
594    pub fn agent_def_has_local_row(&self, id: &str) -> Result<bool, StoreError> {
595        let conn = self.conn.lock().unwrap();
596        let found: bool = conn.query_row(
597            "SELECT EXISTS(SELECT 1 FROM db_agents WHERE id = ?1)",
598            params![id],
599            |r| r.get(0),
600        )?;
601        Ok(found)
602    }
603
604    /// Set `db_agents.default_memory_id` on a LOCAL agent — but only if
605    /// it's currently empty. Exists because `agent_def_update`'s SET clause
606    /// deliberately never touches this column (readonly-after-creation, see
607    /// its own field doc comment) — this is the one narrow, explicit path
608    /// allowed to set it, and only the FIRST time, for `m0021`'s backfill
609    /// and definition-time provisioning to use. Returns `Ok(true)` if the
610    /// row existed and had an empty `default_memory_id` (write applied),
611    /// `Ok(false)` if the row doesn't exist locally OR already has a
612    /// non-empty value (no-op, not an error — matches the "set once"
613    /// semantic silently rather than requiring every caller to pre-check).
614    ///
615    /// LOCAL ONLY, deliberately — a cross-channel (global-registry-only)
616    /// definition can't be reached this way today because
617    /// `DefinitionRecordV1` doesn't carry `memory_id` yet (same accepted gap
618    /// as `model_vendor_base_url`, see `def_registry_mirror.rs`). Callers
619    /// backfilling across the whole agent population must check
620    /// `agent_def_get` first and skip (with a log) anything that only
621    /// resolves via the global registry.
622    pub fn agent_def_set_memory_id_if_empty(
623        &self,
624        id: &str,
625        memory_id: &str,
626    ) -> Result<bool, StoreError> {
627        let conn = self.conn.lock().unwrap();
628        let rows = conn.execute(
629            "UPDATE db_agents SET default_memory_id = ?1 WHERE id = ?2 AND default_memory_id = ''",
630            params![memory_id, id],
631        )?;
632        Ok(rows > 0)
633    }
634
635    /// The provider's default vendor, or `"custom"` when the agent has a
636    /// non-empty `model_vendor_base_url` override — same rule the
637    /// frontend's `resolveEffectiveVendor`
638    /// (`frontend/app/view/agent/providers/catalog.ts`) already uses for
639    /// the dual-icon vendor badge. Falls back to the provider id itself
640    /// if the provider isn't in the registry (e.g. a stale/custom
641    /// provider string), same fallback the frontend uses.
642    ///
643    /// Derived wherever it's needed rather than stored: a Claude agent
644    /// pointed at a custom endpoint is `"custom"`, not `"anthropic"`
645    /// (#2587).
646    pub(crate) fn resolve_effective_vendor(provider: &str, model_vendor_base_url: &str) -> String {
647        if !model_vendor_base_url.trim().is_empty() {
648            return "custom".to_string();
649        }
650        crate::backend::providers::get_provider(provider)
651            .and_then(|p| p.supported_vendors.first().copied())
652            .unwrap_or(provider)
653            .to_string()
654    }
655
656    /// The harness/provider id `agent` runs: its own `provider`. A bundle
657    /// carries no harness (SPEC_AGENT_BUNDLE_FORMAT_V0_3_2026_10_05.md §3.1);
658    /// the agent owns it, read-only after creation (`updateagent` and
659    /// `agent.define` refuse a change). The bound bundle's `provider` is a
660    /// hint and is consulted only when the agent has none at all, a
661    /// definition the §3.3 migration couldn't reach; there it is the value
662    /// the agent has always run with.
663    ///
664    /// **Call this on the EFFECTIVE identity/memory store
665    /// (`AppState.id_store`), never on the per-channel `mstore`** — that
666    /// fallback reads the bundle, which lives in the shared store when one is
667    /// configured. One shared implementation for spawn (`agent_open.rs`),
668    /// the credential gate (`identity/resolver/inject.rs`), clone, fork and
669    /// export, so they can't disagree.
670    pub fn resolve_effective_provider_id(&self, agent: &AgentDefinition) -> String {
671        if !agent.provider.is_empty() || agent.memory_id.is_empty() {
672            return agent.provider.clone();
673        }
674        match self.bundle_get(&agent.memory_id) {
675            Ok(Some(b)) => b.provider,
676            _ => String::new(),
677        }
678    }
679
680    /// Provision a fresh, dedicated ABF bundle for a NEW agent definition —
681    /// the definition-time half of
682    /// `docs/specs/ARCHITECTURE_MANDATORY_ABF_RETHINK_2026_08_14.md` §3.2
683    /// (m0021 is the backfill half, for agents that already existed before
684    /// this shipped). The bundle carries no harness: the agent owns its
685    /// provider (SPEC_AGENT_BUNDLE_FORMAT_V0_3_2026_10_05.md §3.2); the agent
686    /// is passed for the bundle's name.
687    ///
688    /// **Call this on the EFFECTIVE identity/memory store
689    /// (`AppState.id_store`), never on the per-channel `mstore` directly.**
690    /// Bundles live in the shared store when one is configured (the normal
691    /// case) — every real bundle-read path (`listmemories`/`getmemory`/
692    /// the Memory editor/the bundle-summary panel) reads through
693    /// `id_store`, so a bundle created via `mstore.bundle_upsert`
694    /// directly would be written to a different SQLite file and be
695    /// invisible everywhere else (P1 finding, Codex review on PR #2587,
696    /// live in the shipped code this fixes: `agent_def_provision_and_
697    /// bind_bundle`'s callers all used to invoke this method ON `mstore`).
698    ///
699    /// Does NOT mutate `agent` or touch `db_agent_definitions` — returns
700    /// the new bundle's id; callers set `agent.memory_id` themselves before
701    /// their own insert (so the existing `agent_def_insert*` INSERT
702    /// statements, which already include `memory_id`, pick it up with no
703    /// separate write).
704    pub fn bundle_provision_for_new_agent(
705        &self,
706        agent: &AgentDefinition,
707        now: i64,
708    ) -> Result<String, StoreError> {
709        let bundle_id = uuid::Uuid::new_v4().to_string();
710        let name = self.resolve_unique_bundle_name(&format!("{} — ABF", agent.name))?;
711        let bundle = super::super::bundles::Bundle {
712            id: bundle_id.clone(),
713            name,
714            description: String::new(),
715            is_blank: false,
716            is_global: false,
717            // No harness: the agent owns it (SPEC_AGENT_BUNDLE_FORMAT_V0_3_2026_10_05.md §3.2).
718            provider: String::new(),
719            model: String::new(),
720            instructions: String::new(),
721            instructions_by_provider: "{}".to_string(),
722            context_files: "[]".to_string(),
723            mcp_servers: "[]".to_string(),
724            skills: "[]".to_string(),
725            sort_order: 0,
726            created_at: now,
727            updated_at: now,
728            is_system: false,
729        };
730        self.bundle_upsert(&bundle)?;
731        Ok(bundle_id)
732    }
733
734    /// Resolve a `db_bundles.name` guaranteed not to collide with an
735    /// existing row — that column is `TEXT NOT NULL UNIQUE`, but only
736    /// `AgentDefinition.slug` is guaranteed unique, not the display
737    /// `name`, so a naive `"{agent.name} — ABF"` collides whenever two
738    /// agents share a display name. Appends a numeric suffix on
739    /// collision, same shape `agent_def_insert_local_only` already uses
740    /// for slug collisions.
741    ///
742    /// P1 fix (2026-08-15, ReAgent review on PR #2587 round 4): before
743    /// this, a collision here silently left a runtime-provisioned agent
744    /// unbound (the caller's best-effort error handling swallowed it) and
745    /// — worse — unconditionally ABORTED `m0021`'s entire backfill loop
746    /// via `?` on the very first collision, permanently stalling backfill
747    /// for every agent processed after it, on every subsequent boot,
748    /// until the underlying name collision was manually resolved.
749    pub(crate) fn resolve_unique_bundle_name(&self, base_name: &str) -> Result<String, StoreError> {
750        let existing_names: std::collections::HashSet<String> =
751            self.bundle_list()?.into_iter().map(|b| b.name).collect();
752        if !existing_names.contains(base_name) {
753            return Ok(base_name.to_string());
754        }
755        let mut n: u32 = 2;
756        loop {
757            let candidate = format!("{base_name} ({n})");
758            if !existing_names.contains(&candidate) {
759                return Ok(candidate);
760            }
761            n += 1;
762        }
763    }
764
765    /// Provision + bind a fresh bundle to an ALREADY-INSERTED agent
766    /// definition — the two-step, post-insert counterpart to
767    /// `bundle_provision_for_new_agent` above. Deliberately separate from
768    /// the insert itself (rather than setting `memory_id` on the struct
769    /// before calling `agent_def_insert*`/`agent_def_find_or_insert`):
770    /// `agent_def_find_or_insert` in particular uses its `AgentDefinition`
771    /// argument as BOTH the lookup key and the conditional-insert payload,
772    /// so a bundle built up-front would go to waste (leaked, unbound) on
773    /// every `if_exists=skip`/`update` call against an already-existing
774    /// name — and `agent.define` is meant to be called repeatedly/
775    /// idempotently. Binding after the fact, only once the caller has
776    /// confirmed a row was genuinely freshly inserted, avoids that leak.
777    ///
778    /// Best-effort by design (matches `createagent`'s own color-assignment
779    /// comment: a failure here shouldn't fail agent creation) — logs and
780    /// returns on either step's failure rather than propagating, since the
781    /// agent row itself is already durably committed by the time this
782    /// runs, and a still-unbound agent is exactly the pre-existing
783    /// `memory_id=''` state `m0021` already knows how to backfill later.
784    ///
785    /// Takes `&mut AgentDefinition` and writes the new bundle id back into
786    /// `agent.memory_id` on success — same "caller's struct reflects what
787    /// landed" convention `agent_def_update` documents just below — so RPC
788    /// handlers that serialize `agent` straight back to the caller (e.g.
789    /// `createagent`, `importagentfromclaw`) return the real value instead
790    /// of the empty string the struct held before this call.
791    ///
792    /// `self` (the definition store, i.e. `mstore`) and `bundle_store`
793    /// (the effective identity/memory store, i.e. `AppState.id_store`) are
794    /// deliberately two SEPARATE parameters, not the same store used for
795    /// both writes — see `bundle_provision_for_new_agent`'s own doc
796    /// comment for why (P1 fix, Codex review on PR #2587: they used to be
797    /// the same store, silently writing every provisioned bundle
798    /// somewhere the rest of the app can't see it).
799    pub fn agent_def_provision_and_bind_bundle(
800        &self,
801        bundle_store: &Store,
802        agent: &mut AgentDefinition,
803        now: i64,
804    ) {
805        let bundle_id = match bundle_store.bundle_provision_for_new_agent(agent, now) {
806            Ok(id) => id,
807            Err(e) => {
808                tracing::warn!(agent_id = %agent.id, error = %e, "agent_def_provision_and_bind_bundle: bundle create failed (non-fatal)");
809                return;
810            }
811        };
812        match self.agent_def_set_memory_id_if_empty(&agent.id, &bundle_id) {
813            Ok(true) => {
814                // So the agent's other channels bind this bundle instead of
815                // minting their own (m0021).
816                crate::backend::agent_bundle_sidecar::record(&agent.id, &bundle_id);
817                agent.memory_id = bundle_id
818            }
819            Ok(false) => {}
820            Err(e) => {
821                tracing::warn!(agent_id = %agent.id, bundle_id = %bundle_id, error = %e, "agent_def_provision_and_bind_bundle: bind failed (non-fatal)");
822            }
823        }
824    }
825
826    /// Update an existing agent definition (all fields except id, created_at, is_seeded, `parent_id`).
827    /// `parent_id` is NOT updatable post-insert — it describes the agent's
828    /// lineage; re-parenting is done by creating a new fork, not mutating the
829    /// original.
830    ///
831    /// `branch_label` IS written here (unlike `parent_id`) — this storage-
832    /// layer function persists whatever is on `agent`. Immutability for most
833    /// callers is enforced one layer up, at the RPC handler: `updateagent`
834    /// always passes back `old.branch_label.clone()` unchanged, so it's a
835    /// no-op for that caller. `renameagentdefinitiontitle` is the one
836    /// deliberate exception that supplies a real change — see
837    /// `crates/srv/src/server/agent_handlers/template.rs` and
838    /// docs/specs/SPEC_PANE_TAB_STRIP_COMPACT_SIZING_AND_RENAME_2026_07_22.md §4.
839    ///
840    /// Self-stamps `updated_at` with the current time and writes it back into
841    /// `agent.updated_at`, so the caller's struct (e.g. an RPC response body)
842    /// reflects exactly what landed in the database.
843    pub fn agent_def_update(&self, agent: &mut AgentDefinition) -> Result<bool, StoreError> {
844        let now = agentmux_common::time::now_ms();
845        let rows = {
846            let conn = self.conn.lock().unwrap();
847            conn.execute(
848                "UPDATE db_agents SET name=?1, icon=?2, provider=?3, description=?4,
849                 working_directory=?5, shell=?6, provider_flags=?7, auto_start=?8,
850                 restart_on_crash=?9, idle_timeout_minutes=?10,
851                 agent_type=?11, environment=?12, agent_bus_id=?13, accounts=?14, updated_at=?15,
852                 container_image=?17, container_volumes=?18, container_name=?19,
853                 use_ambient_login=?20, branch_label=?21, model_vendor_base_url=?22,
854                 auto_continue_enabled=?23, conversation_visibility=?24
855                 WHERE id=?16",
856                params![
857                    agent.name,
858                    agent.icon,
859                    agent.provider,
860                    agent.description,
861                    agent.working_directory,
862                    agent.shell,
863                    agent.provider_flags,
864                    agent.auto_start,
865                    agent.restart_on_crash,
866                    agent.idle_timeout_minutes,
867                    agent.agent_type,
868                    agent.environment,
869                    agent.agent_bus_id,
870                    agent.accounts,
871                    now,
872                    agent.id,
873                    agent.container_image,
874                    agent.container_volumes,
875                    agent.container_name,
876                    agent.use_ambient_login,
877                    agent.branch_label,
878                    agent.model_vendor_base_url,
879                    agent.auto_continue_enabled,
880                    agent.conversation_visibility,
881                ],
882            )?
883        };
884        // Reflect the persisted timestamp back to the caller's struct so an
885        // RPC response carries the fresh value, not the pre-update one.
886        agent.updated_at = now;
887        if rows > 0 {
888            // Mirror the updated definition into the global store. (P0.2b.)
889            self.registry_def_upsert(&agent.id);
890        }
891        // Cross-channel edit: an agent surfaced only via the global overlay has
892        // no local SQLite row, so the UPDATE affected 0 rows. Apply the edit to
893        // the global record directly (preserving its content/skills) so editing
894        // a cross-channel agent isn't silently dropped with a "not found" error
895        // — symmetric with the unconditional cross-channel delete. (reagent P1
896        // on #1385.)
897        let updated_global = if rows == 0 {
898            self.registry_def_update_definition_fields(agent)
899        } else {
900            false
901        };
902        Ok(rows > 0 || updated_global)
903    }
904
905    /// Delete a agent definition by id. Returns true if a row was deleted.
906    pub fn agent_def_delete(&self, id: &str) -> Result<bool, StoreError> {
907        // Consolidation Phase 3b (PR 2): a user agent launched from this
908        // template keeps its own `db_agents` row (it is an agent, not a
909        // launch of the template), the same way user clones always survived
910        // deleting their template — deleting `id` here removes only its own
911        // row. The JSON registry mirror for the deleted row is covered by
912        // `registry_def_retire` below.
913        let (rows, scope) = {
914            let conn = self.conn.lock().unwrap();
915            // Read BEFORE the DELETE — afterwards there is no row to ask.
916            // See `scope_for_row` for what this decides and why.
917            let scope = Self::scope_for_row(&conn, id);
918            // Identity M4a: also read before the DELETE.
919            tombstone_key_names(&conn, id)?;
920            purge_name_keyed_keys(&conn, id, self.wan_identity().as_deref())?;
921            let rows = conn.execute("DELETE FROM db_agents WHERE id=?1", params![id])?;
922            // Unconditional, same convergence rule as the registry sweep
923            // below (codex P2 on PR #3262): if a previous attempt committed
924            // the `db_agents` DELETE and then failed partway through the
925            // dependent purge, the retry sees `rows == 0` — gating here
926            // would strand that agent's credentials and signing keys
927            // permanently, with no UI path left to try again. Every
928            // statement in the purge is an idempotent DELETE, so running it
929            // for an id with no rows costs a few no-op statements.
930            purge_agent_dependents(&conn, id, self.token_index().as_deref())?;
931            (rows, scope)
932        };
933        // Tombstone the global definition record so another channel's stale
934        // SQLite can't resurrect this deleted user agent — AND so an agent
935        // that exists in THIS channel only via the global overlay (no local
936        // SQLite row, rows == 0) is actually deletable instead of reappearing
937        // on the next agent_def_list. (P0.2b + codex P1 on #1385.)
938        let global_retired = self.registry_def_retire(id);
939        // Unconditional, deliberately.
940        //
941        // Gating this on `rows > 0` was the original bug's second half: the
942        // instance registry is host-global while `db_agents` is
943        // per-channel, so a cross-channel agent — one this channel only ever
944        // saw through the global overlay — deletes with `rows == 0` and
945        // still has a registry record another channel wrote. That record
946        // stayed active with its definition now tombstoned, which
947        // `listrecentsessions` still renders as a row (no provider, so no
948        // icon, and "(missing definition)") — the delete looked like it
949        // half-worked.
950        //
951        // `rows > 0 || global_retired` isn't enough either (codex P2 on
952        // PR #3262): if an earlier attempt retired the definition but failed
953        // to remove a registry file — or another channel wrote a stale
954        // record after this process's startup reconcile — a RETRY sees
955        // `rows == 0` AND `global_retired == false` (the tombstone already
956        // exists), so it would skip the sweep and the ghost row would
957        // survive every further Delete until restart. Sweeping
958        // unconditionally makes Delete converge: the sweep is a no-op for an
959        // id with no records, so there is nothing to gate on in the first
960        // place.
961        self.purge_agent_side_effects(id, "agent_def_delete", scope);
962        Ok(rows > 0 || global_retired)
963    }
964
965    /// The [`RecordScope`] every registry operation on `id` must use.
966    ///
967    /// **Read this before adding another registry call.** The wide scope
968    /// matches records by `definition_id`, and a TEMPLATE's id is the
969    /// `definition_id` of every legacy, never-re-keyed launch record
970    /// belonging to a REAL agent launched from it — so a wide match on a
971    /// template deletes / retires / renames those agents' records along
972    /// with the template, making them vanish from the picker. ReAgent found
973    /// exactly that omission at four separate call sites on PR #3262
974    /// (`agent_def_delete`, `instance_delete`, `instance_set_hidden`,
975    /// `instance_rename`), which is why the rule lives here rather than as
976    /// a fourth hand-written `if`.
977    ///
978    /// An id with no row at all is NOT a template: the global definition
979    /// store only holds `is_seeded == 0` user agents, so a cross-channel id
980    /// is always an agent — which is what lets the delete paths still sweep
981    /// on a retry after the local row is already gone.
982    ///
983    /// Takes the connection rather than `&self` because every caller needs
984    /// this read in the SAME critical section as its own UPDATE/DELETE —
985    /// otherwise the row could change kind in between. A `&self` wrapper
986    /// existed briefly and was dead on arrival for exactly that reason
987    /// (ReAgent P2 on PR #3262).
988    pub(in crate::backend::storage) fn scope_for_row(conn: &rusqlite::Connection, id: &str) -> RecordScope {
989        let is_template = conn
990            .query_row(
991                "SELECT is_template FROM db_agents WHERE id = ?1",
992                params![id],
993                |r| r.get::<_, i64>(0),
994            )
995            .map(|v| v != 0)
996            .unwrap_or(false);
997        if is_template {
998            RecordScope::FileKeyOnly
999        } else {
1000            RecordScope::Agent
1001        }
1002    }
1003
1004    /// Purge this store's rows keyed to a deleted agent, without touching
1005    /// `db_agents` itself. For the OTHER physical database an agent has
1006    /// rows in: `agent_def_delete` covers the object store it runs against,
1007    /// and the `deleteagent` handler calls this on `state.identity_store`,
1008    /// which is where the live `db_agent_identity_links` /
1009    /// `db_agent_credentials` / native-memory rows actually are
1010    /// (`SPEC_IDENTITY_STORE_SPLIT_2026_08_17.md`). See
1011    /// [`purge_agent_dependents`] for the table list and why one list
1012    /// serves both stores. Returns rows removed.
1013    pub fn agent_dependents_purge(&self, id: &str) -> Result<usize, StoreError> {
1014        let conn = self.conn.lock().unwrap();
1015        purge_agent_dependents(&conn, id, self.token_index().as_deref())
1016    }
1017
1018    /// The non-SQLite half of deleting an agent row: observations keyed to
1019    /// it, and its records in the host-global instance registry. Shared by
1020    /// [`Self::agent_def_delete`] and [`Self::instance_delete`] — the two
1021    /// names one deletion is reached by since the definition flip (see
1022    /// `instance_delete`'s own doc comment). Best-effort throughout:
1023    /// SQLite has already committed, and neither side failing is a reason
1024    /// to report the delete as failed. `caller` only labels the logs.
1025    ///
1026    /// `scope` must come from `scope_for_row`, read
1027    /// BEFORE the row was deleted — afterwards there is nothing left to ask.
1028    pub(in crate::backend::storage) fn purge_agent_side_effects(&self, id: &str, caller: &'static str, scope: RecordScope) {
1029        // Project-instruction observations are keyed by agent id with no
1030        // foreign key (they record files this agent READS, which is not a
1031        // relationship SQLite can enforce), so nothing else would remove
1032        // them and a future agent reusing this id would inherit somebody
1033        // else's baseline — every file reporting `unchanged` against
1034        // observations that were never made about it. Cleaned up here,
1035        // beside the row it belongs to, rather than left to each caller
1036        // (ReAgent, PR #3162).
1037        if let Err(e) = self.project_instructions_forget(id) {
1038            tracing::warn!(
1039                agent_def_id = %id, caller, error = %e,
1040                "agent delete: project-instruction observations left behind"
1041            );
1042        }
1043        if let Some(reg) = self.registry() {
1044            // An AGENT's records are matched by either key — see
1045            // `Registry::hard_delete_for_agent` for why the file key and the
1046            // record's own `definition_id` can disagree, and what a missed
1047            // record looks like in the picker.
1048            //
1049            // A TEMPLATE's are matched by file key only. A template's id can
1050            // legitimately be the `definition_id` of a legacy,
1051            // never-re-keyed launch record belonging to a real agent
1052            // launched from it, and the wide match would delete that agent's
1053            // record along with the template — making agents disappear from
1054            // the picker instead of the one thing the user asked to delete
1055            // (ReAgent P1 round 3 on PR #3262;
1056            // `agent_def_delete_removes_only_its_own_registry_file` is the
1057            // protection, and
1058            // `deleting_a_template_spares_a_legacy_launch_record_pointing_at_it`
1059            // pins the legacy shape that test doesn't construct).
1060            match reg.hard_delete_for_agent(id, scope) {
1061                Ok(0) => {}
1062                Ok(removed) => tracing::debug!(
1063                    agent_def_id = %id, caller, removed,
1064                    "registry: purged instance records for deleted agent"
1065                ),
1066                Err(e) => tracing::warn!(
1067                    agent_def_id = %id, caller, error = %e,
1068                    "registry: failed to mirror agent delete"
1069                ),
1070            }
1071        }
1072    }
1073
1074    /// One-shot grandfather pass for the layer-3 ambient-login opt-in
1075    /// (m0017, spec §2.4 of SPEC_ACCOUNT_DELETE_DEAUTH_LAYERS_2_4_2026_07_14.md).
1076    ///
1077    /// Agents WITHOUT any `db_agent_identity_links` row at migration time
1078    /// were de-facto ambient users → `use_ambient_login = 1`; agents WITH
1079    /// links opted into managed accounts → `0` (honest failure is the new
1080    /// behavior for them). `linked_agent_ids` comes from the SHARED store
1081    /// (the live links table); this method writes `db_agents` — the
1082    /// definition flip's only remaining caller of this is `m0017` on an
1083    /// install upgrading through the whole migration chain for the first
1084    /// time, where `db_agents` is already the sole live source. Returns
1085    /// (rows set to 1, rows set to 0).
1086    pub fn agents_grandfather_ambient_login(
1087        &self,
1088        linked_agent_ids: &std::collections::HashSet<String>,
1089    ) -> Result<(usize, usize), StoreError> {
1090        let conn = self.conn.lock().unwrap();
1091        // Set everyone ambient first, then flip the linked set back to the
1092        // fail-by-default 0 — two passes instead of a dynamic IN() list.
1093        let ambient_defs = conn.execute("UPDATE db_agents SET use_ambient_login = 1", [])?;
1094        let mut linked_rows = 0usize;
1095        for id in linked_agent_ids {
1096            linked_rows += conn.execute(
1097                "UPDATE db_agents SET use_ambient_login = 0 WHERE id = ?1",
1098                params![id],
1099            )?;
1100        }
1101        Ok((ambient_defs.saturating_sub(linked_rows), linked_rows))
1102    }
1103
1104    // AgentContent / AgentSkill / AgentHistory CRUD live in
1105    // `super::content` / `super::skills` / `super::history` —
1106    // each adds an `impl Store {}` block to this type.
1107}