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}