Found while verifying the isolation model of a real app (objectstack-ai/ats) on both drivers and getting two different answers to the same question. The app is not the point — the point is that the memory driver is the permissive one, and nothing says so, which makes every memory-driver measurement of an isolation property weaker than it looks.
The asymmetry
Two predicates decide "is this object tenant-scoped", and they disagree on the default case.
The engine scopes unless the object opts OUT — Engine.buildDriverOptions (packages/objectql/src/engine.ts):
const hasTenant =
execCtx?.tenantId !== undefined &&
!isTenancyDisabled(objectSchema) &&
!isFederated;
with its own docstring: "Carries tenantId from the active ExecutionContext so the driver can enforce per-tenant isolation (SQL driver auto-scopes reads and auto-injects the tenant column on writes) — EXCEPT for the two object postures below" — those two being tenancy.enabled: false and federated. ⭐ There is no posture check here: single is scoped exactly like the others. Posture only appears further down, widening to tenantIds under group.
The memory driver refuses only an explicit opt-IN — packages/drivers/driver-memory/src/memory-tenancy-guard.ts:
export function declaresTenantScope(schema: unknown): boolean {
return (schema as TenancyAwareSchema | null | undefined)?.tenancy?.enabled === true;
}
and it says why:
Only an explicit tenancy.enabled === true counts. An absent tenancy block is not treated as a multi-tenant signal here: platform-wide tenant scoping is driven by the deployment posture (checked separately by assertSingleTenantPosture), and every object in a single-tenant deployment omits the block.
⇒ An object that omits tenancy entirely — the common case — is tenant-scoped by the engine and invisible to the guard. assertObjectsNotTenantScoped (called at memory-driver.ts:1907) never fires, assertSingleTenantPosture passes because the posture really is single, and the driver then does nothing with the scope: tenantId, tenantIds and organization_id do not occur anywhere in the 2318-line memory-driver.ts — zero hits across the whole driver-memory/src tree. By contrast driver-sql carries tenantId: string | null in its options and documents the auto-scope at sql-driver.ts:4360.
Why the guard's assumption does not hold
The load-bearing sentence is "every object in a single-tenant deployment omits the block" — which reads "single posture ⇒ one tenant ⇒ nothing to scope."
A single-posture deployment can hold many organizations. In the run below there are 13 sys_organization rows: twelve seeded by the app plus one the platform itself mints ([auth] created Default Organization … for the platform admin (single-org)). single constrains the wall, not the number of organizations, and rows carry whichever organization_id they were written with. So the engine's scope is meaningful under single, and skipping it changes results.
Measured
objectstack-ai/ats at 8e6d7d4, @objectstack/cli 17.3.0, same build, same seed (801 records, 0 errored), same account (admin@platform.example), each run --fresh, counts taken after [Seeder] Seed loading complete:
| object |
tenancy declared |
sqlite |
memory |
ats_employer |
(omitted) |
0 |
12 |
ats_employer_member |
(omitted) |
0 |
30 |
ats_interview |
(omitted) |
0 |
40 |
ats_offer |
(omitted) |
0 |
14 |
ats_job |
{ enabled: false } |
40 |
40 |
ats_application |
{ enabled: false } |
200 |
200 |
ats_candidate |
{ enabled: false } |
80 |
80 |
The split is exactly the declaration: every object that omits the block diverges, every explicit enabled: false agrees. Both drivers log the identical posture line at boot:
[security] tenancy posture 'single' — Layer 0 is inert; the platform's own
tenant-scoped RLS policies are stripped (app-authored ones are retained and
fail closed, ADR-0105 D3)
The caller's active organization is the minted Default Organization; the rows carry the app's org_ats_* ids. sqlite's (organization_id = :tenant OR organization_id IS NULL) therefore matches nothing — correct. Memory returns everything.
⭐ The platform owner reads the same zeros on sqlite. admin_full_access carries wildcard viewAllRecords, and it does not lift this: driver-level scoping sits below the permission evaluator, so a read bypass never sees the rows to bypass. Worth stating because it is the natural first hypothesis and it is wrong.
Why this is worth fixing rather than documenting
The failure direction is toward exposure in the place where isolation is tested. A suite that asserts "tenant A cannot see tenant B's rows" passes trivially on memory — not because isolation works, but because both tenants' rows come back to everyone and the assertion was written against a single tenant's fixture. An app proving out its isolation model on the memory driver, which is the fast path everyone reaches for, is measuring nothing. In the app above every isolation measurement taken on memory has to be re-taken.
Three coherent resolutions
- Make the guard match the engine.
declaresTenantScope becomes the engine's predicate — scoped unless tenancy.enabled === false or federated — so the driver refuses at syncSchema on any object the engine will scope. Safest and loudest; it would refuse the app above outright, which is honest but a real break for existing memory-driver users.
- Implement the scope. Honour
tenantId / tenantIds in the memory driver's read path the way driver-sql does. Most useful — memory stays the fast test driver and becomes truthful — and the semantics are already specified by the SQL driver.
- Warn, at minimum. If neither of the above is wanted now, the driver should log once per object that the engine handed it a
tenantId it is discarding. ⛔ Silence is the one option that should not survive: today the two drivers disagree and neither says a word.
Preference for 2, falling back to 1. Whichever lands, declaresTenantScope's docstring needs the correction that a single-posture deployment can hold many organizations.
Evidence boundary
Source read in this repository at c463d03e0 (2026-09-05), packages at 17.3.0. Runtime measurements are against the published @objectstack/cli 17.3.0 as consumed by the app, not against a build of this tree — so the code paths quoted are this tree's and the counts are the released runtime's. I did not re-run the app against a locally built framework.
Related but distinct: #16518 (accessible_org_ids reserved but never populated into the RLS bag) — a different layer, also fails silently.
Found while verifying the isolation model of a real app (
objectstack-ai/ats) on both drivers and getting two different answers to the same question. The app is not the point — the point is that the memory driver is the permissive one, and nothing says so, which makes every memory-driver measurement of an isolation property weaker than it looks.The asymmetry
Two predicates decide "is this object tenant-scoped", and they disagree on the default case.
The engine scopes unless the object opts OUT —
Engine.buildDriverOptions(packages/objectql/src/engine.ts):with its own docstring: "Carries
tenantIdfrom the active ExecutionContext so the driver can enforce per-tenant isolation (SQL driver auto-scopes reads and auto-injects the tenant column on writes) — EXCEPT for the two object postures below" — those two beingtenancy.enabled: falseand federated. ⭐ There is no posture check here:singleis scoped exactly like the others. Posture only appears further down, widening totenantIdsundergroup.The memory driver refuses only an explicit opt-IN —
packages/drivers/driver-memory/src/memory-tenancy-guard.ts:and it says why:
⇒ An object that omits
tenancyentirely — the common case — is tenant-scoped by the engine and invisible to the guard.assertObjectsNotTenantScoped(called atmemory-driver.ts:1907) never fires,assertSingleTenantPosturepasses because the posture really issingle, and the driver then does nothing with the scope:tenantId,tenantIdsandorganization_iddo not occur anywhere in the 2318-linememory-driver.ts— zero hits across the wholedriver-memory/srctree. By contrastdriver-sqlcarriestenantId: string | nullin its options and documents the auto-scope atsql-driver.ts:4360.Why the guard's assumption does not hold
The load-bearing sentence is "every object in a single-tenant deployment omits the block" — which reads "single posture ⇒ one tenant ⇒ nothing to scope."
A
single-posture deployment can hold many organizations. In the run below there are 13sys_organizationrows: twelve seeded by the app plus one the platform itself mints ([auth] created Default Organization … for the platform admin (single-org)).singleconstrains the wall, not the number of organizations, and rows carry whicheverorganization_idthey were written with. So the engine's scope is meaningful undersingle, and skipping it changes results.Measured
objectstack-ai/atsat8e6d7d4,@objectstack/cli17.3.0, same build, same seed (801 records, 0 errored), same account (admin@platform.example), each run--fresh, counts taken after[Seeder] Seed loading complete:tenancydeclaredats_employerats_employer_memberats_interviewats_offerats_job{ enabled: false }ats_application{ enabled: false }ats_candidate{ enabled: false }The split is exactly the declaration: every object that omits the block diverges, every explicit
enabled: falseagrees. Both drivers log the identical posture line at boot:The caller's active organization is the minted Default Organization; the rows carry the app's
org_ats_*ids. sqlite's(organization_id = :tenant OR organization_id IS NULL)therefore matches nothing — correct. Memory returns everything.⭐ The platform owner reads the same zeros on sqlite.
admin_full_accesscarries wildcardviewAllRecords, and it does not lift this: driver-level scoping sits below the permission evaluator, so a read bypass never sees the rows to bypass. Worth stating because it is the natural first hypothesis and it is wrong.Why this is worth fixing rather than documenting
The failure direction is toward exposure in the place where isolation is tested. A suite that asserts "tenant A cannot see tenant B's rows" passes trivially on memory — not because isolation works, but because both tenants' rows come back to everyone and the assertion was written against a single tenant's fixture. An app proving out its isolation model on the memory driver, which is the fast path everyone reaches for, is measuring nothing. In the app above every isolation measurement taken on memory has to be re-taken.
Three coherent resolutions
declaresTenantScopebecomes the engine's predicate — scoped unlesstenancy.enabled === falseor federated — so the driver refuses atsyncSchemaon any object the engine will scope. Safest and loudest; it would refuse the app above outright, which is honest but a real break for existing memory-driver users.tenantId/tenantIdsin the memory driver's read path the waydriver-sqldoes. Most useful — memory stays the fast test driver and becomes truthful — and the semantics are already specified by the SQL driver.tenantIdit is discarding. ⛔ Silence is the one option that should not survive: today the two drivers disagree and neither says a word.Preference for 2, falling back to 1. Whichever lands,
declaresTenantScope's docstring needs the correction that asingle-posture deployment can hold many organizations.Evidence boundary
Source read in this repository at
c463d03e0(2026-09-05), packages at 17.3.0. Runtime measurements are against the published@objectstack/cli17.3.0 as consumed by the app, not against a build of this tree — so the code paths quoted are this tree's and the counts are the released runtime's. I did not re-run the app against a locally built framework.Related but distinct: #16518 (
accessible_org_idsreserved but never populated into the RLS bag) — a different layer, also fails silently.