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 pulled 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):
initAsync line 115: this.stdioIdentity = await this.resolveStdioIdentity(); — sets the cached identity context for all subsequent tool dispatches
resolveStdioIdentity (line 144): calls StdioIdentityResolver.resolve() → if githubLogin present, calls this.bindAgentIdentity(githubLogin)
bindAgentIdentity (line 206): await GraphService.ready(), then GraphService.getNode({id: '@' + userId})?.id || null
Per-request dispatch (line 343):
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:
- The vicinity-cache gets reset somewhere between boot and first tool call
get_node has a different code path than getNode used by bindAgentIdentity — re-reading: same path. Both call db.getAdjacentNodes(id, 'both').
- 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
- Populate SQLite graph DB with test-fixture nodes (e.g., run Playwright MailboxService.spec.mjs) such that production AgentIdentity nodes are absent
- Run
node ai/scripts/seedAgentIdentities.mjs from main checkout to restore production nodes
- Restart Claude Desktop (or Antigravity)
- Call
healthcheck → observe mailboxPreview: null
- Call
list_messages → observe Cannot list messages: no agent identity context bound
- 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
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
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), tobigit pulled 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/@gemininodes AND wiped the production@neo-opus-ada/@neo-gemini-pro/@tobiuseeds. Re-rannode ai/scripts/seedAgentIdentities.mjsto restore them. Restarted again. Binding STILL fails —list_messageserrors with"Cannot list messages: no agent identity context bound"despiteget_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:
healthcheck→mailboxPreviewfieldnull(indicatesgetAgentIdentityNodeId()returned null insidegetHealthcheckPreview)get_node({id: '@neo-opus-ada'})list_messagesCannot list messages: no agent identity context boundSELECT id FROM Nodes WHERE id='@neo-opus-ada'Environment is correct:
NEO_AGENT_IDENTITY=neo-opus-adais set in the running MCP process env (confirmed viaps -E),.neo-ai-data/sqlite/memory-core-graph.sqliteis the correct shared DB file opened by the process (confirmed vialsof), 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):initAsyncline 115:this.stdioIdentity = await this.resolveStdioIdentity();— sets the cached identity context for all subsequent tool dispatchesresolveStdioIdentity(line 144): callsStdioIdentityResolver.resolve()→ ifgithubLoginpresent, callsthis.bindAgentIdentity(githubLogin)bindAgentIdentity(line 206):await GraphService.ready(), thenGraphService.getNode({id: '@' + userId})?.id || nullPer-request dispatch (line 343):
const result = this.stdioIdentity ? await RequestContextService.run(this.stdioIdentity, dispatch) : await dispatch();The
RequestContextService.run()is wrapped only ifthis.stdioIdentityis truthy. If truthy butagentIdentityNodeId === null, the context IS set but with a null node-id — and downstreamgetAgentIdentityNodeId()returns null.Lazy-load path (
ai/graph/Database.mjsline 252-283):GraphService.getNodecallsdb.getAdjacentNodes(id, 'both')first to trigger lazy SQLite load viastorage.loadNodeVicinitySync(id). Perai/graph/storage/SQLite.mjsline 297-330,loadNodeVicinitySyncdoesSELECT 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 subsequentgetNodecalls 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'sgetNode({id: '@neo-opus-ada'})fires before the SQLite WAL checkpoint from the seed is fully visible to a fresh connection. SQLite'sbetter-sqlite3default 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 = nullcached for the rest of the process lifetime.The "subsequent
get_nodeworks" 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:get_nodehas a different code path thangetNodeused bybindAgentIdentity— re-reading: same path. Both calldb.getAdjacentNodes(id, 'both').@neo-opus-adato the in-memorynodesstore directlyDeeper investigation needed — the stuck-cache theory doesn't quite fit all observations.
Repro
node ai/scripts/seedAgentIdentities.mjsfrom main checkout to restore production nodeshealthcheck→ observemailboxPreview: nulllist_messages→ observeCannot list messages: no agent identity context boundget_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.mjstool dispatch, beforeRequestContextService.run, ifthis.stdioIdentity?.agentIdentityNodeId === null && this.stdioIdentity.userId, callthis.bindAgentIdentity(userId)to attempt re-resolution. If it succeeds, updatethis.stdioIdentity.agentIdentityNodeIdin-place. Self-healing — future dispatches use the resolved identity. Low risk; small blast radius.(b) Fix the stuck
vicinityLoadedNodescache. InDatabase.mjsgetAdjacentNodes, only callvicinityLoadedNodes.add(nodeId)IFvicinity.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
#10176healthcheck identity block. Independent of the fix, the healthcheck response gainsidentity: {source, bound, nodeId}so this class of failure is diagnosable with one MCP call instead of SQLite spelunking + JSDoc reading.#10176is 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
this.stdioIdentity.agentIdentityNodeId === nullanduserIdis truthy, attempt rebind on dispatch; update in-place on successvicinityLoadedNodescache does not stick on empty results (or the failure mode is structurally eliminated)#10176identity-surface healthcheck block (or merge this ticket with #10176 if timing aligns)Out of Scope
TransportServicewith OIDC-derived context, no boot-time cache race.Avoided Traps
RequestContextService.rununconditionally." Doesn't help — the issue is thatstdioIdentity.agentIdentityNodeIdisnull, not that the wrap is missing.Related
#10176(identity observability block in healthcheck — would make this class of failure mechanically diagnosable)#10174(seed-based AgentIdentity substrate this ticket exercises):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