LearnNewsExamplesServices
Frontmatter
id15913
titleMailbox drain: cross-harness bulk mark_read parity + server-side read-all
stateClosed
labels
bugaiarchitecture
assigneesneo-gpt
createdAtJul 25, 2026, 4:51 PM
updatedAt11:39 AM
githubUrlhttps://github.com/neomjs/neo/issues/15913
authorneo-opus-ada
commentsCount3
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAt11:39 AM

Mailbox drain: cross-harness bulk mark_read parity + server-side read-all

Closed Backlog/active-chunk-9 bugaiarchitecture
neo-opus-ada
neo-opus-ada commented on Jul 25, 2026, 4:51 PM

Context

#15428 shipped the service-side bulk primitive on 2026-07-18: mark_read accepts one messageId or a string[], delegates every array element through the single-id authorization/repair path, and isolates failures per item.

The shipped capability is not equally reachable from every agent seat:

observed surface array call
Codex (@neo-gpt-emmy, @neo-gpt) passes
stdio (@neo-kimi-phoebe) passes
two Claude Code / Opus seats (@neo-opus-ada, @neo-opus-grace) the array becomes one JSON-stringified lookup key

The failing one-element discriminator is exact:

messageId: "MESSAGE:bogus"   -> Message not found: MESSAGE:bogus
messageId: ["MESSAGE:bogus"] -> Message not found: ["MESSAGE:bogus"]

That proves the array collapses before MailboxService.markRead() sees it. It does not identify whether the losing owner is a client schema projection, argument marshaller, or bridge.

The ticket's original endpoint prescription was false. Neo does not register /mailbox/messages/{messageId}/read as an independent REST route. ToolService compiles the OpenAPI operation into one MCP input schema and exposes it through tools/list / tools/call at the /mcp transport. On current dev, direct schema generation, raw MCP tools/list, and stdio tools/call all preserve string | string[].

Measured operator friction

On 2026-07-29, @neo-gpt had 763 unread mailbox messages. Clearing them required eight list/mark batches even from a seat where arrays work. A prior 2026-07-26 drain cleared roughly 400 superseded broadcasts in five batches (Memory Core session 318916f0-3f6b-4f1c-b0d2-ee16e2dd8af0).

Bulk-by-id is necessary for selective acknowledgement, but it is still the wrong primitive for routine inbox hygiene: the server already owns recipient visibility and read-carrier semantics, while the caller currently has to page every id out and shuttle every id back.

Problem

Two independent defects now share one resolving lane:

  1. A known client compatibility artifact turns a declared string[] into its JSON string representation. The service interprets that representation as one id, so a valid bulk request fails as Message not found.
  2. There is no server-side "mark my current unread inbox snapshot read" operation. Mailbox hygiene scales with backlog depth and leaks graph-selection work into every harness.

This is not #15825 (read-state resurfacing after restart) and not a wake-policy decision. It repairs acknowledgement reachability and drain ergonomics while preserving existing read-state ownership.

Intended solution

1. Keep mark_read; accept the measured compatibility representation

At the service boundary, recognize a string as a serialized bulk request only when:

  • it parses as a JSON array;
  • every element is a string with the canonical MESSAGE: prefix; and
  • the whole value is therefore unambiguous with a real message id.

Delegate the decoded array through the existing array path. Scalars, native arrays, empty arrays, invalid JSON, and non-message arrays retain their existing behavior. The public schema remains string | string[]; the compatibility representation is accepted, not advertised as a third canonical type.

2. Extend mark_read with all: true

Keep the existing write-tier MCP tool and add an explicit, mutually exclusive all mode. mark_read({all: true}):

  • snapshots the caller's currently unread, unarchived inbox message ids inside the server;
  • marks exactly that snapshot through the canonical single-id markRead() path;
  • therefore preserves direct-message MESSAGE.readAt and broadcast per-recipient DELIVERED_TO.readAt;
  • leaves messages arriving after the snapshot unread;
  • exposes no cross-identity selector; and
  • returns a compact, truthful aggregate receipt with matched/read/durable/failure counts plus only exceptional rows.

messageId and all: true cannot be supplied together. This adds no MCP tool and uses no magic scalar sentinel.

The selector must not call paginated list_messages or resolve related GitHub PR state. Selection belongs beside the indexed mailbox read-state query.

Contract Ledger

Target surface Source of authority New behavior Compatibility Evidence
mark_read input Memory Core OpenAPI + MailboxService.markRead() native scalar/array unchanged; exact JSON-stringified MESSAGE: array accepted at the handler boundary no schema narrowing; ordinary strings remain scalar schema witness + native array + serialized array + invalid-shape tests
mark_read all mode Memory Core OpenAPI + MailboxService.markRead() all: true snapshots and drains caller's current unread/unarchived inbox additive argument on the existing tool; no new tool or scalar sentinel schema no-growth witness + service behavior tests
direct read receipt MESSAGE.properties.readAt selected direct messages use existing durable setter unchanged storage-backed service witness
broadcast read receipt caller's DELIVERED_TO.properties.readAt selected broadcasts update only caller's receipt unchanged; other recipients remain unread two-recipient regression witness
aggregate receipt service response distinguishes matched, read, durable, failed, and non-durable outcomes empty inbox is an explicit no-op partial/non-durable tests

Acceptance criteria

  • Native scalar mark_read remains unchanged.
  • Native array mark_read retains per-item results and failure isolation.
  • A JSON-stringified array of canonical MESSAGE: ids reaches the same array path and succeeds.
  • Invalid JSON, a JSON object, or an array containing a non-MESSAGE: string is not reinterpreted as bulk input.
  • Raw tools/list still advertises messageId as string | string[].
  • The known failing-seat reproduction is green at the repaired exact head; a passing Codex/stdio control remains green.
  • mark_read advertises an all boolean without adding another MCP tool; messageId and all: true are mutually exclusive.
  • mark_read({all: true}) marks every message in the caller's unread/unarchived snapshot, with no pagination cap.
  • A message arriving after snapshot capture remains unread.
  • Direct and broadcast messages use their existing durable read carriers; another broadcast recipient remains unread.
  • An empty inbox returns a compact no-op receipt.
  • Partial failures and non-durable writes are reported honestly; aggregate success never outruns durable evidence.

Avoided traps

  • Reading the service implementation and asserting every harness can reach it.
  • Reading one failing harness and declaring the capability universally absent.
  • Moving an MCP field between fictional REST mounts: OpenAPI paths are schema organization here, not independent runtime endpoints.
  • Solving a 763-message hygiene failure with a larger client-side page loop.
  • Using a magic "all" message id sentinel or overloading an ordinary scalar.
  • Adding another MCP tool for an operation already owned by mark_read.
  • Returning hundreds of successful per-id rows from mark_read({all: true}).

Related

  • #15428 — shipped selective bulk mark_read
  • #15825 — read-state resurfacing, separate persistence lane
  • D#15904 — wake/mailbox separation discussion which surfaced the seat split

Origin Session IDs:

  • e034e3ff-c9af-4f72-a2c3-b1a9fb19a90a — original cross-seat investigation
  • 019fa904-9d8c-7f12-94fe-346ae8e54046 — endpoint-premise falsification
  • 318916f0-3f6b-4f1c-b0d2-ee16e2dd8af0 — prior 400-message drain
tobiu unassigned from @neo-opus-ada on Jul 28, 2026, 11:23 AM
tobiu referenced in commit 7fd041d - "feat(ai): add mailbox snapshot read mode (#15913) (#16118) on 11:39 AM
tobiu closed this issue on 11:39 AM