---
id: 10434
title: 'list_messages status:''unread'' filter misses messages with readAt:null'
state: CLOSED
labels:
  - bug
  - ai
  - architecture
assignees: []
createdAt: '2026-04-27T16:23:24Z'
updatedAt: '2026-06-07T17:22:11Z'
githubUrl: 'https://github.com/neomjs/neo/issues/10434'
author: neo-opus-ada
commentsCount: 2
parentIssue: null
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy: []
blocking: []
closedAt: '2026-05-01T11:32:59Z'
---
# list_messages status:'unread' filter misses messages with readAt:null

## 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-pro` sent a verification ping ("re-test A2A post-restart") and `@neo-opus-ada` confirmed cross-process coherence is restored. During that verification, the `list_messages({status: 'unread'})` filter was empirically broken.

@neo-gemini-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-pro`
- Recipient: `@neo-opus-ada`
- `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.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

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-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"

## Timeline

- 2026-04-27T16:23:26Z @neo-opus-ada added the `bug` label
- 2026-04-27T16:23:26Z @neo-opus-ada added the `ai` label
- 2026-04-27T16:23:26Z @neo-opus-ada added the `architecture` label
### @neo-opus-ada - 2026-05-01T10:40:09Z

**Input from Claude Opus 4.7 (Claude Code):**

> ❋ ## Phase 1 capture — bug does NOT reproduce on current dev tip
> 
> Empirical test against current `origin/dev` (`d84c5e1e1`+) on the canonical instance:
> 
> ```
> list_messages({box: 'inbox', status: 'unread', limit: 5})
> → returns 5 messages, all with readAt: null
> → includes both legacy and current-session messages from multiple senders
> ```
> 
> The reproducer described in the ticket body (`status: 'unread'` returning `[]` while `status: 'all'` returns the same message correctly) does NOT manifest on the current code. Active mailbox state with multiple unread messages from `@neo-gemini-3-1-pro` and `@neo-gpt` filters cleanly.
> 
> ## Probable cause: side-effect fix during subsequent merges
> 
> `git log --since='2 weeks ago' -- ai/mcp/server/memory-core/services/MailboxService.mjs` shows substantial activity in the substrate since the 2026-04-27 reproducer:
> 
> - `4deb52542` fix(memory-core): suppress sunset self-DM wakes (#10498)
> - `f19828597` feat/fix: Support @me addressing + mandate exact PR close syntax (#10426)
> - `ebe5014f9` finalize wake substrate notifications (#10358)
> - TTL/Expired sweeper, transactional load-check-insert (#10347), atomic linkNodes (#10353)
> 
> Any of these graph-substrate fixes could have addressed the underlying SENT_TO/SENT_BY edge resolution OR the `db.getAdjacentNodes` cache-coherence path that was hypothesized as the root cause in the ticket body's hypothesis space.
> 
> ## Code-path verification (no changes needed for the empirical fix)
> 
> `MailboxService.listMessages:386-388`:
> 
> ```js
> const isUnread = !messageNode.properties.readAt;
> if (status === 'unread' && !isUnread) continue;
> if (status === 'read' && isUnread) continue;
> ```
> 
> The filter logic is correct: `!null === true` → `isUnread === true` → message preserved when `status === 'unread'`. So if the ticket body's hypothesis (`'null'` string vs `null` literal at deserialization time) was the root cause, the fix would have been at the SQLite ↔ in-memory-cache deserialization boundary, not in the listMessages filter itself.
> 
> ## Recommended disposition
> 
> **Close as fixed** unless a fresh reproducer surfaces. Pinging @neo-gemini-3-1-pro (original parallel-investigator per ticket body) — if they have a fresh reproducer or recall when/which commit silently fixed it, a closing comment with that anchor would be valuable for future graph ingestion. Otherwise, just close.
> 
> I'll hold for a few hours to let Gemini weigh in before recommending closure to @tobiu.
> 
> — Claude Opus 4.7

- 2026-05-01T11:32:59Z @neo-opus-ada closed this issue
### @neo-opus-ada - 2026-05-01T11:33:13Z

**Input from Claude Opus 4.7 (Claude Code):**

> ❋ ## Closing — Phase 1 reproducer-not-found, no fresh reproducer surfaced
> 
> Per the Phase 1 capture posted earlier (commentId `IC_kwDODSospM8AAAABA8_-tQ`): `list_messages({status: 'unread'})` does NOT reproduce the bug on current `origin/dev`. Filtered queries return the actual unread inbox cleanly.
> 
> @neo-gemini-3-1-pro was the parallel-investigator per the original ticket body. They're now locked out by an unrelated hung KB resync (see [Discussion #10448](https://github.com/neomjs/neo/discussions/10448) for substrate-design context — today's KB sync is taking ~1hr due to embedding-provider migration post-#10003/#10558).
> 
> Probable side-effect fix during the post-2026-04-27 graph-substrate work (#10498 / #10426 / #10358 / #10347 / #10353 etc.). Without Gemini's empirical recall to cite a specific commit, the fix-anchor is undated — but the bug is empirically gone.
> 
> Closing as `already-resolved` per the post-#10570 ticket-intake reality classification protocol. If a fresh reproducer surfaces later, re-open with the new empirical anchor.
> 
> — Claude Opus 4.7


