LearnNewsExamplesServices
Frontmatter
id11029
titleAdd per-recipient read receipts for A2A broadcasts
stateClosed
labels
bugaitestingarchitecturemodel-experience
assigneesneo-gpt
createdAtMay 9, 2026, 6:39 PM
updatedAtMay 9, 2026, 8:54 PM
githubUrlhttps://github.com/neomjs/neo/issues/11029
authorneo-gpt
commentsCount6
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[]
blocking[]
closedAtMay 9, 2026, 8:54 PM

Add per-recipient read receipts for A2A broadcasts

Closedbugaitestingarchitecturemodel-experience
neo-gpt
neo-gpt commented on May 9, 2026, 6:39 PM

Context

During live swarm coordination on 2026-05-09, the operator raised a mailbox safety concern: AGENT:* broadcasts may disappear from other agents' unread views once the first recipient marks the message read. This came up while the team was using broadcasts for design-dialogue handoffs and trying to ensure no peer misses relevant items.

The concern is empirically valid. Current A2A broadcast storage uses one shared MESSAGE node with one shared properties.readAt, then fans that message out through the AGENT:* sentinel. Any recipient authorized through the broadcast edge can call mark_read, which mutates the shared MESSAGE.properties.readAt.

The Problem

AGENT:* broadcasts currently have global read state instead of per-recipient read state. This makes broadcasts unsafe as durable mailbox items:

  • Agent A broadcasts to AGENT:*.
  • Agent B sees the broadcast and calls mark_read.
  • The shared message node now has readAt != null.
  • Agent C's list_messages({status: 'unread'}), add_memory mailbox preview, and swarm heartbeat unread count can no longer see that broadcast as unread, even if Agent C never processed it.

Listing alone does not cause the loss. The lossy transition is mark_read on a broadcast.

The Architectural Reality

Current source shape:

  • ai/services/memory-core/MailboxService.mjs:207-220 stores readAt on the shared message properties.
  • ai/services/memory-core/MailboxService.mjs:360-389 treats SENT_TO -> AGENT:* as inbox fan-out but computes unread from messageNode.properties.readAt.
  • ai/services/memory-core/MailboxService.mjs:537-551 authorizes a broadcast recipient and then sets messageNode.properties.readAt globally.
  • ai/services/memory-core/MemoryService.mjs:42-54 builds mailbox delta/unread preview from the shared message readAt.
  • ai/daemons/SwarmHeartbeatService.mjs:351-360 does the same for heartbeat unread counts.
  • ai/graph/identityRoots.mjs:97-100 defines AGENT:* as a real BroadcastSentinel used for fan-out edge semantics.

Related prior substrate:

  • #10147 created the base mailbox schema with one message-level readAt.
  • #10668 fixed same-sender broadcast wake noise, but explicitly left graph storage intact.
  • #10857 noted that broadcasts get marked read once seen, but targeted a broader operator-mandates feed rather than fixing mailbox broadcast read semantics.

The Fix

Keep one semantic broadcast message, but add graph-native per-recipient delivery/read state.

Resolved design after peer review: use DELIVERED_TO edges with per-recipient readAt metadata. This keeps the fix graph-native without adding MESSAGE_RECEIPT nodes, while avoiding the ambiguity of READ_BY-only edges. A READ_BY edge only exists after a read, so absence is ambiguous between "unread", "not in this broadcast audience", and "legacy message". A DELIVERED_TO edge snapshots the audience at send-time and carries the read state later.

Recommended implementation shape:

  1. Preserve the single MESSAGE node for AGENT:* broadcasts so threading, inReplyTo, task envelopes, graph ingestion, and audit history remain one semantic event.
  2. On broadcast send, snapshot the durable audience from registered peer AgentIdentity nodes, excluding the sender and excluding non-agent/sentinel identities.
  3. Create one per-recipient delivery edge for that audience:
       MESSAGE -[DELIVERED_TO {
        deliveredAt,
        readAt: null,
        deliveryKind: "broadcast",
        userId: sentBy,
        sharedEntity: true
    }]-> AgentIdentity
  4. Update list_messages, MemoryService.buildMailboxDelta, and SwarmHeartbeatService.getUnreadCount() so broadcast unread means: the message is in my broadcast audience and my receipt has no readAt.
  5. Update mark_read so a broadcast recipient updates only their own DELIVERED_TO.readAt.
  6. Keep the AGENT:* sentinel as broadcast scope/audit/trust-lift metadata. It must no longer be the owner of read/unread state.

Legacy migration stance: document a silent-degrade compatibility path for pre-migration broadcasts. Old broadcasts already lost per-recipient fidelity, and backfilling against the current agent registry would invent historical audiences. For messages without DELIVERED_TO edges, preserve the old shared MESSAGE.properties.readAt semantics. Only post-migration broadcasts guarantee per-recipient unread durability.

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback / Edge Case Docs Evidence
MailboxService.addMessage({to:'AGENT:*'}) This ticket; #10147; #10174; #10179 Creates one MESSAGE node plus per-recipient delivery/read state for all registered peer agent identities except sender If no peer identities exist, preserve message/outbox audit without unread receipts JSDoc + Memory Core mailbox docs Unit test: broadcast creates receipts for peers, not sender
MailboxService.listMessages({status:'unread'}) This ticket Broadcast appears unread independently for each recipient until that recipient marks it read Legacy direct DMs continue using existing message-level readAt or equivalent derived state JSDoc / tooling docs if semantics are user-visible Unit test: Agent B marking broadcast read does not hide it from Agent C
MailboxService.markRead({messageId}) This ticket For broadcast messages, updates only caller's receipt readAt; for direct DMs, preserves existing behavior Unauthorized if caller is not direct recipient and has no broadcast receipt JSDoc Unit tests for direct DM, broadcast recipient, non-recipient
MemoryService.buildMailboxDelta() / add_memory mailbox preview This ticket Unread count and latest preview use caller-specific broadcast receipt state If receipt data is missing for a legacy broadcast, use compatibility fallback documented by migration MemoryCore docs Unit test against mailbox preview
SwarmHeartbeatService.getUnreadCount() This ticket Heartbeat unread counts use caller-specific broadcast receipt state Legacy compatibility does not over-count sender self-broadcasts JSDoc / heartbeat docs if needed Unit or integration test with two peer identities

Acceptance Criteria

  • AGENT:* broadcast unread/read state is per recipient, not global.
  • One peer calling mark_read on a broadcast does not remove that broadcast from another peer's unread inbox.
  • Broadcast sender does not receive a sender self-copy receipt by default.
  • The broadcast remains one semantic MESSAGE event for graph/thread/task purposes.
  • list_messages({status:'unread'}), MemoryService.buildMailboxDelta(), and SwarmHeartbeatService.getUnreadCount() all use the same per-recipient broadcast unread semantics.
  • Direct DM mark_read behavior remains backward-compatible.
  • Legacy broadcasts without receipt records have a documented compatibility path.
  • Focused Playwright unit coverage proves at least three identities: sender, recipient B, recipient C.

Out of Scope

  • Redesigning AGENT:* sentinel identity or removing the broadcast sentinel.
  • Changing reachable-counterparty trust-lift semantics from #10179, except where receipt state needs to define broadcast reach more precisely.
  • Building an operator-mandates feed from #10857.
  • Changing same-sender wake suppression from #10668.
  • Replacing targeted DMs for actionable single-owner work. This ticket only makes broadcasts safe when broadcasts are the right coordination primitive.

Avoided Traps / Gold Standards Rejected

  • Clone the full broadcast into N independent messages. Rejected as primary design because it fragments one semantic broadcast into many message IDs, breaking thread history, graph ingestion, and A2A task-envelope semantics.
  • Use a JSON readBy map on MESSAGE.properties. Rejected as primary design because it is less graph-native and makes direct SQLite unread queries awkward.
  • Fan out only to connected agents. Rejected because mailbox durability must cover sleeping peers. Audience should come from registered agent identities, not current harness connections.
  • Agent etiquette only. Rejected because asking peers not to mark_read broadcasts is discipline-only and will fail under routine mailbox cleanup.

Related

  • #10147 — base mailbox message schema and tools.
  • #10174 — seeded AGENT:* broadcast sentinel.
  • #10179 — broadcast reachability for first-message bootstrap.
  • #10668 — suppress same-sender broadcast wakes.
  • #10857 — broader active operator-constraints feed; related observation, not duplicate.
  • learn/agentos/incidents/2026-05-04-runaway-spawn-pattern.md — adjacent mark_read collision failure mode under shared identity / parallel sessions.

Origin Session ID: 20a824b0-29d1-4082-ae12-87705ec69c3f

Retrieval Hint: query_raw_memories(query="broadcast mark_read collision per recipient unread mailbox AGENT:*")

tobiu referenced in commit d40488b - "fix(memory-core): add per-recipient broadcast receipts (#11029) (#11042) on May 9, 2026, 8:54 PM
tobiu closed this issue on May 9, 2026, 8:54 PM