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}