---
id: 10181
title: Memory-core stdio identity binding non-deterministic after DB re-seed
state: CLOSED
labels:
  - bug
  - ai
  - architecture
assignees:
  - neo-opus-ada
createdAt: '2026-04-22T15:54:43Z'
updatedAt: '2026-06-07T17:20:08Z'
githubUrl: 'https://github.com/neomjs/neo/issues/10181'
author: neo-opus-ada
commentsCount: 0
parentIssue: 10139
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy: []
blocking: []
closedAt: '2026-04-22T16:04:41Z'
---
# Memory-core stdio identity binding non-deterministic after DB re-seed

# Memory-core stdio identity binding non-deterministic after DB re-seed

### Context

Empirically surfaced in the 2026-04-22 Phase 4 post-merge validation cycle (sessions `7ee23599-3f94-4f75-b54c-97ba92c9aaef` + successors). After the Phase 4 merge train shipped (`#10175` / `#10178` / `#10177`), tobi `git pull`ed dev on both main checkouts and restarted Claude Desktop + Antigravity to smoke-test the live A2A channel. Binding failed on the first attempt. Diagnosis revealed the live SQLite had been polluted by a prior Playwright run that wrote test-fixture `@opus` / `@gemini` nodes AND wiped the production `@neo-opus-ada` / `@neo-gemini-pro` / `@tobiu` seeds. Re-ran `node ai/scripts/seedAgentIdentities.mjs` to restore them. Restarted again. **Binding STILL fails** — `list_messages` errors with `"Cannot list messages: no agent identity context bound"` despite `get_node({id: '@neo-opus-ada'})` returning the node correctly in the same session.

### The Problem

The symptom is a read-vs-write asymmetry inside the same MCP server process:

| Call | Result |
|---|---|
| `healthcheck` → `mailboxPreview` field | `null` (indicates `getAgentIdentityNodeId()` returned null inside `getHealthcheckPreview`) |
| `get_node({id: '@neo-opus-ada'})` | Returns the node correctly |
| `list_messages` | Throws `Cannot list messages: no agent identity context bound` |
| Direct SQLite `SELECT id FROM Nodes WHERE id='@neo-opus-ada'` | Row exists |

Environment is correct: `NEO_AGENT_IDENTITY=neo-opus-ada` is set in the running MCP process env (confirmed via `ps -E`), `.neo-ai-data/sqlite/memory-core-graph.sqlite` is the correct shared DB file opened by the process (confirmed via `lsof`), and the seed script succeeded against the same path (confirmed by post-seed row count).

The MCP server process was started at 17:46:59 local, after the seed at ~17:43 local — no timing race on the seed-before-boot side.

### The Architectural Reality

**Boot-time binding path (`ai/mcp/server/memory-core/Server.mjs`):**

1. `initAsync` line 115: `this.stdioIdentity = await this.resolveStdioIdentity();` — sets the cached identity context for all subsequent tool dispatches
2. `resolveStdioIdentity` (line 144): calls `StdioIdentityResolver.resolve()` → if `githubLogin` present, calls `this.bindAgentIdentity(githubLogin)`
3. `bindAgentIdentity` (line 206): `await GraphService.ready()`, then `GraphService.getNode({id: '@' + userId})?.id || null`

**Per-request dispatch (line 343):**

```javascript
const result = this.stdioIdentity
    ? await RequestContextService.run(this.stdioIdentity, dispatch)
    : await dispatch();
```

The `RequestContextService.run()` is wrapped only if `this.stdioIdentity` is truthy. If truthy but `agentIdentityNodeId === null`, the context IS set but with a null node-id — and downstream `getAgentIdentityNodeId()` returns null.

**Lazy-load path (`ai/graph/Database.mjs` line 252-283):**

`GraphService.getNode` calls `db.getAdjacentNodes(id, 'both')` first to trigger lazy SQLite load via `storage.loadNodeVicinitySync(id)`. Per `ai/graph/storage/SQLite.mjs` line 297-330, `loadNodeVicinitySync` does `SELECT data FROM Nodes WHERE id IN (?)` — which SHOULD return the target node unconditionally (not only if it has edges).

**However:** `db.vicinityLoadedNodes.add(nodeId)` (Database.mjs line 282) marks the node as loaded **regardless** of whether the SQLite query returned anything. If a FIRST call at boot-time queries SQLite and returns nothing (for whatever reason — possibly a transient miss), the node is marked as "loaded" and subsequent `getNode` calls will NOT re-query — they'll only check the (still-empty) in-memory cache. This creates a stuck-in-null cache.

**Theory to verify:** at boot, `resolveStdioIdentity`'s `getNode({id: '@neo-opus-ada'})` fires before the SQLite WAL checkpoint from the seed is fully visible to a fresh connection. SQLite's `better-sqlite3` default should surface WAL-committed writes immediately, but some boot-time timing could leave the fresh connection with a stale view. First query returns empty → node marked loaded → `stdioIdentity.agentIdentityNodeId = null` cached for the rest of the process lifetime.

The "subsequent `get_node` works" paradox is explained by: the vicinity-load happened at boot (before my session) and cached the "not found" result. But my direct sqlite3 query + the MCP tool call both succeed because... actually they wouldn't succeed if the vicinityLoadedNodes cache were stuck. So either:
1. The vicinity-cache gets reset somewhere between boot and first tool call
2. `get_node` has a different code path than `getNode` used by `bindAgentIdentity` — re-reading: same path. Both call `db.getAdjacentNodes(id, 'both')`.
3. Something between boot and first tool call adds `@neo-opus-ada` to the in-memory `nodes` store directly

Deeper investigation needed — the stuck-cache theory doesn't quite fit all observations.

### Repro

1. Populate SQLite graph DB with test-fixture nodes (e.g., run Playwright MailboxService.spec.mjs) such that production AgentIdentity nodes are absent
2. Run `node ai/scripts/seedAgentIdentities.mjs` from main checkout to restore production nodes
3. Restart Claude Desktop (or Antigravity)
4. Call `healthcheck` → observe `mailboxPreview: null` 
5. Call `list_messages` → observe `Cannot list messages: no agent identity context bound`
6. Call `get_node({id: '@neo-opus-ada'})` → observe node returned correctly (contradicts the binding failure)

### Proposed Fix Paths

Not mutually exclusive:

**(a) Self-healing rebind at dispatch time.** In `Server.mjs` tool dispatch, before `RequestContextService.run`, if `this.stdioIdentity?.agentIdentityNodeId === null && this.stdioIdentity.userId`, call `this.bindAgentIdentity(userId)` to attempt re-resolution. If it succeeds, update `this.stdioIdentity.agentIdentityNodeId` in-place. Self-healing — future dispatches use the resolved identity. Low risk; small blast radius.

**(b) Fix the stuck `vicinityLoadedNodes` cache.** In `Database.mjs` `getAdjacentNodes`, only call `vicinityLoadedNodes.add(nodeId)` IF `vicinity.nodes.length > 0`. If zero nodes returned, leave the cache unmarked so a retry can refresh. Risk: if SQLite is genuinely missing the node, we'd hit SQLite on every access — a minor cost. Guardable via LRU or TTL.

**(c) Diagnostic surfacing via `#10176` healthcheck identity block.** Independent of the fix, the healthcheck response gains `identity: {source, bound, nodeId}` so this class of failure is diagnosable with one MCP call instead of SQLite spelunking + JSDoc reading. `#10176` is already filed and addresses this specifically.

**Recommended:** ship (a) + (c) together as a hotfix. (b) is a follow-up once the race window is better understood.

### Acceptance Criteria

- [ ] Root cause confirmed via reproducible steps (run through the repro above on a clean checkout)
- [ ] Fix path (a) self-healing rebind: if `this.stdioIdentity.agentIdentityNodeId === null` and `userId` is truthy, attempt rebind on dispatch; update in-place on success
- [ ] Fix path (b) or equivalent: `vicinityLoadedNodes` cache does not stick on empty results (or the failure mode is structurally eliminated)
- [ ] Playwright regression: simulate the "node added to SQLite AFTER first binding attempt" scenario, verify binding self-heals on subsequent dispatch
- [ ] `#10176` identity-surface healthcheck block (or merge this ticket with #10176 if timing aligns)

### Out of Scope

- SSE-transport identity binding path — unaffected per observation; SSE wraps per-request via `TransportService` with OIDC-derived context, no boot-time cache race.
- ChromaDB read filtering — this ticket is about graph-node identity binding, not the tenant-filtered vector reads.

### Avoided Traps

- **"Just always call `RequestContextService.run` unconditionally."** Doesn't help — the issue is that `stdioIdentity.agentIdentityNodeId` is `null`, not that the wrap is missing.
- **"Force a full graph load at boot instead of lazy-load."** Scope creep — the lazy-load primitive is architecturally correct at scale; fix the cache semantics, not the strategy.

### Related

- Sibling: `#10176` (identity observability block in healthcheck — would make this class of failure mechanically diagnosable)
- Adjacent: `#10174` (seed-based AgentIdentity substrate this ticket exercises)
- Test-isolation root cause follow-up (the `:memory:` override leak that let tests pollute the live DB in the first place — belongs to a separate ticket if we want to prevent the recurrence scenario entirely)

Origin Session ID: `7ee23599-3f94-4f75-b54c-97ba92c9aaef`

## Timeline

- 2026-04-22T15:54:45Z @neo-opus-ada assigned to @neo-opus-ada
- 2026-04-22T15:54:45Z @neo-opus-ada added the `bug` label
- 2026-04-22T15:54:45Z @neo-opus-ada added the `ai` label
- 2026-04-22T15:54:45Z @neo-opus-ada added the `architecture` label
- 2026-04-22T15:55:10Z @neo-opus-ada added parent issue #10139
- 2026-04-22T16:01:08Z @neo-opus-ada cross-referenced by PR #10182
- 2026-04-22T16:04:41Z @tobiu referenced in commit `fda5dd4` - "fix(memory-core): self-heal stdio identity binding on dispatch (#10181) (#10182)

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"
- 2026-04-22T16:04:42Z @tobiu closed this issue
- 2026-04-22T16:06:30Z @neo-gemini-pro cross-referenced by #10183
- 2026-04-23T08:57:12Z @neo-opus-ada cross-referenced by #10190
- 2026-04-23T14:16:56Z @neo-opus-ada cross-referenced by #10241
- 2026-04-23T15:15:17Z @neo-opus-ada cross-referenced by PR #10242
- 2026-04-27T18:21:14Z @neo-opus-ada cross-referenced by #10437
- 2026-04-27T18:27:58Z @neo-opus-ada cross-referenced by PR #10438
- 2026-04-27T19:21:31Z @tobiu referenced in commit `09984a9` - "fix(memory-core): auto-invoke wake subscription bootstrap on MCP server boot (#10437) (#10438)

* fix(memory-core): auto-invoke wake subscription bootstrap on MCP server boot (#10437)

Closes the missing AC from #10402 (Phase 1 wake substrate self-healing). The
merged implementation (#10404 + #10412) delivered the bootstrap mechanism —
idempotent, restart-safe, identity-template-driven — but never wired up the
auto-trigger. Every new agent session today silently has zero `WAKE_SUBSCRIPTION`
nodes for the bound identity until someone manually calls
`manage_wake_subscription({action: 'bootstrap'})`. Without the auto-invoke, the
bridge daemon polls but has nothing to deliver to → no `[WAKE]` events → silent
wake-substrate degradation.

**Empirical anchor (2026-04-27 session b3c0bfb8):** post-#10433 merge + harness
restart, my MCP server bound `@neo-opus-4-7` and ran for ~2 hours with zero
WAKE_SUB for the bound identity. Bridge daemon (PID 12039) was running healthy
in canonical, polling, but had nothing to deliver to. A2A read/write paths
verified working; wake delivery silently broken.

**The Fix:**
After `StdioIdentityResolver` binds identity at MCP server boot (line 117),
fire-and-forget `WakeSubscriptionService.bootstrap()` wrapped in
`RequestContextService.run()` so `getAgentIdentityNodeId()` resolves to the
bound identity. Failure is logged at warn level + boot continues — bootstrap
legitimately throws when the identity lacks a `subscriptionTemplate` (single-
tenant fallthrough is a valid mode).

**Why server-side (not AGENTS_STARTUP.md doc-only):**
Discipline-layer mandates depend on agent compliance + survive context-pruning
poorly. Per #10181/#10182 precedent (identity-binding self-heal at dispatch
level), the right answer for lifecycle-gap class problems is substrate-level
auto-trigger. The doc-only fix would be a half-measure: future sessions
wouldn't know about it, and the substrate must self-heal regardless.

**Safety properties:**
- Idempotent per #10412's raw-SQL fix — safe to repeat across restarts
- Fire-and-forget — bootstrap failure does not gate boot
- Wrapped in RequestContextService.run() so bootstrap resolves bound identity
- Defensive .catch() on the outer promise — never leaks unhandled rejection

**Test evidence:**
All 25 existing `WakeSubscriptionService.spec.mjs` tests pass. The auto-invoke
uses well-tested primitives (`bootstrap()` template-creation + idempotency +
missing-template throw paths are all covered). The new behavior is the call-
site wiring in `Server.mjs`, which has no direct test surface — AC includes a
manual post-merge validation step (terminate + restart harnesses; verify
auto-seeded WAKE_SUB nodes appear without manual `manage_wake_subscription`
call).

**Out of scope:**
- OIDC-mode dispatch-time self-heal (defer; stdio is the dominant swarm path
  today; file as follow-up if/when OIDC mode is exercised)
- AGENTS_STARTUP.md mandate (the original #10402 AC item — can be added as
  defense-in-depth in a tiny follow-up; not blocking; the substrate-level
  auto-invoke is the load-bearing fix per #10181/#10182 precedent)

Co-Authored-By: Claude Opus 4.7 (1M context) <claude@anthropic.com>

* refactor(memory-core): single error boundary for wake-sub auto-bootstrap (#10437)

Per @neo-gemini-3-1-pro's PR #10438 cycle 1 review observation: the dual-catch
pattern is empirically safe but non-obvious. Refactor to single-error-boundary
design via async IIFE — unifies all failure sources (sync throws during
`RequestContextService.run()` setup, sync throws inside `bootstrap()`'s setup
before its first await, and async rejections from bootstrap's SQLite/GraphService
awaits) onto one canonical catch.

**Behavioral parity:** identical to cycle 1's dual-catch implementation.
- Boot still gates only on synchronous setup that runs *outside* the IIFE
- Bootstrap failure still logs at warn + boot continues (fire-and-forget by not
  awaiting the IIFE)
- Idempotency still preserved per #10412's raw-SQL fix

**Why single boundary is cleaner:**
- One try/catch covers all error surfaces — no "this catch handles X, that
  catch handles Y" mental tax for future readers
- Removes the `.catch()` chained on the outer Promise, which was defensive
  against a non-failure mode (`RequestContextService.run()` doesn't throw
  synchronously in current implementation, but the dual-catch was kept as
  belt-and-suspenders)
- Async IIFE is the canonical pattern for fire-and-forget async operations
  with single error boundary

**Comment block expanded** to document:
- Why the wrap is required (not ceremonial — `getAgentIdentityNodeId()` throws
  unless inside a `run()` scope)
- All four error sources the single catch handles
- Why fire-and-forget is correct here (boot must not gate on subscription seed)

All 25 existing `WakeSubscriptionService.spec.mjs` tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <claude@anthropic.com>

---------

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <claude@anthropic.com>"
- 2026-05-24T21:08:52Z @neo-opus-ada cross-referenced by PR #11920

