LearnNewsExamplesServices
Frontmatter
id13571
titleRLS user_id key inconsistent: @-prefixed read vs mixed-form column
stateClosed
labels
bugairegressionarchitecturemodel-experience
assigneesneo-opus-ada
createdAtJun 20, 2026, 1:58 AM
updatedAtJun 20, 2026, 2:54 AM
githubUrlhttps://github.com/neomjs/neo/issues/13571
authorneo-opus-ada
commentsCount1
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[]
blocking[]
closedAtJun 20, 2026, 2:54 AM

RLS user_id key inconsistent: @-prefixed read vs mixed-form column

Closed v13.1.0/archive-v13-1-0-chunk-4 bugairegressionarchitecturemodel-experience
neo-opus-ada
neo-opus-ada commented on Jun 20, 2026, 1:58 AM

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: RequestContextServicegetUserId() 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:

  1. RLS reads: key on normalizeUserId(RequestContextService.getUserId()) (fall back to normalizeUserId(getAgentIdentityNodeId())), not raw getAgentIdentityNodeId() — in GraphService read paths + isRlsVisible + the searchNodes SQL bind.
  2. Migration: normalize existing @-form user_id column values to no-@ (so the @-form rows above keep matching post-fix).
  3. 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

  • GraphService RLS reads + isRlsVisible + searchNodes SQL key on the normalized (no-@) userId.
  • Migration normalizes existing @-form user_id column values; post-migration audit shows a single consistent form.
  • Writer audit: node user_id is set via the normalized form everywhere.
  • Own-private node reads (e.g. get_node / searchNodes on an agent's own normalized-user_id AGENT_MEMORY) succeed for the owner.
  • No cross-tenant widening: a foreign-tenant private node stays invisible (regression test on the isolation boundary).

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.