Skip to content

Commit 7c2eab9

Browse files
feat(driver-sql,objectql)!: a tenant-scoped read no longer answers a row with no organization (18.x step 7, part 7a-1) (#24825)
Part of #24583 (18.x step 7, part 7a-1: the driver's NULL-organization arm). The card stays open for the other step-7 parts (7b-1, 7b-2, 7b-3). Clause-②: no (narrowing) **Landing order.** This branch started from `main` merged with `claude/issue-24581-ceremony-open` (#24681, steps 5+6), because it retires readers that only certification makes dead. #24681 has since merged (`c6f775cbd`), and this branch has merged `main` again (`307ce4c4b`). The diff against `main` is this part alone: 19 files, +332 / −241, no governed path (`check-governed-merges --branch`: 0 of 19 on the register, under the 3,000-line line). ## What changes - **`SqlDriver.applyTenantScope`** (`packages/drivers/driver-sql/src/sql-driver.ts`). Both `orWhereNull` arms are gone. A tenant scope compiles to `organization_id = :tenant`, or `organization_id IN (…)` under `group`. A row with no organization is no tenant's (ADR-0131 D1, D14). With no tenant on the call, or no tenant column, the read stays unscoped, as before. The Turso remote face compiles the same chokepoint, so it follows by construction; only its two docblocks changed. - **`readBackInsertedRows`** (same file). The docblock is restated. One consequence of the retired arm is handled in code: the rows' own tenant union scopes the read-back only when *every* written row carries a tenant. A batch with no tenant on the call that mixes stamped and unstamped rows would otherwise miss its own unstamped row after it landed, and the refusal would fire after the write. The ids are this call's own primary keys, so the read cannot reach a row this call did not write. - **`LifecycleService` global retention / archive pass** (`packages/objectql/src/lifecycle/lifecycle-service.ts`). On an `organization_id` partition the `{ [column]: null }` disjunct is gone and the bare `$nin` stays. ⚠️ **The disjunct is kept on the attribution partition** (`sys_audit_log.tenant_id`, ADR-0131 D7). There, NULL is a live, deployment-level value and not a pre-ceremony one: on the ceremonied 18.0 fixture, `sys_audit_log.tenant_id` holds 14 NULL rows of 172. `tenantWindowsFor` makes the decision once, as `TenantPartition.nullIsLive`, so the passes name no column of their own (the federated-reader ledger's existing `tenantWindowsFor` row stays true). - **objectql engine docblocks** that named the driver's `OR … IS NULL` (`buildDriverOptions`'s federated note, the reference-probe and master-header notes). - **Pins.** Tests that asserted the retired reach now assert the new rule. Fixtures whose NULL row was incidental are re-stamped. Where an organization-less row *is* the probe, it stays and is now asserted unreached (the driver tenant-scope and read-door suites, the Turso local/remote parity suite, plugin-sharing's rule-sweep / record-share / share-link stamps, service-storage's upload-door writes). ## Premise check: is every retired reader dead? | Population | Reading | |---|---| | Fresh `single` install (`pnpm dev:crm -- --fresh`), after seeded-admin sign-in and a REST create | 35 tables carry `organization_id`; **0** NULL rows. The session carries an active organization. | | Certified database (`ceremoniedFixture` over the 18.0 fixture) | 66 tables carry `organization_id`; **0** NULL rows. `sys_audit_log.tenant_id`: 14 / 172 NULL (the kept attribution arm). | | Walled boot (`isolated` `os serve`, real `@objectstack/organizations`) | 30 tables; **0** NULL rows. | | `single` posture reads | The driver carries no tenant scope at all (ADR-0131 D8), so the arm was never reached there. | | Composition with no organization object (`no-organization-object`) | It still writes NULL rows, but nothing there carries a tenant to the driver. Session, guest and system writes resolve none, so `applyTenantScope` exits before the arm. Shipped compositions: `examples/embed-objectql` (memory driver, which refuses tenant scope); every `os serve` composition with `Auth` registers `sys_organization` (plugin-auth). | ## Proof (head `2ee8e8d87`, after the last `main` merge) - **R1.** `pnpm dev:crm -- --fresh -p <random>` from a full build: health 200, seeded-admin sign-in 200, `crm_account` create 201, list 200, 0 error lines. - **R2 + R3.** `upgrade-18x-harness.dogfood.test.ts` + `organization-ownership-boot-refusal-order.dogfood.test.ts`: 12 / 12. - **R4.** The built CLI (`bin/run-dev.js serve`), `OS_TENANCY_POSTURE=isolated` with the walled prerequisites, on a throwaway app that declares the real `@objectstack/organizations`. It reached the banner, `Tenancy: isolated`, `Organizations` mounted, discovery 200, health 200, 0 refusals; the database it created holds 0 NULL `organization_id` rows in 30 tables. Step 4's seven walled e2e cases (`OS_TEST_TIERS=nightly`): 7 / 7. - **Full suites, on the final head after a full build.** driver-sql: 223 files, 3682 passed. objectql: 451 files, 8495 passed. driver-turso: 92 files, 2430 passed. plugin-sharing: 51 files, 1193 passed. `typecheck` exit 0 for objectql, driver-sql and driver-turso. Downstream: the 92 test files in 13 packages that reach an organization-less row through the real SQL driver, all green (1636 tests). The 5 among them that pinned the retired reach are rewritten here. - **Ablation** (`scripts/ablation-replace.mjs` at `9987cf807`, the same driver code as the final head; mutation landed and restored to `blob == HEAD`, `git diff HEAD` empty). Re-adding `orWhereNull` on the equality arm turns 4 pins red in `sql-driver-tenant-scope.test.ts`. Reverting the read-back guard turns the new mixed-batch pin red on the read-back face. - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands` on the final head: 78 commands. Each was run with its exit code captured before any pipe, and all 78 exited 0. `--ran` reconciliation: 78 derived, 78 run, 0 NOT-MEASURED (a derived zero: every line carries its exit code). ## Acceptance notes - The claim's file list named driver-sql, the Turso parity tests, objectql's lifecycle and engine docblocks, and "the tenant-scope tests". Measured, those tests also live in plugin-sharing (3 files), service-storage (1 file) and driver-sql's #21227 read-back suite (1 new case). Their pins asserted the retired reach and are rewritten here. The one code line beyond the claim's wording is `readBackInsertedRows`'s scope guard (the claim named its docblock); it is the read-back consequence of the retired arm, and it is in the same function. `packages/drivers/driver-turso/src/{turso-driver,remote-transport}.ts` changed in docblocks only. - `engine.ts`'s dangling-reference audit docblock ("a NULL-organization row … probes unscoped") is unchanged. It describes that audit's own code, which this part does not touch. - `packages/runtime/src/domains/packages.ts` still says env-wide `sys_metadata` reads are `organization_id IS NULL`. `sys_metadata` has carried no organization column since `18.sys-metadata-organization-column-retired` (`systemFields: { tenant: false }`), so the driver arm never applied there. Text drift, left for its owner. - service-storage's driver-leg `legacy` row was seeded with `tenantId: 'org_A'`, so `injectTenantOnInsert` stamped it and the old "stays reachable" pin was vacuous. It is now seeded with no tenant and asserted unreached. - Not touched: the schema differ, index keys and COALESCE (7b-1 / 7b-2), `__global__` (7b-3), `docs/adr/**`. --- _Generated by [Claude Code](https://claude.ai/code/session_01DDkA1TNK1gGKSujgXiLLZQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5cb6e6e commit 7c2eab9

19 files changed

Lines changed: 332 additions & 241 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/driver-sql': minor
3+
'@objectstack/objectql': minor
4+
---
5+
6+
feat(driver-sql)!: a tenant-scoped read no longer answers a row with no organization; the driver's `OR organization_id IS NULL` arm is retired
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) A read-scope narrowing in the SQL driver's tenant chokepoint and the lifecycle sweep's global pass: no key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. No export, type or error code is added, removed or renamed. The data it stops reaching is retired by the 18.x upgrade ceremony, which certifies zero organization-less rows before a database boots. -->
11+
12+
**BREAKING** read-scope narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D1, D14). Step 7 of the 18.x organization-ownership line: it lands after the upgrade ceremony opens.
13+
14+
- **Before.** `SqlDriver.applyTenantScope` compiled a tenant scope as `(organization_id = :tenant OR organization_id IS NULL)`, and the `group` posture's union as `(organization_id IN (…) OR organization_id IS NULL)`. That was the v17 reading, in which a row with no organization stood for platform state, and every tenant's scoped read, count, update and delete reached it. The Turso remote face compiles the same scope, so it answered the same rows.
15+
- **Now.** The scope is `organization_id = :tenant`, or `organization_id IN (…)` under `group`. NULL is not an owner: a row with no organization is no tenant's, and only an unscoped read (no tenant on the call) reaches it. The lifecycle sweep's global retention and archive pass over an `organization_id` partition is a bare `$nin` of the overriding tenants: it no longer selects organization-less rows.
16+
- **Unchanged.** A call with no tenant context and an object with no tenant column are unscoped, as before. The `single` posture's driver reads carry no tenant scope at all (ADR-0131 D8). The `sys_audit_log` attribution partition (`tenant_id`, ADR-0131 D7) keeps its NULL arm: a deployment-level ledger row is about no organization by design, and the global pass still reaps it. Insert stamping (`injectTenantOnInsert`) is unchanged.
17+
18+
**Who is affected.** Only a database that still holds organization-less rows in a tenant-scoped table, and only under a walled posture (`group` / `isolated`) or a caller that passes `tenantId` to the driver itself. A certified 18.x database holds none: a v17 database is refused at boot until `os migrate organization-ownership` certifies it, and a fresh install never writes one.
19+
20+
**The remedy.** If a scoped read misses a row you expect, the row has no organization: run the upgrade ceremony (`os migrate organization-ownership` to review its plan, then the same command with `--apply`), which stamps each such row with its owner, or stamp it yourself (`organization_id = <the owning organization>`).

‎packages/drivers/driver-sql/src/sql-driver-21227-create-answers-stored-row.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,22 @@ function suite(face: Face) {
195195
]);
196196
});
197197

198+
it('a batch with no tenant on the call answers every row it stored, the organization-less one included', async () => {
199+
// [ADR-0131 D1] The tenant scope carries no `IS NULL` arm, so the rows'
200+
// own union (`IN ('org_b')`) would miss the unstamped row this call just
201+
// wrote. The read is scoped by that union only when every row carries a
202+
// tenant; here the call's own options (none) decide, and the ids bound it.
203+
const batch = await driver.bulkCreate(TABLE, [
204+
{ id: 't-m1', title: 'named', organization_id: 'org_b' },
205+
{ id: 't-m2', title: 'unstamped' },
206+
]);
207+
expect(batch.map((r) => [r.id, r.organization_id ?? null])).toEqual([
208+
['t-m1', 'org_b'],
209+
['t-m2', null],
210+
]);
211+
expect(batch[1]).toEqual(await stored('t-m2'));
212+
});
213+
198214
it('inside a caller transaction, create and bulkCreate answer the uncommitted rows and the rollback keeps', async () => {
199215
const trx = await driver.beginTransaction();
200216
let single!: Record<string, unknown>;

‎packages/drivers/driver-sql/src/sql-driver-tenant-scope-read-doors.test.ts‎

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,14 @@
5858
* answering `[]` to an unscoped admin read is a different defect, not a fix. So
5959
* each exposure case is stated beside the read it must not become.
6060
*
61-
* The NULL-org row is here for the same reason. `applyTenantScope` emits
62-
* `(field = ? OR field IS NULL)` on purpose (#2734: strict equality hid every
63-
* platform-seeded RBAC row from every tenant admin). A door "fixed" with a bare
64-
* equality would pass a naive cross-tenant assertion and silently lose p1, so
65-
* p1 is what distinguishes routing through the chokepoint from reimplementing
66-
* a worse copy of it.
61+
* The NULL-org row is here for the same reason. The measurement above is the
62+
* v17 chokepoint, `(field = ? OR field IS NULL)` (#2734). [ADR-0131 D1, D14]
63+
* 18.x retired that arm after the upgrade ceremony: NULL is not an owner, so
64+
* `applyTenantScope` emits `field = ?` alone and no scoped read reaches p1. p1
65+
* now holds every door to the SAME rule from both sides: absent under a
66+
* tenant, present with none. A door that kept its own copy of the old arm, or
67+
* that dropped the no-tenant early-out, answers p1 where the chokepoint does
68+
* not.
6769
*/
6870

6971
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
@@ -136,8 +138,8 @@ describe('driver-sql — every read door routes through applyTenantScope (#6792)
136138
await driver.create(TABLE, { id: 'a2', organization_id: 'org_a', name: 'A2', balance: 20 } as any);
137139
await driver.create(TABLE, { id: 'b1', organization_id: 'org_b', name: 'B1', balance: 30 } as any);
138140
await driver.create(TABLE, { id: 'b2', organization_id: 'org_b', name: 'B2', balance: 40 } as any);
139-
// Platform row: belongs to no tenant, so it belongs to no OTHER tenant
140-
// either and must stay visible (#2734).
141+
// An organization-less row: no tenant's, so no scoped read reaches it
142+
// (ADR-0131 D1); only an unscoped one does.
141143
await driver.create(TABLE, { id: 'p1', name: 'P1', balance: 50 } as any);
142144

143145
await driver.create(UNSCOPED_TABLE, { id: 'l1', name: 'L1', balance: 1 } as any);
@@ -155,16 +157,16 @@ describe('driver-sql — every read door routes through applyTenantScope (#6792)
155157
const rows = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any, {
156158
tenantId: 'org_a',
157159
} as any);
158-
expect(ids(rows)).toEqual(['a1', 'a2', 'p1']);
160+
expect(ids(rows)).toEqual(['a1', 'a2']);
159161
expect(ids(rows)).not.toContain('b1');
160162
expect(ids(rows)).not.toContain('b2');
161163
});
162164

163165
it('gives each tenant its own rows, and only its own', async () => {
164166
const a = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any, { tenantId: 'org_a' } as any);
165167
const b = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any, { tenantId: 'org_b' } as any);
166-
expect(ids(a)).toEqual(['a1', 'a2', 'p1']);
167-
expect(ids(b)).toEqual(['b1', 'b2', 'p1']);
168+
expect(ids(a)).toEqual(['a1', 'a2']);
169+
expect(ids(b)).toEqual(['b1', 'b2']);
168170
});
169171

170172
it('agrees with `find()` on the same read — one driver, one answer', async () => {
@@ -173,23 +175,23 @@ describe('driver-sql — every read door routes through applyTenantScope (#6792)
173175
expect(ids(viaWindow)).toEqual(ids(viaFind));
174176
});
175177

176-
it('keeps the NULL-org platform row visible — the chokepoint, not a bare equality (#2734)', async () => {
178+
it('does not reach the organization-less row under a tenant — NULL is not an owner (ADR-0131 D1)', async () => {
177179
const rows = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any, { tenantId: 'org_a' } as any);
178-
expect(ids(rows)).toContain('p1');
180+
expect(ids(rows)).not.toContain('p1');
179181
});
180182

181183
it('honours the ADR-0105 D2 union posture the same way the chokepoint does (#3623)', async () => {
182184
const rows = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any, {
183185
tenantId: 'org_a',
184186
tenantIds: ['org_a', 'org_b'],
185187
} as any);
186-
expect(ids(rows)).toEqual(['a1', 'a2', 'b1', 'b2', 'p1']);
188+
expect(ids(rows)).toEqual(['a1', 'a2', 'b1', 'b2']);
187189

188190
const narrow = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any, {
189191
tenantId: 'org_a',
190192
tenantIds: ['org_a'],
191193
} as any);
192-
expect(ids(narrow)).toEqual(['a1', 'a2', 'p1']);
194+
expect(ids(narrow)).toEqual(['a1', 'a2']);
193195
});
194196

195197
it('still applies the caller\'s own `where` beside the tenant predicate', async () => {
@@ -198,10 +200,9 @@ describe('driver-sql — every read door routes through applyTenantScope (#6792)
198200
{ ...WINDOW, where: { balance: { $gte: 20 } } } as any,
199201
{ tenantId: 'org_a' } as any,
200202
);
201-
// `p1` (balance 50) is the NULL-org platform row and is legitimately
202-
// visible to org_a — it passes the filter AND the chokepoint. b1/b2
203-
// (30/40) pass the filter and must not survive the tenant predicate.
204-
expect(ids(rows)).toEqual(['a2', 'p1']);
203+
// `p1` (balance 50) and b1/b2 (30/40) pass the filter, and none of them
204+
// survives the tenant predicate: p1 is no tenant's (ADR-0131 D1).
205+
expect(ids(rows)).toEqual(['a2']);
205206
});
206207

207208
it('still computes the window function itself', async () => {
@@ -245,7 +246,7 @@ describe('driver-sql — every read door routes through applyTenantScope (#6792)
245246
describe('distinct — the third read door, found by the gate rather than by the card', () => {
246247
it('does not return another tenant\'s values', async () => {
247248
const values = await driver.distinct(TABLE, 'name', undefined, { tenantId: 'org_a' } as any);
248-
expect(values.sort()).toEqual(['A1', 'A2', 'P1']);
249+
expect(values.sort()).toEqual(['A1', 'A2']);
249250
expect(values).not.toContain('B1');
250251
expect(values).not.toContain('B2');
251252
});
@@ -260,9 +261,9 @@ describe('driver-sql — every read door routes through applyTenantScope (#6792)
260261
const values = await driver.distinct(TABLE, 'name', { balance: { $gte: 20 } } as any, {
261262
tenantId: 'org_a',
262263
} as any);
263-
// `P1` is the NULL-org platform row (balance 50) — see the window-door
264-
// case above. `B1`/`B2` also pass the filter and must not survive.
265-
expect(values.sort()).toEqual(['A2', 'P1']);
264+
// `P1` is the organization-less row (balance 50) — see the window-door
265+
// case above. It and `B1`/`B2` pass the filter and must not survive.
266+
expect(values.sort()).toEqual(['A2']);
266267
});
267268

268269
it('is UNSCOPED with no tenantId — the admin / seed path stays open', async () => {

‎packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts‎

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -133,30 +133,43 @@ describe('SqlDriver tenant scope (organization_id)', () => {
133133
});
134134
});
135135

136-
describe('NULL tenant column = GLOBAL/platform row (#2734)', () => {
136+
// [ADR-0131 D1, D14] NULL is not an owner. The v17 reading (#2734) answered
137+
// an organization-less row to EVERY tenant's scoped read, as platform state;
138+
// 18.x retired that arm with the upgrade ceremony, after which no such row
139+
// exists on a certified database. A row written with no organization is
140+
// therefore no tenant's: only an unscoped read reaches it.
141+
describe('NULL tenant column = no tenant\'s row (ADR-0131 D1)', () => {
137142
beforeEach(async () => {
138-
// A platform-seeded row with no organization (bootstrap positions,
139-
// permission sets, business units, pre-org first-boot seeds).
140-
await driver.create('account', { id: 'g1', name: 'Global', tier: 'global' });
143+
await driver.create('account', { id: 'g1', name: 'Orphan', tier: 'none' });
141144
});
142145

143-
it('a scoped read still sees the org-less row (any tenant)', async () => {
146+
it('a scoped read does not see the organization-less row, under any tenant', async () => {
144147
const rowsA = await driver.find('account', {}, { tenantId: 'org_a' });
145148
const rowsB = await driver.find('account', {}, { tenantId: 'org_b' });
146-
expect(rowsA.map((r: any) => r.id)).toContain('g1');
147-
expect(rowsB.map((r: any) => r.id)).toContain('g1');
148-
// …while cross-tenant rows stay hidden exactly as before.
149-
expect(rowsA.map((r: any) => r.id).sort()).toEqual(['a1', 'a2', 'g1']);
149+
expect(rowsA.map((r: any) => r.id).sort()).toEqual(['a1', 'a2']);
150+
expect(rowsB.map((r: any) => r.id).sort()).toEqual(['b1', 'b2']);
150151
});
151152

152-
it('a scoped by-id read resolves the global row', async () => {
153+
it('a scoped by-id read does not resolve it', async () => {
153154
const row = await driver.findOne('account', { where: { id: 'g1' } }, { tenantId: 'org_a' });
154-
expect(row?.id).toBe('g1');
155+
expect(row).toBeNull();
155156
});
156157

157-
it('a scoped count includes the global row', async () => {
158+
it('a scoped count excludes it', async () => {
158159
const a = await driver.count!('account', {}, { tenantId: 'org_a' });
159-
expect(a).toBe(3); // a1, a2, g1 — never org_b's rows
160+
expect(a).toBe(2); // a1, a2
161+
});
162+
163+
it('a scoped update or delete does not reach it', async () => {
164+
await driver.update('account', 'g1', { name: 'Touched' }, { tenantId: 'org_a' }).catch(() => undefined);
165+
await driver.delete('account', 'g1', { tenantId: 'org_a' }).catch(() => undefined);
166+
const row = await driver.findOne('account', { where: { id: 'g1' } });
167+
expect(row?.name).toBe('Orphan');
168+
});
169+
170+
it('an unscoped read still sees it — no tenant context is no scope', async () => {
171+
const rows = await driver.find('account', {});
172+
expect(rows.map((r: any) => r.id)).toContain('g1');
160173
});
161174
});
162175

@@ -464,14 +477,14 @@ describe('SqlDriver tenant scope (organization_id)', () => {
464477
expect(rows.map(r => r.id)).not.toContain('c1');
465478
});
466479

467-
it('keeps the NULL-tenant global-row carve-out (#2734) on the union path', async () => {
468-
await driver.create('account', { id: 'g1', name: 'GLOBAL' });
480+
it('the union path does not reach an organization-less row either (ADR-0131 D1)', async () => {
481+
await driver.create('account', { id: 'g1', name: 'ORPHAN' });
469482
const rows = await driver.find(
470483
'account',
471484
{},
472-
{ tenantId: 'org_a', tenantIds: ['org_a'] } as any,
485+
{ tenantId: 'org_a', tenantIds: ['org_a', 'org_b'] } as any,
473486
);
474-
expect(rows.map(r => r.id).sort()).toEqual(['a1', 'a2', 'g1']);
487+
expect(rows.map(r => r.id).sort()).toEqual(['a1', 'a2', 'b1', 'b2']);
475488
});
476489

477490
it('an empty or malformed tenantIds falls back to tenantId equality (fail toward isolation)', async () => {

‎packages/drivers/driver-sql/src/sql-driver.ts‎

Lines changed: 30 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7616,11 +7616,16 @@ export class SqlDriver implements IDataDriver {
76167616
* are never overwritten), a read scoped to the caller's ACTIVE org would miss
76177617
* a row that really landed. A batch may name several tenants, so the scope is
76187618
* their union through `tenantIds`, which `applyTenantScope` already compiles
7619-
* as `IN (…) OR IS NULL`; the caller's own `tenantIds` membership set is
7620-
* replaced, not widened. With no tenant field, or no tenant on the rows and
7621-
* none on the call, `applyTenantScope` leaves the read unscoped by its own
7622-
* contract. The ids are this call's own and `id` is the PRIMARY KEY, so the
7623-
* read cannot answer a row this call did not write, in any organization.
7619+
* as `IN (…)`; the caller's own `tenantIds` membership set is replaced, not
7620+
* widened. A row written with no tenant is no tenant's, and no tenant scope
7621+
* reaches it (ADR-0131 D1), so the rows' union scopes the read only when
7622+
* every row carries one. Otherwise the call's own options decide, and a call
7623+
* with no tenant leaves a row unstamped only because it carries none:
7624+
* `injectTenantOnInsert` fills every absent column on a call that does. With
7625+
* no tenant field, or no tenant on the call, `applyTenantScope` leaves the
7626+
* read unscoped by its own contract. The ids are this call's own and `id` is
7627+
* the PRIMARY KEY, so the read cannot answer a row this call did not write,
7628+
* in any organization.
76247629
*
76257630
* # A row that is not there
76267631
*
@@ -7646,11 +7651,12 @@ export class SqlDriver implements IDataDriver {
76467651
),
76477652
]
76487653
: [];
7649-
const scopeOptions: DriverOptions = {
7650-
...options,
7651-
tenantId: writtenTenants[0] ?? options?.tenantId,
7652-
tenantIds: writtenTenants.length > 0 ? writtenTenants : undefined,
7653-
};
7654+
const everyRowCarriesTenant =
7655+
tenantField !== null
7656+
&& written.every((row) => row[tenantField] !== undefined && row[tenantField] !== null && row[tenantField] !== '');
7657+
const scopeOptions: DriverOptions = everyRowCarriesTenant
7658+
? { ...options, tenantId: writtenTenants[0], tenantIds: writtenTenants }
7659+
: { ...options };
76547660
const builder = this.getBuilder(writeTable, options);
76557661
this.applyTenantScope(builder, object, scopeOptions);
76567662
const stored: Record<string, any>[] = await builder.whereIn(
@@ -11475,8 +11481,8 @@ export class SqlDriver implements IDataDriver {
1147511481
//
1147611482
// Placed BESIDE `getBuilder` and above the caller's `where`, which is the
1147711483
// position `findRows()` uses: the predicate has to be on the builder before
11478-
// anything reads it, and `applyTenantScope` is what owns the NULL-org
11479-
// platform-row and ADR-0105 D2 union semantics. Re-deriving either here
11484+
// anything reads it, and `applyTenantScope` is what owns the equality and
11485+
// ADR-0105 D2 union semantics. Re-deriving either here
1148011486
// would be a second, worse copy of the wall.
1148111487
this.applyTenantScope(builder, object, options);
1148211488

@@ -15600,27 +15606,19 @@ export class SqlDriver implements IDataDriver {
1560015606
? rawIds.filter((v: unknown) => typeof v === 'string' && v !== '')
1560115607
: [];
1560215608
if (tenantIds.length > 0) {
15603-
return builder.where((b) => {
15604-
void b.whereIn(field, tenantIds.map(String)).orWhereNull(field);
15605-
});
15609+
return builder.whereIn(field, tenantIds.map(String));
1560615610
}
15607-
// `(field = :tenantId OR field IS NULL)`. The NULL arm is the v17 reading
15608-
// (#2734), in which an org-less row stood for platform state (seeded
15609-
// positions and permission sets, business units, first-boot seeds).
15610-
// ADR-0131 replaces that reading with three rules. A managed package's
15611-
// metadata is not in the database: its definitions are sealed, and
15612-
// customization is environment-level state (D2, D6). A row with an
15613-
// organization column was written by that organization, and NULL is not
15614-
// an owner (D1); references to declared items are by machine name (D4). A
15615-
// table with no organization column is deployment-level or code-level,
15616-
// protected by permissions rather than by this wall (D7). So on a fresh
15617-
// 18.0 `single` install, where every insert carries its organization, the
15618-
// arm matches nothing. The arm is removed on the 18.x line, after the
15619-
// upgrade ceremony (ADR-0131 D14). Rows stamped with a DIFFERENT tenant
15620-
// stay invisible either way.
15621-
return builder.where((b) => {
15622-
void b.where(field, String(tenantId)).orWhereNull(field);
15623-
});
15611+
// `field = :tenantId`, and nothing else. [ADR-0131 D1, D14] NULL is not an
15612+
// owner: an organization-less row is no tenant's, so no tenant scope reaches
15613+
// it. The v17 reading (#2734) added an `OR field IS NULL` arm here, under
15614+
// which such a row stood for platform state; ADR-0131 replaced that reading
15615+
// (D2, D6, D7) and the 18.x upgrade ceremony retired the rows it served: a
15616+
// certified database holds no NULL organization, a fresh install never
15617+
// writes one, and a v17 database is refused at boot until it is certified.
15618+
// A composition that registers no organization object can still write
15619+
// organization-less rows, and it never reaches this line with a tenant: no
15620+
// session, guest or system write there carries an organization.
15621+
return builder.where(field, String(tenantId));
1562415622
}
1562515623

1562615624
/**

0 commit comments

Comments
 (0)