fix(driver-memory): refuse a tenant-scoped call instead of returning cross-organization rows - #17005
Conversation
…f answering cross-organization rows The engine scopes an object unless it opts OUT (buildDriverOptions), while the boot guard refuses only an explicit opt-IN (declaresTenantScope). An object that OMITS the tenancy block fell between them: the engine scoped it, the guard never saw it, and the driver discarded the scope and returned every organization's rows. Adds seam 3 to memory-tenancy-guard: assertCallNotTenantScoped judges the scope the engine actually handed over (DriverOptions.tenantId / tenantIds) rather than re-deriving the engine's predicate, and refuses. Called first in every driver door that accepts a DriverOptions, so a refusal leaves no partial effect. Row-level isolation is NOT implemented here and is not the direction: the driver declines to answer. Also records declaresTenantScope's false closing sentence as superseded -- a `single` posture constrains the wall, not the number of organizations. Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg Co-authored-by: Claude <noreply@anthropic.com>
… tenant-scope refusal Seeds three organizations with counts 2/3/7 so that "returns nothing" (0), "the correct subset" (2), "a widened union" (5) and "everything" (12) are four distinct numbers -- the closed round of this card seeded only two orgs, where a widened union covered the whole table and "the scope widened" was indistinguish- able from "no scope ran". The refusal is pinned as none of those readings, which makes the control failable in BOTH wrong directions: a revert to silent non-isolation answers 12, and an implementation of row-level isolation answers 2, and each reds the file. Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg Co-authored-by: Claude <noreply@anthropic.com>
…mory-driver-scope-refusal
📓 Docs Drift CheckThis PR changes 1 package(s): 23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2c92a6a665765281a5049526ac5c3a66c0dd2c53 && git checkout 2c92a6a665765281a5049526ac5c3a66c0dd2c53
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8d4690b8f55d21da6b8530081ab76aa10ac524a7 c8e9c33257c7fdd9ec8ab037464aef31e566ebea && git checkout -B drift-repro 8d4690b8f55d21da6b8530081ab76aa10ac524a7 && git merge --no-ff c8e9c33257c7fdd9ec8ab037464aef31e566ebea
node scripts/docs-audit/affected-docs.mjs --json 8d4690b8f55d21da6b8530081ab76aa10ac524a7
|
Fixes #16589
The memory driver silently ignored the engine's tenant scope for objects that omit a
tenancyblock. It now refuses the call instead of answering with every organization's rows.Clause-②: yesThe ruling this implements, and the direction it closed
Maintainer ruling A, on this card (comment 5580215080, 2026-09-08), verbatim:
It confirms the standing 2026-08-12 ruling on #6915 (comment 5261729371): 「⛔ 不做处置 A(实现行级租户隔离)」.
Row-level tenant isolation on
driver-memoryis refused, read side included. This PR does not implement scoping and must not be read as a step toward it: the driver declines to answer. There is no predicate anywhere in this diff that filters rows byorganization_id.The defect
Two predicates decided "is this object tenant-scoped", and they disagreed on the default case:
Engine.buildDriverOptions:execCtx?.tenantId !== undefined && !isTenancyDisabled(objectSchema) && !isFederated;declaresTenantScope:tenancy.enabled === true.An object that omits the
tenancyblock entirely fell between them: the engine scoped it, the guard never saw it, the posture check passed because the posture really wassingle, and the driver then discarded the scope and returned every organization's rows.The four premises, re-taken on this branch
Re-measured on this PR's own tree, not quoted from the dispatch. Tree:
b834b48e7a(the branch point;origin/mainat cut time).memory-tenancy-guard.ts:113-115—return (schema as ...)?.tenancy?.enabled === true;objectql/src/engine.ts:3925-3928— the predicate abovememory-driver.tsnever consumes the scopetenantId/tenantIds/organization_idin 2318 linesassertObjectsNotTenantScoped= 2,assertSingleTenantPosture= 3; call site relocated by content at:1907insyncSchema:110-111, "and every object in a single-tenant deployment omits the block" — falseA fifth reading, taken while placing the refusal and worth recording:
optionsis never dereferenced anywhere inmemory-driver.ts— accepted in 14 signatures, read in none. That is premise ③ from the other side.A refinement carried from the dispatch, stated the accurate way: the card's "zero hits across the whole
driver-memory/srctree" is loose.memory-driver.tsitself is 0, which is the half that matters; package-wide there are hits inmemory-tenancy-guard.tsand inmemory-unique-constraint.ts— the latter a liveorganization_idkey check about UNIQUE composites, a different concern, not a read-path scope. The read path knows nothing about tenants; the unique-constraint path does.Where the refusal went, and why
It is a third seam beside the two boot seams, on the per-call path, built from the existing guard's shape. No new error family: it reuses
MemoryMultiTenantUnsupportedErrorand the existingMEMORY_MULTI_TENANT_UNSUPPORTEDcode, with one optionalseamparameter that varies only the wording.The placement is justified by measurement, not preference:
syncSchema(tableName, obj)anddropTable(tableName)— two arguments and one argument, noDriverOptionsat all. The scope is structurally invisible atsyncSchema, which is exactly why the gap exists.execCtx?.tenantId !== undefinedis a property of the call, not of the schema or the deployment. AtsyncSchematime the fact does not exist yet.declaresTenantScopematch the engine (refuse anything not explicitly opted out) refuses at boot on objects that merely omit the block — including single-organization apps that never carry an organization context and are never scoped. That is wider than the ruled sentence, which refuses "when the engine hands the memory driver a tenant scope", not "when a schema could one day be scoped".The seam judges the scope the engine actually handed over rather than re-deriving the engine's predicate from metadata. A driver that re-derived it would drift from the engine the first time that reasoning changed, and drift here is silent exposure.
It runs first in every door that accepts a
DriverOptions, before any store access or delegation. That is what makes the closed round's measured F2 defect unreachable:upsert()delegates toupdate()orcreate(), and a refusal that fell through to thecreatearm would land a second row under one primary id.declaresTenantScopeitself is unchanged — its behaviour is still correct for what seam 2 uses it for.The docstring correction
declaresTenantScope's closing sentence — "every object in a single-tenant deployment omits the block" — is false in both halves.singleconstrains the wall, not the number of organizations (the card measured 13sys_organizationrows in asingle-posture run), and the omission it describes is not the absence of a tenant signal but its default presence.The sentence is recorded as superseded rather than deleted, because it is what justified the predicate being an opt-in test and anyone re-reading that decision needs to see the reasoning that was withdrawn.
Acceptance — a control that can fail, in both directions
The card's own subject is a test that cannot fail, so the fixture is built against that. Three organizations are seeded with counts 2 / 3 / 7, chosen so every reading a driver could produce is a different number:
tenantIdsscopeThe closed round seeded only two organizations, so a
[ORG_A, ORG_B]union covered the whole table and "the scope widened" and "no scope ran" were the same answer. With 2/3/7 they are 5 and 12.The refusal is pinned as none of those readings — the driver produces no answer at all, asserted as a nullable count rather than with
toHaveLength, which rejects a null target even under.notand would have passed the block for the wrong reason (this actually fired during development and was corrected).That makes the control failable in both wrong directions: a revert to silent non-isolation answers 12, and an implementation of row-level isolation answers 2, and each one reds the file.
The three ways the same read is distinguishable:
upsertby a foreign id creates nothing.tenancy.enabled: false, an emptytenantIds, and options carrying onlytimezone/skipCache/bypassTenantAuditall keep working unchanged.Ablation
Two legs, run on the committed implementation, under the shared verify lock —
.turbo/cacheis shared across worktrees, so an unlocked ablation can write a mutated artifact into a sibling agent's build cache.The marker is a string literal in code, not a comment: comments do not survive the build, so a comment marker could never be proved to have reached
dist.1ad023cdto42648ea3distablation-dist-preflight --presentexit 0git checkout HEAD -- (the guard path)— HEAD-pinned, never a baregit checkout --, which reads a polluted index1ad023cd82d2f7dadbb94573112f7e9c80648b4d, byte-equal togit rev-parse HEAD:(path)--absentpreflight exit 0, and it independently reported the whole working tree clean against HEADDirection predicted before the run: red. Observed: red. The restore leg is proved by the blob hash and a whole-tree
git status --porcelain, not by an exit code — a restore step that does nothing also exits 0.Green accounting — every case that stayed green, and the layer that holds it
Four are held by the driver's ordinary read path, not by the seam, and stay green by design. Neutering a refusal cannot change what an unscoped read returns:
tenancy.enabled: falseis served — the engine sends no scope for an ADR-0066 opted-out object, so seam 3 is not on this path at all.Two are the seam's negative half, and a removal ablation cannot red them by construction — they assert that something is not refused, so deleting the refusal can only make them greener:
Stated plainly rather than left as an unexplained green: this ablation does not demonstrate those two are discriminating. They discriminate in the opposite direction — against an over-eager seam — and the mutation that reds them is a predicate widened to treat a defined-but-empty
tenantIdsas a scope, which would refuse calls the engine never scoped. They are load-bearing precisely because the risk this card carries is a guard that refuses too much:driver-memoryis the in-process store behind the dev stack and the example apps.Changeset — decided on a measurement
minorwith a**BREAKING**banner, plus an ADR-0087 disposition. Not assumed:majoris forbidden bycheck-changeset-no-major.mjs— every publishable package is in one Changesetsfixedgroup, so a singlemajorpromotes the whole monorepo. Breaking changes ship asminorwith the banner. The same package's [finding]InMemoryDriver.update()returnsnullfor a missing id, whichIDataDriver.update()'s declared return type forbids — hidden for the life of the code by an inferredany#13878 type-surface narrowing did exactly this.minoror above.@objectstack/driver-memory: minoris what satisfies it. The axis is PR-scoped, so it reports NOT APPLICABLE locally and is evaluated on this PR in CI.skip-changesetwas never a candidate: this is not a comment-only diff. It changes runtime behaviour and adds an exported symbol.Disposition:
not-required (no-migration-prescription)— nothing authorable is removed, renamed or re-shaped, soobjectstack migrate metahas nothing to rewrite, and the operator is reached by the refusal message itself.check-adr-0087-registrationaccepts it (exit 0).Verification
Gate reconciliation
Derived with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack. The first derivation warned STALE TREE and named three files it derives from as changed across the gap — one of them a gate this PR runs — soorigin/mainwas merged and it was re-derived clean. The change set stayed exactly these five paths.57 derived · 57 run · 0 UNRUN · 0 NOT MEASURED. Every exit status captured by redirect-then-
$?, never across a pipe.Two were not green on the first pass, and neither was a finding:
check:dual-build-cjs-loadspnpm build(73/73 tasks) — 104 require entry points across 67 packages load, 620 CJS files parsecheck:type-check-debtcheck:type-check-debtneeded two further readings to become real, and the reason is worth recording: the resource-discipline capNODE_OPTIONS=--max-old-space-size=4096sits below the 6144 MB CI-shaped ceiling this gate pins for itself, and tsc OOMed — reported as exit 3, "refusing to record 0", not as a finding. Unsetting the variable fell through to this machine's 2096 MB default, lower still. Run at the pinned 6144 it is green. A tighter heap cap is not the conservative choice here; it is a different measurement.Also run although the derivation scores it silent:
check:tenant-chokepoint— exit 0. The tool flags its roster as living underpackages/drivers, which these paths are in, and says in terms that such silence is not a clearance in either direction. It reports 20getBuilder()bindings across 3 files (driver-sql, driver-sqlite-wasm, driver-turso), so seam 3 readingtenantIddid not pull this driver into its scan — which is what the guard docblock now claims in prose.Repo-wide
pnpm lint— exit 0, run in full. No narrowing claimed.Tests
@objectstack/driver-memorytest (post-merge)@objectstack/driver-memorytypecheck (both tsc legs)@objectstack/runtime@objectstack/service-datasource@objectstack/plugin-dev@objectstack/cliunit layerConsumers were run because this changes on-the-wire behaviour, not merely an internal. The five dependents were enumerated from
package.json, not guessed. On a first passservice-datasourcereported 76 failures and the CLI 2 — every one a missingdistin a fresh worktree ("Failed to resolve entry for package @objectstack/driver-sql"; "packages/cli is not built … would fail for the wrong reason"). Those were NOT MEASURED, not reds; with the closures built, all six suites above are green.objectqlis not a dependent of this package, and separately: zero of its test files combine atenantIdwith the memory driver, so nothing there was riding the discarded scope.The new test file is inside a tsc program, proved rather than assumed:
tsc --noEmit --listFileslists it (1), a nonsense filename reads 0 (the instrument can return zero), and the guard source and the pre-existing guard test read 1 each — over 726 files, exit 0. That is leg 1 of this package's two-legtypecheck; leg 2 istsconfig.typecheck.json, whoseincludeis the rootobjectstack.config.tsalone.Deliberately not done
current_user.accessible_org_idsis reserved and ADR-0105 D11 calls it core-resolved, but nothing resolves it into the RLS variable bag — every predicate using it fails closed #16518 (accessible_org_idsreserved but never injected into the RLS bag) — different layer, its own card. Untouched.syncSchemare-registration silently flips a platform-global object's UNIQUE partition from global to per-organization — the explicittenancy.enabled: falseopt-out is not sticky here the way it is in driver-sql #16729 (partialsyncSchemaflipping a UNIQUE partition) — its own card.memory-unique-constraint.ts/tenantFieldOfuntouched.admin_full_access's wildcardviewAllRecordscannot lift it. Not "fixed".scripts/check-tenant-chokepoint.mjs— left alone. Its criterion is knex'sgetBuilder, which this driver has no analogue for. The guard's docblock now explains why seam 3 readingtenantIddoes not pull this driver into that gate's scan: it inspects the scope only to refuse, never to narrow or re-target rows, so there is still no read-side chokepoint to re-derive.Review posture
Draft, and it stays draft: this carries
Clause-②: yesandneeds:contract-reviewon both carriers, so it cannot land before an in-seat contract-tier review returns. Not ready-for-review, no auto-merge, not queued.Generated by Claude Code