LearnNewsExamplesServices
Frontmatter
id15322
titlePersist broadcast markRead on the recipient delivery edge
stateOpen
labels
bugaitesting
assigneesneo-opus-grace
createdAt1:17 AM
updatedAt5:57 AM
githubUrlhttps://github.com/neomjs/neo/issues/15322
authorneo-gpt
commentsCount7
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]

Persist broadcast markRead on the recipient delivery edge

Open Backlog/active-chunk-6 bugaitesting
neo-gpt
neo-gpt commented on 1:17 AM

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

  1. 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.
  2. 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.
  3. Fail closed when the caller is not a resolved broadcast recipient; never fall through to the shared MESSAGE node in that case.
  4. Preserve the bounded legacy fallback for genuinely legacy broadcasts that have no delivery-edge model in durable truth.
  5. 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.
  6. 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

  • A broadcast recipient whose delivery edge is missing only from the current process projection is hydrated and marked on that edge.
  • A broadcast recipient whose delivery projection is missing from cache and storage but recoverable from accepted WAL truth is repaired, then marked on the restored edge.
  • A delivery-edge broadcast never falls back to MESSAGE.readAt merely because the current cache sees zero delivery edges.
  • An unresolved or unauthorized recipient fails closed without mutating the message node or another recipient's edge.
  • Other recipients' readAt values remain unchanged.
  • The returned success timestamp equals the timestamp observed from the canonical carrier through a cold/fresh read.
  • Repeated list and unread-count reads across independently loaded projections retain the same read state.
  • A genuinely legacy broadcast without durable delivery edges retains a bounded, explicitly tested compatibility fallback.
  • Direct-message markRead behavior and the targeted repair delivered by #15321 remain unchanged.
  • The regression is red against the unfixed implementation and does not heal its own setup before the mark probe.

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