---
id: 10174
title: Mailbox SENT_TO edges silently culled by linkNodes FK check
state: CLOSED
labels:
  - bug
  - ai
  - testing
  - architecture
  - core
assignees:
  - neo-opus-ada
createdAt: '2026-04-22T14:04:44Z'
updatedAt: '2026-06-07T17:20:04Z'
githubUrl: 'https://github.com/neomjs/neo/issues/10174'
author: neo-opus-ada
commentsCount: 0
parentIssue: 10139
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy: []
blocking: []
closedAt: '2026-04-22T15:21:22Z'
---
# Mailbox SENT_TO edges silently culled by linkNodes FK check

# 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-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:

```javascript
// 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**:

| Tool input shape | Guard verdict | Why |
|---|---|---|
| `to: '@neo-opus-ada'` (bare GitHub login) | ✓ passes | Matches seeded `AgentIdentity` node (`seedAgentIdentities.mjs`) |
| `to: 'AGENT:@neo-opus-ada'` (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:

1. **`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.

2. **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

1. **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

2. **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.

3. **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.

4. **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.

5. **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

- [ ] `MailboxService.addMessage` normalizes `to` input: `AGENT:@login` → `@login`; bare `@login` preserved; `AGENT:*` preserved; roles/humans preserved
- [ ] `seedAgentIdentities.mjs` seeds `AGENT:*` as a `BroadcastSentinel` node
- [ ] Re-running the seed script is idempotent for the new sentinel (existing defensive `createdAt` retention holds)
- [ ] `AuthMiddleware.forbiddenKeys` no longer contains `'from'`; other spoof keys unchanged
- [ ] `add_memory` response shape extended with `mailbox: {unreadCount, latestPreview}` — null-safe when identity is unbound
- [ ] Playwright regression covers all 3 addressing modes end-to-end (send → listMessages → verify visible)
- [ ] Playwright regression covers `listMessages({from: X})` passing without anti-spoof rejection
- [ ] Playwright regression covers `add_memory` inbox-delta block matches subsequent inbox read counts
- [ ] OpenAPI spec for `add_memory` response updated to document the `mailbox` block; schema validation tests pass
- [ ] Existing `#10147` / `#10167` / `#10170` mailbox tests continue to pass

### 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`

## Timeline

- 2026-04-22T14:04:45Z @neo-opus-ada assigned to @neo-opus-ada
- 2026-04-22T14:04:45Z @neo-opus-ada added the `bug` label
- 2026-04-22T14:04:45Z @neo-opus-ada added the `ai` label
- 2026-04-22T14:04:46Z @neo-opus-ada added the `testing` label
- 2026-04-22T14:04:46Z @neo-opus-ada added the `architecture` label
- 2026-04-22T14:04:46Z @neo-opus-ada added the `core` label
- 2026-04-22T14:05:58Z @neo-opus-ada added parent issue #10139
- 2026-04-22T14:06:15Z @neo-opus-ada cross-referenced by #10176
- 2026-04-22T14:24:46Z @neo-opus-ada cross-referenced by PR #10178
- 2026-04-22T15:18:11Z @neo-opus-ada referenced in commit `1443401` - "fix(memory-core): mailbox SENT_TO persistence + fromIdentity filter + add_memory inbox delta (#10174)"
- 2026-04-22T15:21:22Z @tobiu referenced in commit `7fe21ca` - "fix(memory-core): mailbox SENT_TO persistence + fromIdentity filter + add_memory inbox delta (#10174) (#10178)

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"
- 2026-04-22T15:21:23Z @tobiu closed this issue
- 2026-04-22T16:01:08Z @neo-opus-ada cross-referenced by PR #10182
- 2026-04-23T08:56:50Z @neo-opus-ada cross-referenced by PR #10220
- 2026-04-23T12:00:50Z @neo-opus-ada cross-referenced by #10230
- 2026-04-23T12:01:29Z @neo-opus-ada cross-referenced by #10231
- 2026-04-23T18:47:10Z @neo-opus-ada cross-referenced by PR #10258
- 2026-04-23T21:12:22Z @neo-opus-ada cross-referenced by PR #10262
- 2026-04-25T18:34:02Z @neo-opus-ada cross-referenced by #10330
- 2026-04-25T22:47:15Z @neo-opus-ada cross-referenced by #10347
- 2026-04-26T00:38:45Z @neo-opus-ada cross-referenced by PR #10350
- 2026-04-26T01:02:19Z @neo-opus-ada cross-referenced by PR #10353
- 2026-04-27T13:32:08Z @neo-opus-ada cross-referenced by PR #10427
- 2026-04-27T16:23:25Z @neo-opus-ada cross-referenced by #10434
- 2026-05-03T21:29:12Z @neo-gpt cross-referenced by #10668
- 2026-05-04T19:45:04Z @neo-opus-ada cross-referenced by PR #10713
- 2026-05-09T16:39:30Z @neo-gpt cross-referenced by #11029
- 2026-05-09T18:43:13Z @neo-opus-ada cross-referenced by PR #11042
- 2026-05-19T03:14:25Z @neo-opus-ada cross-referenced by PR #11615

