Skip to main content

agentmux_srv/migrations/
m0024_native_memory_backfill_from_fs.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Finish the job `m0023` was meant to do, and repair the label it left
5//! behind — `SPEC_MEMORY_VERSION_CONTROL_AND_ARMORY_AUDIT_2026_08_19.md`
6//! §9 (rollout backfill) and §4.5 (drift detection).
7//!
8//! ## What went wrong
9//!
10//! §9 asks for "a one-time backfill [of] a single `source: 'agent_inferred'`
11//! version per existing `(agent_id, filename)` row ... so history views
12//! aren't empty for pre-existing memory on upgrade". `m0023` implements
13//! that literally, iterating `db_agent_native_memory` — the **mirror**
14//! table. The spec's own wording ("per existing ... row") is what led
15//! there, and it is wrong in practice for one reason: that mirror is
16//! populated lazily, only once something reads or exports an agent's
17//! memory. On a typical install it is EMPTY, so `m0023` iterates nothing
18//! and backfills nothing, in 0 ms.
19//!
20//! The drift sweep (`backend::native_memory_drift`) does not use the
21//! mirror — it reads the real filesystem, which is authoritative here
22//! (`SPEC_NATIVE_MEMORY_DURABLE_SYNC_2026_08_07.md`). Starting moments
23//! after migrations finish, it finds those same files, sees no recorded
24//! version, and records each as `external_fs_write` — rendered in Memory
25//! as **"Detected outside AgentMux — provenance unknown"**.
26//!
27//! Net effect on every install that upgraded with an unpopulated mirror:
28//! ordinary pre-existing memory files are permanently tagged with a
29//! security-flavored warning, and the benign backfill the spec asked for
30//! never happened. Observed on a real install: `m0023` applied at
31//! `…285933` (0 ms, 0 rows, mirror empty), 21 `external_fs_write` rows
32//! written across 6 agents and 16 files 61 ms later at `…285994`.
33//!
34//! This is NOT a race — `bootstrap` runs migrations to completion before
35//! the drift tasks spawn. Ordering was never the problem; reading the
36//! wrong source was. That is why the fix below is a migration and not a
37//! change to startup sequencing.
38//!
39//! ## What this does
40//!
41//! 1. *(Moved out of this migration by SPEC_MEMORY_FOLLOWS_THE_AGENT
42//!    phase M1 — see `up`.)* **Backfill from the filesystem**, the same source the detector
43//!    trusts, via the same `list_all_memory_targets` enumeration — so the
44//!    two agree by construction instead of by coincidence. Idempotent and
45//!    ordering-safe for exactly the reason `m0023` documents: only a pair
46//!    with NO version yet is touched, so a pair written normally since
47//!    upgrade never gets an out-of-chronology row appended.
48//! 2. **Repair the mislabeled rows** already written by the sweep, under a
49//!    deliberately narrow predicate — see
50//!    `agent_native_memory_version_relabel_rollout_first_sight`, which
51//!    documents each clause. It cannot touch a genuine external write.
52//!
53//! ## Why this is Channel-scoped, and which store enumerates
54//!
55//! The two stores are not interchangeable, and picking the wrong one here
56//! would have reproduced the exact bug this migration exists to fix
57//! (caught in review before merge, not by me):
58//!
59//! - **Versions** (`db_agent_native_memory_versions`) live in the SHARED
60//!   store, so every read/insert/relabel below uses `shared_store_path`.
61//! - **Enumeration** does not. `list_all_memory_targets` calls
62//!   `agent_def_list()`, which prepares against `db_agents`, and
63//!   `memory_dir_for_agent_by_id`, which reads `db_agent_content` — both
64//!   per-channel tables created by `run_object_schema`, absent from
65//!   `Store::open_shared` by design (see its doc comment). Handing it the
66//!   shared store makes `agent_def_list()` fail with a SQL error that
67//!   `list_all_memory_targets` swallows (`if let Ok(agents)`), so that
68//!   whole enumeration path silently yields nothing and only currently
69//!   *active* registry agents get backfilled — every persisted-but-idle
70//!   agent missed, which is the same "read the wrong source, get an empty
71//!   list, look successful" failure as `m0023`.
72//!
73//! So the migration is `Channel`-scoped: it runs once per channel, with
74//! that channel's own store doing the enumeration — matching what the
75//! drift sweep itself gets in production (`state.mstore` is the channel
76//! store) — while still reading and writing versions in the shared store.
77//! The repair half is idempotent, so running once per channel is safe.
78//!
79//! Order matters: repair runs FIRST. Relabeling turns a row into the
80//! `agent_inferred` version the pair should have had, which then makes
81//! step 2's "has no version yet" test correctly skip that pair. Backfilling
82//! first would leave the pair versioned but still mislabeled, and the
83//! repair's `parent_version_id IS NULL` clause would no longer match.
84
85use std::sync::Arc;
86
87use crate::backend::storage::store::Store;
88
89use super::{Migration, MigrationContext, MigrationError, MigrationScope};
90
91pub struct M0024NativeMemoryBackfillFromFs;
92
93impl Migration for M0024NativeMemoryBackfillFromFs {
94    fn id(&self) -> &'static str { "0024_native_memory_backfill_from_fs" }
95    fn scope(&self) -> MigrationScope { MigrationScope::Channel }
96    fn description(&self) -> &'static str {
97        "Backfill native memory versions from disk; repair mislabeled rollout rows"
98    }
99
100    fn up(&self, ctx: &MigrationContext) -> Result<(), MigrationError> {
101        if !ctx.shared_store_path.exists() {
102            return Ok(());
103        }
104        let store = Arc::new(
105            Store::open_shared(&ctx.shared_store_path).map_err(|e| {
106                MigrationError(format!("native_memory_backfill_from_fs: open shared store: {e}"))
107            })?,
108        );
109
110        // ── 1. Repair first (see the module doc for why the order matters).
111        let relabeled = store
112            .agent_native_memory_version_relabel_rollout_first_sight()
113            .map_err(|e| {
114                MigrationError(format!("native_memory_backfill_from_fs: relabel: {e}"))
115            })?;
116
117        // ── 2. The filesystem backfill no longer runs here. Migrations run
118        // before the identity stores are attached, so an agent's
119        // account-linked directory can't be resolved yet, and the registry
120        // guess it fell back to attributed one agent's files to another.
121        // Startup runs it after the stores are attached instead
122        // (`native_memory_drift::backfill_newly_verified`, in the drift sweep,
123        // SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md phase M1).
124        let _ = &ctx.channel_store_path;
125        tracing::info!(relabeled, "native_memory_backfill_from_fs: complete (repair only)");
126        Ok(())
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    fn ctx_for(shared_store_path: std::path::PathBuf) -> MigrationContext {
135        MigrationContext {
136            home: std::env::temp_dir(),
137            data_dir: std::env::temp_dir(),
138            shared_store_path,
139            channel_store_path: std::env::temp_dir().join("unused-objects.db"),
140        }
141    }
142
143    /// Record that `m0023` ran just now, which is what puts a version
144    /// inserted by a test inside the repair's rollout window. Writes
145    /// through its own connection to the same file rather than the
146    /// `Store`, whose `conn` is private to `backend::storage`.
147    fn record_m0023_now(db_path: &std::path::Path) {
148        let conn = rusqlite::Connection::open(db_path).unwrap();
149        let secs = std::time::SystemTime::now()
150            .duration_since(std::time::UNIX_EPOCH)
151            .unwrap()
152            .as_secs() as i64;
153        let applied_at: String = conn
154            .query_row(
155                "SELECT strftime('%Y-%m-%dT%H:%M:%S+00:00', ?1, 'unixepoch')",
156                rusqlite::params![secs],
157                |r| r.get(0),
158            )
159            .unwrap();
160        conn.execute(
161            "INSERT INTO db_migrations (id, applied_at, duration_ms, scope)
162             VALUES ('0023_native_memory_versions_backfill', ?1, 0, 'global')",
163            rusqlite::params![applied_at],
164        )
165        .unwrap();
166    }
167
168    /// The real-world regression, end to end at the migration level: an
169    /// install where m0023 backfilled nothing and the sweep then claimed
170    /// the pre-existing files as "detected outside AgentMux".
171    #[test]
172    fn repairs_the_mislabeled_rollout_rows() {
173        let tmp = tempfile::NamedTempFile::new().unwrap();
174        let store = Store::open_shared(tmp.path()).unwrap();
175        record_m0023_now(tmp.path());
176        store
177            .agent_native_memory_version_insert(
178                "agent-1", "MEMORY.md", "pre-existing", "external_fs_write",
179                r#"{"detected_via":"reconciliation_sweep"}"#, "",
180            )
181            .unwrap();
182
183        M0024NativeMemoryBackfillFromFs.up(&ctx_for(tmp.path().to_path_buf())).unwrap();
184
185        let versions = store.agent_native_memory_version_list("agent-1", "MEMORY.md").unwrap();
186        assert_eq!(versions.len(), 1, "repair must relabel in place, never append a row");
187        assert_eq!(versions[0].source, "agent_inferred");
188    }
189
190    /// Running it twice must not double-apply or append anything — an
191    /// operator re-running migrations is routine.
192    #[test]
193    fn is_idempotent_on_a_second_run() {
194        let tmp = tempfile::NamedTempFile::new().unwrap();
195        let store = Store::open_shared(tmp.path()).unwrap();
196        record_m0023_now(tmp.path());
197        store
198            .agent_native_memory_version_insert(
199                "agent-1", "MEMORY.md", "pre-existing", "external_fs_write",
200                r#"{"detected_via":"reconciliation_sweep"}"#, "",
201            )
202            .unwrap();
203
204        let ctx = ctx_for(tmp.path().to_path_buf());
205        M0024NativeMemoryBackfillFromFs.up(&ctx).unwrap();
206        M0024NativeMemoryBackfillFromFs.up(&ctx).unwrap();
207
208        let versions = store.agent_native_memory_version_list("agent-1", "MEMORY.md").unwrap();
209        assert_eq!(versions.len(), 1);
210        assert_eq!(versions[0].source, "agent_inferred");
211    }
212
213    /// A store with no shared file at all must not error — the migration
214    /// runner calls every migration on every install.
215    #[test]
216    fn is_a_no_op_when_the_shared_store_does_not_exist() {
217        let missing = std::env::temp_dir().join("agentmux-m0024-does-not-exist.db");
218        let _ = std::fs::remove_file(&missing);
219        M0024NativeMemoryBackfillFromFs.up(&ctx_for(missing)).unwrap();
220    }
221}