feat(isolation): Layer 2 foundation — per-token namespace wall (#185) - #190
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
core.Principal{Read: []namespace, Write: namespace}.projectdiscriminator value — reuses Layer 1 identity, no migration.Principalrides oncontext.Context;readScopewalls reads,pinWritepins writes.What's enforced
store.Wall, compiler-forced on every multi-row read):SearchChunks,FindNodesByMention,FindSimilarNodes,EdgesForNode,GraphSnapshot,GetChunksBySourceURI. Predicate ondiscriminators->>'project'so it walls multi-axis rows (project=A;env=prod) correctly;$wall IS NULL= Layer 1.projectto the principal's single write target; a foreign namespace →ErrForbiddenNamespace.?project=may narrow within the read-set, never widen past it.Adapters (thin)
principalAuth: per-request principal from the token; under isolation all/apireads 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).AddReceivingMiddleware: one process-wide principal from config, selected byBRAINIAC_PRINCIPAL.Backward-compat
No
principals:configured ⇒ Layer 1, byte-identical. Proven byTestNilPrincipalIsLayer1(core) andTestLayer1ReadsStayOpen(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
internal/core/isolation_test.go: read-wall, global default, narrow-cannot-widen, write-pin, cross-namespace edge, Layer-1 open.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