Mailbox SENT_TO edges silently culled by linkNodes FK check
Context
Surfaced during the first real A2A handshake attempt (2026-04-22, sessions d907e342-28fc-4eb6-8c51-355772dbfb44 through 0327771f-b383-472b-8f05-ea8e35e9a65c). Opus sent a broadcast handshake to @neo-gemini-3-1-pro; addMessage returned {status: 'sent', messageId: MESSAGE:778e33a3-...}. Gemini replied from Antigravity with her own status: 'sent' confirmation. Neither message ever surfaced via listMessages({box: 'inbox'}) on the recipient side — for either agent, in any session, after three MCP server restarts, both before and after seeding identity nodes.
Direct SQLite inspection of memory-core-graph.sqlite revealed the actual state:
Edge type counts (full DB):
SPAWNED_MEMORY: 323
ACTIVE_FOCUS: 4
CONTAINS: 3
SENT_BY: 2 ← both messages have this
IN_REPLY_TO: 1
CAN_REPLY_TO: 1
SENT_TO: 0 ← ZERO across the entire history
The MESSAGE nodes exist. The SENT_BY edges exist (sender → identity). The SENT_TO edges — the sole substrate listMessages scans to discover a recipient's inbox — have never been persisted in this DB, for any message, since the mailbox primitive shipped.
The Problem
addMessage calls GraphService.linkNodes(messageId, to, 'SENT_TO', ...) on line 101 of MailboxService.mjs. That call hits an FK-style guard:
const verifyStmt = this.db.storage.db.prepare(
'SELECT count(*) as count FROM Nodes WHERE id IN (?, ?)');
const count = verifyStmt.get(source, target).count;
if (source !== target && count !== 2) {
logger.warn(`[GraphService] Culling hallucinated edge mapping: ${source} -> ${target}`);
return;
}
The guard exists to defend against hallucinated LLM-generated edges whose endpoints don't correspond to real graph nodes. It is correct defense-in-depth for that threat model. It is wrong for the mailbox's legitimate addressing surface:
| Tool input shape |
Guard verdict |
Why |
to: '@neo-opus-4-7' (bare GitHub login) |
✓ passes |
Matches seeded AgentIdentity node (seedAgentIdentities.mjs) |
to: 'AGENT:@neo-opus-4-7' (prefixed form, per tool-schema wording) |
✗ culled |
No node with AGENT: prefix exists; seed uses bare @login |
to: 'AGENT:*' (broadcast sentinel, per tool-schema wording) |
✗ culled |
AGENT:* is a sentinel, never seeded as a real node |
to: 'role:librarian' (role-based addressing, per MemoryCoreMcpAuth.md) |
✗ culled (presumed) |
Roles are not seeded AgentIdentity nodes |
to: 'human:tobiu' (human addressing, per auth doc) |
✗ culled (presumed) |
Same |
Of the five addressing modes documented in #10147 AC ("Addressing modes tested: direct identity, role-based, broadcast"), only the first — bare-@login direct — has any chance of functioning. And even that requires the caller to use the exact bare-@login spelling despite the tool-schema explicitly documenting 'AGENT:@login' as a valid form.
#10147's tests reported AC-satisfied, but clearly asserted on addMessage's optimistic return payload rather than reading back through listMessages. The end-to-end path has never been exercised.
Two adjacent defects surfaced during the same diagnostic chain:
listMessages from filter is anti-spoof-rejected. The tool schema documents from as a legitimate filter parameter. MailboxService.listMessages uses it as a post-filter on edges (line 185). But AuthMiddleware.forbiddenKeys contains 'from', so any tool call with from: <login> is rejected with "Identity-override spoof rejected." Tool surface contradicts its own middleware.
No per-turn inbox awareness. add_memory is guaranteed to run every turn per the Memory Core Protocol (CLAUDE.md §4.2). Piggybacking a per-turn inbox delta signal onto its response payload is the lowest-friction way to give agents push-like inbox awareness without a separate polling tool, without client opt-in, and — importantly — without requiring the cross-process cache-coherence primitive of ai/graph/Database.mjs to be repaired. A direct SQLite SELECT COUNT(*) at save-time bypasses the in-memory edge cache entirely, so the signal is reliable even for writes originating in other harness processes.
The Architectural Reality
Files in scope:
ai/mcp/server/memory-core/services/MailboxService.mjs (lines 46-113, addMessage)
ai/mcp/server/memory-core/services/GraphService.mjs (lines 173-228, linkNodes)
ai/mcp/server/shared/services/AuthMiddleware.mjs (line 16, forbiddenKeys array)
ai/mcp/server/memory-core/services/MemoryService.mjs or equivalent (add_memory write path)
ai/scripts/seedAgentIdentities.mjs (sentinel-node seed extension)
test/playwright/unit/ai/mcp/server/memory-core/MailboxService.spec.mjs (end-to-end regression tests)
ai/mcp/server/memory-core/openapi.yaml or equivalent (tool schema for add_memory response)
The fix is intentionally minimal and scoped to the mailbox write/read path. It does NOT touch linkNodes's FK check itself — the guard remains useful defense for other edge-creation paths in the graph. Instead, addMessage normalizes input before calling linkNodes, and seedAgentIdentities ensures the sentinel target node exists.
The Fix
Normalize to at the mailbox boundary (MailboxService.addMessage). Accept the three documented shapes and canonicalize to the seeded form:
'AGENT:@login' → '@login' (strip AGENT: prefix)
'AGENT:*' → 'AGENT:*' (unchanged; handled by step 2)
'@login' → unchanged
'role:<name>' / 'human:<login>' → unchanged; seed them in step 2 as needed, or fall back to creating an on-demand role node
Seed AGENT:* as the broadcast sentinel in seedAgentIdentities.mjs. Its graph node carries {type: 'BroadcastSentinel', name: 'Broadcast'}. The FK check then passes for linkNodes(messageId, 'AGENT:*', 'SENT_TO', ...), and listMessages's existing targetNode === 'AGENT:*' filter (line 151) continues to work unchanged.
Unblock from in AuthMiddleware.forbiddenKeys. The schema authorizes it as a legitimate filter parameter; the middleware must not reject it. Spoofing is already prevented structurally: listMessages only reads, addMessage's authorship is derived from RequestContext.getAgentIdentityNodeId() with no input-side from field. Keep the remaining spoof-vector keys (userId, agentId, sender, authorLogin, etc.) unchanged.
Extend add_memory response payload with an inbox delta signal. Add mailbox: {unreadCount: Number, latestPreview: {messageId, subject, from, sentAt} | null} to the response. Populate via a direct SQL SELECT against unread-status SENT_TO edges targeting the caller's bound identity — bypasses the in-memory graph cache entirely for cross-process correctness. Non-fatal on failure: an error in the mailbox-delta query must not abort the memory write.
End-to-end regression tests (the gap that let #10147 AC report success with the feature broken). Single Playwright test class covering:
- Send → read-back via
listMessages({box: 'inbox'}) for direct-bare-login addressing
- Send → read-back for
'AGENT:@login' prefixed addressing
- Send → read-back for
'AGENT:*' broadcast (all authenticated recipients must find it)
listMessages({from: '<login>'}) succeeds without anti-spoof rejection
add_memory response includes mailbox block; unreadCount reflects newly-sent messages visible in subsequent inbox reads
Acceptance Criteria
Out of Scope
- Cross-process in-memory graph cache coherence for non-write-path reads. The
ai/graph/Database.mjs in-memory edge cache does not refresh on remote writes, which means traversal queries (listMessages) still require the recipient's MCP server to boot after the sender's write landed in SQLite. Fix #4 (the add_memory inbox signal) makes this invisible in practice — agents poll via their per-turn save. A proper coherence primitive would require either SQLite change-feed triggers or promoting the graph to a network-addressable service, both of which properly belong to #9999's "Federated Cloud" architectural phase. File a follow-up if empirical friction demands it.
- Role / human addressing node seeding — roles and humans are expected to materialize on first reference or be seeded by separate ticket scope (
role:librarian, role:next-session, etc.). This ticket normalizes input but does not seed the node population itself.
- Lifecycle operations (archive/delete) —
#10148.
- Healthcheck inbox/outbox preview —
#10149.
- Semantic "find related messages" layer —
#10150.
- TAGGED_CONCEPT auto-emit —
#10169, parallel-track.
Avoided Traps
- "Fix
linkNodes itself by removing the FK check." Rejected. The guard is legitimate defense against hallucinated LLM edges across the entire graph. Removing it opens a data-integrity regression for every other linkNodes caller. The correct fix is input normalization + sentinel seeding at the mailbox boundary, leaving the guard intact.
- "Make
linkNodes auto-create missing nodes." Rejected — same reason. It is the hallucinated-path vector the guard exists to stop. The linkNodesAsync back-fill path from #10153 handles the memory:/session: prefix lazy-fill narrowly; generalizing that to all unknown targets undoes the guard's value.
- "Split into three tickets (SENT_TO, from-filter, add_memory signal)." Rejected this session per an explicit aggregation decision (review throughput matters; all three touch the same service layer and test class; one PR, one review cycle).
- "Require the client to always use bare
@login." Rejected. The tool schema documents 'AGENT:@login' and 'AGENT:*' as valid shapes; the auth doc (MemoryCoreMcpAuth.md) documents role/human prefixes. Tightening the surface to the one form that happens to work today is a user-friction regression and abandons all documented addressing modes. Normalize at the boundary instead.
- "Fix the cache coherence primitive first so broadcasts propagate without restart." Rejected for scope — that's a
#9999 Federated Cloud concern. The add_memory inbox-delta signal delivers the same UX outcome (agent sees "3 new messages" on next turn) for zero architectural cost.
Related
- Parent:
#10139 Mailbox A2A primitive (epic)
- Builds on:
#10147 (mailbox primitives, closed — shipped this gap; AC test-coverage gap fixed here), #10145 (identity substrate), #10144 (AgentIdentity seed convention)
- Adjacent:
#10153 (linkNodesAsync lazy back-fill for memory:/session: prefix — different normalization problem, different scope)
- Blocks:
#10148, #10149, #10150 (all Phase 4 mailbox subs effectively gated on this fix)
- Not related to:
#10172 (node-ID case canonicalization — sibling concern in the normalization space, different cause)
Origin Session ID: d907e342-28fc-4eb6-8c51-355772dbfb44
Mailbox SENT_TO edges silently culled by linkNodes FK check
Context
Surfaced during the first real A2A handshake attempt (2026-04-22, sessions
d907e342-28fc-4eb6-8c51-355772dbfb44through0327771f-b383-472b-8f05-ea8e35e9a65c). Opus sent a broadcast handshake to@neo-gemini-3-1-pro;addMessagereturned{status: 'sent', messageId: MESSAGE:778e33a3-...}. Gemini replied from Antigravity with her ownstatus: 'sent'confirmation. Neither message ever surfaced vialistMessages({box: 'inbox'})on the recipient side — for either agent, in any session, after three MCP server restarts, both before and after seeding identity nodes.Direct SQLite inspection of
memory-core-graph.sqliterevealed the actual state:The
MESSAGEnodes exist. TheSENT_BYedges exist (sender → identity). TheSENT_TOedges — the sole substratelistMessagesscans to discover a recipient's inbox — have never been persisted in this DB, for any message, since the mailbox primitive shipped.The Problem
addMessagecallsGraphService.linkNodes(messageId, to, 'SENT_TO', ...)on line 101 ofMailboxService.mjs. That call hits an FK-style guard:// GraphService.mjs:178-184 // Enforce Foreign Key constraints preemptively to prevent SQLite crash // from hallucinated paths const verifyStmt = this.db.storage.db.prepare( 'SELECT count(*) as count FROM Nodes WHERE id IN (?, ?)'); const count = verifyStmt.get(source, target).count; if (source !== target && count !== 2) { logger.warn(`[GraphService] Culling hallucinated edge mapping: ${source} -> ${target}`); return; // ← silent drop; addMessage never sees this }The guard exists to defend against hallucinated LLM-generated edges whose endpoints don't correspond to real graph nodes. It is correct defense-in-depth for that threat model. It is wrong for the mailbox's legitimate addressing surface:
to: '@neo-opus-4-7'(bare GitHub login)AgentIdentitynode (seedAgentIdentities.mjs)to: 'AGENT:@neo-opus-4-7'(prefixed form, per tool-schema wording)AGENT:prefix exists; seed uses bare@loginto: 'AGENT:*'(broadcast sentinel, per tool-schema wording)AGENT:*is a sentinel, never seeded as a real nodeto: 'role:librarian'(role-based addressing, perMemoryCoreMcpAuth.md)to: 'human:tobiu'(human addressing, per auth doc)Of the five addressing modes documented in
#10147AC ("Addressing modes tested: direct identity, role-based, broadcast"), only the first — bare-@logindirect — has any chance of functioning. And even that requires the caller to use the exact bare-@loginspelling despite the tool-schema explicitly documenting'AGENT:@login'as a valid form.#10147's tests reported AC-satisfied, but clearly asserted onaddMessage's optimistic return payload rather than reading back throughlistMessages. The end-to-end path has never been exercised.Two adjacent defects surfaced during the same diagnostic chain:
listMessagesfromfilter is anti-spoof-rejected. The tool schema documentsfromas a legitimate filter parameter.MailboxService.listMessagesuses it as a post-filter on edges (line 185). ButAuthMiddleware.forbiddenKeyscontains'from', so any tool call withfrom: <login>is rejected with "Identity-override spoof rejected." Tool surface contradicts its own middleware.No per-turn inbox awareness.
add_memoryis guaranteed to run every turn per the Memory Core Protocol (CLAUDE.md §4.2). Piggybacking a per-turn inbox delta signal onto its response payload is the lowest-friction way to give agents push-like inbox awareness without a separate polling tool, without client opt-in, and — importantly — without requiring the cross-process cache-coherence primitive ofai/graph/Database.mjsto be repaired. A direct SQLiteSELECT COUNT(*)at save-time bypasses the in-memory edge cache entirely, so the signal is reliable even for writes originating in other harness processes.The Architectural Reality
Files in scope:
ai/mcp/server/memory-core/services/MailboxService.mjs(lines 46-113, addMessage)ai/mcp/server/memory-core/services/GraphService.mjs(lines 173-228, linkNodes)ai/mcp/server/shared/services/AuthMiddleware.mjs(line 16, forbiddenKeys array)ai/mcp/server/memory-core/services/MemoryService.mjsor equivalent (add_memory write path)ai/scripts/seedAgentIdentities.mjs(sentinel-node seed extension)test/playwright/unit/ai/mcp/server/memory-core/MailboxService.spec.mjs(end-to-end regression tests)ai/mcp/server/memory-core/openapi.yamlor equivalent (tool schema for add_memory response)The fix is intentionally minimal and scoped to the mailbox write/read path. It does NOT touch
linkNodes's FK check itself — the guard remains useful defense for other edge-creation paths in the graph. Instead, addMessage normalizes input before calling linkNodes, and seedAgentIdentities ensures the sentinel target node exists.The Fix
Normalize
toat the mailbox boundary (MailboxService.addMessage). Accept the three documented shapes and canonicalize to the seeded form:'AGENT:@login'→'@login'(stripAGENT:prefix)'AGENT:*'→'AGENT:*'(unchanged; handled by step 2)'@login'→ unchanged'role:<name>'/'human:<login>'→ unchanged; seed them in step 2 as needed, or fall back to creating an on-demand role nodeSeed
AGENT:*as the broadcast sentinel inseedAgentIdentities.mjs. Its graph node carries{type: 'BroadcastSentinel', name: 'Broadcast'}. The FK check then passes forlinkNodes(messageId, 'AGENT:*', 'SENT_TO', ...), andlistMessages's existingtargetNode === 'AGENT:*'filter (line 151) continues to work unchanged.Unblock
frominAuthMiddleware.forbiddenKeys. The schema authorizes it as a legitimate filter parameter; the middleware must not reject it. Spoofing is already prevented structurally:listMessagesonly reads,addMessage's authorship is derived fromRequestContext.getAgentIdentityNodeId()with no input-sidefromfield. Keep the remaining spoof-vector keys (userId,agentId,sender,authorLogin, etc.) unchanged.Extend
add_memoryresponse payload with an inbox delta signal. Addmailbox: {unreadCount: Number, latestPreview: {messageId, subject, from, sentAt} | null}to the response. Populate via a direct SQLSELECTagainst unread-statusSENT_TOedges targeting the caller's bound identity — bypasses the in-memory graph cache entirely for cross-process correctness. Non-fatal on failure: an error in the mailbox-delta query must not abort the memory write.End-to-end regression tests (the gap that let
#10147AC report success with the feature broken). Single Playwright test class covering:listMessages({box: 'inbox'})for direct-bare-login addressing'AGENT:@login'prefixed addressing'AGENT:*'broadcast (all authenticated recipients must find it)listMessages({from: '<login>'})succeeds without anti-spoof rejectionadd_memoryresponse includesmailboxblock; unreadCount reflects newly-sent messages visible in subsequent inbox readsAcceptance Criteria
MailboxService.addMessagenormalizestoinput:AGENT:@login→@login; bare@loginpreserved;AGENT:*preserved; roles/humans preservedseedAgentIdentities.mjsseedsAGENT:*as aBroadcastSentinelnodecreatedAtretention holds)AuthMiddleware.forbiddenKeysno longer contains'from'; other spoof keys unchangedadd_memoryresponse shape extended withmailbox: {unreadCount, latestPreview}— null-safe when identity is unboundlistMessages({from: X})passing without anti-spoof rejectionadd_memoryinbox-delta block matches subsequent inbox read countsadd_memoryresponse updated to document themailboxblock; schema validation tests pass#10147/#10167/#10170mailbox tests continue to passOut of Scope
ai/graph/Database.mjsin-memory edge cache does not refresh on remote writes, which means traversal queries (listMessages) still require the recipient's MCP server to boot after the sender's write landed in SQLite. Fix #4 (theadd_memoryinbox signal) makes this invisible in practice — agents poll via their per-turn save. A proper coherence primitive would require either SQLite change-feed triggers or promoting the graph to a network-addressable service, both of which properly belong to#9999's "Federated Cloud" architectural phase. File a follow-up if empirical friction demands it.role:librarian,role:next-session, etc.). This ticket normalizes input but does not seed the node population itself.#10148.#10149.#10150.#10169, parallel-track.Avoided Traps
linkNodesitself by removing the FK check." Rejected. The guard is legitimate defense against hallucinated LLM edges across the entire graph. Removing it opens a data-integrity regression for every otherlinkNodescaller. The correct fix is input normalization + sentinel seeding at the mailbox boundary, leaving the guard intact.linkNodesauto-create missing nodes." Rejected — same reason. It is the hallucinated-path vector the guard exists to stop. ThelinkNodesAsyncback-fill path from#10153handles thememory:/session:prefix lazy-fill narrowly; generalizing that to all unknown targets undoes the guard's value.@login." Rejected. The tool schema documents'AGENT:@login'and'AGENT:*'as valid shapes; the auth doc (MemoryCoreMcpAuth.md) documents role/human prefixes. Tightening the surface to the one form that happens to work today is a user-friction regression and abandons all documented addressing modes. Normalize at the boundary instead.#9999Federated Cloud concern. Theadd_memoryinbox-delta signal delivers the same UX outcome (agent sees "3 new messages" on next turn) for zero architectural cost.Related
#10139Mailbox A2A primitive (epic)#10147(mailbox primitives, closed — shipped this gap; AC test-coverage gap fixed here),#10145(identity substrate),#10144(AgentIdentity seed convention)#10153(linkNodesAsynclazy back-fill formemory:/session:prefix — different normalization problem, different scope)#10148,#10149,#10150(all Phase 4 mailbox subs effectively gated on this fix)#10172(node-ID case canonicalization — sibling concern in the normalization space, different cause)Origin Session ID:
d907e342-28fc-4eb6-8c51-355772dbfb44