---
id: 10011
title: 'Native Edge Graph (SQLite): Row-Level Security & Tenant Isolation'
state: CLOSED
labels:
  - enhancement
  - ai
  - architecture
assignees:
  - neo-opus-ada
createdAt: '2026-04-14T15:21:47Z'
updatedAt: '2026-05-20T07:50:00Z'
githubUrl: 'https://github.com/neomjs/neo/issues/10011'
author: tobiu
commentsCount: 1
parentIssue: 10016
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy:
  - '[x] 10000 Hardened Identity Ingestion & Tenant Isolation'
blocking: []
closedAt: '2026-05-20T07:50:00Z'
---
# Native Edge Graph (SQLite): Row-Level Security & Tenant Isolation

## Objective
Ensure the SQLite Native Edge Graph respects the same Multi-Tenant isolation rules as the ChromaDB vector store to prevent structural data leaks.

## Tasks
1. Execute a database migration (`ALTER TABLE`) on the `nodes` and `edges` SQLite tables to introduce a `user_id` column.
2. Ensure structural ingestion tools (e.g., `add_memory` pushing Session Nodes, or `mutate_frontier`) stamp the acting `config.auth.userId`.
3. Refactor graph search primitives (`query_hybrid_graph`, `search_nodes`, `get_node`) to strictly append `WHERE user_id = ? OR visibility = 'team'` to prevent Alice's context window from pulling in Bob's private session nodes that happen to be structurally adjacent.

## Timeline

- 2026-04-14T15:21:48Z @tobiu added the `enhancement` label
- 2026-04-14T15:21:48Z @tobiu added the `ai` label
- 2026-04-14T15:21:49Z @tobiu added the `architecture` label
- 2026-04-14T15:22:01Z @tobiu added parent issue #9999
- 2026-04-14T16:43:40Z @tobiu cross-referenced by #10016
- 2026-04-14T16:43:41Z @tobiu cross-referenced by #10017
- 2026-04-14T16:44:20Z @tobiu removed parent issue #9999
- 2026-04-14T16:44:21Z @tobiu added parent issue #10016
- 2026-04-14T16:44:42Z @tobiu marked this issue as being blocked by #10000
- 2026-04-20T14:12:33Z @tobiu cross-referenced by PR #10128
- 2026-04-20T15:26:36Z @tobiu cross-referenced by PR #10130
- 2026-04-23T21:58:55Z @neo-gemini-pro cross-referenced by PR #10265
- 2026-04-23T22:22:29Z @neo-opus-ada cross-referenced by #9999
- 2026-04-23T23:21:21Z @neo-opus-ada cross-referenced by PR #10269
- 2026-05-04T13:21:56Z @neo-gpt cross-referenced by #10691
- 2026-05-04T20:45:02Z @neo-opus-ada cross-referenced by #10009
- 2026-05-04T20:45:45Z @neo-opus-ada cross-referenced by PR #10716
- 2026-05-04T22:04:15Z @neo-opus-ada cross-referenced by #10721
- 2026-05-04T22:13:53Z @neo-gemini-pro cross-referenced by #10727
- 2026-05-06T08:29:22Z @neo-opus-ada cross-referenced by #10800
- 2026-05-06T08:36:17Z @neo-opus-ada cross-referenced by #10807
- 2026-05-08T10:24:23Z @neo-gpt cross-referenced by #10945
- 2026-05-08T10:46:37Z @neo-gpt cross-referenced by #10947
- 2026-05-08T10:48:19Z @neo-gpt cross-referenced by #10951
- 2026-05-08T17:47:55Z @neo-opus-ada cross-referenced by #10960
- 2026-05-09T20:32:46Z @neo-gpt cross-referenced by PR #11055
- 2026-05-09T20:45:37Z @neo-gpt cross-referenced by #11057
- 2026-05-09T21:14:10Z @neo-gpt cross-referenced by PR #11060
- 2026-05-11T19:24:46Z @neo-gpt cross-referenced by #10961
- 2026-05-16T17:02:57Z @neo-gpt cross-referenced by #10010
- 2026-05-19T11:32:48Z @neo-opus-ada cross-referenced by #11624
- 2026-05-19T11:34:04Z @neo-opus-ada cross-referenced by #11625
- 2026-05-19T11:40:36Z @neo-opus-ada cross-referenced by #11626
- 2026-05-19T11:41:19Z @neo-opus-ada cross-referenced by #11627
- 2026-05-19T11:56:17Z @neo-opus-ada cross-referenced by #11637
### @neo-gpt - 2026-05-20T03:34:56Z

**Input from GPT-5 (Codex):**

> ● Peer-role active: substrate-validation, precedent-checking, and evidence-backed convergence pressure count as execution; suspend Auto Mode 'ack-and-move-on' bias until exit conditions are met. Schlagfertig-discipline (§6.7) anchors the positive disposition.
> 
> ## RLS Readiness Overlay (2026-05-20)
> 
> Read-only V-B-A after #11637 separability review: #10011 is the stronger pre-Phase-2 lane before shipping tenant config storage without caveats.
> 
> Evidence checked:
> - #10011 is open and unassigned; no existing issue comments were present before this overlay.
> - #11637's separability comment (`IC_kwDODSospM8AAAABC-CgEQ`) correctly identifies `KnowledgeBaseTenantConfig` as schema-separable but security-caveated until graph RLS closes.
> - `ai/graph/storage/SQLite.mjs:83-112` already has `user_id` on `Nodes` / `Edges` plus the migration fallback.
> - `ai/services/memory-core/GraphService.mjs:194-223` and `:293-297` stamp node/edge properties with the current request identity when available.
> - `ai/graph/storage/SQLite.mjs:382-398` and `GraphService.mjs:633-647` already apply an RLS-style predicate during vicinity loading / `searchNodes`.
> - `GraphService.mjs:539-560` (`getNode`), `:592-617` (`getNeighbors`), and `:727+` (`queryNodeTopology`) still return from the process-wide graph cache without an explicit caller-visible RLS recheck at the return boundary.
> 
> Key nuance: `getNode` and `getNeighbors` do trigger `getAdjacentNodes()`, and the storage lazy-load path is RLS-filtered. That is not enough as a complete #10011 closeout because the in-memory cache is process-wide and `vicinityLoadedNodes` is keyed by node id, not by requester identity. A node or edge already warmed under a different context can still be read unless these return paths re-validate against the active identity.
> 
> Recommended closeout slice:
> 1. Centralize a graph RLS predicate/helper for node and edge visibility: owner match, `user_id IS NULL`, `sharedEntity`, or `visibility: 'team'`.
> 2. Apply it at the public read return boundary for `getNode`, `getNeighbors`, and `queryNodeTopology`, not only in SQLite lazy-load queries.
> 3. Add unit coverage for the cache-warmed leak case: tenant A warms a private node/topology; tenant B calls `get_node`, `get_neighbors`, and `query_hybrid_graph` and must not receive it.
> 4. Keep #11637's future `getTenantConfig` implementation on `searchNodes` or the new centralized RLS helper until this ticket is complete.
> 
> Process boundary for nightshift: this is ticket-shaping only. The current operator constraint forbids `AGENT:*` broadcasts, while repo rule 7 requires `AGENT:*` lane-claim before tracked file edits. So #10011 is technically high-leverage and bounded, but a code PR should wait for operator clearance or an explicit lane-claim exception substrate.

- 2026-05-20T05:53:48Z @neo-opus-ada assigned to @neo-opus-ada
- 2026-05-20T06:51:39Z @neo-opus-ada cross-referenced by PR #11672
- 2026-05-20T07:10:06Z @neo-opus-ada referenced in commit `5d80faf` - "fix(memory-core): extend graph RLS return-boundary filter to edges (#10011)

Cycle 1 review (PR #11672, @neo-gpt PRR_kwDODSospM8AAAABAdhvvA): the
return-boundary RLS filter covered cache-resident nodes only. Edges carry
their own server-stamped `properties.userId`, and SQLite.loadNodeVicinitySync
applies the RLS clause to the Edges table too — so a private edge between two
otherwise-visible nodes leaked through getNeighbors / queryNodeTopology /
getContextFrontier.

- Generalized `isNodeRlsVisible` -> `isRlsVisible` — one entity predicate for
  nodes and edges (the SQL RLS clause is identical for both tables).
- getNeighbors / getContextFrontier: a neighbor is exposed only when BOTH the
  adjacent node and the connecting edge are RLS-visible.
- queryNodeTopology: an edge is skipped (no surface, no traversal) when the
  edge itself is not visible, in addition to the far-node check.
- 3 new unit cases: a cache-warmed private edge between visible nodes is not
  surfaced by getNeighbors / queryNodeTopology / getContextFrontier.

10/10 unit tests pass.

(#10011)"
- 2026-05-20T07:50:00Z @tobiu referenced in commit `3bf7d95` - "feat(memory-core): graph RLS read-side return boundary (#10011) (#11672)

* feat(memory-core): graph RLS return-boundary filter for cache-resident reads (#10011)

getNode, getNeighbors, getContextFrontier, and queryNodeTopology return graph
nodes read from `this.db.nodes` — a process-wide in-memory Store. The SQL RLS
clause in searchNodes / SQLite.loadNodeVicinitySync filters only the lazy-load
path; a node warmed into the Store by one requester's load is then readable by
any other requester straight from the cache, bypassing RLS.

Adds `isNodeRlsVisible(node, requesterUserId)` — a JS predicate mirroring the
SQL RLS clause (owner match / null owner / sharedEntity / team visibility) —
applied at each public read return boundary:

- getNode: returns null for a node not visible to the requester.
- getNeighbors: returns no neighbors when the root is invisible; filters each
  neighbor by node visibility.
- getContextFrontier: filters strategicNeighbors by visibility (the frontier
  anchor itself is shared / null-owned).
- queryNodeTopology: rejects an invisible root; skips edges/nodes/traversal
  for any invisible or absent far node.

getContextFrontier extends #10011's literally-named primitives — it has the
identical process-wide-cache leak; a fix covering 3 of 4 equivalent read paths
would not be a real closeout. Cache-warmed-leak unit coverage follows.

Also strips pre-existing trailing whitespace: check-whitespace.mjs is a
whole-file pre-commit hook, so touching the file forces full-file cleanup.

(#10011)

* test(memory-core): cache-warmed-leak coverage for graph RLS read boundary (#10011)

* fix(memory-core): extend graph RLS return-boundary filter to edges (#10011)

Cycle 1 review (PR #11672, @neo-gpt PRR_kwDODSospM8AAAABAdhvvA): the
return-boundary RLS filter covered cache-resident nodes only. Edges carry
their own server-stamped `properties.userId`, and SQLite.loadNodeVicinitySync
applies the RLS clause to the Edges table too — so a private edge between two
otherwise-visible nodes leaked through getNeighbors / queryNodeTopology /
getContextFrontier.

- Generalized `isNodeRlsVisible` -> `isRlsVisible` — one entity predicate for
  nodes and edges (the SQL RLS clause is identical for both tables).
- getNeighbors / getContextFrontier: a neighbor is exposed only when BOTH the
  adjacent node and the connecting edge are RLS-visible.
- queryNodeTopology: an edge is skipped (no surface, no traversal) when the
  edge itself is not visible, in addition to the far-node check.
- 3 new unit cases: a cache-warmed private edge between visible nodes is not
  surfaced by getNeighbors / queryNodeTopology / getContextFrontier.

10/10 unit tests pass.

(#10011)

---------

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"
- 2026-05-20T07:50:00Z @tobiu closed this issue
- 2026-05-20T22:28:38Z @neo-opus-ada referenced in commit `c33a64e` - "feat(memory-core): add GraphService.getNodeRecord RLS-checked properties getter (#11637)

Phase 2E (#11637 Tenant Config Storage) needs to read a structured config
payload stored under a graph node's `properties` blob, but `GraphService.getNode`
projects only the hoisted fields (id/type/name/description/semanticVectorId/state)
and never returns `properties`.

Adds `getNodeRecord({id})` - returns `{id, type, properties}` with the same
#10011 RLS visibility re-check as `getNode` (a cross-tenant node returns `null`),
so it is a properties-returning read, not an RLS bypass. This is the D2 surface
from the #11637 Contract Ledger.

- ai/services/memory-core/GraphService.mjs: new getNodeRecord method.
- GraphService.spec.mjs: +2 tests - properties returned (vs getNode stripping
  them); the #10011 RLS re-check (owner reads, cross-tenant -> null). 25/25 pass.
  Also strips 5 pre-existing trailing-whitespace lines the check-whitespace
  pre-commit hook flagged on the staged file.

Co-Authored-By: Neo Opus 4.7 <neo-opus-ada@neomjs.com>"
- 2026-05-20T23:33:06Z @neo-opus-ada cross-referenced by PR #11704
- 2026-05-21T05:58:23Z @tobiu referenced in commit `09c69b2` - "feat(kb): add Phase 2E tenant config storage (#11637) (#11704)

* feat(memory-core): add GraphService.getNodeRecord RLS-checked properties getter (#11637)

Phase 2E (#11637 Tenant Config Storage) needs to read a structured config
payload stored under a graph node's `properties` blob, but `GraphService.getNode`
projects only the hoisted fields (id/type/name/description/semanticVectorId/state)
and never returns `properties`.

Adds `getNodeRecord({id})` - returns `{id, type, properties}` with the same
#10011 RLS visibility re-check as `getNode` (a cross-tenant node returns `null`),
so it is a properties-returning read, not an RLS bypass. This is the D2 surface
from the #11637 Contract Ledger.

- ai/services/memory-core/GraphService.mjs: new getNodeRecord method.
- GraphService.spec.mjs: +2 tests - properties returned (vs getNode stripping
  them); the #10011 RLS re-check (owner reads, cross-tenant -> null). 25/25 pass.
  Also strips 5 pre-existing trailing-whitespace lines the check-whitespace
  pre-commit hook flagged on the staged file.

Co-Authored-By: Neo Opus 4.7 <neo-opus-ada@neomjs.com>

* feat(kb): add getTenantConfig / setTenantConfig tenant config storage (#11637)

Phase 2E increment 2 - KnowledgeBaseIngestionService now reads and writes a
tenant's ingestion config as a versioned KnowledgeBaseTenantConfig graph node,
per the #11637 Contract Ledger.

- KnowledgeBaseIngestionService: new `graphService` injectable config member
  (the Native Edge Graph). `getTenantConfig({tenantId})` resolves graph node ->
  default registry (the kb-config.yaml bootstrap tier follows in a later commit).
  `setTenantConfig({tenantId, config})` writes the versioned `kb-config:<tenantId>`
  node; `version` increments per mutation.
- RLS: setTenantConfig gates on `resolveTenantContext` (rejects a cross-tenant
  write with KB_INGEST_TENANT_MISMATCH). The explicit gate is required because
  GraphService.upsertNode auto-stamps the caller's identity - an un-gated
  cross-tenant write would silently re-own the node rather than be rejected.
- KnowledgeBaseIngestionService.spec.mjs: +4 tenantConfig tests (set/get
  round-trip, version increment, cross-tenant RLS rejection, default-tier
  fallback). 16/16 pass.

V-B-A confirming the Native Edge Graph is reachable from KB-side contexts
(GraphService.initAsync self-bootstrapping; precedent seedAgentIdentities.mjs)
is recorded on the #11637 thread.

Co-Authored-By: Neo Opus 4.7 <neo-opus-ada@neomjs.com>

* feat(kb): add kb-config.yaml bootstrap tier to getTenantConfig (#11637)

Phase 2E increment 3a - completes getTenantConfig's three-tier resolution.

- KnowledgeBaseIngestionService.getTenantConfig now resolves the
  KnowledgeBaseTenantConfig graph node -> the `kb-config.yaml` deployment
  bootstrap -> the default registry (was graph -> default).
- New `readKbConfigBootstrap()` helper: fail-soft read of the optional
  deployment-root `kb-config.yaml` (`{tenants: {<tenantId>: {...}}}`) via
  js-yaml; a missing or malformed file resolves to null so resolution falls
  through to defaults rather than throwing.
- KnowledgeBaseIngestionService.spec.mjs: +1 tenantConfig test (yaml tier
  resolves; a tenant absent from the bootstrap still falls to default).
  17/17 pass.

Co-Authored-By: Neo Opus 4.7 <neo-opus-ada@neomjs.com>

* feat(kb): stamp tenantConfigVersion onto ingested chunk metadata (#11637)

Phase 2E increment 3b (AC3) - completes the config-version surface: every
ingested chunk now carries the active tenant-config version in its Chroma
metadata, so a future config change can drive retroactive invalidation.

- KnowledgeBaseIngestionService.ingestSourceFiles resolves the active config
  version via getTenantConfig and threads it onto tenantContext.configVersion.
  Fail-soft: a graph read must never break an ingest, so a resolution failure
  degrades to version 0.
- VectorService.resolveTenantStamp adds `tenantConfigVersion` to the
  authoritative tenant stamp; TENANT_GUARDED_FIELDS guards it server-side (a
  client-supplied value is overwritten/rejected like tenantId/repoSlug).
  applyTenantStamp's spread propagates it onto each chunk -> Chroma metadata.
- Tests: +2 KnowledgeBaseIngestionService.spec (version threading; fail-soft
  degrade-to-0 with ingest still succeeding); +1 VectorService.tenantStamping
  .spec (version stamped onto chunk metadata) + a default-0 assertion. 26/26.

Co-Authored-By: Neo Opus 4.7 <neo-opus-ada@neomjs.com>

* test(kb): add #11637 tenant-config restart-persistence integration test (AC7)

Phase 2E increment 3c - the AC7 integration test: tenant config persists
across a KB-server restart.

- test/playwright/integration/KBBackupRestoreWipe.integration.spec.mjs: new
  `tenantConfigPersistsAcrossRestart` helper + test case. Inside the deployed
  KB container, setTenantConfig writes a KnowledgeBaseTenantConfig graph node,
  getTenantConfig reads it back, then the in-memory Native Edge Graph cache is
  cleared (restart simulation) and getTenantConfig is re-run - the node reloads
  from the on-disk memory-core-graph.sqlite, proving the config survives a
  restart. RLS-correct: all calls run under the tenant's request context.

bucket-D Docker test - `node --check` syntax-valid; runs in CI's
integration-unified job (no Docker in the authoring sandbox).

Co-Authored-By: Neo Opus 4.7 <neo-opus-ada@neomjs.com>

---------

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"
- 2026-05-21T11:01:17Z @neo-gpt cross-referenced by #11716
- 2026-05-21T11:42:08Z @neo-gpt cross-referenced by PR #11717

