Skip to content

feat(isolation): Layer 2 foundation — per-token namespace wall (#185) - #190

Merged
CheeryProgrammer merged 2 commits into
mainfrom
feat/185-layer2-foundation
Jul 15, 2026
Merged

feat(isolation): Layer 2 foundation — per-token namespace wall (#185)#190
CheeryProgrammer merged 2 commits into
mainfrom
feat/185-layer2-foundation

Conversation

@CheeryProgrammer

Copy link
Copy Markdown
Contributor

Foundation slice of #120 (tracked as #185). Turns "hard isolation = a separate stack per team" into one server, many namespaces, per-token wall — the only shape that adds value over running a second stack (shared graph + shared/global layer behind a per-caller wall).

Model

  • Bearer token → core.Principal{Read: []namespace, Write: namespace}.
  • Namespace = the project discriminator value — reuses Layer 1 identity, no migration.
  • Enforcement in the core (single choke point): a Principal rides on context.Context; readScope walls reads, pinWrite pins writes.

What's enforced

  • Read wall (store.Wall, compiler-forced on every multi-row read): SearchChunks, FindNodesByMention, FindSimilarNodes, EdgesForNode, GraphSnapshot, GetChunksBySourceURI. Predicate on discriminators->>'project' so it walls multi-axis rows (project=A;env=prod) correctly; $wall IS NULL = Layer 1.
  • Edges have no scope column → visible only when both endpoints are in the wall (cross-namespace edge hidden).
  • get_node single-node lookups post-filter in core → an id/name guess across the wall reads as "not found", never a leak.
  • Secure global default — a scoped principal sees global only if its read-set lists it.
  • Write-pin — remember/link/ingest force project to the principal's single write target; a foreign namespace → ErrForbiddenNamespace.
  • ?project= may narrow within the read-set, never widen past it.

Adapters (thin)

  • HTTP principalAuth: per-request principal from the token; under isolation all /api reads require a token, and the operator-only id-based curation write group is not mounted (crosses namespaces; deferred to Layer 2: whole-namespace delete + handoff (#120) #188).
  • MCP AddReceivingMiddleware: one process-wide principal from config, selected by BRAINIAC_PRINCIPAL.

Backward-compat

No principals: configured ⇒ Layer 1, byte-identical. Proven by TestNilPrincipalIsLayer1 (core) and TestLayer1ReadsStayOpen (server).

Acceptance (#120)

A principal scoped to A cannot read B's nodes/edges/chunks via search, recall, get_node, or graph — by any query or ?project=.

Tests

  • DB-gated internal/core/isolation_test.go: read-wall, global default, narrow-cannot-widen, write-pin, cross-namespace edge, Layer-1 open.
  • Non-DB internal/server (401 on reads under isolation, open in Layer 1) + internal/config (roster validation, token→principal map, global→"" normalization, dup-token reject, envKey).

Deferred (issues)

#186 quotas · #187 export/backup · #188 whole-namespace delete + handoff · #189 WebUI read-auth.

SYSTEM.md §10 (decision log) + §12 (open questions) and config/.env examples updated in this PR.

Closes #185.

🤖 Generated with Claude Code

CheeryProgrammer and others added 2 commits July 15, 2026 07:13
One server, many namespaces, per-token hard visibility wall on top of the
Layer 1 soft scoping (#120). A bearer token maps to a Principal{read:[ns],
write:ns}; namespace = the `project` discriminator value (no migration).

Enforcement lives in the core (single choke point), driven by a Principal on
context.Context:
- readScope walls every multi-row read — search, recall (nodes/edges/chunks/
  evidence), graph — via a store.Wall on discriminators->>'project'; edges are
  visible only when BOTH endpoints are in the wall. get_node post-filters its
  single-node result so an id/name guess across the wall reads as "not found".
- pinWrite pins remember/link/ingest to the principal's single write namespace;
  a caller naming another namespace is rejected (ErrForbiddenNamespace).
- Secure global default: a scoped principal sees global only if its read-set
  lists it.

Adapters set the principal: HTTP principalAuth (per request; under isolation
ALL /api reads require a token and the operator-only curation writes are not
mounted); MCP AddReceivingMiddleware (one process-wide principal from config,
picked by BRAINIAC_PRINCIPAL).

Off by default: no `principals:` configured => Layer 1, byte-identical
(TestNilPrincipalIsLayer1 + TestLayer1ReadsStayOpen). DB-gated acceptance
tests in internal/core/isolation_test.go; non-DB auth tests in server/config.

Follow-ups: #186 quotas, #187 export, #188 delete/handoff, #189 WebUI read-auth.
SYSTEM.md §10/§12 + config/.env examples updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…test

- lint (unparam): projectClause's alias arg was always "" — inline it.
- TestNilPrincipalIsLayer1: a bare-name get_node resolves only the global
  scope in Layer 1 (unchanged behavior), so a B-scoped node was correctly
  nil. Assert Layer-1 resolution by id and by explicit project instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@CheeryProgrammer
CheeryProgrammer merged commit 5abff29 into main Jul 15, 2026
4 checks passed
@CheeryProgrammer
CheeryProgrammer deleted the feat/185-layer2-foundation branch July 15, 2026 07:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Layer 2 foundation: per-token principal + enforced read-wall + write-pin (#120)

1 participant