Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/memory-driver-read-side-tenant-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@objectstack/driver-memory': minor
---

**BREAKING** — a caller that passes `DriverOptions.tenantId` to `@objectstack/driver-memory` now receives FEWER rows. The narrowed accept set was documented behaviour, not merely a defect: this package's own published docblock told that caller the driver never reads `DriverOptions.tenantId`. Same package, same subject and the same declaration as the released precedent #6915 / PR #7924 (`feat(driver-memory)!`), which shipped a refusal that was always owed and still declared it, so the release notes could say so. The bump level stays `minor` because the launch-window convention forbids `major`; during that window this banner, not the level, is the carrier.

The in-memory driver now honours `DriverOptions.tenantId` / `tenantIds` instead of discarding them, so a scoped read no longer returns other organizations' rows.

Two predicates decided "is this object tenant-scoped" and they disagreed on the default case. `Engine.buildDriverOptions` scopes unless the object 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`. The read path knew nothing about tenants; the unique-constraint path did.

Measured on one app across two drivers, same build, same seed, same account: the four objects that omit the block returned 12 / 30 / 40 / 14 rows on this driver against **0** on sqlite, and the three that declare `tenancy.enabled: false` agreed exactly. The split line was the declaration. Neither driver said a word about the disagreement.

⚠️ **Every isolation measurement previously taken on this driver is void and must be re-taken.** The failure direction was toward exposure in the place where isolation is tested: a suite asserting "tenant A cannot see tenant B's rows" passed here not because isolation worked, but because both tenants' rows came back to everyone and the assertion had been written against a single tenant's fixture.

The semantics are `driver-sql`'s, read off `applyTenantScope` and reproduced arm for arm rather than invented — `col = :tenantId OR col IS NULL` for the equality path, `col IN (…) OR col IS NULL` under the ADR-0105 D2 union posture, and the NULL arm keeps the #2734 global-row carve-out so a platform row that belongs to no organization stays visible to all of them. Every door that **selects rows** routes through one chokepoint: `find`, `findOne`, `count`, `aggregate` (both arms), `update`, `upsert`, `delete`, `updateMany`, `deleteMany`, `bulkUpdate` and `bulkDelete`. Four doors that take a `DriverOptions` are deliberately **not** routed through it: `create` and `bulkCreate` are the insert doors, which `driver-sql` scopes through `injectTenantOnInsert` rather than `applyTenantScope` and which the write half left out of this change does not stamp; `syncSchema` and `dropTable` are DDL, which `driver-sql` does not scope either. `distinct()` accepts no `DriverOptions` at all and is therefore still unscoped — the one selecting door named rather than left to be discovered.

An `upsert` addressed by an explicit `id` refuses when a row carrying that id exists outside the caller's scope, on this driver's own "not found" contract, rather than inserting: `id` is the primary id here and `create` checks only declared unique constraints, so falling through would leave two rows carrying one id. `driver-sql` cannot reach that state — it merges on the PRIMARY KEY regardless of tenant and scopes only the readback.

**What changes for an existing consumer.** A caller that passes no `tenantId` — every seed script, admin path and legacy call — is unaffected down to the array it allocates. A caller that does pass one, on an object carrying a tenant column and no `tenancy` declaration, now sees its own organization's rows plus organization-less rows, where it previously saw everything. That is the fix, and it is the reason a `single`-posture deployment is affected at all: `single` constrains the wall, not the number of organizations — one measured run held 13 `sys_organization` rows.

Write-side tenancy is deliberately not included: nothing stamps a tenant column on insert the way `SqlDriver.injectTenantOnInsert` does, so the boot guard still refuses a walled posture and still refuses 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.

<!-- adr-0087: not-required (no-migration-prescription) Nothing is retired or renamed: no authorable key, no option and no exported symbol is removed, so the ledger has no upgrade path to serve. The affected consumer is reached by this changeset body and by the driver's corrected refusal message, which is the same disposition the same package registered for the same subject in #6915 / PR #7924. -->
9 changes: 9 additions & 0 deletions packages/drivers/driver-memory/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ export type {
UniqueAwareSchema,
} from './memory-unique-constraint.js';

// [#16589] Read-side tenant scoping, exported for the same reason the
// uniqueness helpers above are: a consumer verifying an isolation property on
// this driver can assert the PREDICATE directly instead of inferring it from a
// row count. ⚠️ Every isolation measurement taken on this driver BEFORE #16589
// is void — it was taken against a driver that returned every organization's
// rows to everyone — and has to be re-taken.
export { recordTenantField, tenantScopePredicate } from './memory-tenant-scope.js';
export type { TenantRowPredicate } from './memory-tenant-scope.js';

export default {
id: 'com.objectstack.driver.memory',
version: '1.0.0',
Expand Down
Loading
Loading