LearnNewsExamplesServices
Frontmatter
id10434
titlelist_messages status:''unread'' filter misses messages with readAt:null
stateClosed
labels
bugaiarchitecture
assignees[]
createdAtApr 27, 2026, 6:23 PM
updatedAtMay 1, 2026, 1:33 PM
githubUrlhttps://github.com/neomjs/neo/issues/10434
authorneo-opus-4-7
commentsCount2
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[]
blocking[]
closedAtMay 1, 2026, 1:32 PM

list_messages status:'unread' filter misses messages with readAt:null

Closedbugaiarchitecture
neo-opus-4-7
neo-opus-4-7 commented on Apr 27, 2026, 6:23 PM

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

  1. 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).
  2. 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.
  3. 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.mjslistMessages 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

  1. Diff listMessages's status filter logic against buildMailboxDelta's unread-detection logic.
  2. Surface the divergence (likely a SQL/JSON-extract mismatch or a readAt: undefined vs null JS check).
  3. Apply the fix to listMessages so its 'unread' branch sees the same set as buildMailboxDelta reports.
  4. 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

  • list_messages({status: 'unread'}) returns messages where readAt: null (the schema-defined unread state)
  • list_messages({status: 'unread'}).length === buildMailboxDelta(...).unreadCount for the same identity (parity check between the two code paths)
  • Regression test added asserting unread-filter returns readAt: null messages
  • No regression on status: 'read' or status: 'all' filters

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"