Context
Bug surfaced empirically during the post-#10433-merge A2A coherence verification turn (2026-04-27, session b3c0bfb8-44e1-4646-9c62-110ef16b0fad). After the granular symlink fix landed and the harnesses + bridge daemon restarted, @neo-gemini-3-1-pro sent a verification ping ("re-test A2A post-restart") and @neo-opus-4-7 confirmed cross-process coherence is restored. During that verification, the list_messages({status: 'unread'}) filter was empirically broken.
@neo-gemini-3-1-pro is also investigating in parallel — this ticket serves as the coordination anchor.
The Problem
mcp__neo-mjs-memory-core__list_messages({status: 'unread'}) returns an empty array even when an inbox contains a message with readAt: null (the canonical "unread" state per the schema).
Empirical reproducer (preserved at file time):
- Message:
MESSAGE:8ccca595-55e8-4923-a13c-88c990588230
- Sender:
@neo-gemini-3-1-pro
- Recipient:
@neo-opus-4-7
readAt (raw): null
list_messages({status: 'all'}) → returns this message correctly
list_messages({status: 'unread'}) → returns []
add_memory's response payload mailbox.unreadCount → returns 1 correctly (this isolates the bug)
Bug isolation (sharp):
Two distinct code paths compute "unread" state. They diverge:
| Code path |
Behavior on readAt: null |
add_memory response → buildMailboxDelta helper → unreadCount field |
✓ correctly counts as unread |
list_messages({status: 'unread'}) filter |
✗ excludes the message |
Both paths read the same canonical MESSAGE node with the same readAt: null. Only list_messages's status: 'unread' branch is broken. The shared mailbox-state computation is not affected.
Likely root cause hypotheses (to verify in implementation):
- SQL filter mismatch: the WHERE clause for
status: 'unread' may use readAt IS NULL semantics inconsistently with how the row gets serialized (e.g., json_extract returns 'null' string vs SQL NULL).
- State-machine binary check: the filter may treat unread as
readAt = undefined rather than null — a JS-side === undefined check would fail on a null value.
- Recent regression: the granular sync work landed in #10412 (post-restart bootstrap idempotency) may have changed the WHERE clause shape for status filters.
The buildMailboxDelta helper provides a working reference implementation — diff the two unread-detection logics to surface the divergence.
The Architectural Reality
ai/mcp/server/memory-core/services/MailboxService.mjs — listMessages method handles the status filter. The recipient is computed via SENT_TO edge join (per the recently-clarified schema: to is NOT a node property, it's an edge target).
- The same file's
buildMailboxDelta helper computes unreadCount via the working logic.
test/playwright/unit/ai/mcp/server/memory-core/services/MailboxService.spec.mjs — existing test surface; the unread-filter regression should have a permanent reproducer added per AGENTS.md §10.3.
The Fix
- Diff
listMessages's status filter logic against buildMailboxDelta's unread-detection logic.
- Surface the divergence (likely a SQL/JSON-extract mismatch or a
readAt: undefined vs null JS check).
- Apply the fix to
listMessages so its 'unread' branch sees the same set as buildMailboxDelta reports.
- Add a permanent test in
MailboxService.spec.mjs that asserts list_messages({status: 'unread'}) returns a message with readAt: null (regression-safety per #10174 production-convention describe block conventions).
Acceptance Criteria
Out of Scope
- Refactoring
listMessages to share its filter logic with buildMailboxDelta (a worthwhile follow-up but architecturally larger; this ticket is scoped to fix the divergence, not eliminate it)
- Other potential filter-divergence cases (
fromIdentity, threadId, etc.) — verify they pass parity checks but don't expand scope without evidence of breakage
mark_read semantics (out of scope; the unread filter test should NOT depend on mark_read behavior — assert directly on a readAt: null row)
Avoided Traps
- Trap: Assume the bug is in the shared mailbox-state code. Avoided: the
add_memory response's mailbox.unreadCount: 1 proves the shared logic works; the bug is specifically in list_messages's filter branch.
- Trap: Patch
list_messages to call buildMailboxDelta-internal logic directly. Avoided: that's an architectural refactor disguised as a bug fix; the surface fix should preserve the existing public contract.
- Trap: File a duplicate or reactive ticket without acknowledging Gemini's parallel investigation. Avoided: explicit coordination note above; whichever agent picks this up first should self-assign and ping the other.
Related
- Surfaced post-merge of #10433 (granular symlink fix that restored cross-process A2A coherence)
- Anti-discipline impact: breaks §22.1 mailbox-check Pre-Flight (agents poll
status: 'unread' as canonical truth — if the filter misses unread messages, they won't see them)
- @neo-gemini-3-1-pro investigating in parallel as of 2026-04-27 ~16:21Z
Origin Session ID: b3c0bfb8-44e1-4646-9c62-110ef16b0fad
Retrieval Hint: "list_messages unread filter readAt null divergence buildMailboxDelta parity"
Context
Bug surfaced empirically during the post-#10433-merge A2A coherence verification turn (2026-04-27, session
b3c0bfb8-44e1-4646-9c62-110ef16b0fad). After the granular symlink fix landed and the harnesses + bridge daemon restarted,@neo-gemini-3-1-prosent a verification ping ("re-test A2A post-restart") and@neo-opus-4-7confirmed cross-process coherence is restored. During that verification, thelist_messages({status: 'unread'})filter was empirically broken.@neo-gemini-3-1-pro is also investigating in parallel — this ticket serves as the coordination anchor.
The Problem
mcp__neo-mjs-memory-core__list_messages({status: 'unread'})returns an empty array even when an inbox contains a message withreadAt: null(the canonical "unread" state per the schema).Empirical reproducer (preserved at file time):
MESSAGE:8ccca595-55e8-4923-a13c-88c990588230@neo-gemini-3-1-pro@neo-opus-4-7readAt(raw):nulllist_messages({status: 'all'})→ returns this message correctlylist_messages({status: 'unread'})→ returns[]add_memory's response payloadmailbox.unreadCount→ returns1correctly (this isolates the bug)Bug isolation (sharp):
Two distinct code paths compute "unread" state. They diverge:
readAt: nulladd_memoryresponse →buildMailboxDeltahelper →unreadCountfieldlist_messages({status: 'unread'})filterBoth paths read the same canonical
MESSAGEnode with the samereadAt: null. Onlylist_messages'sstatus: 'unread'branch is broken. The shared mailbox-state computation is not affected.Likely root cause hypotheses (to verify in implementation):
status: 'unread'may usereadAt IS NULLsemantics inconsistently with how the row gets serialized (e.g.,json_extractreturns'null'string vs SQLNULL).readAt = undefinedrather thannull— a JS-side=== undefinedcheck would fail on anullvalue.The
buildMailboxDeltahelper provides a working reference implementation — diff the two unread-detection logics to surface the divergence.The Architectural Reality
ai/mcp/server/memory-core/services/MailboxService.mjs—listMessagesmethod handles thestatusfilter. The recipient is computed viaSENT_TOedge join (per the recently-clarified schema:tois NOT a node property, it's an edge target).buildMailboxDeltahelper computesunreadCountvia the working logic.test/playwright/unit/ai/mcp/server/memory-core/services/MailboxService.spec.mjs— existing test surface; the unread-filter regression should have a permanent reproducer added per AGENTS.md §10.3.The Fix
listMessages'sstatusfilter logic againstbuildMailboxDelta's unread-detection logic.readAt: undefinedvsnullJS check).listMessagesso its'unread'branch sees the same set asbuildMailboxDeltareports.MailboxService.spec.mjsthat assertslist_messages({status: 'unread'})returns a message withreadAt: null(regression-safety per #10174 production-convention describe block conventions).Acceptance Criteria
list_messages({status: 'unread'})returns messages wherereadAt: null(the schema-defined unread state)list_messages({status: 'unread'}).length === buildMailboxDelta(...).unreadCountfor the same identity (parity check between the two code paths)readAt: nullmessagesstatus: 'read'orstatus: 'all'filtersOut of Scope
listMessagesto share its filter logic withbuildMailboxDelta(a worthwhile follow-up but architecturally larger; this ticket is scoped to fix the divergence, not eliminate it)fromIdentity,threadId, etc.) — verify they pass parity checks but don't expand scope without evidence of breakagemark_readsemantics (out of scope; the unread filter test should NOT depend on mark_read behavior — assert directly on areadAt: nullrow)Avoided Traps
add_memoryresponse'smailbox.unreadCount: 1proves the shared logic works; the bug is specifically inlist_messages's filter branch.list_messagesto callbuildMailboxDelta-internal logic directly. Avoided: that's an architectural refactor disguised as a bug fix; the surface fix should preserve the existing public contract.Related
status: 'unread'as canonical truth — if the filter misses unread messages, they won't see them)Origin Session ID: b3c0bfb8-44e1-4646-9c62-110ef16b0fad
Retrieval Hint: "list_messages unread filter readAt null divergence buildMailboxDelta parity"