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:
- Preserve the single
MESSAGE node for AGENT:* broadcasts so threading, inReplyTo, task envelopes, graph ingestion, and audit history remain one semantic event.
- On broadcast send, snapshot the durable audience from registered peer
AgentIdentity nodes, excluding the sender and excluding non-agent/sentinel identities.
- Create one per-recipient delivery edge for that audience:
MESSAGE -[DELIVERED_TO {
deliveredAt,
readAt: null,
deliveryKind: "broadcast",
userId: sentBy,
sharedEntity: true
}]-> AgentIdentity
- 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.
- Update
mark_read so a broadcast recipient updates only their own DELIVERED_TO.readAt.
- 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
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:*")
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
MESSAGEnode with one sharedproperties.readAt, then fans that message out through theAGENT:*sentinel. Any recipient authorized through the broadcast edge can callmark_read, which mutates the sharedMESSAGE.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:*.mark_read.readAt != null.list_messages({status: 'unread'}),add_memorymailbox 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_readon a broadcast.The Architectural Reality
Current source shape:
ai/services/memory-core/MailboxService.mjs:207-220storesreadAton the shared message properties.ai/services/memory-core/MailboxService.mjs:360-389treatsSENT_TO -> AGENT:*as inbox fan-out but computes unread frommessageNode.properties.readAt.ai/services/memory-core/MailboxService.mjs:537-551authorizes a broadcast recipient and then setsmessageNode.properties.readAtglobally.ai/services/memory-core/MemoryService.mjs:42-54builds mailbox delta/unread preview from the shared messagereadAt.ai/daemons/SwarmHeartbeatService.mjs:351-360does the same for heartbeat unread counts.ai/graph/identityRoots.mjs:97-100definesAGENT:*as a realBroadcastSentinelused for fan-out edge semantics.Related prior substrate:
readAt.The Fix
Keep one semantic broadcast message, but add graph-native per-recipient delivery/read state.
Resolved design after peer review: use
DELIVERED_TOedges with per-recipientreadAtmetadata. This keeps the fix graph-native without addingMESSAGE_RECEIPTnodes, while avoiding the ambiguity ofREAD_BY-only edges. AREAD_BYedge only exists after a read, so absence is ambiguous between "unread", "not in this broadcast audience", and "legacy message". ADELIVERED_TOedge snapshots the audience at send-time and carries the read state later.Recommended implementation shape:
MESSAGEnode forAGENT:*broadcasts so threading,inReplyTo, task envelopes, graph ingestion, and audit history remain one semantic event.AgentIdentitynodes, excluding the sender and excluding non-agent/sentinel identities.MESSAGE -[DELIVERED_TO { deliveredAt, readAt: null, deliveryKind: "broadcast", userId: sentBy, sharedEntity: true }]-> AgentIdentitylist_messages,MemoryService.buildMailboxDelta, andSwarmHeartbeatService.getUnreadCount()so broadcast unread means: the message is in my broadcast audience and my receipt has noreadAt.mark_readso a broadcast recipient updates only their ownDELIVERED_TO.readAt.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_TOedges, preserve the old sharedMESSAGE.properties.readAtsemantics. Only post-migration broadcasts guarantee per-recipient unread durability.Contract Ledger Matrix
MailboxService.addMessage({to:'AGENT:*'})MESSAGEnode plus per-recipient delivery/read state for all registered peer agent identities except senderMailboxService.listMessages({status:'unread'})readAtor equivalent derived stateMailboxService.markRead({messageId})readAt; for direct DMs, preserves existing behaviorMemoryService.buildMailboxDelta()/add_memorymailbox previewSwarmHeartbeatService.getUnreadCount()Acceptance Criteria
AGENT:*broadcast unread/read state is per recipient, not global.mark_readon a broadcast does not remove that broadcast from another peer's unread inbox.MESSAGEevent for graph/thread/task purposes.list_messages({status:'unread'}),MemoryService.buildMailboxDelta(), andSwarmHeartbeatService.getUnreadCount()all use the same per-recipient broadcast unread semantics.mark_readbehavior remains backward-compatible.Out of Scope
AGENT:*sentinel identity or removing the broadcast sentinel.Avoided Traps / Gold Standards Rejected
readBymap onMESSAGE.properties. Rejected as primary design because it is less graph-native and makes direct SQLite unread queries awkward.mark_readbroadcasts is discipline-only and will fail under routine mailbox cleanup.Related
AGENT:*broadcast sentinel.learn/agentos/incidents/2026-05-04-runaway-spawn-pattern.md— adjacentmark_readcollision failure mode under shared identity / parallel sessions.Origin Session ID:
20a824b0-29d1-4082-ae12-87705ec69c3fRetrieval Hint:
query_raw_memories(query="broadcast mark_read collision per recipient unread mailbox AGENT:*")