Context
Surfaced during the who_is_online dig (#13557). The Memory Core graph RLS tenant-isolation key is not canonical: reads compare one form, the user_id column is written in two. Own-private graph reads therefore succeed or fail by node type. Verified against the live graph (read-only).
Release classification: post-release (isolation-correctness hardening; boardless).
The Problem
All GraphService RLS reads key the visibility predicate on the @-prefixed RequestContextService.getAgentIdentityNodeId() — which RequestContextService docs explicitly call "NOT for isolation" (the isolation key is getUserId(), and normalizeUserId strips the @ at the AgentIdentity↔userId boundary).
But the user_id column is written in both forms across node types (live counts, non-null user_id rows):
| label |
@-form |
normalized (no-@) |
| AGENT_MEMORY |
731 |
11,756 |
| MEMORY |
— |
9,405 |
| MESSAGE |
6,923 |
113 |
| CONCEPT |
17,190 |
7 |
| CLASS |
3,640 |
1 |
| SESSION / SESSION_SUMMARY |
— |
548 / 181 |
| HARNESS_PRESENCE / WAKE_SUBSCRIPTION / AGENT_TURN_PRESENCE |
817 / 10 / 58 |
— |
Effect: user_id = '@neo-opus-ada' matches @-form rows but never the normalized rows, which aren't NULL/sharedEntity/team either → own-private nodes in the normalized labels are invisible to their own owner (fail-CLOSED — availability bug, not a cross-tenant leak). Empirically confirmed: get_node returns null on my own recent (normalized-user_id) AGENT_MEMORY node that demonstrably exists. The newer normalizeUserId writes (MemoryService raw-memory path) produced the 11,756 no-@ AGENT_MEMORY rows; the reads never followed → a regression of the normalizeUserId migration.
This is the deeper systemic cause behind who_is_online's all-dark (#13557 fixed who_is_online specifically by switching to roster-scoping; searchNodes / loadNodeVicinity / getNode remain exposed).
The Architectural Reality
- RLS read key (raw
@-form, no normalizeUserId): GraphService.mjs searchNodes:802 (user_id = ? SQL clause :804), loadNodeVicinity:663/706/755, :848, :909 — all getAgentIdentityNodeId() ?? null.
isRlsVisible(node, rlsUserId) (GraphService) compares against that raw @-form key (no normalization — confirmed by the empirical get_node null).
- Canonical boundary:
RequestContextService — getUserId() is the isolation key; normalizeUserId(input) strips a leading @; getAgentIdentityNodeId() is "NOT for isolation". MemoryService writes properties.userId = normalizeUserId(getUserId()) (no-@) → user_id column.
The Fix
Canonicalize the RLS key on both sides to the normalized (no-@) form, plus a one-time data migration:
- RLS reads: key on
normalizeUserId(RequestContextService.getUserId()) (fall back to normalizeUserId(getAgentIdentityNodeId())), not raw getAgentIdentityNodeId() — in GraphService read paths + isRlsVisible + the searchNodes SQL bind.
- Migration: normalize existing
@-form user_id column values to no-@ (so the @-form rows above keep matching post-fix).
- Writer audit: ensure all node writers set
user_id/properties.userId via the normalized form (close the drift at source).
Security gate: this is the tenant-isolation boundary — the fix must canonicalize symmetrically (read key + column) so it does NOT widen visibility across tenants; each tenant's normalized id stays distinct. Requires cross-family review + @neo-opus-grace (identity↔userId boundary, #11318).
Contract Ledger
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
| GraphService RLS read key |
RequestContextService.getUserId (isolation key) + normalizeUserId boundary |
reads key on normalizeUserId(getUserId()) (no-@); isRlsVisible + searchNodes SQL aligned |
raw @-form key (the bug) |
RequestContextService JSDoc |
live user_id-format audit + get_node null repro |
Nodes.user_id column form |
normalizeUserId boundary |
one-time migration of @-form values to no-@; writers use normalized form |
mixed-form column (the bug) |
migration script + JSDoc |
post-migration: 0 @-form user_id rows |
Decision Record impact
none (no ADR). Aligned-with the normalizeUserId AgentIdentity↔userId boundary; corrects RLS reads + data to honor it. Touches the tenant-isolation contract → cross-family + identity-domain (#11318) review.
Acceptance Criteria
Out of Scope
- who_is_online (already fixed via roster-scoping in #13561).
- The raw-
prepare() RLS-bypass read tools (separate concern — those skip RLS entirely).
Avoided Traps
- One-sided fix (normalize only the read key) — would break the
@-form rows (CONCEPT/CLASS/MESSAGE) that currently match. The column must be migrated too.
- Widening to fix availability — must not relax the predicate (e.g. drop the
user_id check); that trades a fail-closed availability bug for a cross-tenant leak.
Related
Origin Session ID: abe80be3-6235-4a9e-99bc-b14659ba806a
Handoff Retrieval Hints: query_raw_memories("RLS user_id @-prefix getAgentIdentityNodeId normalizeUserId GraphService isRlsVisible"); GraphService RLS read sites 663/706/755/802/848/909.
Context
Surfaced during the who_is_online dig (#13557). The Memory Core graph RLS tenant-isolation key is not canonical: reads compare one form, the
user_idcolumn is written in two. Own-private graph reads therefore succeed or fail by node type. Verified against the live graph (read-only).Release classification: post-release (isolation-correctness hardening; boardless).
The Problem
All
GraphServiceRLS reads key the visibility predicate on the@-prefixedRequestContextService.getAgentIdentityNodeId()— whichRequestContextServicedocs explicitly call "NOT for isolation" (the isolation key isgetUserId(), andnormalizeUserIdstrips the@at the AgentIdentity↔userId boundary).But the
user_idcolumn is written in both forms across node types (live counts, non-nulluser_idrows):@-form@)Effect:
user_id = '@neo-opus-ada'matches@-form rows but never the normalized rows, which aren'tNULL/sharedEntity/teameither → own-private nodes in the normalized labels are invisible to their own owner (fail-CLOSED — availability bug, not a cross-tenant leak). Empirically confirmed:get_nodereturnsnullon my own recent (normalized-user_id)AGENT_MEMORYnode that demonstrably exists. The newer normalizeUserId writes (MemoryServiceraw-memory path) produced the 11,756 no-@AGENT_MEMORYrows; the reads never followed → a regression of the normalizeUserId migration.This is the deeper systemic cause behind who_is_online's all-dark (#13557 fixed who_is_online specifically by switching to roster-scoping;
searchNodes/loadNodeVicinity/getNoderemain exposed).The Architectural Reality
@-form, nonormalizeUserId):GraphService.mjssearchNodes:802(user_id = ?SQL clause :804),loadNodeVicinity:663/706/755,:848,:909— allgetAgentIdentityNodeId() ?? null.isRlsVisible(node, rlsUserId)(GraphService) compares against that raw@-form key (no normalization — confirmed by the empiricalget_nodenull).RequestContextService—getUserId()is the isolation key;normalizeUserId(input)strips a leading@;getAgentIdentityNodeId()is "NOT for isolation".MemoryServicewritesproperties.userId = normalizeUserId(getUserId())(no-@) →user_idcolumn.The Fix
Canonicalize the RLS key on both sides to the normalized (no-
@) form, plus a one-time data migration:normalizeUserId(RequestContextService.getUserId())(fall back tonormalizeUserId(getAgentIdentityNodeId())), not rawgetAgentIdentityNodeId()— inGraphServiceread paths +isRlsVisible+ thesearchNodesSQL bind.@-formuser_idcolumn values to no-@(so the@-form rows above keep matching post-fix).user_id/properties.userIdvia the normalized form (close the drift at source).Security gate: this is the tenant-isolation boundary — the fix must canonicalize symmetrically (read key + column) so it does NOT widen visibility across tenants; each tenant's normalized id stays distinct. Requires cross-family review + @neo-opus-grace (identity↔userId boundary, #11318).
Contract Ledger
RequestContextService.getUserId(isolation key) +normalizeUserIdboundarynormalizeUserId(getUserId())(no-@);isRlsVisible+ searchNodes SQL aligned@-form key (the bug)get_nodenull reproNodes.user_idcolumn form@-form values to no-@; writers use normalized form@-formuser_idrowsDecision Record impact
none(no ADR). Aligned-with thenormalizeUserIdAgentIdentity↔userId boundary; corrects RLS reads + data to honor it. Touches the tenant-isolation contract → cross-family + identity-domain (#11318) review.Acceptance Criteria
isRlsVisible+searchNodesSQL key on the normalized (no-@) userId.@-formuser_idcolumn values; post-migration audit shows a single consistent form.user_idis set via the normalized form everywhere.get_node/searchNodeson an agent's own normalized-user_idAGENT_MEMORY) succeed for the owner.Out of Scope
prepare()RLS-bypass read tools (separate concern — those skip RLS entirely).Avoided Traps
@-form rows (CONCEPT/CLASS/MESSAGE) that currently match. The column must be migrated too.user_idcheck); that trades a fail-closed availability bug for a cross-tenant leak.Related
Origin Session ID: abe80be3-6235-4a9e-99bc-b14659ba806a
Handoff Retrieval Hints:
query_raw_memories("RLS user_id @-prefix getAgentIdentityNodeId normalizeUserId GraphService isRlsVisible"); GraphService RLS read sites 663/706/755/802/848/909.