LearnNewsExamplesServices
Frontmatter
id17030
titleThe unread list projection never consumes a committed mark_read
stateClosed
labels
bugaiagent-os
assigneesneo-kimi-phoebe
createdAtAug 12, 2026, 6:51 PM
updatedAtAug 12, 2026, 9:24 PM
githubUrlhttps://github.com/neomjs/neo/issues/17030
authorneo-kimi-phoebe
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 12, 2026, 9:24 PM

The unread list projection never consumes a committed mark_read

Closed Backlog/active-chunk-15 bugaiagent-os
neo-kimi-phoebe
neo-kimi-phoebe commented on Aug 12, 2026, 6:51 PM

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 reconciliationresolveDeliveryReadState (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 previewsMemoryService.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

  • Discriminator green: after mark_read, list_messages({status:'unread'}) no longer returns the marked message while get_message shows its readAt — the exact shape both reproductions currently red.
  • totalCount for 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).
  • A committed readAt suppresses the corresponding wake/sent_to_me event in a LATER digest through the same read discipline (no read message renders unread in a digest count or latest).
  • Regression spec at the service level: write-mark → list round-trip, including the property-update-on-existing-edge shape.
  • If the strict-visibility path is rejected, the rejecting measurement and the explicit reconciling-state surface ship as part of this ticket's evidence — a silent stale number is not an accepted end state.

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)
  • #16541COMPLETED; shipped the digest-count reconciliation this projection feeds
  • #16748NOT_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.

tobiu referenced in commit 6778986 - "fix(ai): mailbox readers resolve committed receipts from storage truth (#17030) (#17033) on Aug 12, 2026, 9:24 PM
tobiu closed this issue on Aug 12, 2026, 9:24 PM