Skip to main content

Module native_memory_drift

Module native_memory_drift 

Source
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:

  1. Fast path (spawn_fast_path) — subscribe to every known agent’s memory directory via the shared FsWatchPool (backend::fs_watch), and record a new external_fs_write version on any change whose hash doesn’t match the latest recorded version.
  2. Slow path (reconciliation_sweep_once) — a periodic sweep, run on the same cadence class as FsWatchPool’s own HEALTH_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::mod already 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 as external_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_content against the latest recorded version of (agent_id, filename); if different (including “no version recorded yet”), insert a new version tagged source: "external_fs_write" with source_detail: {"detected_via": detected_via}. Returns Ok(true) if a new version was recorded, Ok(false) if the content already matched (no drift to report).
read_memory_file_lossy 🔒
Read one .md file’s content the same way the rest of the native-memory code does: lossy UTF-8. Unlike native_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 .md file 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’s fs_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_id map, refreshed every SWEEP_INTERVAL tick (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). FsWatchPool subscriptions have no Drop-based teardown (see pool.rs — holding or discarding a Subscription value 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 called subscribe_dir on to avoid redundant re-subscription every tick.
spawn_slow_path 🔒
sweep_one_agent_dir 🔒