Skip to main content

agentmux_srv/backend/storage/
bundles.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Bundles — the agent's personality and capability stack
5//! (provider, model, instructions, context files, MCP servers, skills).
6//!
7//! Extracted from `store.rs` in Phase R.3 of the storage
8//! modularization plan
9//! (`docs/specs/SPEC_STORE_MODULARIZATION_2026_05_27.md`). The
10//! method surface is unchanged — `Store::bundle_*` still
11//! lives on `Store` via this `impl` block; callers stay on
12//! `storage::store::Bundle` thanks to the re-export.
13
14use rusqlite::{params, OptionalExtension};
15use serde::{Deserialize, Serialize};
16
17use super::bundle_versions::{bundle_version_insert_tx, BundleVersion};
18use super::error::StoreError;
19use super::store::Store;
20
21/// A Bundle — the agent's personality and capability stack.
22/// Provider, model, instructions, and JSON-encoded arrays of context
23/// files / MCP servers / skills. Agent definitions shadow-migrate into this
24/// table during the v7 migration.
25#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
26#[ts(export, export_to = "../../../frontend/types/rpc/")]
27pub struct Bundle {
28    pub id: String,
29    pub name: String,
30    #[serde(default)]
31    pub description: String,
32    #[serde(default)]
33    pub is_blank: bool,
34    /// Global bundles are injected into every agent's CLAUDE.md at launch,
35    /// regardless of per-agent memory selection. Managed in Memory → Global.
36    /// Seeded from workspace-wide rule sets.
37    #[serde(default)]
38    pub is_global: bool,
39    /// "claude" | "codex" | "gemini" | empty string
40    #[serde(default)]
41    pub provider: String,
42    #[serde(default)]
43    pub model: String,
44    #[serde(default)]
45    pub instructions: String,
46    /// JSON-encoded object of `{provider_id: content}` — additive,
47    /// harness-scoped instruction variants alongside the flat
48    /// `instructions` column above (which keeps meaning "default"). ABF v0.2
49    /// §2.2; see SPEC_ABF_V0_2_PROVIDER_AWARE_COMPONENTS_AND_NATIVE_MEMORY_2026_08_10.md.
50    #[serde(default = "default_json_object_string")]
51    pub instructions_by_provider: String,
52    /// JSON-encoded array; the renderer types it as `[{path, content}]`.
53    #[serde(default = "default_json_array_string")]
54    pub context_files: String,
55    /// Always `"[]"`: a bundle carries no MCP servers
56    /// (`SPEC_BUNDLE_CONTENTS_MEMORY_NOT_MCP_2026_10_07.md` §3.1). Kept on
57    /// the wire for older clients; see [`NO_INLINE_MCP_SERVERS`].
58    #[serde(default = "default_json_array_string")]
59    pub mcp_servers: String,
60    /// JSON-encoded array of skill IDs.
61    #[serde(default = "default_json_array_string")]
62    pub skills: String,
63    /// Explicit ordering within Memory → Global. Lower sorts
64    /// first; this is the order sections inject into CLAUDE.md at launch.
65    /// Only meaningful for `is_global` bundles; 0 for the rest. Owned by the
66    /// `reorderglobalbrain` RPC — `bundle_upsert` never overwrites it
67    /// on conflict, so editing a bundle via the regular form keeps its place.
68    #[serde(default)]
69    #[ts(type = "number")]
70    pub sort_order: i64,
71    /// AgentMux-controlled, highest-priority Global Bundle tier — always
72    /// also `is_global`, injected first in `format_global_bundle_block`'s
73    /// output with explicit override wording. Writable ONLY through
74    /// `bundle_upsert_system`/`bundle_delete_system` — the
75    /// generic `bundle_upsert`/`_delete`/`_reorder` all refuse to
76    /// touch a row with this set. See
77    /// docs/specs/SPEC_GLOBAL_MEMORY_SYSTEM_TIER_2026_08_24.md.
78    #[serde(default)]
79    pub is_system: bool,
80    // created_at / updated_at are server-owned: the upsert handler stamps
81    // created_at = now when 0 and always overwrites updated_at with now. They
82    // default on input so partial upserts (e.g. a "new section" that only
83    // sends id/name/instructions) deserialize cleanly. (reagent P0 on #1608)
84    #[serde(default)]
85    #[ts(type = "number")]
86    pub created_at: i64,
87    #[serde(default)]
88    #[ts(type = "number")]
89    pub updated_at: i64,
90}
91
92fn default_json_array_string() -> String {
93    "[]".to_string()
94}
95
96/// The base check behind `bundle_upsert_with_version_based` /
97/// `bundle_upsert_system_if_changed_based`, run inside the caller's own
98/// transaction so nothing can land between the check and the write.
99fn check_bundle_base_tx(tx: &rusqlite::Transaction, id: &str, base: &str) -> Result<(), StoreError> {
100    let current: Option<(String, String)> = tx
101        .query_row(
102            "SELECT name, instructions FROM db_bundles WHERE id = ?1",
103            params![id],
104            |r| Ok((r.get(0)?, r.get(1)?)),
105        )
106        .optional()?;
107    match current {
108        None => Err(StoreError::Conflict(format!(
109            "Global Memory entry {id} no longer exists (your edit was based on {base}); not saved"
110        ))),
111        Some((name, instructions)) => {
112            let current_hash = super::bundle_versions::content_hash(&name, &instructions);
113            if current_hash.eq_ignore_ascii_case(base) {
114                Ok(())
115            } else {
116                Err(StoreError::Conflict(format!(
117                    "Global Memory entry {id} changed since your edit began (base {base}, current {current_hash}); not saved"
118                )))
119            }
120        }
121    }
122}
123
124/// Outcome of `Store::bundle_reseed_system_if_owned` — see that method's own
125/// doc comment for what each variant means and why the decision is made
126/// atomically rather than by the caller.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum BundleReseedOutcome {
129    Created,
130    Updated,
131    Unchanged,
132    SkippedLocalEdit,
133    /// This write's manifest generation (see `manifest_generation_from_
134    /// source_detail`) is older than the generation already recorded for
135    /// this row — an older AgentMux process must never downgrade a newer
136    /// seed. See `bundle_reseed_system_if_owned`'s own doc comment.
137    SkippedOlderGeneration,
138    /// The row doesn't exist, but a retirement tombstone (`RETIREMENT_
139    /// TOMBSTONE_SOURCE`) recorded by a newer manifest generation says it
140    /// was deliberately pruned — this caller's own (older) manifest must
141    /// not resurrect it. See `bundle_reseed_system_if_owned`'s own doc
142    /// comment.
143    SkippedRetired,
144}
145
146/// The `source` sentinel `bundle_delete_system_if_owned` stamps on the
147/// tombstone version row it appends after deleting a row — distinguishes a
148/// deliberate, generation-aware retirement (pruned because a manifest no
149/// longer lists this id) from an ordinary human delete in Memory
150/// (`bundle_delete_system`, which records no version at all). `bundle_
151/// reseed_system_if_owned`'s missing-row branch checks for this sentinel
152/// specifically — see that method's own doc comment.
153const RETIREMENT_TOMBSTONE_SOURCE: &str = "operator_config_seed_retired";
154
155/// Extracts a `{"manifest_version": N}` field from a version row's
156/// `source_detail` JSON, defaulting to `0` when absent or unparseable — so a
157/// pre-existing row seeded before this field existed is always treated as
158/// generation 0, never blocking a real (>=1) manifest generation from
159/// writing over it. Shared convention between `bundle_reseed_system_if_
160/// owned`'s own generation guard and any seeder populating `source_detail`
161/// (see `operator_config_seed.rs`).
162fn manifest_generation_from_source_detail(source_detail: &str) -> u64 {
163    serde_json::from_str::<serde_json::Value>(source_detail)
164        .ok()
165        .and_then(|v| v.get("manifest_version").and_then(|g| g.as_u64()))
166        .unwrap_or(0)
167}
168
169fn default_json_object_string() -> String {
170    "{}".to_string()
171}
172
173/// Format global bundles into the block injected into an agent's
174/// CLAUDE.md. `is_system` sections (see `Bundle::is_system`) are split out
175/// and rendered FIRST, wrapped in explicit override wording, so they
176/// outrank every ordinary `# [Workspace] <name>` section that follows —
177/// see docs/specs/SPEC_GLOBAL_MEMORY_SYSTEM_TIER_2026_08_24.md §3.4. Bundles
178/// arrive already ordered by `bundle_list_global` (is_system DESC,
179/// sort_order, name), so this only needs to partition, not re-sort.
180/// Sections are separated by a `---` rule ([`GLOBAL_SECTION_SEPARATOR`]).
181/// Returns an empty string when no section has instructions.
182///
183/// Exactly [`global_bundle_sections`] joined by the separator: memory
184/// reinjection sends those same sections, so what it re-delivers is this
185/// block byte for byte (SPEC_GLOBAL_MEMORY_DELIVERY_2026_09_27.md §7 P1).
186pub fn format_global_bundle_block(bundles: &[Bundle]) -> String {
187    global_bundle_sections(bundles)
188        .into_iter()
189        .map(|s| s.text)
190        .collect::<Vec<_>>()
191        .join(GLOBAL_SECTION_SEPARATOR)
192}
193
194/// The rule between two sections of the Global Memory block.
195pub const GLOBAL_SECTION_SEPARATOR: &str = "\n\n---\n\n";
196
197/// The override wording that opens the system (`is_system`) part of the block.
198const SYSTEM_TIER_PREAMBLE: &str = "IMPORTANT: The following AgentMux-controlled instructions take \
199     the HIGHEST PRIORITY of any content in this file. They OVERRIDE \
200     any default behavior, any other section below, and any \
201     conflicting instruction elsewhere — you MUST follow them \
202     exactly as written.";
203
204/// One section of the Global Memory block: one global bundle with
205/// instructions, rendered as the startup file carries it.
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
207#[ts(export, export_to = "../../../frontend/types/rpc/")]
208pub struct GlobalMemorySection {
209    /// The Global Memory entry's id (the bundle id).
210    pub id: String,
211    pub name: String,
212    pub is_system: bool,
213    /// `# [AgentMux System] <name>` or `# [Workspace] <name>`, a blank line,
214    /// then the instructions. The first system section also opens with the
215    /// override preamble.
216    pub text: String,
217    /// `text`'s length in bytes: what this section adds to an injection.
218    #[ts(type = "number")]
219    pub size_bytes: u64,
220}
221
222/// The sections of the Global Memory block, system (`is_system`) first, each
223/// in the order `bundle_list_global` returns them. Bundles with no
224/// instructions are skipped.
225pub fn global_bundle_sections(bundles: &[Bundle]) -> Vec<GlobalMemorySection> {
226    let non_empty = bundles.iter().filter(|b| !b.instructions.trim().is_empty());
227    let (system, ordinary): (Vec<&Bundle>, Vec<&Bundle>) = non_empty.partition(|b| b.is_system);
228    let section = |b: &Bundle, text: String| GlobalMemorySection {
229        id: b.id.clone(),
230        name: b.name.clone(),
231        is_system: b.is_system,
232        size_bytes: text.len() as u64,
233        text,
234    };
235    let system = system.into_iter().enumerate().map(|(i, b)| {
236        let body = format!("# [AgentMux System] {}\n\n{}", b.name, b.instructions);
237        section(b, if i == 0 { format!("{SYSTEM_TIER_PREAMBLE}\n\n{body}") } else { body })
238    });
239    let ordinary = ordinary
240        .into_iter()
241        .map(|b| section(b, format!("# [Workspace] {}\n\n{}", b.name, b.instructions)));
242    system.chain(ordinary).collect()
243}
244
245impl Store {
246    pub fn bundle_list(&self) -> Result<Vec<Bundle>, StoreError> {
247        let conn = self.conn.lock().unwrap();
248        let mut stmt = conn.prepare(
249            "SELECT id, name, description, is_blank, is_global, provider, model, instructions,
250                    context_files, mcp_servers, skills, sort_order, created_at, updated_at,
251                    instructions_by_provider, is_system
252             FROM db_bundles
253             ORDER BY is_blank ASC, is_global DESC, updated_at DESC",
254        )?;
255        let iter = stmt.query_map([], map_memory_row)?;
256        let mut out = Vec::new();
257        for r in iter {
258            out.push(r?);
259        }
260        Ok(out)
261    }
262
263    /// Returns only the global bundles (`is_global = 1`), `is_system` rows
264    /// first (regardless of `sort_order` — see
265    /// docs/specs/SPEC_GLOBAL_MEMORY_SYSTEM_TIER_2026_08_24.md), then by
266    /// explicit `sort_order` (then name as a stable tiebreak). Called at
267    /// agent launch to inject workspace-wide rules into every agent in the
268    /// order the user arranged them in Memory → Global.
269    pub fn bundle_list_global(&self) -> Result<Vec<Bundle>, StoreError> {
270        let conn = self.conn.lock().unwrap();
271        let mut stmt = conn.prepare(
272            "SELECT id, name, description, is_blank, is_global, provider, model, instructions,
273                    context_files, mcp_servers, skills, sort_order, created_at, updated_at,
274                    instructions_by_provider, is_system
275             FROM db_bundles
276             WHERE is_global = 1
277             ORDER BY is_system DESC, sort_order ASC, name ASC",
278        )?;
279        let iter = stmt.query_map([], map_memory_row)?;
280        let mut out = Vec::new();
281        for r in iter {
282            out.push(r?);
283        }
284        Ok(out)
285    }
286
287    pub fn bundle_get(&self, id: &str) -> Result<Option<Bundle>, StoreError> {
288        let conn = self.conn.lock().unwrap();
289        let mut stmt = conn.prepare(
290            "SELECT id, name, description, is_blank, is_global, provider, model, instructions,
291                    context_files, mcp_servers, skills, sort_order, created_at, updated_at,
292                    instructions_by_provider, is_system
293             FROM db_bundles WHERE id = ?1",
294        )?;
295        let result = stmt.query_row(params![id], map_memory_row);
296        match result {
297            Ok(m) => Ok(Some(m)),
298            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
299            Err(e) => Err(e.into()),
300        }
301    }
302
303    /// Look up just the `is_system` flag for `id`, without decoding a full
304    /// `Bundle` row. Shared by every guard in this file that needs to know
305    /// "is the EXISTING row (if any) a system entry" before deciding
306    /// whether to allow a write through the generic path.
307    fn bundle_is_system(&self, conn: &rusqlite::Connection, id: &str) -> Result<Option<bool>, StoreError> {
308        let existing: Option<i64> = conn
309            .query_row(
310                "SELECT is_system FROM db_bundles WHERE id = ?1",
311                params![id],
312                |r| r.get(0),
313            )
314            .optional()?;
315        Ok(existing.map(|v| v != 0))
316    }
317
318    /// [`Self::bundle_upsert_unobserved`], then tell the Global Memory record's
319    /// observer (`GlobalMemoryChange`).
320    pub fn bundle_upsert(&self, memory: &Bundle) -> Result<(), StoreError> {
321        let r = self.bundle_upsert_unobserved(memory);
322        if r.is_ok() {
323            self.notify_global_memory(super::store::GlobalMemoryChange::Entry(memory.id.clone()));
324        }
325        r
326    }
327
328    /// Generic Global Bundle upsert — used by the ordinary Memory editor,
329    /// the per-agent Bundle editor, ABF import, and internal seeding.
330    /// Refuses outright to touch an existing `is_system=1` row (content
331    /// included, not just the flag) — see
332    /// docs/specs/SPEC_GLOBAL_MEMORY_SYSTEM_TIER_2026_08_24.md §3.2. Use
333    /// `bundle_upsert_system` to create/edit a system entry.
334    fn bundle_upsert_unobserved(&self, memory: &Bundle) -> Result<(), StoreError> {
335        let conn = self.conn.lock().unwrap();
336        if self.bundle_is_system(&conn, &memory.id)? == Some(true) {
337            return Err(StoreError::Other(
338                "cannot modify a system Global Memory entry via the generic bundle upsert path"
339                    .to_string(),
340            ));
341        }
342        conn.execute(
343            // sort_order is deliberately NOT in the ON CONFLICT update set:
344            // it is owned by `bundle_reorder`, so editing a bundle
345            // through the regular Bundle form never disturbs its position in
346            // the global bundles. is_system is hardcoded to 0 on insert (this
347            // path can never CREATE a system row) and omitted from the
348            // update set entirely (an existing row's tier — always 0, given
349            // the guard above — is never touched here either).
350            "INSERT INTO db_bundles
351                (id, name, description, is_blank, is_global, provider, model, instructions,
352                 context_files, mcp_servers, skills, sort_order, created_at, updated_at,
353                 instructions_by_provider, is_system)
354             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, 0)
355             ON CONFLICT(id) DO UPDATE SET
356                name = excluded.name,
357                description = excluded.description,
358                is_global = excluded.is_global,
359                provider = excluded.provider,
360                model = excluded.model,
361                instructions = excluded.instructions,
362                context_files = excluded.context_files,
363                mcp_servers = excluded.mcp_servers,
364                skills = excluded.skills,
365                updated_at = excluded.updated_at,
366                instructions_by_provider = excluded.instructions_by_provider",
367            params![
368                memory.id,
369                memory.name,
370                memory.description,
371                memory.is_blank as i64,
372                memory.is_global as i64,
373                memory.provider,
374                memory.model,
375                memory.instructions,
376                memory.context_files,
377                NO_INLINE_MCP_SERVERS,
378                memory.skills,
379                memory.sort_order,
380                memory.created_at,
381                memory.updated_at,
382                memory.instructions_by_provider,
383            ],
384        )?;
385        Ok(())
386    }
387
388    /// Same as `bundle_upsert`, but when `memory.is_global` is true, also
389    /// appends a `db_bundle_versions` row for it — atomically, in the SAME
390    /// transaction as the `db_bundles` write, not as two separate calls.
391    /// codex P2, PR #3237: two separate lock/transaction acquisitions (the
392    /// original shape — call `bundle_upsert`, then separately call
393    /// `bundle_version_insert`) let a concurrent writer interleave between
394    /// them: writer A upserts, writer B upserts AND versions, then A's own
395    /// (now-stale) version-insert lands last — the history would then claim
396    /// A's content is current while the live row actually holds B's. One
397    /// transaction makes that interleaving impossible.
398    ///
399    /// `written_by` is the caller's own TRUSTED identity (an agent's real
400    /// `AGENTMUX_AGENT_ID`, or `"armory-ui"` for the human-facing Memory
401    /// editor) — see `BundleVersion::written_by`'s own doc comment for why
402    /// this is a separate field from `source`/`source_detail`.
403    /// `written_by_uid` is the writer's UID from the request's `Caller`
404    /// (identity M4c-1, spec §6.5.9) — `""` when Unattributed, and for the
405    /// Memory pane. Returns
406    /// `None` (no version recorded) when `memory.is_global` is false —
407    /// versioning is scoped to Global Memory specifically, matching this
408    /// table's whole purpose; a private, per-agent bundle upsert still
409    /// writes normally but has nothing to audit here.
410    ///
411    /// Shares `bundle_upsert`'s exact guard (refuses an existing
412    /// `is_system=1` row) and exact `db_bundles` SQL — kept as a literal
413    /// duplicate rather than having one call the other, since `self.conn`
414    /// is a plain (non-reentrant) `Mutex`: calling `bundle_upsert` (which
415    /// takes its own lock) from inside a transaction already holding that
416    /// same lock would deadlock.
417    pub fn bundle_upsert_with_version(
418        &self,
419        memory: &Bundle,
420        written_by: &str,
421        written_by_uid: &str,
422        source: &str,
423        source_detail: &str,
424    ) -> Result<Option<BundleVersion>, StoreError> {
425        self.bundle_upsert_with_version_based(memory, written_by, written_by_uid, source, source_detail, None)
426    }
427
428    /// [`Self::bundle_upsert_with_version_based_unobserved`], then tell the Global Memory record's
429    /// observer (`GlobalMemoryChange`).
430    pub fn bundle_upsert_with_version_based(&self, memory: &Bundle, written_by: &str, written_by_uid: &str, source: &str, source_detail: &str, base_sha256: Option<&str>) -> Result<Option<BundleVersion>, StoreError> {
431        let r = self.bundle_upsert_with_version_based_unobserved(memory, written_by, written_by_uid, source, source_detail, base_sha256);
432        if r.is_ok() {
433            self.notify_global_memory(super::store::GlobalMemoryChange::Entry(memory.id.clone()));
434        }
435        r
436    }
437
438    /// `bundle_upsert_with_version` with an optional base: when
439    /// `base_sha256` is `Some`, the row's CURRENT `name` + `instructions`
440    /// must still hash to it (`bundle_versions::content_hash` — the same
441    /// value as the latest version's `content_hash`), checked inside the
442    /// same `IMMEDIATE` transaction as the write, else `StoreError::Conflict`
443    /// and nothing is written. A missing row is a conflict too. `None`
444    /// behaves exactly like `bundle_upsert_with_version`. Memory →
445    /// Global's editor sends it (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md §2.4).
446    fn bundle_upsert_with_version_based_unobserved(
447        &self,
448        memory: &Bundle,
449        written_by: &str,
450        written_by_uid: &str,
451        source: &str,
452        source_detail: &str,
453        base_sha256: Option<&str>,
454    ) -> Result<Option<BundleVersion>, StoreError> {
455        let mut conn = self.conn.lock().unwrap();
456        let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
457
458        if let Some(base) = base_sha256 {
459            check_bundle_base_tx(&tx, &memory.id, base)?;
460        }
461
462        let existing_is_system: Option<i64> = tx
463            .query_row(
464                "SELECT is_system FROM db_bundles WHERE id = ?1",
465                params![memory.id],
466                |r| r.get(0),
467            )
468            .optional()?;
469        if existing_is_system == Some(1) {
470            return Err(StoreError::Other(
471                "cannot modify a system Global Memory entry via the generic bundle upsert path"
472                    .to_string(),
473            ));
474        }
475
476        tx.execute(
477            "INSERT INTO db_bundles
478                (id, name, description, is_blank, is_global, provider, model, instructions,
479                 context_files, mcp_servers, skills, sort_order, created_at, updated_at,
480                 instructions_by_provider, is_system)
481             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, 0)
482             ON CONFLICT(id) DO UPDATE SET
483                name = excluded.name,
484                description = excluded.description,
485                is_global = excluded.is_global,
486                provider = excluded.provider,
487                model = excluded.model,
488                instructions = excluded.instructions,
489                context_files = excluded.context_files,
490                mcp_servers = excluded.mcp_servers,
491                skills = excluded.skills,
492                updated_at = excluded.updated_at,
493                instructions_by_provider = excluded.instructions_by_provider",
494            params![
495                memory.id,
496                memory.name,
497                memory.description,
498                memory.is_blank as i64,
499                memory.is_global as i64,
500                memory.provider,
501                memory.model,
502                memory.instructions,
503                memory.context_files,
504                NO_INLINE_MCP_SERVERS,
505                memory.skills,
506                memory.sort_order,
507                memory.created_at,
508                memory.updated_at,
509                memory.instructions_by_provider,
510            ],
511        )?;
512
513        let version = if memory.is_global {
514            Some(bundle_version_insert_tx(
515                &tx,
516                &memory.id,
517                &memory.name,
518                &memory.instructions,
519                source,
520                source_detail,
521                written_by,
522                written_by_uid,
523            )?)
524        } else {
525            None
526        };
527
528        tx.commit()?;
529        Ok(version)
530    }
531
532    /// [`Self::bundle_upsert_system_unobserved`], then tell the Global Memory record's
533    /// observer (`GlobalMemoryChange`).
534    #[cfg(test)]
535    pub fn bundle_upsert_system(&self, memory: &Bundle) -> Result<(), StoreError> {
536        let r = self.bundle_upsert_system_unobserved(memory);
537        if r.is_ok() {
538            self.notify_global_memory(super::store::GlobalMemoryChange::Entry(memory.id.clone()));
539        }
540        r
541    }
542
543    /// The ONLY path that can write `is_system=1`. Refuses the mirror-image
544    /// case of `bundle_upsert`'s guard: converting an EXISTING
545    /// non-system row into a system one by id collision is not allowed —
546    /// `id` must be either brand new or already a system entry.
547    /// `is_blank`/`is_global`/`is_system` are hardcoded (not read from
548    /// `memory`) so this method can never produce anything other than a
549    /// well-formed system row regardless of what the caller passed in.
550    #[cfg(test)]
551    fn bundle_upsert_system_unobserved(&self, memory: &Bundle) -> Result<(), StoreError> {
552        let conn = self.conn.lock().unwrap();
553        if self.bundle_is_system(&conn, &memory.id)? == Some(false) {
554            return Err(StoreError::Other(
555                "cannot convert an existing non-system Global Memory entry into a system entry"
556                    .to_string(),
557            ));
558        }
559        conn.execute(
560            "INSERT INTO db_bundles
561                (id, name, description, is_blank, is_global, provider, model, instructions,
562                 context_files, mcp_servers, skills, sort_order, created_at, updated_at,
563                 instructions_by_provider, is_system)
564             VALUES (?1, ?2, ?3, 0, 1, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 1)
565             ON CONFLICT(id) DO UPDATE SET
566                name = excluded.name,
567                description = excluded.description,
568                is_global = 1,
569                is_system = 1,
570                provider = excluded.provider,
571                model = excluded.model,
572                instructions = excluded.instructions,
573                context_files = excluded.context_files,
574                mcp_servers = excluded.mcp_servers,
575                skills = excluded.skills,
576                updated_at = excluded.updated_at,
577                instructions_by_provider = excluded.instructions_by_provider",
578            params![
579                memory.id,
580                memory.name,
581                memory.description,
582                memory.provider,
583                memory.model,
584                memory.instructions,
585                memory.context_files,
586                NO_INLINE_MCP_SERVERS,
587                memory.skills,
588                memory.sort_order,
589                memory.created_at,
590                memory.updated_at,
591                memory.instructions_by_provider,
592            ],
593        )?;
594        Ok(())
595    }
596
597    /// [`Self::bundle_upsert_system_with_version_unobserved`], then tell the Global Memory record's
598    /// observer (`GlobalMemoryChange`).
599    #[cfg(test)]
600    pub fn bundle_upsert_system_with_version(&self, memory: &Bundle, written_by: &str, source: &str, source_detail: &str) -> Result<BundleVersion, StoreError> {
601        let r = self.bundle_upsert_system_with_version_unobserved(memory, written_by, source, source_detail);
602        if r.is_ok() {
603            self.notify_global_memory(super::store::GlobalMemoryChange::Entry(memory.id.clone()));
604        }
605        r
606    }
607
608    /// Same as `bundle_upsert_system`, but also appends a `db_bundle_versions`
609    /// row in the SAME transaction — the system-tier counterpart of
610    /// `bundle_upsert_with_version`, for the same atomicity reason (codex P2,
611    /// PR #3237). Always records a version (unlike the ordinary method, this
612    /// one never returns `None`): a system row is unconditionally
613    /// `is_global=1`, so there is never a case where versioning would not
614    /// apply. See docs/specs/SPEC_SYSTEM_TIER_GLOBAL_MEMORY_SEEDING_2026_09_15.md
615    /// — this is what lets `operator_config_seed`'s startup reseed tell "did
616    /// AgentMux's own seeder write this last, or did a human edit it in
617    /// Memory" apart, via the returned/stored `written_by`.
618    ///
619    /// Shares `bundle_upsert_system`'s exact guard and SQL, duplicated for
620    /// the same non-reentrant-`Mutex` reason `bundle_upsert_with_version`
621    /// documents on itself.
622    #[cfg(test)]
623    fn bundle_upsert_system_with_version_unobserved(
624        &self,
625        memory: &Bundle,
626        written_by: &str,
627        source: &str,
628        source_detail: &str,
629    ) -> Result<BundleVersion, StoreError> {
630        let mut conn = self.conn.lock().unwrap();
631        let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
632
633        let existing_is_system: Option<i64> = tx
634            .query_row(
635                "SELECT is_system FROM db_bundles WHERE id = ?1",
636                params![memory.id],
637                |r| r.get(0),
638            )
639            .optional()?;
640        if existing_is_system == Some(0) {
641            return Err(StoreError::Other(
642                "cannot convert an existing non-system Global Memory entry into a system entry"
643                    .to_string(),
644            ));
645        }
646
647        tx.execute(
648            "INSERT INTO db_bundles
649                (id, name, description, is_blank, is_global, provider, model, instructions,
650                 context_files, mcp_servers, skills, sort_order, created_at, updated_at,
651                 instructions_by_provider, is_system)
652             VALUES (?1, ?2, ?3, 0, 1, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 1)
653             ON CONFLICT(id) DO UPDATE SET
654                name = excluded.name,
655                description = excluded.description,
656                is_global = 1,
657                is_system = 1,
658                provider = excluded.provider,
659                model = excluded.model,
660                instructions = excluded.instructions,
661                context_files = excluded.context_files,
662                mcp_servers = excluded.mcp_servers,
663                skills = excluded.skills,
664                updated_at = excluded.updated_at,
665                instructions_by_provider = excluded.instructions_by_provider",
666            params![
667                memory.id,
668                memory.name,
669                memory.description,
670                memory.provider,
671                memory.model,
672                memory.instructions,
673                memory.context_files,
674                NO_INLINE_MCP_SERVERS,
675                memory.skills,
676                memory.sort_order,
677                memory.created_at,
678                memory.updated_at,
679                memory.instructions_by_provider,
680            ],
681        )?;
682
683        let version = bundle_version_insert_tx(
684            &tx,
685            &memory.id,
686            &memory.name,
687            &memory.instructions,
688            source,
689            source_detail,
690            written_by,
691            "", // the system tier is written by seeders, never a Caller
692        )?;
693
694        tx.commit()?;
695        Ok(version)
696    }
697
698    /// Same as `bundle_upsert_system_with_version`, but skips recording a
699    /// version when the write is byte-identical (`name` + `instructions`) to
700    /// what's already stored — the case where an operator opens a seeded
701    /// Operator Config entry in Memory and clicks Save without
702    /// changing anything (the UI does not suppress this request). Recording
703    /// a version for a true no-op would permanently mark the row as
704    /// human-owned even though nothing actually changed, breaking `bundle_
705    /// reseed_system_if_owned`'s ownership signal for no reason (Codex P2,
706    /// PR #3244).
707    ///
708    /// The existing-content comparison happens in the SAME transaction as
709    /// the resulting write, for the identical check-then-act reason `bundle_
710    /// reseed_system_if_owned` documents on itself — an earlier revision of
711    /// `upsertsystemmemory` (`bundle.rs`) composed this from a separate
712    /// `bundle_get` call plus a conditional branch, which raced a concurrent
713    /// writer between the read and the write (ReAgent P2, PR #3244).
714    ///
715    /// Returns `Ok(None)` when the save was a no-op (still refreshes every
716    /// other field via the plain upsert; just records no version); `Ok(Some(
717    /// version))` otherwise, including for a brand-new row (never a no-op).
718    pub fn bundle_upsert_system_if_changed(
719        &self,
720        memory: &Bundle,
721        written_by: &str,
722        source: &str,
723        source_detail: &str,
724    ) -> Result<Option<BundleVersion>, StoreError> {
725        self.bundle_upsert_system_if_changed_based(memory, written_by, source, source_detail, None)
726    }
727
728    /// [`Self::bundle_upsert_system_if_changed_based_unobserved`], then tell the Global Memory record's
729    /// observer (`GlobalMemoryChange`).
730    pub fn bundle_upsert_system_if_changed_based(&self, memory: &Bundle, written_by: &str, source: &str, source_detail: &str, base_sha256: Option<&str>) -> Result<Option<BundleVersion>, StoreError> {
731        let r = self.bundle_upsert_system_if_changed_based_unobserved(memory, written_by, source, source_detail, base_sha256);
732        if r.is_ok() {
733            self.notify_global_memory(super::store::GlobalMemoryChange::Entry(memory.id.clone()));
734        }
735        r
736    }
737
738    /// `bundle_upsert_system_if_changed` with the same optional base check
739    /// `bundle_upsert_with_version_based` documents — the system-tier half
740    /// of Memory → Global's conditional save.
741    fn bundle_upsert_system_if_changed_based_unobserved(
742        &self,
743        memory: &Bundle,
744        written_by: &str,
745        source: &str,
746        source_detail: &str,
747        base_sha256: Option<&str>,
748    ) -> Result<Option<BundleVersion>, StoreError> {
749        let mut conn = self.conn.lock().unwrap();
750        let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
751
752        if let Some(base) = base_sha256 {
753            check_bundle_base_tx(&tx, &memory.id, base)?;
754        }
755
756        let existing: Option<(String, String, i64)> = tx
757            .query_row(
758                "SELECT name, instructions, is_system FROM db_bundles WHERE id = ?1",
759                params![memory.id],
760                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
761            )
762            .optional()?;
763
764        if let Some((_, _, is_system)) = &existing {
765            if *is_system == 0 {
766                return Err(StoreError::Other(
767                    "cannot convert an existing non-system Global Memory entry into a system entry"
768                        .to_string(),
769                ));
770            }
771        }
772
773        let is_noop = existing
774            .as_ref()
775            .is_some_and(|(name, instructions, _)| *name == memory.name && *instructions == memory.instructions);
776
777        tx.execute(
778            "INSERT INTO db_bundles
779                (id, name, description, is_blank, is_global, provider, model, instructions,
780                 context_files, mcp_servers, skills, sort_order, created_at, updated_at,
781                 instructions_by_provider, is_system)
782             VALUES (?1, ?2, ?3, 0, 1, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 1)
783             ON CONFLICT(id) DO UPDATE SET
784                name = excluded.name,
785                description = excluded.description,
786                is_global = 1,
787                is_system = 1,
788                provider = excluded.provider,
789                model = excluded.model,
790                instructions = excluded.instructions,
791                context_files = excluded.context_files,
792                mcp_servers = excluded.mcp_servers,
793                skills = excluded.skills,
794                updated_at = excluded.updated_at,
795                instructions_by_provider = excluded.instructions_by_provider",
796            params![
797                memory.id,
798                memory.name,
799                memory.description,
800                memory.provider,
801                memory.model,
802                memory.instructions,
803                memory.context_files,
804                NO_INLINE_MCP_SERVERS,
805                memory.skills,
806                memory.sort_order,
807                memory.created_at,
808                memory.updated_at,
809                memory.instructions_by_provider,
810            ],
811        )?;
812
813        let version = if is_noop {
814            None
815        } else {
816            Some(bundle_version_insert_tx(
817                &tx,
818                &memory.id,
819                &memory.name,
820                &memory.instructions,
821                source,
822                source_detail,
823                written_by,
824                "", // the system tier is written by seeders, never a Caller
825            )?)
826        };
827
828        tx.commit()?;
829        Ok(version)
830    }
831
832    /// [`Self::bundle_reseed_system_if_owned_unobserved`], then tell the Global Memory record's
833    /// observer (`GlobalMemoryChange`).
834    pub fn bundle_reseed_system_if_owned(&self, memory: &Bundle, seeder_identity: &str, source: &str, source_detail: &str) -> Result<BundleReseedOutcome, StoreError> {
835        let r = self.bundle_reseed_system_if_owned_unobserved(memory, seeder_identity, source, source_detail);
836        if r.is_ok() {
837            self.notify_global_memory(super::store::GlobalMemoryChange::Entry(memory.id.clone()));
838        }
839        r
840    }
841
842    /// Atomically decide-and-apply an Operator Config reseed for one system
843    /// row — the ownership check (is this row still owned by AgentMux's own
844    /// seeder, or has a human/another writer claimed it) and the resulting
845    /// write happen inside the SAME transaction, not as two separate `Store`
846    /// calls composed by the caller. A caller-composed version (read latest
847    /// version via `bundle_version_list`, decide, then separately call
848    /// `bundle_upsert_system_with_version`) is a check-then-act race: a
849    /// concurrent writer — a human editing this row in Memory, or
850    /// another AgentMux instance's own startup reseed, since multiple
851    /// instances can run in parallel by design — could land a new version
852    /// between the read and the write, and the caller's decision would be
853    /// based on data that was already stale by the time it acted (muxreview
854    /// P1, PR #3244).
855    ///
856    /// `seeder_identity` is the reserved `written_by` value that marks a row
857    /// as still owned by the seeder (see `operator_config_seed::WRITTEN_BY`)
858    /// — a latest version written by anything else means a human (or some
859    /// other writer) has claimed this row, and it is left alone.
860    ///
861    /// - No existing `db_bundles` row at all -> (re)create, UNLESS the
862    ///   latest version for this id is a `RETIREMENT_TOMBSTONE_SOURCE`
863    ///   marker whose recorded generation is newer than `source_detail`'s
864    ///   own (`SkippedRetired` in that case) — see `bundle_delete_system_
865    ///   if_owned`'s own doc comment for why the tombstone exists: without
866    ///   it, an older AgentMux build (or a rollback) sharing this store.db
867    ///   would read "no row" as "never existed" and resurrect content a
868    ///   newer build's prune deliberately retired (ReAgent/Codex P2, PR
869    ///   #3244). An ordinary human delete (`bundle_delete_system`, which
870    ///   records no version at all) leaves no tombstone, so a manually
871    ///   deleted entry still comes back exactly as before — only a
872    ///   generation-aware prune leaves a tombstone behind.
873    /// - Existing row, latest version's `written_by != seeder_identity` ->
874    ///   `SkippedLocalEdit`, no write at all.
875    /// - Existing row, latest version's `content_hash` already matches the
876    ///   target (`name`+`instructions`) -> `Unchanged`, no write.
877    /// - Otherwise -> upsert + version insert, `Updated`.
878    fn bundle_reseed_system_if_owned_unobserved(
879        &self,
880        memory: &Bundle,
881        seeder_identity: &str,
882        source: &str,
883        source_detail: &str,
884    ) -> Result<BundleReseedOutcome, StoreError> {
885        let mut conn = self.conn.lock().unwrap();
886        let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
887
888        let existing: Option<(i64, String, String)> = tx
889            .query_row(
890                "SELECT is_system, name, instructions FROM db_bundles WHERE id = ?1",
891                params![memory.id],
892                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
893            )
894            .optional()?;
895        let row_exists = existing.is_some();
896
897        if let Some((0, _, _)) = existing {
898            return Err(StoreError::Other(
899                "cannot convert an existing non-system Global Memory entry into a system entry"
900                    .to_string(),
901            ));
902        }
903
904        if let Some((_, live_name, live_instructions)) = &existing {
905            // Ordered by rowid (insertion sequence), NOT created_at: unlike
906            // bundle_version_list's own history ordering (a display
907            // concern, where wall-clock order is what a human expects to
908            // see), this ownership decision has a real behavioral
909            // consequence if it picks the wrong "latest" row. A backward
910            // system-clock jump (e.g. correcting a VM whose clock was
911            // ahead) between two writes to the same bundle_id would give
912            // the chronologically-later write a SMALLER created_at, so
913            // `ORDER BY created_at DESC` could keep selecting the older
914            // row as "latest" — silently defeating the ownership
915            // guarantee this whole method exists for. rowid is monotonic
916            // per insert regardless of wall-clock time. Codex P2, PR #3244.
917            let latest: Option<(String, String, String)> = tx
918                .query_row(
919                    "SELECT written_by, content_hash, source_detail FROM db_bundle_versions
920                     WHERE bundle_id = ?1
921                     ORDER BY rowid DESC
922                     LIMIT 1",
923                    params![memory.id],
924                    |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
925                )
926                .optional()?;
927            if let Some((written_by, content_hash, stored_source_detail)) = latest {
928                // The live db_bundles row must match what the latest
929                // version CLAIMS is there before `written_by` can be
930                // trusted at all — a compatible-schema OLDER build sharing
931                // this store might still run a pre-PR-#3244
932                // `upsertsystemmemory` handler that calls the unversioned
933                // `bundle_upsert_system` directly, changing the live row
934                // without ever touching `db_bundle_versions`. In that case
935                // the latest version would still (wrongly) show the seeder
936                // as the last writer, even though a human's edit is
937                // sitting live and unrecorded. Codex P2, PR #3244.
938                let live_hash = super::bundle_versions::content_hash(live_name, live_instructions);
939                if live_hash != content_hash {
940                    return Ok(BundleReseedOutcome::SkippedLocalEdit);
941                }
942                if written_by != seeder_identity {
943                    return Ok(BundleReseedOutcome::SkippedLocalEdit);
944                }
945                let target_hash = super::bundle_versions::content_hash(&memory.name, &memory.instructions);
946                let stored_generation = manifest_generation_from_source_detail(&stored_source_detail);
947                let target_generation = manifest_generation_from_source_detail(source_detail);
948                if content_hash == target_hash {
949                    // Content is identical, but if THIS call's own manifest
950                    // generation is strictly newer than what's recorded,
951                    // that fact must still be persisted — otherwise a
952                    // concurrently-running build on a LOWER generation
953                    // (still not "older" than the stale recorded one) could
954                    // later see its own differing content as valid to write
955                    // and downgrade content a newer generation already
956                    // confirmed as current. E.g. generation 3's text
957                    // happens to match generation 1's (a revert); without
958                    // this, a generation-2 build's own different text would
959                    // still pass the generation check against the
960                    // never-advanced "1" and overwrite generation 3's
961                    // result. Codex P2, PR #3244.
962                    if target_generation > stored_generation {
963                        bundle_version_insert_tx(
964                            &tx,
965                            &memory.id,
966                            &memory.name,
967                            &memory.instructions,
968                            source,
969                            source_detail,
970                            seeder_identity,
971                            "",
972                        )?;
973                        // Returning without an explicit commit drops `tx`,
974                        // which rolls back — harmless for every OTHER early
975                        // return in this method (they make no writes), but
976                        // this one just did. Must commit explicitly before
977                        // returning.
978                        tx.commit()?;
979                    }
980                    return Ok(BundleReseedOutcome::Unchanged);
981                }
982                // Content differs, but only from THIS process's point of
983                // view — on a machine sharing store.db across multiple
984                // AgentMux builds/versions (a supported configuration; see
985                // "Multiple Instances Run in Parallel" in this repo's own
986                // CLAUDE.md), an older build's own (older) manifest must
987                // never overwrite a newer build's already-seeded content
988                // just because both processes use the same seeder_identity.
989                // Codex P2, PR #3244.
990                if target_generation < stored_generation {
991                    return Ok(BundleReseedOutcome::SkippedOlderGeneration);
992                }
993            } else {
994                // Row exists but has ZERO version history (possible via the
995                // still-present unversioned bundle_upsert_system, or data
996                // that predates this tier having versioning at all) —
997                // conservatively treated as NOT owned by the seeder and
998                // left alone, same policy `bundle_delete_system_if_owned`
999                // already documents and implements for the identical edge
1000                // case. Falling through to the unconditional upsert below
1001                // would silently overwrite a row this method has no basis
1002                // to claim is safe to touch. ReAgent P2, PR #3244.
1003                return Ok(BundleReseedOutcome::SkippedLocalEdit);
1004            }
1005        } else {
1006            // No db_bundles row — but check for a retirement tombstone
1007            // before assuming that means "never existed, safe to create."
1008            // See this method's own doc comment and `bundle_delete_system_
1009            // if_owned`'s. rowid DESC for the same reason as the row_exists
1010            // branch above.
1011            let tombstone: Option<(String, String)> = tx
1012                .query_row(
1013                    "SELECT source, source_detail FROM db_bundle_versions
1014                     WHERE bundle_id = ?1
1015                     ORDER BY rowid DESC
1016                     LIMIT 1",
1017                    params![memory.id],
1018                    |r| Ok((r.get(0)?, r.get(1)?)),
1019                )
1020                .optional()?;
1021            if let Some((last_source, last_source_detail)) = tombstone {
1022                if last_source == RETIREMENT_TOMBSTONE_SOURCE {
1023                    let retired_generation = manifest_generation_from_source_detail(&last_source_detail);
1024                    let target_generation = manifest_generation_from_source_detail(source_detail);
1025                    if target_generation < retired_generation {
1026                        return Ok(BundleReseedOutcome::SkippedRetired);
1027                    }
1028                }
1029            }
1030        }
1031
1032        tx.execute(
1033            "INSERT INTO db_bundles
1034                (id, name, description, is_blank, is_global, provider, model, instructions,
1035                 context_files, mcp_servers, skills, sort_order, created_at, updated_at,
1036                 instructions_by_provider, is_system)
1037             VALUES (?1, ?2, ?3, 0, 1, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 1)
1038             ON CONFLICT(id) DO UPDATE SET
1039                name = excluded.name,
1040                description = excluded.description,
1041                is_global = 1,
1042                is_system = 1,
1043                provider = excluded.provider,
1044                model = excluded.model,
1045                instructions = excluded.instructions,
1046                context_files = excluded.context_files,
1047                mcp_servers = excluded.mcp_servers,
1048                skills = excluded.skills,
1049                updated_at = excluded.updated_at,
1050                instructions_by_provider = excluded.instructions_by_provider",
1051            params![
1052                memory.id,
1053                memory.name,
1054                memory.description,
1055                memory.provider,
1056                memory.model,
1057                memory.instructions,
1058                memory.context_files,
1059                NO_INLINE_MCP_SERVERS,
1060                memory.skills,
1061                memory.sort_order,
1062                memory.created_at,
1063                memory.updated_at,
1064                memory.instructions_by_provider,
1065            ],
1066        )?;
1067
1068        bundle_version_insert_tx(
1069            &tx,
1070            &memory.id,
1071            &memory.name,
1072            &memory.instructions,
1073            source,
1074            source_detail,
1075            seeder_identity,
1076            "",
1077        )?;
1078
1079        tx.commit()?;
1080        Ok(if row_exists {
1081            BundleReseedOutcome::Updated
1082        } else {
1083            BundleReseedOutcome::Created
1084        })
1085    }
1086
1087    /// [`Self::bundle_delete_unobserved`], then tell the Global Memory record's
1088    /// observer (`GlobalMemoryChange`).
1089    pub fn bundle_delete(&self, id: &str) -> Result<bool, StoreError> {
1090        let r = self.bundle_delete_unobserved(id);
1091        if r.is_ok() {
1092            self.notify_global_memory(super::store::GlobalMemoryChange::Entry(id.to_string()));
1093        }
1094        r
1095    }
1096
1097    /// Delete a Bundle. Refuses to delete the blank singleton, a
1098    /// seeded bundle, or (new) a system entry — use
1099    /// `bundle_delete_system` for the last case.
1100    fn bundle_delete_unobserved(&self, id: &str) -> Result<bool, StoreError> {
1101        if id == "blank" {
1102            return Err(StoreError::Other(
1103                "cannot delete the blank bundle".to_string(),
1104            ));
1105        }
1106        // Seeded bundles (IDs prefixed "seed-") are workspace defaults that
1107        // re-seed on every startup; blocking deletion is cleaner than a
1108        // tombstone table and avoids the re-creation loop.
1109        if id.starts_with("seed-") {
1110            return Err(StoreError::Other(
1111                "cannot delete a seeded bundle; toggle is_global or clear its instructions instead".to_string(),
1112            ));
1113        }
1114        let conn = self.conn.lock().unwrap();
1115        if self.bundle_is_system(&conn, id)? == Some(true) {
1116            return Err(StoreError::Other(
1117                "cannot delete a system Global Memory entry via the generic delete path; use bundle_delete_system".to_string(),
1118            ));
1119        }
1120        let rows = conn.execute("DELETE FROM db_bundles WHERE id = ?1", params![id])?;
1121        Ok(rows > 0)
1122    }
1123
1124    /// [`Self::bundle_delete_system_unobserved`], then tell the Global Memory record's
1125    /// observer (`GlobalMemoryChange`).
1126    pub fn bundle_delete_system(&self, id: &str) -> Result<bool, StoreError> {
1127        let r = self.bundle_delete_system_unobserved(id);
1128        if r.is_ok() {
1129            self.notify_global_memory(super::store::GlobalMemoryChange::Entry(id.to_string()));
1130        }
1131        r
1132    }
1133
1134    /// The ONLY path that can remove an `is_system=1` row — structurally
1135    /// incapable of deleting anything else, even if misused.
1136    fn bundle_delete_system_unobserved(&self, id: &str) -> Result<bool, StoreError> {
1137        let conn = self.conn.lock().unwrap();
1138        let rows = conn.execute(
1139            "DELETE FROM db_bundles WHERE id = ?1 AND is_system = 1",
1140            params![id],
1141        )?;
1142        Ok(rows > 0)
1143    }
1144
1145    /// All `is_system=1` row ids — the full universe `operator_config_seed`'s
1146    /// startup prune pass checks its manifest against, to find rows for an
1147    /// entry AgentMux itself removed or renamed in a later release (Codex
1148    /// P2, PR #3244). Ids only, not full `Bundle`s: the prune pass needs
1149    /// nothing else before deciding whether a given id is still in the
1150    /// manifest.
1151    pub fn bundle_list_system_ids(&self) -> Result<Vec<String>, StoreError> {
1152        let conn = self.conn.lock().unwrap();
1153        let mut stmt = conn.prepare("SELECT id FROM db_bundles WHERE is_system = 1")?;
1154        let rows = stmt
1155            .query_map([], |r| r.get::<_, String>(0))?
1156            .collect::<Result<Vec<_>, _>>()?;
1157        Ok(rows)
1158    }
1159
1160    /// [`Self::bundle_delete_system_if_owned_unobserved`], then tell the Global Memory record's
1161    /// observer (`GlobalMemoryChange`).
1162    pub fn bundle_delete_system_if_owned(&self, id: &str, seeder_identity: &str, caller_manifest_version: u32) -> Result<bool, StoreError> {
1163        let r = self.bundle_delete_system_if_owned_unobserved(id, seeder_identity, caller_manifest_version);
1164        if r.is_ok() {
1165            self.notify_global_memory(super::store::GlobalMemoryChange::Entry(id.to_string()));
1166        }
1167        r
1168    }
1169
1170    /// Atomically deletes an `is_system=1` row ONLY if its latest version is
1171    /// still owned by `seeder_identity` AND that version's own recorded
1172    /// manifest generation is `<= caller_manifest_version` — the delete-side
1173    /// counterpart of `bundle_reseed_system_if_owned`'s ownership check,
1174    /// used to prune a row whose manifest entry AgentMux itself removed in a
1175    /// later release.
1176    ///
1177    /// The generation gate exists for the same reason `bundle_reseed_
1178    /// system_if_owned` has one: on a machine sharing `store.db` across
1179    /// multiple AgentMux builds/versions, an OLDER build's own manifest
1180    /// simply doesn't mention an entry a NEWER build already added — that
1181    /// absence means "I don't know about this yet," not "this was removed."
1182    /// Without gating on generation, an older build's prune pass would
1183    /// delete a newer build's own addition on every one of its startups.
1184    /// Only a row whose generation this process's OWN manifest has already
1185    /// caught up to (or exceeds) is eligible for pruning at all.
1186    ///
1187    /// A row a human has edited (`written_by` on the latest version is
1188    /// anything else) is left in place — same "never clobber a local edit"
1189    /// posture as the reseed path; an orphaned-but-human-owned entry is a
1190    /// human's to clean up, not this seeder's. A row with NO version
1191    /// history at all (should not normally happen — every write path that
1192    /// can create an `is_system` row also records one) is conservatively
1193    /// treated as NOT owned by the seeder and left alone, rather than
1194    /// guessing. Returns `true` only when a deletion actually happened.
1195    ///
1196    /// Also appends a `RETIREMENT_TOMBSTONE_SOURCE`-marked version row in
1197    /// the SAME transaction as the delete — without it, an older AgentMux
1198    /// build (or a rollback to one) sharing this store.db, whose own
1199    /// manifest still lists this id, would see no `db_bundles` row and
1200    /// treat that as "never existed, safe to create," silently resurrecting
1201    /// content this deletion deliberately retired. `bundle_reseed_system_
1202    /// if_owned`'s missing-row branch checks for this tombstone before
1203    /// recreating anything (ReAgent/Codex P2, PR #3244).
1204    ///
1205    /// Also validates the LIVE row's content against what the latest
1206    /// version claims before trusting `written_by` at all — the delete-side
1207    /// mirror of the same check `bundle_reseed_system_if_owned` gained
1208    /// earlier in this PR. Without it, a compatible-schema OLDER build's
1209    /// unversioned write (still possible during a rolling upgrade — see
1210    /// that method's own doc comment) would leave `written_by` stale at
1211    /// the seeder's identity, and this method would DELETE the unrecorded
1212    /// edit outright — strictly worse than the overwrite the reseed-path
1213    /// fix was built to prevent, since deletion cannot be undone from
1214    /// `db_bundles` alone. ReAgent P1, PR #3244.
1215    fn bundle_delete_system_if_owned_unobserved(
1216        &self,
1217        id: &str,
1218        seeder_identity: &str,
1219        caller_manifest_version: u32,
1220    ) -> Result<bool, StoreError> {
1221        let mut conn = self.conn.lock().unwrap();
1222        let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1223
1224        let live: Option<(String, String)> = tx
1225            .query_row(
1226                "SELECT name, instructions FROM db_bundles WHERE id = ?1 AND is_system = 1",
1227                params![id],
1228                |r| Ok((r.get(0)?, r.get(1)?)),
1229            )
1230            .optional()?;
1231        let Some((live_name, live_instructions)) = live else {
1232            return Ok(false);
1233        };
1234
1235        // rowid, not created_at — see bundle_reseed_system_if_owned's own
1236        // comment on the same ordering choice.
1237        let latest: Option<(String, String, String)> = tx
1238            .query_row(
1239                "SELECT written_by, content_hash, source_detail FROM db_bundle_versions
1240                 WHERE bundle_id = ?1
1241                 ORDER BY rowid DESC
1242                 LIMIT 1",
1243                params![id],
1244                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1245            )
1246            .optional()?;
1247
1248        let Some((written_by, content_hash, source_detail)) = latest else {
1249            return Ok(false);
1250        };
1251        let live_hash = super::bundle_versions::content_hash(&live_name, &live_instructions);
1252        if live_hash != content_hash {
1253            return Ok(false);
1254        }
1255        if written_by != seeder_identity {
1256            return Ok(false);
1257        }
1258        let row_generation = manifest_generation_from_source_detail(&source_detail);
1259        if row_generation > caller_manifest_version as u64 {
1260            return Ok(false);
1261        }
1262
1263        let rows = tx.execute("DELETE FROM db_bundles WHERE id = ?1 AND is_system = 1", params![id])?;
1264        if rows > 0 {
1265            let tombstone_detail = format!(r#"{{"manifest_version":{caller_manifest_version}}}"#);
1266            bundle_version_insert_tx(&tx, id, "", "", RETIREMENT_TOMBSTONE_SOURCE, &tombstone_detail, seeder_identity, "")?;
1267        }
1268        tx.commit()?;
1269        Ok(rows > 0)
1270    }
1271
1272    /// [`Self::bundle_reorder_unobserved`], then tell the Global Memory record's
1273    /// observer (`GlobalMemoryChange`).
1274    pub fn bundle_reorder(&self, ordered_ids: &[String]) -> Result<usize, StoreError> {
1275        let r = self.bundle_reorder_unobserved(ordered_ids);
1276        if r.is_ok() {
1277            self.notify_global_memory(super::store::GlobalMemoryChange::Order);
1278        }
1279        r
1280    }
1281
1282    /// Assign `sort_order` to the given bundle ids in the order supplied
1283    /// (position 0, 1, 2, …). Drives the Memory → Global ordering,
1284    /// which in turn controls CLAUDE.md injection order. Ids not present in
1285    /// the table, OR present but `is_system=1`, are skipped silently — a
1286    /// system row's position is fixed (always first, see
1287    /// `bundle_list_global`) and never disturbed by the generic
1288    /// reorder command. Runs in a single transaction so a partial reorder
1289    /// never lands. Returns the number of rows updated.
1290    fn bundle_reorder_unobserved(&self, ordered_ids: &[String]) -> Result<usize, StoreError> {
1291        let mut conn = self.conn.lock().unwrap();
1292        let tx = conn.transaction()?;
1293        let mut updated = 0usize;
1294        {
1295            let mut stmt =
1296                tx.prepare("UPDATE db_bundles SET sort_order = ?1 WHERE id = ?2 AND is_system = 0")?;
1297            for (idx, id) in ordered_ids.iter().enumerate() {
1298                updated += stmt.execute(params![idx as i64, id])?;
1299            }
1300        }
1301        tx.commit()?;
1302        Ok(updated)
1303    }
1304}
1305
1306/// What every bundle write stores in `db_bundles.mcp_servers`, and what every
1307/// read returns, whatever the caller sent or an older build wrote. A bundle
1308/// carries no MCP servers (`SPEC_BUNDLE_CONTENTS_MEMORY_NOT_MCP_2026_10_07.md`
1309/// §3.1), and the column held server configs, credentials included:
1310/// `m0036_drop_bundle_mcp` clears it once, and this keeps it clear and
1311/// unreadable from this build on, whichever client writes.
1312pub(crate) const NO_INLINE_MCP_SERVERS: &str = "[]";
1313
1314fn map_memory_row(row: &rusqlite::Row) -> rusqlite::Result<Bundle> {
1315    Ok(Bundle {
1316        id: row.get(0)?,
1317        name: row.get(1)?,
1318        description: row.get(2)?,
1319        is_blank: row.get::<_, i64>(3)? != 0,
1320        is_global: row.get::<_, i64>(4)? != 0,
1321        provider: row.get(5)?,
1322        model: row.get(6)?,
1323        instructions: row.get(7)?,
1324        context_files: row.get(8)?,
1325        // Column 9 is never read back; see NO_INLINE_MCP_SERVERS.
1326        mcp_servers: NO_INLINE_MCP_SERVERS.to_string(),
1327        skills: row.get(10)?,
1328        sort_order: row.get(11)?,
1329        created_at: row.get(12)?,
1330        updated_at: row.get(13)?,
1331        instructions_by_provider: row.get(14)?,
1332        is_system: row.get::<_, i64>(15)? != 0,
1333    })
1334}