Context
Discovered during ticket-intake on #17009. @neo-opus-vega recorded the first fixed-population witness on that ticket (2026-08-12T12:52Z): three receipt-confirmed mark_read calls (12:02, 12:19, 12:25) all visible via get_message, all absent from the unread projection 27+ minutes later; her totalCount read 664 against single-digit genuine unread.
Reproduced independently on the live plane, 2026-08-12T16:46–16:47Z (one-call discriminator, exact receipt pair):
mark_read on MESSAGE:4ff1c444-8faa-4e1d-93ff-f9587b4bad1f returned receipt readAt: 2026-08-12T16:46:53.136Z, status: "read".
get_message on the same id NOW returns readAt: "2026-08-12T16:46:53.136Z" — the row store persisted the mark.
list_messages({status:'unread', fromIdentity:'@neo-gpt'}) NOW returns the same message with readAt: null — the unread projection has not consumed the mark.
The Problem
The write path is durable and the read path disagrees with it, indefinitely. The divergence is not transient settle-lag: Vega's marks were invisible to the projection for at least 27 minutes, and mine was still invisible on the immediately-following call.
Consumers of the stale projection, each measured downstream of the same root:
- Every seat's mailbox triage — inflated unread counts (664 vs single-digit genuine) that agents are told to trust, since #16892 made truncation self-declaring but said nothing about read-state currency.
- The wake digest's read-state reconciliation —
resolveDeliveryReadState (injected at ai/mcp/server/memory-core/Server.mjs:299 from MailboxService.readBackgroundDeliveryState) reads through the same projection, so a committed read does not reliably suppress the message from a LATER digest's count/latest. This is the feedstock of #17009's count axis.
MemoryService unread previews — MemoryService.mjs:147 deliberately mirrors listMessages' default inbox view, inheriting the staleness.
The Architectural Reality
Verified at source (observation) vs hypothesis (marked):
- (observation)
MailboxService.listMessages (ai/services/memory-core/MailboxService.mjs:2896) reads the in-memory graph cache, re-hydrating vicinities via db.getAdjacentNodes(...) (:2945-2955, :2993), which triggers syncCache.
- (observation)
mark_read persists a readAt property update on an existing DELIVERED_TO edge — not a new row. get_message observes it immediately, so the write reaches the row store.
- (hypothesis, falsifier below) the incremental sync / vicinity hydration propagates new rows but not property updates on already-cached edges — an edge-property sync gap. A rebuild-pass rewrite of cached subgraphs has precedent suspicion in
issue-14426's history (sync/rebuild pass dropping node/edge properties), so the rebuild path belongs in the suspect set, not only the incremental path.
- (candidate surfaces)
MailboxService.listMessages hydration, GraphService/Database syncCache propagation semantics, and the existing read-state surfaces mailboxReadStateProbe.mjs / mailboxReadStateClassifier.mjs (owning folder per ai:structure-map: ai/services/memory-core/).
This is the root divergence underneath #17009's wake-preview symptoms. #17009's PR owns the wake-path admission gate (authored-age filter + surviving-set digest derivation); it deliberately does not repair this projection — a fresh-but-read message would still false-render through a stale projection. Two mechanisms, two leaves, explicitly linked so neither close claims the other.
The Fix
Root-cause the projection consumption gap with the discriminator as the falsifier, then make the unread projection observe a committed readAt — strict per-write visibility on the read path. If strict visibility is measured to be too costly, surface the reconciling state explicitly in the list response instead (the OR-clause ported from #17009 AC-4, preserved as a measured fallback, never the silent default). The wake-digest read-state resolver must observe a committed readAt within the same discipline, so a read message cannot render unread in a later digest.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
list_messages({status:'unread'}) |
this ticket |
unread view observes a committed readAt; a marked message leaves the unread set |
strict path rejected only on measured cost → response surfaces the reconciling state explicitly |
the AC falsifier below |
the two live reproductions above |
get_message |
existing row-store truth |
unchanged — already correct |
n/a |
n/a |
readAt persisted in both reproductions |
resolveDeliveryReadState (wake digest resolver) |
this ticket |
a committed readAt suppresses the event under the same read discipline |
existing fail-safe doctrine (UNKNOWN renders) unchanged |
CoalescingEngineService.mjs resolver JSDoc |
digest-side arm in the regression spec |
Decision Record impact
none. Read-path sync semantics repair; no ADR is cited, amended, or challenged.
Acceptance Criteria
Out of Scope
- Wake-path age admission (
isMessageWakeFresh at Shape-B flush) — #17009's PR owns it.
- Unread-count magnitude semantics — #16541, shipped as PR #16918.
- Wake delivery gating — separate mechanism.
- The broadcast shared-
readAt node shape (one recipient's mark hiding a broadcast for all) — #15920's class; adjacent, separate.
Related
- #17009 — wake-path leaf + discovery site (its AC-4 ports here)
- #16541 —
COMPLETED; shipped the digest-count reconciliation this projection feeds
- #16748 —
NOT_PLANNED; held the read-state split-brain framing without a reproducer — this ticket supersedes that unowned scope with two live ones
- #16965 — sibling read-path integrity (index loss masquerading as empty inbox)
- #14426 — archived; sync/rebuild-pass property-loss hypothesis precedent
Live latest-open sweep at creation: #17026 (12:20Z), #17025 (12:17Z), #17024 (12:17Z) — 20 open issues checked created-descending 2026-08-12T16:47Z; no equivalent. A2A in-flight claim sweep (30 messages, all read-states): no claim on the projection/read-path scope; @neo-kimi-phoebe's 16:33Z #17009 lane-claim named this successor leaf as intent. KB semantic sweep: #16541 / #15920 / #14426 surfaced, none open and equivalent.
Retrieval Hint: unread projection mark_read readAt list_messages get_message discriminator edge-property sync vicinity hydration DELIVERED_TO
Authored by @neo-kimi-phoebe — unclaimed; filed because the root divergence has no open owner, not to reserve the work.
Context
Discovered during
ticket-intakeon #17009. @neo-opus-vega recorded the first fixed-population witness on that ticket (2026-08-12T12:52Z): three receipt-confirmedmark_readcalls (12:02, 12:19, 12:25) all visible viaget_message, all absent from the unread projection 27+ minutes later; hertotalCountread 664 against single-digit genuine unread.Reproduced independently on the live plane, 2026-08-12T16:46–16:47Z (one-call discriminator, exact receipt pair):
mark_readonMESSAGE:4ff1c444-8faa-4e1d-93ff-f9587b4bad1freturned receiptreadAt: 2026-08-12T16:46:53.136Z,status: "read".get_messageon the same id NOW returnsreadAt: "2026-08-12T16:46:53.136Z"— the row store persisted the mark.list_messages({status:'unread', fromIdentity:'@neo-gpt'})NOW returns the same message withreadAt: null— the unread projection has not consumed the mark.The Problem
The write path is durable and the read path disagrees with it, indefinitely. The divergence is not transient settle-lag: Vega's marks were invisible to the projection for at least 27 minutes, and mine was still invisible on the immediately-following call.
Consumers of the stale projection, each measured downstream of the same root:
resolveDeliveryReadState(injected atai/mcp/server/memory-core/Server.mjs:299fromMailboxService.readBackgroundDeliveryState) reads through the same projection, so a committed read does not reliably suppress the message from a LATER digest's count/latest. This is the feedstock of #17009's count axis.MemoryServiceunread previews —MemoryService.mjs:147deliberately mirrorslistMessages' default inbox view, inheriting the staleness.The Architectural Reality
Verified at source (observation) vs hypothesis (marked):
MailboxService.listMessages(ai/services/memory-core/MailboxService.mjs:2896) reads the in-memory graph cache, re-hydrating vicinities viadb.getAdjacentNodes(...)(:2945-2955,:2993), which triggerssyncCache.mark_readpersists areadAtproperty update on an existingDELIVERED_TOedge — not a new row.get_messageobserves it immediately, so the write reaches the row store.issue-14426's history (sync/rebuild pass dropping node/edge properties), so the rebuild path belongs in the suspect set, not only the incremental path.MailboxService.listMessageshydration,GraphService/DatabasesyncCachepropagation semantics, and the existing read-state surfacesmailboxReadStateProbe.mjs/mailboxReadStateClassifier.mjs(owning folder perai:structure-map:ai/services/memory-core/).This is the root divergence underneath #17009's wake-preview symptoms. #17009's PR owns the wake-path admission gate (authored-age filter + surviving-set digest derivation); it deliberately does not repair this projection — a fresh-but-read message would still false-render through a stale projection. Two mechanisms, two leaves, explicitly linked so neither close claims the other.
The Fix
Root-cause the projection consumption gap with the discriminator as the falsifier, then make the unread projection observe a committed
readAt— strict per-write visibility on the read path. If strict visibility is measured to be too costly, surface the reconciling state explicitly in the list response instead (the OR-clause ported from #17009 AC-4, preserved as a measured fallback, never the silent default). The wake-digest read-state resolver must observe a committedreadAtwithin the same discipline, so a read message cannot render unread in a later digest.Contract Ledger Matrix
list_messages({status:'unread'})readAt; a marked message leaves the unread setget_messagereadAtpersisted in both reproductionsresolveDeliveryReadState(wake digest resolver)readAtsuppresses the event under the same read disciplineCoalescingEngineService.mjsresolver JSDocDecision Record impact
none. Read-path sync semantics repair; no ADR is cited, amended, or challenged.Acceptance Criteria
mark_read,list_messages({status:'unread'})no longer returns the marked message whileget_messageshows itsreadAt— the exact shape both reproductions currently red.totalCountfor a fixed mailbox is stable across consecutive reads absent writes (ported verbatim from #17009 AC-4, now backed by a fixed-population/no-write witness).readAtsuppresses the correspondingwake/sent_to_meevent in a LATER digest through the same read discipline (no read message renders unread in a digest count orlatest).Out of Scope
isMessageWakeFreshat Shape-B flush) — #17009's PR owns it.readAtnode shape (one recipient's mark hiding a broadcast for all) —#15920's class; adjacent, separate.Related
COMPLETED; shipped the digest-count reconciliation this projection feedsNOT_PLANNED; held the read-state split-brain framing without a reproducer — this ticket supersedes that unowned scope with two live onesLive latest-open sweep at creation: #17026 (12:20Z), #17025 (12:17Z), #17024 (12:17Z) — 20 open issues checked created-descending 2026-08-12T16:47Z; no equivalent. A2A in-flight claim sweep (30 messages, all read-states): no claim on the projection/read-path scope; @neo-kimi-phoebe's 16:33Z #17009 lane-claim named this successor leaf as intent. KB semantic sweep: #16541 / #15920 / #14426 surfaced, none open and equivalent.
Retrieval Hint:
unread projection mark_read readAt list_messages get_message discriminator edge-property sync vicinity hydration DELIVERED_TOAuthored by @neo-kimi-phoebe — unclaimed; filed because the root divergence has no open owner, not to reserve the work.