Skip to content

fix(scope): tenant-isolation batch — suppressions, env scope ceiling, forget override, session pinning, entity-link edges (#106–#112) - #113

Closed
devinmlowe wants to merge 8 commits into
mainfrom
fix/scope-isolation
Closed

devinmlowe wants to merge 8 commits into
mainfrom
fix/scope-isolation

Conversation

@devinmlowe

Copy link
Copy Markdown
Owner

Scope-isolation bug hunt (phase 1) turned into seven fixes (phase 2). Every
finding was re-verified against current main before an issue was filed; the
bridge finding from the same audit was already open as #87 and is untouched
here.

Issues fixed

Issue Fix Commit
#106 memory_suppressions keyed on (content_hash, scope) (new checkpointed migration suppression_scope_v1); suppression lookups take the caller's scope, clearSuppression/restore delete only that scope's row, dream dropSuppressed passes the conversation's scope a32fab7
#108 resolveCallScoping intersects a call's read_scopes with ENGRAM_READ_SCOPES and requires a call's scope to be readable — the env is a ceiling, not a default b85f08a
#109 forget's scope: "global" override is ignored when the process env pins the server to a tenant 9a9fe2c
#110 A recall session stores the scopes it was created with and reuses them on every refine 77491fb
#111 linkMemoryToEntities stamps new related_to edges with the caller's scope and widens an existing edge seen from a second scope 00e560b
#107 scopeInClause coalesces a NULL scope column to 'global', matching scopeVisible 07ea87a
#112 ftsIndexedRowids enumerates memories_fts_docsize instead of an fts5vocab('instance') scan, so zero-token documents are audited 2a5af6b

Docs in 54f90f1 (CHANGELOG [Unreleased], CLAUDE.md).

Predicate log

npm run build && npm run test:run after every commit. No revert was needed.

issue	attempt	predicate	action
baseline	-	137 files / 1798 tests pass	-
108	1	1 failed (per-request-scoping asserted the escalation being removed)	amend
108	2	137 / 1801 pass	keep
109	1	137 / 1802 pass	keep
106	1	1 failed (update.test.ts hardcoded schema version 4 -> 5)	amend
106	2	137 / 1805 pass	keep
111	1	137 / 1806 pass	keep
110	1	137 / 1807 pass	keep
107+112	1	137 / 1808 pass	keep
docs	1	137 / 1808 pass	keep

Two pre-existing tests changed because they asserted the old behaviour, not
because the fix broke them:

Each new test was checked to fail without its fix (#110 and #112
explicitly reverted and re-run).

Not fixed here

Draft — not for merge until #87 is decided, since the two changes overlap in
what they promise about stdio isolation.

🤖 Generated with Claude Code

https://claude.ai/code/session_01K88GYGYEE6wuuhewcsTbso

devinmlowe and others added 8 commits September 20, 2026 22:41
… instead of replacing it (#108)

resolveCallScoping let a tool call's read_scopes replace ENGRAM_READ_SCOPES and
its scope replace ENGRAM_SCOPE, so an env-pinned stdio child (the Hermes stdio
transport) could read and write any tenant with one argument. When the env
restricts reads, read_scopes is now intersected with it (empty → error) and a
client scope must be one of the readable scopes. With no env restriction the
params still apply as given, so the shared HTTP daemon path is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K88GYGYEE6wuuhewcsTbso
…oes not pin (#109)

`scope: "global"` short-circuits assertScope and widens the query-mode
candidate search to every tenant. A stdio child pinned by ENGRAM_SCOPE /
ENGRAM_READ_SCOPES could therefore delete another tenant's memories with one
argument. handleForget now drops the override when the process env restricts
reads; an unpinned server (CLI, shared HTTP daemon) keeps it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K88GYGYEE6wuuhewcsTbso
forgetMemory stored the forgotten memory's scope but isSuppressed,
clearSuppression, filterSuppressedFacts and restoreMemory all matched on the
content hash alone, so one tenant's forget suppressed dream re-extraction of
the same sentence for every tenant and any tenant's remember lifted it.

New checkpointed migration suppression_scope_v1 rebuilds the table with a
composite primary key (columns unchanged, so it stays additive for an older
build). Lookups now match the caller's scope plus 'global' (global knowledge
still suppresses everywhere); clearSuppression and restore delete only the
exact scope's row. dream dropSuppressed passes the conversation's scope,
remember/remember_batch the write scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K88GYGYEE6wuuhewcsTbso
…s scope (#111)

remember_batch's relates_to_entities path inserted related_to edges without a
scope column, so every edge took the 'global' default regardless of the write
scope, and an existing edge was never widened when a second tenant supplied
the same evidence. linkMemoryToEntities now takes the scope (passed from
storeMemoryBatch), writes it on the INSERT and calls widenScope on the update
branch — the same shape findOrCreateRelationship already uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K88GYGYEE6wuuhewcsTbso
createOrRefineRecallSession searched with the scopes of the current call, so a
session opened with read_scopes and refined without them merged other tenants'
results into the same session — which the caller then drills into. The scopes
are now stored on the RecallSession at create and reused on every refine.

Not fixed here: recall_drill still identifies a result by session id +index
and re-checks nothing, because SearchResult metadata carries no scope today.
Pinning closes the only path by which a foreign result can enter a session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K88GYGYEE6wuuhewcsTbso
scopeVisible reads a NULL scope column as 'global', but the IN-clause helper
did not — and `col IN (...)` is never true for NULL — so a row with a NULL
scope was visible to the semantic readers and invisible to every scopeInClause
reader (episodic recall, `engram memories list`, commitments) for any
read_scopes, including ones containing 'global'.

No in-tree producer of NULL scope exists today (every INSERT coalesces), so
this closes a latent divergence rather than an observed leak.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K88GYGYEE6wuuhewcsTbso
ftsIndexedRowids listed the index through an fts5vocab 'instance' table, which
has one row per term occurrence: a document whose content and context tokenize
to nothing (punctuation or emoji only) appears nowhere in it. Such a row was
therefore never reported by auditMemoryIndex and never repaired by
`engram validate --fix`, so validate could report clean on a dirty index. The
docsize shadow table holds one row per indexed document regardless of terms,
and needs no temp table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K88GYGYEE6wuuhewcsTbso
…E.md

Covers #106-#112: per-scope suppressions, the env scope ceiling, the forget
global override, session scope pinning, scoped entity-link edges, the NULL
scope coalesce and the FTS docsize enumeration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K88GYGYEE6wuuhewcsTbso
@devinmlowe

Copy link
Copy Markdown
Owner Author

Superseded by PRs #142, #143, #144, #145 — each issue (#106, #108, #109, #87) landed as its own fix on main for v0.5.0.

@devinmlowe devinmlowe closed this Sep 21, 2026
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.

1 participant