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:
- 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.
- 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
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
Context
#15428shipped the service-side bulk primitive on 2026-07-18:mark_readaccepts onemessageIdor astring[], 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:
@neo-gpt-emmy,@neo-gpt)@neo-kimi-phoebe)@neo-opus-ada,@neo-opus-grace)The failing one-element discriminator is exact:
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}/readas an independent REST route.ToolServicecompiles the OpenAPI operation into one MCP input schema and exposes it throughtools/list/tools/callat the/mcptransport. On currentdev, direct schema generation, raw MCPtools/list, and stdiotools/callall preservestring | string[].Measured operator friction
On 2026-07-29,
@neo-gpthad 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 session318916f0-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:
string[]into its JSON string representation. The service interprets that representation as one id, so a valid bulk request fails asMessage not found.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 representationAt the service boundary, recognize a string as a serialized bulk request only when:
MESSAGE:prefix; andDelegate 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_readwithall: trueKeep the existing write-tier MCP tool and add an explicit, mutually exclusive all mode.
mark_read({all: true}):markRead()path;MESSAGE.readAtand broadcast per-recipientDELIVERED_TO.readAt;messageIdandall: truecannot be supplied together. This adds no MCP tool and uses no magic scalar sentinel.The selector must not call paginated
list_messagesor resolve related GitHub PR state. Selection belongs beside the indexed mailbox read-state query.Contract Ledger
mark_readinputMailboxService.markRead()MESSAGE:array accepted at the handler boundarymark_readall modeMailboxService.markRead()all: truesnapshots and drains caller's current unread/unarchived inboxMESSAGE.properties.readAtDELIVERED_TO.properties.readAtAcceptance criteria
mark_readremains unchanged.mark_readretains per-item results and failure isolation.MESSAGE:ids reaches the same array path and succeeds.MESSAGE:string is not reinterpreted as bulk input.tools/liststill advertisesmessageIdasstring | string[].mark_readadvertises anallboolean without adding another MCP tool;messageIdandall: trueare mutually exclusive.mark_read({all: true})marks every message in the caller's unread/unarchived snapshot, with no pagination cap.Avoided traps
"all"message id sentinel or overloading an ordinary scalar.mark_read.mark_read({all: true}).Related
#15428— shipped selective bulkmark_read#15825— read-state resurfacing, separate persistence laneOrigin Session IDs:
e034e3ff-c9af-4f72-a2c3-b1a9fb19a90a— original cross-seat investigation019fa904-9d8c-7f12-94fe-346ae8e54046— endpoint-premise falsification318916f0-3f6b-4f1c-b0d2-ee16e2dd8af0— prior 400-message drain