Expand description
Out-of-band write detection for native memory files — §4.5 of
docs/specs/SPEC_MEMORY_VERSION_CONTROL_AND_ARMORY_AUDIT_2026_08_19.md.
Claude Code’s CLI writes memory .md files directly to
~/.claude/projects/<hash>/memory/*.md, with no obligation to go
through agent:memory:write_file/memory.write — an agent’s own
general-purpose filesystem tools can (and, per this spec’s own
motivating incident, did) write there directly. When that happens, no
RPC fires, so nothing in native_memory_handlers.rs or app_api::mod
ever sees it.
Two independent layers close that gap, so failure of one degrades rather than blinds the whole system:
- Fast path (
spawn_fast_path) — subscribe to every known agent’s memory directory via the sharedFsWatchPool(backend::fs_watch), and record a newexternal_fs_writeversion on any change whose hash doesn’t match the latest recorded version. - Slow path (
reconciliation_sweep_once) — a periodic sweep, run on the same cadence class asFsWatchPool’s ownHEALTH_SWEEP_INTERVAL(30s), that hashes every known agent’s live memory files and compares against the latest recorded version — the backstop for a missed fs-watch event, a watch still in retry-backoff, or srv having been down entirely when the write happened.
Both layers call the same check_and_record_drift — the only
difference between them is how they learn a file might have changed.
Neither promises zero data loss (see the spec’s §4.5 “precision,
honestly bounded” section): two out-of-band writes to the same file
between one sweep and the next collapse into one detected version, and
source_detail never guesses a session_id for a drift-detected write.
Constants§
- MAX_
MEMORY_ 🔒FILE_ BYTES - Same cap
native_memory_handlers.rs/app_api::modalready use for a memory file’s live content. - SWEEP_
INTERVAL 🔒 - Same cadence class as
FsWatchPool::HEALTH_SWEEP_INTERVAL(backend/fs_watch/recovery.rs) — chosen for the same reason: frequent enough that an operator reviewing history isn’t waiting long for a drifted write to surface, infrequent enough not to matter for a background sweep over what’s normally a handful of small markdown files per agent.
Functions§
- backfill_
first_ 🔒sight_ versions_ for - backfill_
newly_ 🔒verified - Give every verified agent’s memory file with no recorded version its
first one, labelled
agent_inferred(“Agent” in Memory), before the drift detector starts. Otherwise the detector finds these ordinary files on its first sweep and records them asexternal_fs_write, “Detected outside AgentMux”, as it did for every file after a new local-build channel (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md §1.3, phase M1). - capture_
into_ 🔒record - Also record the provider’s writes into the agent’s memory record — a folder its spawn has reconciled, and whose claim it still holds alone (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md §2.1.3). Capture only; the folder isn’t touched.
- check_
and_ 🔒record_ drift - Compare
live_contentagainst the latest recorded version of(agent_id, filename); if different (including “no version recorded yet”), insert a new version taggedsource: "external_fs_write"withsource_detail: {"detected_via": detected_via}. ReturnsOk(true)if a new version was recorded,Ok(false)if the content already matched (no drift to report). - read_
memory_ 🔒file_ lossy - Read one
.mdfile’s content the same way the rest of the native-memory code does: lossy UTF-8. Unlikenative_memory_handlers.rs’s own mirror-refresh path — which truncates an oversized file and separately reports the filename so an interactive caller (bundle.export_for_agent) can surface a warning — the drift detector has no interactive caller to warn. Silently truncating here would instead record the truncated tail as a new version, permanently misrepresenting content the file never actually contained as if it were authoritative (reagent P2 on PR #2675). So an oversized file is refused outright (ErrorKind:: InvalidData, distinguishable from a transient I/O error) rather than read partially; callers log and skip it, same treatment as any other per-file read failure. - reconciliation_
sweep_ 🔒once - One pass over every agent definition with a configured working
directory: hash each live
.mdfile in its memory dir and record drift if found. Returns the number of drifted files recorded (for logging; tests assert on it directly). Errors from an individual agent/file are logged and swallowed — one agent’s bad state (e.g. a permissions issue) must not stop the sweep from covering every other agent. - refresh_
subscriptions 🔒 - spawn
- Start both layers. Call once at server startup with a fully-built
AppState’sfs_watch_pool/mstore/id_store. Returns immediately — both loops run as spawned background tasks for the lifetime of the process (no shutdown handle; srv itself owns the process lifetime). - spawn_
fast_ 🔒path - Maintains a
memory_dir -> agent_idmap, refreshed everySWEEP_INTERVALtick (piggybacking on the same cadence as the slow path rather than requiring a separate agent-spawn/teardown hook — a newly-defined agent is picked up within one tick, not instantly, which is an acceptable, honestly-bounded lag for a fast path whose whole purpose is narrowing a gap, not eliminating it).FsWatchPoolsubscriptions have noDrop-based teardown (seepool.rs— holding or discarding aSubscriptionvalue doesn’t affect the underlying watch), so a dir is watched for the life of the process once first seen; we only need to track which dirs we’ve already calledsubscribe_diron to avoid redundant re-subscription every tick. - spawn_
slow_ 🔒path - sweep_
one_ 🔒agent_ dir