fix(driver-memory): honour DriverOptions.tenantId on the read path — a scoped read no longer returns other organizations' rows - #16733
Conversation
The engine scopes an object unless it opts OUT (`tenantId !== undefined && !isTenancyDisabled(schema) && !isFederated`); this driver's boot guard refuses only an explicit opt-IN (`tenancy.enabled === true`). An object that omits the `tenancy` block — the common case — was therefore scoped by the engine and invisible to the guard, and the driver did nothing with the scope: `tenantId`, `tenantIds` and `organization_id` occurred nowhere in `memory-driver.ts`. Memory runs returned cross-organization rows a SQL driver refuses, and neither driver said a word. `memory-tenant-scope.ts` is the read half, with `SqlDriver.applyTenantScope`'s semantics reproduced arm for arm — equality or union, both keeping the #2734 NULL-tenant global-row carve-out. Every door that accepts a `DriverOptions` routes through one chokepoint; `distinct()` accepts none and is named as the one door that cannot. Write-side stamping is deliberately not included, so the boot guard still refuses a walled posture and an object declaring `tenancy.enabled: true`. `declaresTenantScope`'s docstring is corrected in the same change: its load-bearing sentence, "every object in a single-tenant deployment omits the block", was false — `single` constrains the wall, not the number of organizations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
📓 Docs Drift CheckThis PR changes 1 package(s): 12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
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 ebe843a28a9327e0ed40909b7549a64bcc59ba5c && git checkout ebe843a28a9327e0ed40909b7549a64bcc59ba5c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ed7243d52bbc1b6d00a3b621b0dcea4925df32b5 bb394b3fefb2d4b51dac9f1ca7f44af3f2d7f7e3 && git checkout -B drift-repro ed7243d52bbc1b6d00a3b621b0dcea4925df32b5 && git merge --no-ff bb394b3fefb2d4b51dac9f1ca7f44af3f2d7f7e3
node scripts/docs-audit/affected-docs.mjs --json ed7243d52bbc1b6d00a3b621b0dcea4925df32b5
|
Contract review (
|
| arm | SQL | memory | parity |
|---|---|---|---|
tenantId undefined / null / '' |
builder untouched | null predicate |
yes |
no tenant column (resolveTenantField → null) |
untouched | null predicate (tenantFieldByObject miss or tenantFieldOf null) |
yes |
non-empty tenantIds (filter typeof === 'string' && !== '') |
whereIn(field, ids.map(String)).orWhereNull |
Set(String) membership or absent/null key |
yes |
| otherwise | where(field, String(tenantId)).orWhereNull |
String(value) === String(tenantId) or absent/null key |
yes |
Sticky opt-out: recordTenantField reproduces computeAndRecordTenantField (sql-driver.ts:9555-9565) line for line — a schema carrying tenancy sets/clears the opt-out and is computed; a schema without one returns null if opted out, else the resolver. Verified for READ scope only. The uniqueness path is untouched: memory-unique-constraint.ts is not in the diff and uniqueConstraintsFromFields still calls the non-sticky tenantFieldOf directly. #16729 exists, is open / bug / p3 / pm:queue, and says what the PR claims; its triage (5578291123) additionally notes the in-package asymmetry becomes real when this PR lands and names #16729 as this PR's closure, plus a driver-sql shard-path instance (sql-driver.ts:9479) of the same gap.
3. Chokepoint coverage
InMemoryDriver methods accepting DriverOptions on the head (15): find, findOne, create, update, upsert, delete, count, bulkCreate, updateMany, deleteMany, bulkUpdate, bulkDelete, aggregate, syncSchema, dropTable. this.tenantScope( call sites: 10 (find :596, update :741, upsert :780, delete :813, count :832, updateMany :903, deleteMany :952, bulkUpdate :1030, bulkDelete :1114, aggregate :1265 — one scope feeding both arms); findOne delegates to find with options (:678). That is the eleven doors named, and every door that selects rows by a predicate or an id is covered. Not routed: create, bulkCreate (insert doors — SQL routes these through injectTenantOnInsert, not applyTenantScope; deliberately excluded here), syncSchema, dropTable (DDL; SQL scopes neither). No read/update/delete door bypasses the chokepoint. The PR-body sentence "every door that accepts a DriverOptions routes through one chokepoint" is nonetheless literally false for those four — F4.
distinct(object, field, query?) (:1206) genuinely has no DriverOptions. IDataDriver in packages/spec/src/contracts/data-driver.ts declares no distinct member at all, and .distinct( over packages/*/src, apps, examples (non-test) matches only CHANGELOG/migration prose and the driver's own comment — no producer.
4. Boot guard
assertSingleTenantPosture and assertObjectsNotTenantScoped are byte-identical in logic on the head (walled postures refused; only explicit tenancy.enabled === true refused at syncSchema); call sites intact at constructor :423, connect :484, syncSchema :1984. memory-tenancy-guard.test.ts is not in the diff (18 cases including "the constructor refuses in multi-tenant mode" and "syncSchema() refuses a tenant-scoped object"). The read-half fix does not weaken the #6915 gate.
Docstring claim: packages/spec/src/security/tenancy-posture.ts defines posture as the Layer 0 wall (single → "none (inert)"; postureEnforcesWall is false for single only) and the engine's hasTenant (engine.ts:3925) has no posture term, so the scope is threaded under single. The spec's own ledger prose (error-code-ledger.zod.ts:645) already speaks of "a single posture whose data holds several organizations". "single constrains the wall, not the number of organizations" is supported. One nuance the corrected docstring could carry: the same spec table also calls single "one logical tenant", so the honest statement is that the posture treats them as one tenant while the engine still scopes by organization_id.
On the PM's second question (does the write-side exclusion hide a row a caller legitimately owns): no. A row is hidden only when it carries a different, non-null organization — identical to SQL. An unstamped own write lands org-less and is visible to its author (and to everyone). The residual is exposure of org-less rows, which is the #2734 semantics and what the corrected refusal message now names.
5. Files in the diff — governed paths: no
Six files, all under the merge-base three-dot diff: .changeset/memory-driver-read-side-tenant-scope.md, packages/drivers/driver-memory/src/{index.ts, memory-driver.ts, memory-tenancy-guard.ts, memory-tenant-scope.ts, memory-tenant-scope.test.ts}. None under docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, content/docs/releases/**.
6. Changeset
Level: minor is correct and required twice over — batch #35 prose in pr-automation.yml:667-675 ("a new exported symbol on an index … takes at least minor"; three symbols are added) and the LEVEL axis in check-changeset-no-major.mjs:734ff (a Clause-②: yes declaration plus patch would be refused).
Carriers: the script's header is explicit that during the launch window "the mandatory information carriers for breaking-ness … are the BREAKING banner the author writes in the changeset body and the ADR-0087 migration-ledger disposition", and check-adr-0087-registration.mjs:51-54 fires only on a declared breaking change. No gate infers it, so the PR's reading of the gates is accurate. My own view on whether one is owed: a caller passing tenantId now receives fewer rows, and this package's own published docblock told that caller the driver "never reads DriverOptions.tenantId" — so the narrowed accept set was documented behaviour, not merely a defect. The closest released precedent is in this same package and on this same subject: #6915 / PR #7924 shipped as feat(driver-memory)!: with an ADR-0087 not-required (no-migration-prescription) disposition, on the reasoning "a refusal that was always owed, but it is still a behavior change, and the release notes must be able to say so" (driver-memory CHANGELOG, 17.x entry 45d5bd2). The precedent the PR cites (session-unbacked-org-claim-dropped.md, patch) is pending stock, as the PM already flagged. F3.
7. Tests
memory-tenant-scope.test.ts: the fixture seeds a1,a2 (A), b1 (B), g1 (org-less) on every object; the union case adds c1 (org_c) so [A,B] no longer covers the table. Every scoped case asserts the caller's own rows with a full toEqual id list (not merely absence). Forcing tenantScope to null reddens the binding control (['a1','a2','g1'] vs four rows), count (3 vs 4), both aggregate arms, the id-addressed doors and the many-row doors — the PR's 16/23 is plausible on inspection; CI on the head is green with the chokepoint live. tsconfig.json includes src/**/*, so the test file is in leg 1 of typecheck; tsconfig.typecheck.json covers objectstack.config.ts only, as the PR states. Missing pins: F2 and F5.
8. CI on 40300f01075578dba473fd87a34f17a68a893d69
37 check runs: 31 success, 6 skipped, 0 failure, 0 in_progress. Skipped: Auto Label, Check PR Size (one duplicate run each), Packed-tarball smoke (opt-in) ×2, Console Pin Gate, Build Docs. Nothing red or pending. mergeable_state: clean; draft; no reviews.
Findings
F1 — Direction reverses the last maintainer ruling on this subject without a recorded reversal. #6915's maintainer ruling (5261729371) chose Route B and wrote 「⛔ 不做处置 A(实现行级租户隔离)」; this PR implements the read half of A. Its authority is triage's standing meta-criterion plus a PM dispatch, both COLLABORATOR/MEMBER seats; the ground the maintainer gave (#5499) has since been dissolved by the maintainer, which makes reversal plausible but not recorded. Expectation for this PR: the PR body should stop describing this as "#6915's Route A" as if that route were sanctioned, and the merge waits for one maintainer line on #16589 (or #6915) confirming that row-level isolation on driver-memory is now permitted. Until then this is a maintainer-only merge — it is already outside the queue on needs:contract-review / Clause-② and should stay there.
F2 — upsert by explicit id across the wall now lands a second row with the same primary id (code change required). upsert (:770-806) scopes the conflict lookup to visible, so upsert(obj, { id: 'b1', … }, undefined, { tenantId: A }) where b1 belongs to B misses and falls to create, and create (:700-722) checks only declared unique constraints — id is not one (the package's own memory-bulk-create-atomicity.test.ts:181 records that the driver does not reject duplicate ids) — so the table now holds two rows with id === 'b1'. Before this PR the same call overwrote B's row (the defect); after it, the store carries a duplicate primary id, which then corrupts every id-addressed door for both tenants (update/delete take the first index; deleteMany's matchedIds set removes both). On driver-sql this cannot happen: INSERT … ON CONFLICT(id) merges on the primary key regardless of tenant (its own docblock: "the verdict itself is tenant-independent regardless: id is the PRIMARY KEY, so at most one row in the table can carry it") and only the readback is scoped (sql-driver.ts:7825). The PR-body claim "Matches driver-sql, which scopes its own upsert door" is therefore inaccurate — SQL scopes the readback, not the conflict target. The engine's only upsert producer (lifecycle-service.ts:1386, ['id'], no options) is unscoped and unaffected, so the radius is direct driver callers — but the PR explicitly promises "id-addressed doors land on their own existing 'not found' contract", and this door does not. Expectation: in the data.id arm, when a row with that id exists in the table but outside the scope, refuse (the existing not-found/strictMode contract or a UNIQUE_VIOLATION-shaped refusal — never a second row with the same id); add a test pinning that no table ever holds two rows with one id after a scoped upsert; correct the body sentence about SQL.
F3 — Changeset carriers: add the **BREAKING** banner and an ADR-0087 not-required disposition, or record why #7924 is not the governing precedent. The level is right; the carriers are author-declared; and the closest released precedent (same package, same subject, smaller radius) declared breaking and registered not-required (no-migration-prescription). The disposition text would be near-verbatim #7924's (no authorable surface retired; the affected consumer is reached by the changeset body and the corrected refusal message). Expectation: add both, or have the PM record on the card that the "bug fix aligning to the spec'd DriverOptions contract" reading is accepted in place of the #7924 precedent.
F4 — PR body and changeset overstate the chokepoint. "Every door that accepts a DriverOptions routes through one chokepoint" is false for create, bulkCreate, syncSchema, dropTable. The first two are the unstamped write doors the PR deliberately leaves open, and should be named as such next to the eleven; the last two are DDL. Expectation: reword to "every door that selects rows" and list the four exclusions.
F5 — Two pins missing from the suite. (a) The F2 case. (b) deleteMany under scope with a where (only the delete-all arm is exercised; the where arm filters visible then rebuilds by matchedIds, which is exactly where a duplicate id would cross tenants). Expectation: add both; the updateMany/deleteMany arms are the ones the PR itself calls "where the old silence cost the most".
Not findings, recorded so they are not re-derived: check-tenant-chokepoint.mjs cannot cover this driver (its criterion is getBuilder) — a coverage limit, accepted; distinct() unscoped with no producer — accepted as pinned; memory-analytics.ts's pipeline-arm call passes no options and is unscoped exactly as before — accepted.
Generated by Claude Code
…mory-driver-tenant-scope
…d of landing a duplicate primary id Patch round on the contract review of PR #16733, per the director seat's ruling comment 5579651209. F2 (code): `upsert`'s `data.id` arm scoped its conflict lookup to `visible`, so an id addressed across the tenant wall missed and fell through to `create`. `create` checks only DECLARED unique constraints and `id` is not one, so the table ended up holding two rows with one primary id, which then corrupts every id-addressed door for both tenants. It now refuses on this driver's own existing "not found" contract. `driver-sql` is not the precedent for falling through: it merges on the PRIMARY KEY regardless of tenant and scopes only the readback, so the duplicate is unreachable there. The code comment claiming otherwise is corrected. F5 (pins): the F2 case, with merge-inside-scope and insert-of-a-fresh-id as positive controls; and `deleteMany` under scope WITH a `where`, which takes the matched-id rebuild path rather than the delete-all path. F3 (changeset carriers): the `**BREAKING**` banner and the ADR-0087 `not-required (no-migration-prescription)` disposition, following the same package's released precedent PR #7924. Level stays `minor`. F4 (wording): the chokepoint claim is "every door that selects rows", with `create`, `bulkCreate`, `syncSchema` and `dropTable` named as the exclusions. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TezFG8ZMrNH6n5VTNpPpdH
Patch round — F1–F5 (director seat ruling 5579651209)Head Delivered exactly the five items the ruling names, and nothing else. F2 — code:
|
Contract review (
|
| # | Finding at 40300f0 |
Head bb394b3 |
Discharged |
|---|---|---|---|
| F1 | Direction reversed #6915's 「不做处置 A」 with no recorded reversal; body presented Route A as sanctioned | Ruling 5579651209 recorded on the card; PR body opens with an Authority section that quotes it verbatim, quotes #6915's 「⛔ 不做处置 A」, names the earlier "read half of #6915's Route A" phrasing as the error, and says ⛔ #6915 is not offered as sanctioning this |
yes |
| F2 | Scoped upsert by a foreign-tenant id fell through to create → two rows, one primary id |
data.id arm now refuses before any write when the id exists in table but not in visible (memory-driver.ts:809-812); same message as update/delete; code comment correcting the driver-sql claim |
yes |
| F3 | No **BREAKING** banner, no ADR-0087 disposition; cited pending-stock precedent |
Both carriers present in the spelling both gates parse; level minor; precedent now #7924; the session-unbacked-org-claim-dropped.md reading withdrawn in the body |
yes |
| F4 | "Every door that accepts a DriverOptions" — false for four |
Changeset and body both say "every door that selects rows" and name create / bulkCreate (insert, injectTenantOnInsert territory) and syncSchema / dropTable (DDL) as the exclusions |
yes |
| F5 | Pins missing: the F2 case; deleteMany under scope with a where |
Two cases added, 23 → 25 (grep -c on it( = 25) |
yes |
Verification, numbered
-
Files and governed paths.
git diff ed7243d52..bb394b3f --stat: exactly six files —.changeset/memory-driver-read-side-tenant-scope.md,packages/drivers/driver-memory/src/{index.ts, memory-driver.ts, memory-tenancy-guard.ts, memory-tenant-scope.ts, memory-tenant-scope.test.ts}(891+/41−).grepover the name list forcontent/docs,packages/spec,adr,releases,.github: none. The head is40300f0+ a plain merge oforigin/main(9edef33ca, parents40300f01anded7243d52) + one commit; the patch-round-only diff9edef33ca..bb394b3ftouches three files (changeset 8 lines, driver 34, test 60) — matches the claim. -
The
upsertdata.idarm (memory-driver.ts:770-836). Read arm by arm:- foreign id refuses before any write:
existingRecord = visible.find(r => r.id === data.id); thenif (!existingRecord && table.some(r => r.id === data.id)) throw new Error('Record with ID <id> not found in <object>')— the throw sits before theupdate/createbranch, so nothing is written. Whenscopeisnull,visible === table, thesomecan only be true whenfindalready hit, so the refusal is unreachable on the unscoped path — unscoped callers are unchanged. - in-scope merge still updates:
existingRecordvisible →this.update(object, existingRecord.id, data, options);updateresolves through the same scope (:741), the row is visible, so it cannot miss. - fresh id still inserts:
!existingRecord && !table.some(...)→ falls tothis.create(object, data, options). - no path leaves two rows with one id: the three outcomes above are exhaustive on the
idarm; the only insert is the one where no row in the whole table carries the id. - non-strict-mode behaviour: the declared contract is
IDataDriver.upsert(...): Promise<Record<string, unknown>>(packages/spec/src/contracts/data-driver.ts:203) and the driver's signature matches it — nonull/falsemiss arm, unlikeupdate(Promise<Record<string, unknown> | null>,:732) anddelete(Promise<boolean>,:209in the contract). Throwing in and out ofstrictModeis therefore the only expressible refusal short of widening the published type; the asymmetry is stated in the code comment. Defensible. bulkUpdate/updateManydo not share the fall-through.bulkUpdate(:1050ff) resolves every id through the scope (table.findIndex((r) => r.id == u.id && (!scope || scope(r)))); a-1takes its own missing-id arm (strict: throw before any write; otherwise skip) and the method has no insert path at all.updateMany(:923ff) drawstargetRecordsfrom the scoped table and only ever rewrites indexes it resolved; no insert path either.bulkDelete(:1141ff) is the same shape asbulkUpdate.
- foreign id refuses before any write:
-
The two new pins (
memory-tenant-scope.test.ts, patch-round diff read in full; fixturea1/a2→org_a,b1='B one'→org_b,g1org-less,ids()sorts):- F2 pin (
:393-430):upsert(obj, { id: 'b1', name: 'hijacked' }, undefined, { tenantId: ORG_A })rejects.toThrow(/Record with ID b1 not found/); thenafter.filter(r => r.id === 'b1')).toHaveLength(1)— exactly one row carries the id — andafter.find(r => r.id === 'b1')).toMatchObject({ name: 'B one', organization_id: ORG_B })— the foreign row is unchanged; table still['a1','a2','b1','g1']. Two positive controls:a1in-scope merges (name: 'renamed'), fresha3inserts, final table['a1','a2','a3','b1','g1']. deleteManypin (:344-363):where name contains 'one'matches exactlya1andb1; scoped toORG_Athe return is assertedtoBe(1)(unscoped would be2) and the survivors['a2','b1','g1']— the discrimination is by count, as required.- I could not run the suite from this seat (the primary checkout carries no
node_modulesfor the package and installing is not a read-only act); the colour on the head is CI's Test Core, item 7.
- F2 pin (
-
Changeset (
.changeset/memory-driver-read-side-tenant-scope.mdat head):- frontmatter
'@objectstack/driver-memory': minor— unchanged; - line 5 opens
**BREAKING** — a caller that passes …;check-adr-0087-registration.mjs:572reads breaking-ness with/\*\*BREAKING/ion the body — matches; - last line
<!-- adr-0087: not-required (no-migration-prescription) Nothing is retired or renamed … #6915 / PR #7924. -->; the gate's marker regex is/<!--\s*adr-0087\s*:\s*([\s\S]*?)-->/g(:1488, exactly one expected,:1491) and the category regex/adr-0087\s*:\s*not-required\s*\(\s*([a-z-]+)/g(:1547) — matches, categoryno-migration-prescription, which the gate re-validates by refusing a body that carries a migration prescription; - chokepoint sentence: "Every door that selects rows routes through one chokepoint: …" followed by "Four doors that take a
DriverOptionsare deliberately not routed through it:createandbulkCreate…syncSchemaanddropTable…" — the four exclusions named; - a paragraph on the
upsertrefusal and the correcteddriver-sqlcomparison is present. - Gates run read-only against the ref from this seat (
--base ed7243d52 --head refs/review/16733b):check-adr-0087-registrationexit 0 —1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … [BREAKING] not-required (no-migration-prescription);check-changeset-no-majorexit 0 — "This diff introduces no major bump";check-empty-changesetexit 0 —1 declaring changeset(s) added. On CI the first and third run inside jobchangeset-check/ Check Changeset (pr-automation.yml:193, steps at:784and:825) — success on this head.
- frontmatter
-
PR body. Opens with
## Authority — the director seat's ruling, ⛔ not #6915's Route A; cites5579651209by number and quotes "option 2 stands: read-side row-level tenant scope ondriver-memoryis permitted" — verbatim against the ruling's title; quotes driver-memory 完全没有行级租户隔离(#3724 的未修姊妹面):多租户下静默不隔离 #6915's 「⛔ 不做处置 A(实现行级租户隔离):[裁决] driver-memory / driver-mongodb 投入冻结 —— 维护者 2026-08-05 口径(跨单锚点) #5499 投资冻结继续有效」 as the declined route; states the earlier phrasing "presented a declined route as an approved one". Thedriver-sqlupsert comparison is corrected under a ⛔ heading: SQL "merges on the PRIMARY KEY regardless of tenant … and only the readback is scoped". The "Matchesdriver-sql, which scopes its own upsert door" sentence is gone from the code comment (9edef33ca..bb394b3fdiff shows its removal). The withdrawn pending-stock precedent is marked ⛔ Withdrawn.Fixes #16589+Clause-②: yesas bare lines; still draft,needs:contract-reviewstill hung. -
Unchanged files since
40300f0. Blob ids:memory-tenancy-guard.tse0e630c6…=e0e630c6…;memory-tenant-scope.ts0a162919…=0a162919…;index.tsae976518…=ae976518…. Identical at both commits, andorigin/mainbetween the two merge-bases touched nothing underpackages/drivers/driver-memory. -
CI on
bb394b3f. Measured at this seat's last poll (05:39Z; the run started 05:31Z): 34 check runs — 25 success, 3 skipped, 6 in_progress, 0 failure. Skipped: Console Pin Gate, Build Docs, Packed-tarball smoke (opt-in) — the same three that were skipped on40300f0. In progress: Test Core (4/6), Test Core (5/6), Dogfood Regression Gate (1/3), (2/3), (3/3), Lint & Repo Gates. Green and relevant to this diff: Build Core, Test Core (1/6, 2/6, 3/6, 6/6), Type Check · workspace / source gates / consumer gates / debt ledger, TypeScript Type Check, Check Changeset, Governed Surface Queue Guard, Temporal Conformance, Dogfood Verify CLI, the three single-writer / same-issue / part-of guards.⚠️ The verdict above is conditional: it stands only if the six in-progress jobs complete green. Whichever Test Core shard carriespackages/drivers/driver-memoryis the one that colours the 25-case suite on this head (this seat could not run it locally — item 3); if any of the six goes red, treat this as CHANGES REQUIRED until read. The director seat reads CI itself before landing;mergeable_stateisblocked(draft + pending checks), as expected.
Residual findings
R1 — Two contradictory board posts on #16589, five minutes apart, and neither cites the other (board hygiene, not code). The ruling 5579651209 (05:15Z) says "option 2 stands … non-governed, the director seat lands it". The PM's 5579692717 (05:20Z) moves the card pm:dispatched → needs-user-decision, says "PR #16733 stays draft and must not land until a maintainer line settles the question", and asks the maintainer for A/B/C with A (= void this PR) recommended. Both are on the card now. This seat reviews against the ruling because it carries a maintainer delegation in its provenance line and the PM's post does not claim to override it — but a future reader sweeping the card meets the later post first. Expectation before landing: one line on #16589, from the director seat, stating that 5579651209 governs and 5579692717's question is closed by it (or, if the director seat wants the maintainer's A/B/C anyway, that the landing waits for it) — and the card's needs-user-decision label reconciled accordingly. Not a change to this PR; recorded here because "anything that still blocks landing" has to include it.
Recorded, not findings (so they are not re-derived): (a) the id arm compares with === (visible.find and table.some) while update/delete use == — a caller passing numeric 1 for a stored '1' falls to create exactly as it did before this PR; pre-existing, not tenancy-related, out of scope. (b) create / bulkCreate still accept an explicit data.id that already exists in the table (pinned as accepted by memory-bulk-create-atomicity.test.ts), so a duplicate primary id remains reachable through the insert doors this PR deliberately leaves unscoped; under scope, updateMany then resolves record.id by first index across the whole table (:958). Only reachable through a pre-existing, accepted state — not introduced here, not a blocker; a card if anyone wants id uniqueness enforced on this store. (c) The conflictKeys arm still inserts when the only matching row sits outside the scope; that insert carries a fresh id, so it cannot produce a duplicate primary id, and it was accepted at the prior review. (d) distinct() unscoped with no producer, check-tenant-chokepoint.mjs coverage limit, memory-analytics.ts pipeline arm unscoped as before — all accepted at 40300f0, unchanged.
⛔ This seat did not approve, label, mark ready, or edit anything; the PR stays draft with needs:contract-review hung, as the ruling's "non-governed, the director seat lands it" line requires.
Generated by Claude Code
Closed unmerged — maintainer ruling A on #16589 (decision batch #85, 2026-09-08)Maintainer, verbatim: 「16589 内存驱动不需要支持多租户,业务上没有任何意义啊」. The memory driver does not implement tenant isolation; #6915's ruling B (loud refusal instead of silent non-isolation) stands and is the card's remaining deliverable. This PR's read-side scope, its F2–F5 patch round ( Generated by Claude Code |
Fixes #16589
Clause-②: yes
Authority — the director seat's ruling, ⛔ not #6915's Route A
This change is read-side, row-level tenant isolation on
driver-memory, which is the shape #6915 calls Route A. ⛔ It is not offered as sanctioned by #6915. That card's maintainer ruling (5261729371, 2026-08-12) chose 处置 B and wrote 「⛔ 不做处置 A(实现行级租户隔离):#5499 投资冻结继续有效」. An earlier revision of this body described the change as "the read half of #6915's Route A" without saying that Route A is the route that ruling declined — the contract review (5578985922, F1) is right that this presented a declined route as an approved one, and it is corrected here.What authorises it is the director seat's ruling on the card, comment
5579651209(decision batch #84, 2026-09-08). Verbatim: "option 2 stands: read-side row-level tenant scope ondriver-memoryis permitted". Its reasoning: #6915's exclusion of A rested on one stated ground, the #5499 freeze, which the maintainer dissolved on 2026-08-11 (5252526378— "fully dissolved … no longer gates anything"); with the ground gone the standing meta-criterion decides (「一个操作两个实现且行为不一致 ⇒ 带治理的一侧胜出」), and the governed side isdriver-sql's enforced scope. The same ruling records that the #6915 boot guard is not weakened — walled postures andtenancy.enabled: truestay refused, write-side stamping stays out — so this removes the one silence #6915 left rather than reversing its purpose.⛔ Still draft, still
needs:contract-review: nothing in this round marks it ready, queues it or touches its labels.The defect
Two predicates decided "is this object tenant-scoped", and they disagreed on the default case.
Engine.buildDriverOptionsinpackages/objectql/src/engine.ts, athasTenant:execCtx?.tenantId !== undefined && !isTenancyDisabled(objectSchema) && !isFederated.declaresTenantScopeinpackages/drivers/driver-memory/src/memory-tenancy-guard.ts:(schema as ...)?.tenancy?.enabled === true.So an object that omits the
tenancyblock — the common case — was scoped by the engine and invisible to the boot guard, and the driver then did nothing with the scope. Memory-driver runs returned cross-organization rows a SQL driver refuses, and neither driver said a word.The accurate way to state the absence: the read path knew nothing about tenants; the unique-constraint path did.
memory-driver.tsitself measured 0 hits fortenantId|tenantIds|organization_id; the package as a whole measured 82 matching lines across 4 of 56 files, all of them docblock prose plus one liveorganization_idkey check inmemory-unique-constraint.tsabout UNIQUE composites — a different concern.What this changes
memory-tenant-scope.tsis the read half, withSqlDriver.applyTenantScope's semantics read off the SQL driver and reproduced arm for arm rather than invented:driver-sqltenantId(undefined/null/ empty)tenantIds(ADR-0105 D2)col IN (...) OR col IS NULLcol = :tenantId OR col IS NULLThe NULL arm is the #2734 global-row carve-out and it is load-bearing, not lenient: a row with no organization belongs to no OTHER tenant, and strict equality once made every tenant admin read zero RBAC rows on a fresh deployment. In this store the absence of the key is that same fact.
The chokepoint, and the four doors that are NOT behind it
Every door that selects rows routes through one chokepoint:
find,findOne,count,aggregate(both arms),update,upsert,delete,updateMany,deleteMany,bulkUpdate,bulkDelete.Four methods take a
DriverOptionsand are deliberately not routed through it. They are named rather than left under a claim that does not hold for them (contract review F4 — an earlier revision said "every door that accepts aDriverOptions", which is literally false for these four):createandbulkCreate— the insert doors.driver-sqlscopes those throughinjectTenantOnInsert, notapplyTenantScope, and the write half this PR leaves out is exactly the stamp they would need.syncSchemaanddropTable— DDL.driver-sqlscopes neither.distinct()is the one selecting door that stays unscoped, and it is named rather than left to be found: its signature (object, field, query?) accepts noDriverOptions, so a caller has nowhere to pass a tenant.driver-sql'sdistinctdoes scope. Nothing in this repository callsdriver.distinct(), so widening the signature would add a parameter no producer supplies. It has a pin of its own in the suite.Id-addressed doors, including
upsertId-addressed doors land on their own existing "not found" contract rather than inventing a cross-tenant refusal shape — and
upsertis now genuinely inside that promise (contract review F2, required by the ruling).An
upsertaddressed by an explicitidwhose row exists in the table but outside the caller's scope refuses; it does not fall through tocreate.idis this store's primary id andcreatechecks only DECLARED unique constraints —idis not one, whichmemory-bulk-create-atomicity.test.tsalready records — so falling through left the table holding two rows with one primary id, which then corrupts every id-addressed door for both tenants (update/deletetake the first matching index;deleteManyrebuilds from a matched-id set). Before this PR that call overwrote the other organization's row, which was the original defect; the first revision of this branch traded it for a duplicate id.⛔
driver-sqlis not the precedent for falling through, and this branch's code comment wrongly said it was. SQL'sINSERT … ON CONFLICT(id)merges on the PRIMARY KEY regardless of tenant — its own docblock: "the verdict itself is tenant-independent regardless:idis the PRIMARY KEY, so at most one row in the table can carry it" — and only the readback is scoped. A second row with one id is unreachable there. The comment is corrected in this round.The refusal is raised in
strictModeand outside it alike, and that asymmetry withupdate/deleteis deliberate:upsert's declared return is a bare record promise —PromiseofRecordofstringtounknown, spelled without the angle brackets so this body survives GitHub's sanitizer — with no miss arm (#13878), so the quiet non-strictModemiss (nullforupdate,falsefordelete) is not expressible here. The two alternatives were widening this door's declared return with an arm no caller was ever asked to narrow, or landing the duplicate id. Both are worse than throwing the same "not found" message the other id-addressed doors throw.Write-side
Write-side tenancy is deliberately not included. Nothing stamps a tenant column on insert the way
SqlDriver.injectTenantOnInsertdoes, so a row created without an explicit organization lands org-less and is then global by the rule above. That is why the boot guard still refuses a walled posture and still refuses an object declaringtenancy.enabled: true, and the guard's refusal message now names unstamped writes as the half that is genuinely missing.The residual exposure is org-less rows, which is the #2734 semantics: a row is hidden from a caller only when it carries a different, non-null organization, identical to SQL. An unstamped own write lands org-less and stays visible to its author.
The docstring correction
declaresTenantScope's load-bearing sentence — "every object in a single-tenant deployment omits the block" — was false, and it is recorded in place rather than quietly deleted.singleconstrains the wall, not the number of organizations: the reporting run held 13sys_organizationrows under asingleposture (twelve seeded by the app, one the platform mints for the admin), and rows carry whicheverorganization_idthey were written with.The guard's head docblock and its refusal message carried the same claim ("never reads
DriverOptions.tenantId") and are corrected with it.The control can fail, and here is the measurement
src/memory-tenant-scope.test.ts, 25 cases (23 before this round; F5 added two). The fixture seeds two organizations plus an org-less row, so "returns nothing", "returns everything" and "returns the right subset" are three distinguishable answers, and every scoped case asserts the caller's OWN rows still come back. Asserting only the "after" half would have reproduced this card's own subject.The two pins added this round:
upsertby an id that exists outside the scope refuses, and the table still holds exactly one row carrying that id — the assertion the whole finding is about. Two positive controls sit beside it so it reads as "the cross-wall id is refused" rather than "upsert by id is broken": inside the organization the same door still MERGES, and an id no row in the table carries still INSERTS.deleteManyunder scope WITH awhere. Only the delete-all arm was exercised before; thewherearm filtersvisible, collectsmatchedIdsand rebuilds the whole table from that set, which is exactly where a duplicate id would cross tenants. The filter matches one row in each organization, so "scoped" and "unscoped" are two different numbers (1 vs 2), not just two row lists.Two-leg ablation,
InMemoryDriver.tenantScopeforced tonull, rebuilt, marker verified present indist/before the run — taken on the pre-patch head40300f0107and not re-run this round:Restore was blob-verified, not eyeballed:
git checkout HEAD -- THE-MUTATED-FILE(never a baregit checkout --, which reads from the polluted index), restored blob2e6c7b1c3dequals the HEAD blob2e6c7b1c3d,git diff HEADempty, whole-treegit status --porcelainempty, andablation-dist-preflight --absentconfirms the marker gone from all 6 built files.The 7 that stayed green are accounted for, not waved through. Five are negative controls whose subject IS the unscoped answer (no
tenantId;tenancy.enabled: false; no tenant column; the sticky opt-out; unscopeddistinct()) — a mutation that forces "no scope" cannot redden a case that expects no scope, and those are the half that catches OVER-scoping. Two test the puretenantScopePredicate, a layer the chokepoint ablation deliberately does not touch.An eighth case was green for a bad reason and the ablation is what found it: with only two organizations seeded, the union case's
[ORG_A, ORG_B]covered the whole table, so "the union widened the scope" and "no scope ran" were the same answer. It carries its own third organization now, and reds under ablation.Evidence boundary, reconciled
The card quoted this tree's source and measured counts against the published
@objectstack/cli17.3.0 runtime. Reconciled rather than assumed:@objectstack/cli17.3.0 depends on@objectstack/driver-memoryand@objectstack/objectqlat^17.3.0, and 17.3.0 is the newest published version of both, so the app ran exactly those. The publisheddriver-memory17.3.0 tarball carriesdeclaresTenantScopeasschema?.tenancy?.enabled === trueand its.d.tscarries the false docstring sentence verbatim;tenantId|tenantIds|organization_idappear in its runtime only inside the refusal message and the unique-constraintorganization_idkey check. The published runtime and this tree agree on all four premises.Verification
This patch round, on head
bb394b3fef(branch merged withorigin/mainfirst, plain merge, no rebase)pnpm --filter @objectstack/driver-memory test— 45 files, 1120 passed, 15 todo (1135), exit 0. That is 1118 → 1120: the two F5 pins. The 15 todo are unchanged and none are in the tenancy path.pnpm --filter @objectstack/driver-memory typecheck— both legs (tsc --noEmit && tsc --noEmit -p tsconfig.typecheck.json), exit 0.node scripts/check-empty-changeset.mjs --base origin/main— exit 0: "No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added)."node scripts/check-changeset-no-major.mjs --base origin/main— exit 0: "This diff introduces nomajorbump."pull_requestpayload was available to read a declaration from" — which is neither a pass nor a failure (check:react-declaration-parity 是唯一没接进任何 workflow 的源码审计门禁,且无 MANIFEST 时静默 skip 退出 0 —— 它现在永远不可能红 #4690). CI runs it with the payload.node scripts/check-adr-0087-registration.mjs --base origin/main— exit 0: "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition", listing.changeset/memory-driver-read-side-tenant-scope.md [BREAKING] not-required (no-migration-prescription). ⭐ That line is itself the F3 evidence: before this round the gate saw no declaration to judge, and it now judges one and passes it.From the pre-patch head
40300f0107, not re-run this roundpnpm lint(eslint . --no-inline-config) — exit 0 over the whole repository: 6338 files, 0 errors, 0 warnings, read from--format json. Not narrowed.@objectstack/runtime240 files / 3340 tests,@objectstack/cli--project unit184 files / 2509 passed + 6 expected-fail,@objectstack/service-datasource32 / 676,@objectstack/plugin-dev7 / 72 — all green.@objectstack/cli'sintegrationtier is declared to CI: this diff touches no spawn entry point and no driver/kernel start-up path.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackand reconciled with--ran, verbatim:✓ dispatch-gates --ran: 56 derived famil(ies) accounted for — 56 run, 0 NOT-MEASURED.40300f0107and are not re-measurements of the current head; the patch touches three files inside one package, and CI is the run that answers for the rest.Changeset
minoron@objectstack/driver-memory, with both breaking-ness carriers present (contract review F3, required by ruling5579651209).Level.
minoris unchanged and required twice over: the 2026-09-04 maintainer ruling (decision batch #35, on #15294), written out inpr-automation.yml's "WHICH LEVEL" prose — "A purely additive widening of a published package's public surface (a new exported symbol on anindex, a new accepted key or value) takes at leastminor" — and this PR addsrecordTenantField,tenantScopePredicateandTenantRowPredicateto the package index.The two carriers, added this round:
check-changeset-no-major.mjsnames andcheck-adr-0087-registration.mjsmatches. During the launch window the bump level is not the carrier at all —check-changeset-no-major.mjsforbidsmajoroutright — so, in that file's own words, the banner and the ADR-0087 disposition "are not documentation niceties — during the window they are the only signal there is". A caller passingtenantIdnow receives fewer rows, and this package's own published docblock told that caller the driver never readsDriverOptions.tenantId: the narrowed accept set was documented behaviour, not merely a defect.not-required (no-migration-prescription). Nothing here is retired or renamed — no authorable key, no option, no exported symbol removed — so the ledger has no upgrade path to serve, and the affected consumer is reached by the changeset body and by the driver's corrected refusal message.The governing precedent is #7924, and it is the same package on the same subject. #6915 / PR #7924 shipped as
feat(driver-memory)!:with an ADR-0087not-required (no-migration-prescription)disposition, on the reasoning recorded in the driver-memory CHANGELOG's 17.x entry45d5bd2: "a refusal that was always owed, but it is still a behavior change, and the release notes must be able to say so". That is this change's shape exactly.⛔ Withdrawn. An earlier revision of this body argued for no carriers on three readings, the closest of which cited
.changeset/session-unbacked-org-claim-dropped.md(@objectstack/core,patch). That changeset is pending stock, not released precedent — the PM flagged it and the contract review confirmed it. It is no longer offered as a basis, and the "no carriers" reading is withdrawn in full.验收备注
Filed —
#16729: on this driver a partialsyncSchemare-registration (a schema with notenancyblock) silently flips a platform-global object's UNIQUE partition from global to per-organization, becausetenantFieldOfreads only the schema it is handed whiledriver-sqlkeeps a sticky opt-out record for exactly that shape (#3249). Reproduced at the export boundary against the built artifact. It is not a regression from this PR: this PR adds the sticky record for the READ scope only, deliberately, because making the uniqueness path sticky in the same stroke changes uniqueness semantics (#13197 / #13239 territory) and needs its own test. The card says so, and says to close the gap by routing uniqueness through the same record rather than by removing the read-side one.Noted, not filed:
scripts/check-tenant-chokepoint.mjscannot re-derive the new chokepoint. Its criterion isthis.getBuilder(object, options), the single constructor of every knex query in theSqlDriverfamily; this driver filters an array and builds no query, so there is nothing for the gate to key on. Its scope paragraph stays accurate and is left alone; the in-memory doors are held by the new suite instead. A coverage limit, not a violated contract.distinct()stays unscoped here whiledriver-sql's is scoped, for the structural reason above. No producer in this repository calls it.#15212(ADR-0131 D1/D8/D9, protocol 18) proposes retiring bothorWhereNullarms and the__global__sentinel, and#13564is the census behind that decision. This PR reproduces today's shippeddriver-sqlsemantics on purpose; if that ruling lands, both drivers move together and this is one more call site, not a second dialect.Draft only, per the dispatch: never ready, never auto-merge, never queued.
The patch-round figures above were taken on
bb394b3fef; every figure explicitly attributed to40300f0107was taken there and is not restated as current. Patch round delivered from sessionhttps://claude.ai/code/session_01TezFG8ZMrNH6n5VTNpPpdH; the original round was delivered fromsession_01ADLdAs2pVcH17h9tZKWMBg.