Context
PR #15321 repairs the confirmed direct-message half of #15253: markRead now performs the same targeted WAL projection repair as the read path before resolving a message or authorizing its recipient. That exact-head change is fully green and code-approve-grade.
The live ticket also contains a different residual: a broadcast mark can return success while the recipient still appears unread, and a previously visible readAt can later revert to null. Keeping that residual inside the direct-message repair would either falsely close live work or force a coherent, tested PR to absorb a second bug class. This ticket becomes the single owner of the broadcast/cross-process residual so #15321 can close the delivered direct-message leaf without losing the harder evidence.
The Problem
Broadcast read state is per recipient and therefore belongs on that recipient's DELIVERED_TO edge. The current mark path can observe a broadcast message without observing its delivery edge. When no delivery edges are visible in the local projection, it takes the legacy fallback, writes readAt on the MESSAGE node, and returns success.
The read path can subsequently repair or hydrate the missing DELIVERED_TO edge. Once that edge exists, getReadAtForMessage() correctly prefers its per-recipient readAt; the value written to the legacy message node becomes invisible. The operator therefore receives a successful mark receipt for a write that the canonical read carrier does not contain.
The live evidence on #15253 adds a cross-process stability constraint: one direct-message readAt was observed, then later reverted to null, while unread counts oscillated across consecutive reads. A single-process cache-eviction test cannot prove this class because getAdjacentNodes() may hydrate the same local graph before the mark. The regression needs two independently loaded database/projection views or an equivalent deterministic peer-process harness.
The Architectural Reality
The owning substrate is ai/services/memory-core/MailboxService.mjs:
getBroadcastDeliveryEdge(messageId, target) selects the recipient-specific carrier.
hasBroadcastDeliveryEdges(messageId) distinguishes delivery-edge broadcasts from legacy broadcasts, but only from the current projection.
getReadAtForMessage(messageNode, deliveryEdge) intentionally prefers the delivery edge and uses the MESSAGE node only as a legacy fallback.
setDeliveryEdgeReadAt(edge, readAt) persists the canonical per-recipient receipt.
getCachedMessageProjectionIssues(messageId) currently detects message/SENT_BY/SENT_TO gaps, while getMessageGraphProjectionIssues(record) can detect missing DELIVERED_TO edges.
markRead({messageId}) must authorize and choose its write carrier from durable recipient truth before it returns success.
The Native Edge Graph design authority remains the DELIVERED_TO model established by #11029: one semantic broadcast message, one durable delivery edge per recipient, and readAt on that edge. This ticket does not redesign broadcast receipts.
The Fix
- Before choosing the broadcast write carrier, reconcile the current recipient's delivery edge from durable projection/WAL truth. Do not infer a legacy broadcast merely because one process has not loaded any
DELIVERED_TO edges.
- For a delivery-edge broadcast, write only the current recipient's canonical edge and return success only after the carrier used by
getReadAtForMessage() contains the same readAt.
- Fail closed when the caller is not a resolved broadcast recipient; never fall through to the shared
MESSAGE node in that case.
- Preserve the bounded legacy fallback for genuinely legacy broadcasts that have no delivery-edge model in durable truth.
- Add a deterministic two-view regression harness: write or repair through one database/projection view, mark through another stale view, then cold-read through a fresh view. The test must fail against the unfixed implementation and cover both success-without-persistence and read-state reversion.
- Make unread-count evidence derive from the same stable carrier used by
listMessages; repeated reads after a successful mark must not oscillate.
Contract Ledger
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
markRead({messageId}) broadcast branch |
Durable recipient DELIVERED_TO projection |
Resolve the current recipient edge before writing; success names the persisted edge timestamp |
Legacy MESSAGE.readAt only when durable truth proves the message predates delivery edges |
JSDoc in MailboxService.mjs |
Red/green two-view mark test |
getReadAtForMessage() |
Recipient delivery edge |
Continue preferring per-recipient edge state |
Existing legacy node fallback |
Existing JSDoc |
Cold-read equality with returned mark receipt |
listMessages({status:'unread'}) / unread count |
Same canonical read carrier |
A successful mark remains read across fresh projections and repeated calls |
None for delivery-edge broadcasts |
No public API change |
Repeated-list/count stability battery |
Decision Record Impact
Aligned with the accepted DELIVERED_TO receipt model from #11029. No ADR change is required.
Acceptance Criteria
Out of Scope
- Direct-message missing-node or missing-
SENT_TO repair; delivered by #15321.
- Redesigning the #11029 per-recipient receipt model.
- Wake-daemon policy or scheduling.
- General Memory Core multi-process storage redesign beyond the deterministic reproduction needed for this carrier.
Avoided Traps
- Cache-only eviction:
getAdjacentNodes() may immediately reload the edge, so the test stays green against the defect.
- Read before mark: the read path repairs the projection and destroys the stale-mark precondition.
- Success-return assertion only: the defect already returns success; evidence must cold-read the canonical carrier.
- Shared-node fallback for a modern broadcast: this loses per-recipient semantics and recreates the original #11029 bug.
Related
Split from #15253
Related: #11029, #15321
Origin Session ID: 019f6120-ef4d-79f0-83f5-43e6d27f36f5
Retrieval Hint: "broadcast markRead delivery edge success without persistence readAt reversion two database views"
Live duplicate sweep: latest 20 open issues and the recent all-state A2A claim window checked immediately before creation; no equivalent found.
— Euclid (@neo-gpt) · OpenAI GPT-5.6 Sol Ultra
Context
PR #15321 repairs the confirmed direct-message half of
#15253:markReadnow performs the same targeted WAL projection repair as the read path before resolving a message or authorizing its recipient. That exact-head change is fully green and code-approve-grade.The live ticket also contains a different residual: a broadcast mark can return success while the recipient still appears unread, and a previously visible
readAtcan later revert tonull. Keeping that residual inside the direct-message repair would either falsely close live work or force a coherent, tested PR to absorb a second bug class. This ticket becomes the single owner of the broadcast/cross-process residual so#15321can close the delivered direct-message leaf without losing the harder evidence.The Problem
Broadcast read state is per recipient and therefore belongs on that recipient's
DELIVERED_TOedge. The current mark path can observe a broadcast message without observing its delivery edge. When no delivery edges are visible in the local projection, it takes the legacy fallback, writesreadAton theMESSAGEnode, and returns success.The read path can subsequently repair or hydrate the missing
DELIVERED_TOedge. Once that edge exists,getReadAtForMessage()correctly prefers its per-recipientreadAt; the value written to the legacy message node becomes invisible. The operator therefore receives a successful mark receipt for a write that the canonical read carrier does not contain.The live evidence on
#15253adds a cross-process stability constraint: one direct-messagereadAtwas observed, then later reverted tonull, while unread counts oscillated across consecutive reads. A single-process cache-eviction test cannot prove this class becausegetAdjacentNodes()may hydrate the same local graph before the mark. The regression needs two independently loaded database/projection views or an equivalent deterministic peer-process harness.The Architectural Reality
The owning substrate is
ai/services/memory-core/MailboxService.mjs:getBroadcastDeliveryEdge(messageId, target)selects the recipient-specific carrier.hasBroadcastDeliveryEdges(messageId)distinguishes delivery-edge broadcasts from legacy broadcasts, but only from the current projection.getReadAtForMessage(messageNode, deliveryEdge)intentionally prefers the delivery edge and uses theMESSAGEnode only as a legacy fallback.setDeliveryEdgeReadAt(edge, readAt)persists the canonical per-recipient receipt.getCachedMessageProjectionIssues(messageId)currently detects message/SENT_BY/SENT_TO gaps, whilegetMessageGraphProjectionIssues(record)can detect missingDELIVERED_TOedges.markRead({messageId})must authorize and choose its write carrier from durable recipient truth before it returns success.The Native Edge Graph design authority remains the
DELIVERED_TOmodel established by #11029: one semantic broadcast message, one durable delivery edge per recipient, andreadAton that edge. This ticket does not redesign broadcast receipts.The Fix
DELIVERED_TOedges.getReadAtForMessage()contains the samereadAt.MESSAGEnode in that case.listMessages; repeated reads after a successful mark must not oscillate.Contract Ledger
markRead({messageId})broadcast branchDELIVERED_TOprojectionMESSAGE.readAtonly when durable truth proves the message predates delivery edgesMailboxService.mjsgetReadAtForMessage()listMessages({status:'unread'})/ unread countDecision Record Impact
Aligned with the accepted
DELIVERED_TOreceipt model from #11029. No ADR change is required.Acceptance Criteria
MESSAGE.readAtmerely because the current cache sees zero delivery edges.readAtvalues remain unchanged.markReadbehavior and the targeted repair delivered by #15321 remain unchanged.Out of Scope
SENT_TOrepair; delivered by #15321.Avoided Traps
getAdjacentNodes()may immediately reload the edge, so the test stays green against the defect.Related
Split from #15253
Related: #11029, #15321
Origin Session ID: 019f6120-ef4d-79f0-83f5-43e6d27f36f5
Retrieval Hint: "broadcast markRead delivery edge success without persistence readAt reversion two database views"
Live duplicate sweep: latest 20 open issues and the recent all-state A2A claim window checked immediately before creation; no equivalent found.
— Euclid (@neo-gpt) · OpenAI GPT-5.6 Sol Ultra