Skip to main content

agentmux_srv/backend/storage/
bundle_versions.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Append-only version history for Global Memory entry content — mirrors
5//! `agent_native_memory_versions.rs`'s shape exactly, adapted for a single
6//! `bundle_id` key instead of that table's composite `(agent_id, filename)`
7//! (a Bundle's identity is already a single id). See
8//! `docs/specs/SPEC_AGENT_FACING_GLOBAL_MEMORY_API_2026_09_15.md` Phase 0 —
9//! Global Memory (`db_bundles`) previously had no audit trail at all, only
10//! `updated_at`; this exists specifically because Global Memory is about to
11//! gain a write path an agent, not just a human in Memory, can reach.
12//!
13//! The WRITE side (`bundle_version_insert`, called via
14//! `Store::bundle_upsert_with_version` in `bundles.rs` on every
15//! `globalmemory.write` AND Memory → Global's own `upsertmemory` save path)
16//! and the READ side (`bundle_version_list`/`bundle_version_get`, called by
17//! `global_memory_history_impl`/`global_memory_diff_impl`/
18//! `global_memory_revert_impl` in `app_api/mod.rs`, backing the
19//! `GlobalMemoryHistory`/`GlobalMemoryDiff`/`GlobalMemoryRevert` MCP tools)
20//! are both wired into the agent-facing MCP surface now — see
21//! `docs/specs/SPEC_AGENT_FACING_GLOBAL_MEMORY_API_2026_09_15.md`. No
22//! Memory pane surface for this yet — that remains out of scope (MCP/REST
23//! only, per that spec's own non-goals).
24
25use rusqlite::params;
26use sha2::{Digest, Sha256};
27
28use super::error::StoreError;
29use super::store::Store;
30
31/// One version of one bundle's Global Memory content, including its full
32/// `name`/`instructions`. Deliberately no serde derives, same as
33/// `NativeMemoryVersion` — a wire type belongs in `rpc_types`, not here.
34#[derive(Debug, Clone, PartialEq)]
35pub struct BundleVersion {
36    pub id: String,
37    pub bundle_id: String,
38    pub name: String,
39    pub instructions: String,
40    pub content_hash: String,
41    pub parent_version_id: Option<String>,
42    pub source: String,
43    pub source_detail: String,
44    /// The TRUSTED writer identity — an agent's own `AGENTMUX_AGENT_ID`
45    /// (unforgeable, stamped server-side by the caller of
46    /// `bundle_upsert_with_version`, never read from caller-supplied
47    /// `source`/`source_detail`) or `"armory-ui"` for a write through the
48    /// human-facing Memory editor. Distinct from `source` on purpose
49    /// (codex P2, PR #3237): `source` is free-form annotation the caller
50    /// can say anything in (an agent could label its own write
51    /// `source: "human"`), so it alone cannot answer "who actually made
52    /// this change" — this field is what a reviewer should trust for that.
53    pub written_by: String,
54    /// Identity M4c-1 (SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md
55    /// §6.5.9): the writer's UID beside `written_by`, taken from the
56    /// request's `Caller` — its `X-Agent-Token` — never from a body field.
57    /// Empty = unknown: an Unattributed write, the Memory pane, a seeder.
58    /// Dual-written; nothing branches on it.
59    pub written_by_uid: String,
60    pub created_at: i64,
61}
62
63/// A version's metadata without its full `name`/`instructions` — the
64/// list-view shape.
65#[derive(Debug, Clone, PartialEq)]
66pub struct BundleVersionSummary {
67    pub id: String,
68    pub content_hash: String,
69    pub parent_version_id: Option<String>,
70    pub source: String,
71    pub source_detail: String,
72    pub written_by: String,
73    /// See [`BundleVersion::written_by_uid`].
74    pub written_by_uid: String,
75    pub created_at: i64,
76}
77
78use agentmux_common::time::now_ms;
79
80/// Hashes `name` + `instructions` together (not `instructions` alone) —
81/// unlike native memory, where a version's whole identity IS its content, a
82/// bundle's `name` is content too (renaming a Global Memory entry without
83/// touching its body is a real, meaningful edit an operator or agent might
84/// make, and should still produce a new version + a changed hash).
85pub(crate) fn content_hash(name: &str, instructions: &str) -> String {
86    let mut hasher = Sha256::new();
87    hasher.update(name.as_bytes());
88    hasher.update(b"\0");
89    hasher.update(instructions.as_bytes());
90    hex::encode(hasher.finalize())
91}
92
93/// Appends a version row within an ALREADY-OPEN transaction — the shared
94/// implementation both `Store::bundle_version_insert` below (opens its own
95/// transaction) and `Store::bundle_upsert_with_version` (`bundles.rs`,
96/// reuses the SAME transaction as its own `db_bundles` write, so the bundle
97/// row and its version can never observably diverge under a concurrent
98/// writer — codex P2, PR #3237) call, rather than duplicating the
99/// parent-chain-lookup + INSERT SQL in two places. `pub(crate)`, not
100/// private: `bundles.rs` is a sibling module in this same crate, not this
101/// one.
102#[allow(clippy::too_many_arguments)]
103pub(crate) fn bundle_version_insert_tx(
104    tx: &rusqlite::Transaction,
105    bundle_id: &str,
106    name: &str,
107    instructions: &str,
108    source: &str,
109    source_detail: &str,
110    written_by: &str,
111    written_by_uid: &str,
112) -> Result<BundleVersion, StoreError> {
113    let id = uuid::Uuid::new_v4().to_string();
114    let hash = content_hash(name, instructions);
115    let created_at = now_ms();
116    let parent_version_id: Option<String> = {
117        let mut stmt = tx.prepare(
118            "SELECT id FROM db_bundle_versions
119             WHERE bundle_id = ?1
120             ORDER BY created_at DESC, rowid DESC
121             LIMIT 1",
122        )?;
123        match stmt.query_row(params![bundle_id], |row| row.get::<_, String>(0)) {
124            Ok(id) => Some(id),
125            Err(rusqlite::Error::QueryReturnedNoRows) => None,
126            Err(e) => return Err(e.into()),
127        }
128    };
129    tx.execute(
130        "INSERT INTO db_bundle_versions
131             (id, bundle_id, name, instructions, content_hash, parent_version_id, source, source_detail, written_by, created_at, written_by_uid)
132         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
133        params![
134            id,
135            bundle_id,
136            name,
137            instructions,
138            hash,
139            parent_version_id,
140            source,
141            source_detail,
142            written_by,
143            created_at,
144            written_by_uid,
145        ],
146    )?;
147
148    Ok(BundleVersion {
149        id,
150        bundle_id: bundle_id.to_string(),
151        name: name.to_string(),
152        instructions: instructions.to_string(),
153        content_hash: hash,
154        parent_version_id,
155        source: source.to_string(),
156        source_detail: source_detail.to_string(),
157        written_by: written_by.to_string(),
158        written_by_uid: written_by_uid.to_string(),
159        created_at,
160    })
161}
162
163impl Store {
164    /// Records a new version for `bundle_id`, chained onto whatever the
165    /// current latest version for this bundle is (`None` for a brand-new
166    /// bundle's first version). Every call records a version, even if
167    /// content is byte-identical to the previous one — same "simplicity
168    /// over cleverness, no dedup-by-hash" posture as
169    /// `agent_native_memory_version_insert`. `BEGIN IMMEDIATE` for the same
170    /// cross-process reason that function's own doc comment gives.
171    ///
172    /// Standalone (not composed with a `db_bundles` write) — kept for
173    /// direct testing and any future caller that only needs to append a
174    /// version without also touching the bundle row itself. Production
175    /// write paths use `Store::bundle_upsert_with_version` (`bundles.rs`)
176    /// instead, which keeps the bundle row and its version atomic.
177    #[cfg(test)]
178    pub fn bundle_version_insert(
179        &self,
180        bundle_id: &str,
181        name: &str,
182        instructions: &str,
183        source: &str,
184        source_detail: &str,
185        written_by: &str,
186    ) -> Result<BundleVersion, StoreError> {
187        let mut conn = self.conn.lock().unwrap();
188        let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
189        // No `Caller` reaches this standalone path, so no writer UID.
190        let version = bundle_version_insert_tx(&tx, bundle_id, name, instructions, source, source_detail, written_by, "")?;
191        tx.commit()?;
192        Ok(version)
193    }
194
195    /// History for one bundle, newest first — metadata only, no content
196    /// (mirrors `agent_native_memory_version_list`). Not called by the
197    /// Operator Config reseed path — `Store::bundle_reseed_system_if_owned`
198    /// (`bundles.rs`) performs its own inline `SELECT written_by,
199    /// content_hash, source_detail ... LIMIT 1` query instead, so its
200    /// ownership check and the write it gates happen in one transaction
201    /// (see that method's own doc comment for why). Backs the
202    /// `GlobalMemoryHistory` MCP tool (`global_memory_history_impl`,
203    /// `server/app_api/mod.rs`) as of
204    /// SPEC_AGENT_FACING_GLOBAL_MEMORY_API_2026_09_15.md's Phase-3
205    /// follow-up — also still exercised directly by this file's own tests.
206    pub fn bundle_version_list(&self, bundle_id: &str) -> Result<Vec<BundleVersionSummary>, StoreError> {
207        let conn = self.conn.lock().unwrap();
208        let mut stmt = conn.prepare(
209            "SELECT id, content_hash, parent_version_id, source, source_detail, written_by, created_at, written_by_uid
210             FROM db_bundle_versions
211             WHERE bundle_id = ?1
212             ORDER BY created_at DESC, rowid DESC",
213        )?;
214        let rows = stmt
215            .query_map(params![bundle_id], |row| {
216                Ok(BundleVersionSummary {
217                    id: row.get(0)?,
218                    content_hash: row.get(1)?,
219                    parent_version_id: row.get(2)?,
220                    source: row.get(3)?,
221                    source_detail: row.get(4)?,
222                    written_by: row.get(5)?,
223                    created_at: row.get(6)?,
224                    written_by_uid: row.get(7)?,
225                })
226            })?
227            .collect::<Result<Vec<_>, _>>()?;
228        Ok(rows)
229    }
230
231    /// One version by id, full content included — the lookup `diff`/`revert`
232    /// build on (mirrors `agent_native_memory_version_get`). Called by
233    /// `global_memory_diff_impl`/`global_memory_revert_impl`
234    /// (`app_api/mod.rs`), which back the `GlobalMemoryDiff`/
235    /// `GlobalMemoryRevert` MCP tools.
236    pub fn bundle_version_get(&self, version_id: &str) -> Result<Option<BundleVersion>, StoreError> {
237        let conn = self.conn.lock().unwrap();
238        let mut stmt = conn.prepare(
239            "SELECT id, bundle_id, name, instructions, content_hash, parent_version_id, source, source_detail, written_by, created_at, written_by_uid
240             FROM db_bundle_versions
241             WHERE id = ?1",
242        )?;
243        match stmt.query_row(params![version_id], |row| {
244            Ok(BundleVersion {
245                id: row.get(0)?,
246                bundle_id: row.get(1)?,
247                name: row.get(2)?,
248                instructions: row.get(3)?,
249                content_hash: row.get(4)?,
250                parent_version_id: row.get(5)?,
251                source: row.get(6)?,
252                source_detail: row.get(7)?,
253                written_by: row.get(8)?,
254                created_at: row.get(9)?,
255                written_by_uid: row.get(10)?,
256            })
257        }) {
258            Ok(v) => Ok(Some(v)),
259            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
260            Err(e) => Err(e.into()),
261        }
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    fn test_store() -> Store {
270        Store::open_shared(std::path::Path::new(":memory:")).unwrap()
271    }
272
273    #[test]
274    fn insert_chains_parent_version_id() {
275        let store = test_store();
276        let v1 = store
277            .bundle_version_insert("bundle-1", "Coding Standards", "use tabs", "agent_inferred", "{}", "agent-1")
278            .unwrap();
279        assert!(v1.parent_version_id.is_none());
280
281        let v2 = store
282            .bundle_version_insert("bundle-1", "Coding Standards", "use spaces", "agent_inferred", "{}", "agent-1")
283            .unwrap();
284        assert_eq!(v2.parent_version_id, Some(v1.id.clone()));
285        assert_ne!(v1.content_hash, v2.content_hash);
286    }
287
288    #[test]
289    fn list_is_newest_first_and_scoped_to_one_bundle() {
290        let store = test_store();
291        store
292            .bundle_version_insert("bundle-a", "A", "content 1", "agent_inferred", "{}", "agent-1")
293            .unwrap();
294        let a2 = store
295            .bundle_version_insert("bundle-a", "A", "content 2", "agent_inferred", "{}", "agent-1")
296            .unwrap();
297        store
298            .bundle_version_insert("bundle-b", "B", "unrelated", "agent_inferred", "{}", "agent-1")
299            .unwrap();
300
301        let history = store.bundle_version_list("bundle-a").unwrap();
302        assert_eq!(history.len(), 2);
303        assert_eq!(history[0].id, a2.id, "newest first");
304    }
305
306    #[test]
307    fn get_returns_full_content_by_id() {
308        let store = test_store();
309        let v = store
310            .bundle_version_insert("bundle-1", "Name", "the body", "human", "{}", "armory-ui")
311            .unwrap();
312        let fetched = store.bundle_version_get(&v.id).unwrap().expect("version exists");
313        assert_eq!(fetched.instructions, "the body");
314        assert_eq!(fetched.bundle_id, "bundle-1");
315        assert_eq!(fetched.written_by, "armory-ui");
316    }
317
318    #[test]
319    fn get_returns_none_for_unknown_id() {
320        let store = test_store();
321        assert!(store.bundle_version_get("does-not-exist").unwrap().is_none());
322    }
323
324    #[test]
325    fn renaming_without_changing_instructions_still_changes_the_hash() {
326        let store = test_store();
327        let v1 = store
328            .bundle_version_insert("bundle-1", "Old Name", "same body", "agent_inferred", "{}", "agent-1")
329            .unwrap();
330        let v2 = store
331            .bundle_version_insert("bundle-1", "New Name", "same body", "agent_inferred", "{}", "agent-1")
332            .unwrap();
333        assert_ne!(v1.content_hash, v2.content_hash, "renaming is a real edit, not a no-op");
334    }
335
336    /// codex P2, PR #3237: `written_by` must reflect the TRUSTED caller
337    /// identity, independent of whatever the caller claims via
338    /// `source`/`source_detail` — an agent could otherwise claim
339    /// `source: "human"` with nothing to contradict it.
340    #[test]
341    fn written_by_is_independent_of_the_caller_supplied_source() {
342        let store = test_store();
343        let v = store
344            .bundle_version_insert("bundle-1", "Name", "body", "human", "{}", "agent-should-not-be-trusted-as-human")
345            .unwrap();
346        assert_eq!(v.source, "human", "source is caller-supplied annotation, kept as-is");
347        assert_eq!(v.written_by, "agent-should-not-be-trusted-as-human", "written_by is the real, separate identity field");
348    }
349}