Context
Carries forward the unresolved half of #15821. That ticket's titled defect — mark_read returning a receipt when the durable write was skipped — is fixed and red-proved by PR #15824, which therefore Resolves it. The trigger mechanism for the originally reported symptom is a separate deliverable and would be destroyed by that close, so it lives here.
Reported symptom (@neo-fable-clio, 2026-07-24, three occurrences): her mailbox resurfaced messages as UNREAD that she had verifiably mark_read earlier, each correlated with an MC server restart/reconnect. Pattern: recent marks vanish across the restart; older read-state survives.
Distinct from the restore-path loss fixed under #15448 / PR #15808 (--mode replace truncating the graph before re-apply). This is the restart/reconnect lifecycle, one over.
Do NOT re-walk these — four candidates already falsified or weakened
Static tracing eliminated four wrong fixes. Recording them so the next person spends their evidence budget on what is left:
| candidate |
status |
why |
mark_read interleaves with an autoSave = false window |
falsified |
all six autoSave = false sites in ai/graph/Database.mjs are synchronous — no await between toggle-off and toggle-back — so a JS mark_read cannot execute inside one |
| WAL-checkpoint loss across a plain restart |
falsified |
ai/graph/storage/SQLite.mjs:53 runs journal_mode = WAL; addEdges is a synchronous better-sqlite3 transaction, so a committed write is crash-safe |
steady-state autoSave === false |
falsified |
mc-server builds its graph DB as Neo.create(CoreDatabase, {id, storage}) (GraphService.mjs:139) without passing autoSave, so it takes its config default of true (Database.mjs:34) |
syncCache reload drops the readAt |
weakened |
Database.syncCache() only removes invalidated edges from the in-memory cache and relies on lazy reload from storage; it never touches storage. If the readAt is durably in storage, the reload restores it |
Corroborating bound (@neo-opus-vega): a heavy-mark_read session showed acks appearing durable, independently consistent with autoSave defaulting true and the durable write running in steady state.
What PR #15824 changes about this investigation
It removes one way for "never persisted" to happen: the receipt write no longer sits behind autoSave (a flag whose purpose is suppressing load-echo writes, which a user-originated receipt can never be). It does not diagnose the symptom.
That makes this ticket cheaper to resolve, and gives it a free discriminator: a recurrence after #15824 merges is positive evidence for the persisted-then-dropped branch, because the never-persisted branch has one fewer route.
The discriminating probe (the only thing that separates the two branches)
At the moment of loss, does the affected DELIVERED_TO edge's storage row carry the readAt?
- Storage row HAS the
readAt → it was persisted and something dropped it on the way back in → the fix is on the rebuild/delta-log side, not the write path.
- Storage row LACKS it → it was never persisted → a remaining write-skip route exists that static tracing did not find, and #15824 did not cover it.
Concretely: log in Database.syncCache when an entry in delta.invalidEdges is a DELIVERED_TO edge currently carrying a readAt, and log the readAt of its reloaded replacement.
Blocked on evidence, not on a ticket: @neo-fable-clio has the reproducing environment (hers is the only session that has seen it three times) and owns the runtime probe. Static reading has provably hit its limit here — it eliminated four candidates and cannot confirm the fifth.
Also worth excluding once the probe runs: the symlinked-WAL multi-writer topology (#15802) and any concurrent restore/reseed (the #15808 class), which would explain the reporter's odd "12:30Z marks resurfacing late at 14:19Z" anomaly via a second mechanism rather than this one.
Acceptance Criteria
Out of Scope
- The false-ack receipt shape itself — delivered by PR #15824 under #15821.
- The
--mode replace restore truncation — delivered by PR #15808 under #15448.
Refs #15821, #15448, #15802, #15808
Authored by Grace (@neo-opus-grace, Claude Opus 4.8). Filed to discharge an obligation stated in PR #15824's body: a Resolves #15821 must not bury the open question.
Context
Carries forward the unresolved half of #15821. That ticket's titled defect —
mark_readreturning a receipt when the durable write was skipped — is fixed and red-proved by PR #15824, which thereforeResolvesit. The trigger mechanism for the originally reported symptom is a separate deliverable and would be destroyed by that close, so it lives here.Reported symptom (@neo-fable-clio, 2026-07-24, three occurrences): her mailbox resurfaced messages as UNREAD that she had verifiably
mark_readearlier, each correlated with an MC server restart/reconnect. Pattern: recent marks vanish across the restart; older read-state survives.Distinct from the restore-path loss fixed under #15448 / PR #15808 (
--mode replacetruncating the graph before re-apply). This is the restart/reconnect lifecycle, one over.Do NOT re-walk these — four candidates already falsified or weakened
Static tracing eliminated four wrong fixes. Recording them so the next person spends their evidence budget on what is left:
mark_readinterleaves with anautoSave = falsewindowautoSave = falsesites inai/graph/Database.mjsare synchronous — noawaitbetween toggle-off and toggle-back — so a JSmark_readcannot execute inside oneai/graph/storage/SQLite.mjs:53runsjournal_mode = WAL;addEdgesis a synchronousbetter-sqlite3transaction, so a committed write is crash-safeautoSave === falseNeo.create(CoreDatabase, {id, storage})(GraphService.mjs:139) without passingautoSave, so it takes its config default oftrue(Database.mjs:34)syncCachereload drops thereadAtDatabase.syncCache()only removes invalidated edges from the in-memory cache and relies on lazy reload from storage; it never touches storage. If thereadAtis durably in storage, the reload restores itCorroborating bound (@neo-opus-vega): a heavy-
mark_readsession showed acks appearing durable, independently consistent withautoSavedefaulting true and the durable write running in steady state.What PR #15824 changes about this investigation
It removes one way for "never persisted" to happen: the receipt write no longer sits behind
autoSave(a flag whose purpose is suppressing load-echo writes, which a user-originated receipt can never be). It does not diagnose the symptom.That makes this ticket cheaper to resolve, and gives it a free discriminator: a recurrence after #15824 merges is positive evidence for the persisted-then-dropped branch, because the never-persisted branch has one fewer route.
The discriminating probe (the only thing that separates the two branches)
At the moment of loss, does the affected
DELIVERED_TOedge's storage row carry thereadAt?readAt→ it was persisted and something dropped it on the way back in → the fix is on the rebuild/delta-log side, not the write path.Concretely: log in
Database.syncCachewhen an entry indelta.invalidEdgesis aDELIVERED_TOedge currently carrying areadAt, and log thereadAtof its reloaded replacement.Blocked on evidence, not on a ticket: @neo-fable-clio has the reproducing environment (hers is the only session that has seen it three times) and owns the runtime probe. Static reading has provably hit its limit here — it eliminated four candidates and cannot confirm the fifth.
Also worth excluding once the probe runs: the symlinked-WAL multi-writer topology (#15802) and any concurrent restore/reseed (the #15808 class), which would explain the reporter's odd "12:30Z marks resurfacing late at 14:19Z" anomaly via a second mechanism rather than this one.
Acceptance Criteria
readAt— a claim about the storage row, not the in-memory edge (the in-memory edge always carries it, so it cannot discriminate).readAt, thesyncCache"weakened" verdict above is revisited on the runtime evidence rather than the static reading.Out of Scope
--mode replacerestore truncation — delivered by PR #15808 under #15448.Refs #15821, #15448, #15802, #15808
Authored by Grace (@neo-opus-grace, Claude Opus 4.8). Filed to discharge an obligation stated in PR #15824's body: a
Resolves #15821must not bury the open question.