diff --git a/.changeset/tenant-chokepoint-read-doors.md b/.changeset/tenant-chokepoint-read-doors.md
new file mode 100644
index 0000000000..ab0165e97f
--- /dev/null
+++ b/.changeset/tenant-chokepoint-read-doors.md
@@ -0,0 +1,55 @@
+---
+'@objectstack/driver-sql': patch
+'@objectstack/driver-sqlite-wasm': patch
+'@objectstack/driver-turso': patch
+---
+
+drivers: every SQL read door routes through the tenant chokepoint (#6792)
+
+`SqlDriver.applyTenantScope()` owns read-side tenant isolation for the whole SQL family —
+the `tenantId` early-out, the "object has no tenant field" early-out, the NULL-org
+platform-row rule (#2734) and the ADR-0105 D2 union posture (#3623). Its own docstring
+said "every CRUD method routes through it". Nothing ever checked that, and it was false
+for as long as it had existed. **Three** read doors built their query through
+`getBuilder()` and never arrived:
+
+- **`findWithWindowFunctions()`** — the documented #4286 window door. It returns **rows**,
+ so on a deployment where the scope would have applied (`options.tenantId` set, object
+ has a tenant field) it returned rows belonging to **every** tenant. Measured with two
+ tenants seeded plus one NULL-org platform row: `tenantId: 'org_a'` returned
+ `[a1, a2, b1, b2, p1]` here against `find()`'s `[a1, a2, p1]` — another tenant's rows,
+ handed over at the driver layer.
+- **`analyzeQuery()` / `explain()`** — returns a **plan**, not rows, so this is a smaller
+ fix and it is made on its own merits rather than folded into the one above. It is the
+ same defect #6577 fixed on these two methods one builder line lower: a plan is only
+ worth reading if it explains the statement `find()` would actually run, and a missing
+ tenant predicate changes selectivity and therefore which index the planner picks.
+ Compiled `select * from account` where `find()` sent the `organization_id` clause.
+- **`distinct()`** — returns one column's **values** for every tenant. This one was in no
+ card. #6792 states the opposite, listing `distinct` among the scoped call sites; the
+ 13th read site is `aggregate()`. It was found by measuring the invariant rather than
+ re-reading it.
+
+All three now call `applyTenantScope()` beside their `getBuilder()` line, the position
+`findRows()` uses. They route through the chokepoint rather than re-deriving a predicate:
+a local equality would silently drop NULL-org platform rows (#2734) and collapse group
+reads to active-org reach (#3623). Both of the chokepoint's early-outs are inherited
+unchanged, so an unscoped admin/seed read (no `tenantId`) and any object without a tenant
+field behave exactly as before.
+
+**The durable half is a gate, not the three lines.** `pnpm check:tenant-chokepoint`
+(`scripts/check-tenant-chokepoint.mjs`, wired into `.github/workflows/lint.yml`) re-derives
+the invariant from the AST across the `SqlDriver` family on every run: a method that builds
+through `getBuilder(object, options)` must call `applyTenantScope()` on that builder, or
+carry a written exemption. Insert builders are exempt structurally — write-side tenancy is
+`injectTenantOnInsert` — rather than by a name list. It is keyed on the **builder** and not
+on the method signature, because the signature criterion the card sketches ("takes
+`(object, …, options)` and returns rows") misses `distinct` (no `query` parameter) and
+`analyzeQuery` (returns a plan). Verified red against the pre-fix tree, red against a
+newly-added unscoped door, and silent once that door is scoped.
+
+The chokepoint docstring no longer asserts the invariant; it names the gate that proves it.
+
+If you call these doors directly on a multi-tenant deployment, pass `options.tenantId` as
+you would to `find()` — that is what now takes effect. Callers that never passed it are
+unaffected; that remains the documented unscoped/admin path.
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 12dd33a4f6..6ed50e30c5 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -575,6 +575,44 @@ jobs:
- name: Spec type-alias convention gate (ADR-0122)
run: pnpm check:spec-parsed-alias
+ # Read-side tenant chokepoint gate (#6792, from #3724 / #6577).
+ # `SqlDriver.applyTenantScope()` owns read-side tenant isolation for the
+ # whole SQL family — the tenantId early-out, the no-tenant-field early-out,
+ # the NULL-org platform-row rule (#2734) and the ADR-0105 D2 union posture
+ # (#3623). Its own docstring claimed "every CRUD method routes through it".
+ # Nothing checked that, and it was FALSE for as long as it had existed:
+ # three doors built through `getBuilder()` and never arrived —
+ # `findWithWindowFunctions` (ROWS: a caller passing `tenantId` got every
+ # tenant's rows, measured `[a1,a2,b1,b2,p1]` against `find()`'s
+ # `[a1,a2,p1]`), `analyzeQuery`/`explain` (a PLAN for a statement `find()`
+ # would not run — the same defect #6577 fixed on these methods one builder
+ # line lower), and `distinct` (every tenant's values for one column).
+ #
+ # The third is the argument for gating rather than fixing. It was in NO
+ # card: #6792 asserts the opposite — that `distinct` is among the 13 scoped
+ # sites — and the triage comment and two rounds of measurement all
+ # inherited that sentence without re-deriving it. The 13th read site is
+ # `aggregate()`. Two of the three doors were found by a human reading the
+ # file for another reason; the third was found only by measuring, which is
+ # the thing a prose invariant can never do for itself.
+ #
+ # Keyed on the BUILDER, not the method signature. #6792 sketches "every
+ # method taking `(object, …, options)` and returning rows"; that criterion
+ # is measurably too narrow — `distinct(object, field, filters, options)`
+ # takes no query and `analyzeQuery` returns a plan, so it misses two of the
+ # three. `getBuilder()` is the single constructor of every statement this
+ # driver sends, so every builder is classified and one that cannot be
+ # classified is an error, never a default (#4690's family). Insert builders
+ # are exempt structurally, not by name: write-side tenancy is
+ # `injectTenantOnInsert`.
+ #
+ # Static AST over three files, no build needed, so it belongs in this job.
+ # Runs its own --self-test first, in both directions — the detector can be
+ # broken while every door is fine, and a scan that stops matching would
+ # report OK while reading nothing.
+ - name: Read-side tenant chokepoint gate
+ run: pnpm check:tenant-chokepoint
+
typecheck:
name: TypeScript Type Check
runs-on: ubuntu-latest
diff --git a/content/docs/data-modeling/queries.mdx b/content/docs/data-modeling/queries.mdx
index f6980a387d..1b7c034d99 100644
--- a/content/docs/data-modeling/queries.mdx
+++ b/content/docs/data-modeling/queries.mdx
@@ -590,6 +590,17 @@ const ranked = await sqlDriver.findWithWindowFunctions('employee', {
});
```
+
+**Pass `options.tenantId` on a multi-tenant deployment.** Like `find()`, this door is
+tenant-scoped only when the caller supplies it — the example above omits it, so it reads
+across every tenant. That is the driver layer's documented contract (seed scripts and
+cross-org tooling depend on the unscoped path), but it is a decision to make deliberately.
+
+Until #6792 the door ignored `options.tenantId` even when you *did* pass it and returned
+every tenant's rows regardless. It now routes through the driver's `applyTenantScope`
+chokepoint like every other read.
+
+
For request-level analytics, use `aggregations` + `groupBy`, or model rankings in
report/dashboard metadata.
diff --git a/content/docs/protocol/objectql/query-syntax.mdx b/content/docs/protocol/objectql/query-syntax.mdx
index a0d35c3894..638ecfb9eb 100644
--- a/content/docs/protocol/objectql/query-syntax.mdx
+++ b/content/docs/protocol/objectql/query-syntax.mdx
@@ -826,6 +826,12 @@ from presentation; Postgres and MySQL hand back their own native temporal value)
then re-deduplicates the presented values, because SQL `DISTINCT` compares the *stored*
form.
+It is tenant-scoped on the same terms as `find()` — the `organization_id` predicate is
+applied when the call carries `options.tenantId` (the fourth argument), and the example
+above omits it, so it returns the column's values across every tenant. Until #6792 the
+predicate was dropped even when `tenantId` *was* supplied, which made a scoped call
+disclose every other tenant's values for that column.
+
### Full-Text Search
The `search` parameter does **not** reach a full-text index. The engine expands it into
@@ -959,6 +965,13 @@ projection, and niladic rendering means argument-taking functions (`LAG(field)`)
emit without their argument. For request-level analytics use `aggregations` +
`groupBy` (§5).
+Tenancy works exactly as it does on `find()`: the driver applies its
+`organization_id` predicate only when the call carries `options.tenantId`. The example
+above omits it and therefore reads across every tenant — the intended unscoped/admin
+path, but a deliberate choice rather than a default to inherit. (Until #6792 this door
+dropped `options.tenantId` even when it was supplied, so a scoped call still returned
+every tenant's rows.)
+
---
## 7. Pagination
diff --git a/package.json b/package.json
index 31fbed3777..aa6b571cb4 100644
--- a/package.json
+++ b/package.json
@@ -80,6 +80,7 @@
"check:engine-double-contract": "node scripts/check-engine-double-contract.mjs --self-test && node scripts/check-engine-double-contract.mjs",
"check:resume-authority-declared": "node scripts/check-resume-authority-declared.mjs --self-test && node scripts/check-resume-authority-declared.mjs",
"check:spec-parsed-alias": "node scripts/check-spec-parsed-alias.mjs --self-test && node scripts/check-spec-parsed-alias.mjs",
+ "check:tenant-chokepoint": "node scripts/check-tenant-chokepoint.mjs --self-test && node scripts/check-tenant-chokepoint.mjs",
"check:stall-guard": "node scripts/run-with-stall-guard.mjs --self-test"
},
"keywords": [
diff --git a/packages/drivers/driver-sql/src/sql-driver-tenant-scope-read-doors.test.ts b/packages/drivers/driver-sql/src/sql-driver-tenant-scope-read-doors.test.ts
new file mode 100644
index 0000000000..cad767cd5a
--- /dev/null
+++ b/packages/drivers/driver-sql/src/sql-driver-tenant-scope-read-doors.test.ts
@@ -0,0 +1,334 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Every read door routes through the tenant chokepoint — objectstack#6792.
+ *
+ * # What was open
+ *
+ * `applyTenantScope()` says of itself that it is "the single chokepoint for
+ * read-side tenant isolation in the SQL driver; every CRUD method routes
+ * through it". THREE methods did not:
+ *
+ * - `findWithWindowFunctions()` — the live window-function read door (#4286).
+ * It returns ROWS. With `options.tenantId` set on an object that has a
+ * tenant field, it returned rows belonging to EVERY tenant: cross-tenant
+ * read exposure at the driver layer, the #3724 class.
+ * - `analyzeQuery()` / `explain()` — returns a PLAN. Lower severity and a
+ * different defect: a plan is only worth reading if it explains the
+ * statement `find()` would actually run, and a statement missing the tenant
+ * predicate has different selectivity and picks different indexes. This is
+ * the same failure #6577 fixed on these two methods for `limit`, one
+ * builder line higher.
+ * - `distinct()` — returns one column's VALUES for every tenant. Named in no
+ * card; #6792 says the OPPOSITE, listing `distinct` among the 13 scoped
+ * sites (the 13th read site is `aggregate()`). Found by running the gate
+ * this change ships, which is the argument for having one.
+ *
+ * All three built through `this.getBuilder(object, options)` and none followed
+ * it with `applyTenantScope`, which is what every other read site does.
+ *
+ * Measured on `main` at `6595262`, before any line was added, against the
+ * fixture below (`org_a` owns a1/a2, `org_b` owns b1/b2, p1 is a NULL-org
+ * platform row):
+ *
+ * ```
+ * find {} tenantId=org_a -> 3 rows (a1, a2, p1)
+ * window {} tenantId=org_a -> 5 rows (a1, a2, b1, b2, p1) <- org_b's rows
+ * distinct 'name' tenantId=org_a -> [A1, A2, B1, B2, P1] <- org_b's values
+ * analyze {} tenantId=org_a -> select * from `os6792_account` <- no predicate
+ * find {} tenantId=org_a -> select * from `os6792_account`
+ * where (`organization_id` = ? or `organization_id`
+ * is null) order by `id` asc
+ * ```
+ *
+ * # Why the plan half asserts against a captured statement
+ *
+ * It cannot assert rows. So it asserts the tenant predicate against the
+ * statement a real `find()` **actually sent**, read off knex's `query` event,
+ * rather than against a predicate this file spells out itself. A test that
+ * rebuilds the expectation asserts only that knex works, and stays green on the
+ * day the two doors diverge again — which is the day this file exists for.
+ *
+ * # The negative cases are the fix's real boundary
+ *
+ * `applyTenantScope` is a no-op in two situations that must stay no-ops: no
+ * `tenantId` (the admin / seed / cross-org tooling path, kept working
+ * deliberately) and an object with no tenant field. Adding a scope call to a
+ * door is only correct if it inherits BOTH early-outs — a door that starts
+ * answering `[]` to an unscoped admin read is a different defect, not a fix. So
+ * each exposure case is stated beside the read it must not become.
+ *
+ * The NULL-org row is here for the same reason. `applyTenantScope` emits
+ * `(field = ? OR field IS NULL)` on purpose (#2734: strict equality hid every
+ * platform-seeded RBAC row from every tenant admin). A door "fixed" with a bare
+ * equality would pass a naive cross-tenant assertion and silently lose p1, so
+ * p1 is what distinguishes routing through the chokepoint from reimplementing
+ * a worse copy of it.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { SqlDriver } from './index.js';
+
+const TABLE = 'os6792_account';
+const UNSCOPED_TABLE = 'os6792_ledger';
+
+/** Records the SQL this driver really sent, so the plan door can be held to it. */
+class InspectableSqlDriver extends SqlDriver {
+ readonly statements: Array<{ sql: string; bindings: readonly unknown[] }> = [];
+
+ captureStatements(): void {
+ this.knex.on('query', (data: { sql?: string; bindings?: readonly unknown[] }) => {
+ if (typeof data?.sql === 'string') {
+ this.statements.push({ sql: data.sql, bindings: data.bindings ?? [] });
+ }
+ });
+ }
+
+ /** The one statement issued against `table` while `run` executed. */
+ async statementFor(
+ table: string,
+ run: () => Promise,
+ ): Promise<{ sql: string; bindings: readonly unknown[] }> {
+ const before = this.statements.length;
+ await run();
+ const issued = this.statements.slice(before).filter((s) => s.sql.includes(table));
+ expect(issued).toHaveLength(1);
+ return issued[0]!;
+ }
+}
+
+/** The window spec this file reads with — argument-less by the #4286 contract. */
+const WINDOW = {
+ windowFunctions: [
+ { function: 'ROW_NUMBER', alias: 'rn', orderBy: [{ field: 'balance', order: 'desc' }] },
+ ],
+} as const;
+
+const ids = (rows: any[]): string[] => rows.map((r) => r.id).sort();
+
+describe('driver-sql — every read door routes through applyTenantScope (#6792)', () => {
+ let driver: InspectableSqlDriver;
+
+ beforeAll(async () => {
+ driver = new InspectableSqlDriver({
+ client: 'better-sqlite3',
+ connection: { filename: ':memory:' },
+ useNullAsDefault: true,
+ } as any);
+ await driver.initObjects([
+ {
+ name: TABLE,
+ fields: {
+ organization_id: { type: 'string' },
+ name: { type: 'string' },
+ balance: { type: 'number' },
+ },
+ },
+ // No `organization_id`: `resolveTenantField` returns null, so the
+ // chokepoint is a no-op here by design.
+ {
+ name: UNSCOPED_TABLE,
+ fields: { name: { type: 'string' }, balance: { type: 'number' } },
+ },
+ ] as any);
+
+ await driver.create(TABLE, { id: 'a1', organization_id: 'org_a', name: 'A1', balance: 10 } as any);
+ await driver.create(TABLE, { id: 'a2', organization_id: 'org_a', name: 'A2', balance: 20 } as any);
+ await driver.create(TABLE, { id: 'b1', organization_id: 'org_b', name: 'B1', balance: 30 } as any);
+ await driver.create(TABLE, { id: 'b2', organization_id: 'org_b', name: 'B2', balance: 40 } as any);
+ // Platform row: belongs to no tenant, so it belongs to no OTHER tenant
+ // either and must stay visible (#2734).
+ await driver.create(TABLE, { id: 'p1', name: 'P1', balance: 50 } as any);
+
+ await driver.create(UNSCOPED_TABLE, { id: 'l1', name: 'L1', balance: 1 } as any);
+ await driver.create(UNSCOPED_TABLE, { id: 'l2', name: 'L2', balance: 2 } as any);
+
+ driver.captureStatements();
+ });
+
+ afterAll(async () => {
+ await driver.disconnect();
+ });
+
+ describe('findWithWindowFunctions — the ROW-returning door', () => {
+ it('does not return another tenant\'s rows', async () => {
+ const rows = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any, {
+ tenantId: 'org_a',
+ } as any);
+ expect(ids(rows)).toEqual(['a1', 'a2', 'p1']);
+ expect(ids(rows)).not.toContain('b1');
+ expect(ids(rows)).not.toContain('b2');
+ });
+
+ it('gives each tenant its own rows, and only its own', async () => {
+ const a = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any, { tenantId: 'org_a' } as any);
+ const b = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any, { tenantId: 'org_b' } as any);
+ expect(ids(a)).toEqual(['a1', 'a2', 'p1']);
+ expect(ids(b)).toEqual(['b1', 'b2', 'p1']);
+ });
+
+ it('agrees with `find()` on the same read — one driver, one answer', async () => {
+ const viaWindow = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any, { tenantId: 'org_a' } as any);
+ const viaFind = await driver.find(TABLE, {}, { tenantId: 'org_a' });
+ expect(ids(viaWindow)).toEqual(ids(viaFind));
+ });
+
+ it('keeps the NULL-org platform row visible — the chokepoint, not a bare equality (#2734)', async () => {
+ const rows = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any, { tenantId: 'org_a' } as any);
+ expect(ids(rows)).toContain('p1');
+ });
+
+ it('honours the ADR-0105 D2 union posture the same way the chokepoint does (#3623)', async () => {
+ const rows = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any, {
+ tenantId: 'org_a',
+ tenantIds: ['org_a', 'org_b'],
+ } as any);
+ expect(ids(rows)).toEqual(['a1', 'a2', 'b1', 'b2', 'p1']);
+
+ const narrow = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any, {
+ tenantId: 'org_a',
+ tenantIds: ['org_a'],
+ } as any);
+ expect(ids(narrow)).toEqual(['a1', 'a2', 'p1']);
+ });
+
+ it('still applies the caller\'s own `where` beside the tenant predicate', async () => {
+ const rows = await driver.findWithWindowFunctions(
+ TABLE,
+ { ...WINDOW, where: { balance: { $gte: 20 } } } as any,
+ { tenantId: 'org_a' } as any,
+ );
+ // `p1` (balance 50) is the NULL-org platform row and is legitimately
+ // visible to org_a — it passes the filter AND the chokepoint. b1/b2
+ // (30/40) pass the filter and must not survive the tenant predicate.
+ expect(ids(rows)).toEqual(['a2', 'p1']);
+ });
+
+ it('still computes the window function itself', async () => {
+ const rows = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any, { tenantId: 'org_a' } as any);
+ expect(rows.every((r) => r.rn !== undefined)).toBe(true);
+ });
+
+ // ── the two early-outs the fix must inherit, not override ────────────────
+
+ it('is UNSCOPED with no tenantId — the admin / seed / cross-org path stays open', async () => {
+ const rows = await driver.findWithWindowFunctions(TABLE, { ...WINDOW } as any);
+ expect(ids(rows)).toEqual(['a1', 'a2', 'b1', 'b2', 'p1']);
+ });
+
+ it('is unaffected on an object with no tenant field, tenantId or not', async () => {
+ const scoped = await driver.findWithWindowFunctions(UNSCOPED_TABLE, { ...WINDOW } as any, {
+ tenantId: 'org_a',
+ } as any);
+ expect(ids(scoped)).toEqual(['l1', 'l2']);
+ });
+ });
+
+ /**
+ * NOT one of the two doors #6792 names. Found by the gate this card ships,
+ * while checking what it would be red on — and the card's own enumeration
+ * says the opposite ("It is called at 13 sites — `findRows` …, `count`,
+ * `distinct`, the write paths"). `distinct` is not among them; the 13th read
+ * site is `aggregate`. Triage repeated the count without re-deriving which
+ * methods it covered, and both PM measurements inherited that.
+ *
+ * It returns column VALUES rather than whole rows, which lowers the volume
+ * and not the class: one call hands back every OTHER tenant's values for the
+ * named column. The door is documented with a runnable example
+ * (`content/docs/protocol/objectql/query-syntax.mdx:819`,
+ * `const industries = await driver.distinct('account', 'industry')`), so it
+ * is exposed the same way `findWithWindowFunctions` is.
+ *
+ * (Unrelated to the v17 note that "`aggregate()` / `distinct()` leaked" —
+ * that one is raw epoch storage reaching presentation, #3839/#3849.)
+ */
+ describe('distinct — the third read door, found by the gate rather than by the card', () => {
+ it('does not return another tenant\'s values', async () => {
+ const values = await driver.distinct(TABLE, 'name', undefined, { tenantId: 'org_a' } as any);
+ expect(values.sort()).toEqual(['A1', 'A2', 'P1']);
+ expect(values).not.toContain('B1');
+ expect(values).not.toContain('B2');
+ });
+
+ it('agrees with `find()` about which rows it may see', async () => {
+ const values = await driver.distinct(TABLE, 'name', undefined, { tenantId: 'org_b' } as any);
+ const rows = await driver.find(TABLE, {}, { tenantId: 'org_b' });
+ expect(values.sort()).toEqual(rows.map((r: any) => r.name).sort());
+ });
+
+ it('still narrows by the caller\'s own filter beside the tenant predicate', async () => {
+ const values = await driver.distinct(TABLE, 'name', { balance: { $gte: 20 } } as any, {
+ tenantId: 'org_a',
+ } as any);
+ // `P1` is the NULL-org platform row (balance 50) — see the window-door
+ // case above. `B1`/`B2` also pass the filter and must not survive.
+ expect(values.sort()).toEqual(['A2', 'P1']);
+ });
+
+ it('is UNSCOPED with no tenantId — the admin / seed path stays open', async () => {
+ const values = await driver.distinct(TABLE, 'name');
+ expect(values.sort()).toEqual(['A1', 'A2', 'B1', 'B2', 'P1']);
+ });
+
+ it('is unaffected on an object with no tenant field', async () => {
+ const values = await driver.distinct(UNSCOPED_TABLE, 'name', undefined, {
+ tenantId: 'org_a',
+ } as any);
+ expect(values.sort()).toEqual(['L1', 'L2']);
+ });
+ });
+
+ describe('analyzeQuery / explain — the PLAN door', () => {
+ it('carries the tenant predicate `find()` actually emitted', async () => {
+ const emitted = await driver.statementFor(TABLE, () =>
+ driver.find(TABLE, {}, { tenantId: 'org_a' }),
+ );
+ const analyzed = await driver.analyzeQuery(TABLE, {} as any, { tenantId: 'org_a' } as any);
+
+ // Read the predicate off the statement `find()` really sent rather than
+ // spelling it here, so a change to the chokepoint's SQL moves both sides.
+ expect(emitted.sql).toContain('organization_id');
+ expect(emitted.bindings).toContain('org_a');
+
+ expect(analyzed.sql).toContain('organization_id');
+ expect(analyzed.bindings).toContain('org_a');
+ });
+
+ it('emitted a statement with NO tenant predicate before this fix — the regression sentinel', async () => {
+ const analyzed = await driver.analyzeQuery(TABLE, {} as any, { tenantId: 'org_a' } as any);
+ expect(analyzed.sql.toLowerCase()).toContain('organization_id');
+ });
+
+ it('reaches the same statement through `explain()`, which forwards here', async () => {
+ const explained = await driver.explain(TABLE, {} as any, { tenantId: 'org_a' } as any);
+ expect(explained.sql).toContain('organization_id');
+ expect(explained.bindings).toContain('org_a');
+ });
+
+ it('carries the union predicate under the group posture', async () => {
+ const analyzed = await driver.analyzeQuery(TABLE, {} as any, {
+ tenantId: 'org_a',
+ tenantIds: ['org_a', 'org_b'],
+ } as any);
+ expect(analyzed.sql).toContain('organization_id');
+ expect(analyzed.bindings).toContain('org_a');
+ expect(analyzed.bindings).toContain('org_b');
+ });
+
+ it('still returns a plan alongside the statement, unchanged by any of this', async () => {
+ const analyzed = await driver.analyzeQuery(TABLE, {} as any, { tenantId: 'org_a' } as any);
+ expect(analyzed.plan).toBeDefined();
+ expect(analyzed.error).toBeUndefined();
+ });
+
+ it('emits no tenant predicate when the caller gave no tenantId', async () => {
+ const analyzed = await driver.analyzeQuery(TABLE, {} as any);
+ expect(analyzed.sql).not.toContain('organization_id');
+ });
+
+ it('emits no tenant predicate for an object with no tenant field', async () => {
+ const analyzed = await driver.analyzeQuery(UNSCOPED_TABLE, {} as any, { tenantId: 'org_a' } as any);
+ expect(analyzed.sql).not.toContain('organization_id');
+ });
+ });
+});
diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts
index ab194a973c..4d5c6e7371 100644
--- a/packages/drivers/driver-sql/src/sql-driver.ts
+++ b/packages/drivers/driver-sql/src/sql-driver.ts
@@ -4252,6 +4252,18 @@ export class SqlDriver implements IDataDriver {
*/
async distinct(object: string, field: string, filters?: FilterCondition, options?: DriverOptions): Promise {
const builder = this.getBuilder(object, options);
+ // The third read door that skipped the chokepoint, and the one #6792 does
+ // NOT name — the card asserts the opposite ("called at 13 sites — …,
+ // `count`, `distinct`, …"). It is not among them; the 13th read site is
+ // `aggregate()`. Found by the gate this change ships, not by the card.
+ //
+ // VALUES rather than rows, which lowers the volume and not the class:
+ // measured on `main` at `6595262`, `distinct(account, 'name', undefined,
+ // { tenantId: 'org_a' })` returned `[A1, A2, B1, B2, P1]` — every other
+ // tenant's values for the named column. Documented with a runnable example
+ // (`content/docs/protocol/objectql/query-syntax.mdx`), so it is exposed the
+ // same way the window door is.
+ this.applyTenantScope(builder, object, options);
if (filters) {
this.applyFilters(builder, filters);
@@ -4286,6 +4298,20 @@ export class SqlDriver implements IDataDriver {
*/
async findWithWindowFunctions(object: string, query: SqlWindowFunctionQuery, options?: DriverOptions): Promise {
const builder = this.getBuilder(object, options);
+ // ROWS, so this is the read-side wall itself — not a consistency tidy-up
+ // (#6792). This door returned every tenant's rows to a caller that passed
+ // `options.tenantId`, because it built through `getBuilder` and then simply
+ // never reached the chokepoint all thirteen other doors route through.
+ // Measured on `main` at `6595262` with two tenants seeded: `tenantId:
+ // 'org_a'` returned `[a1, a2, b1, b2, p1]` here and `[a1, a2, p1]` through
+ // `find()` — org_b's rows, handed to org_a, at the driver layer.
+ //
+ // Placed BESIDE `getBuilder` and above the caller's `where`, which is the
+ // position `findRows()` uses: the predicate has to be on the builder before
+ // anything reads it, and `applyTenantScope` is what owns the NULL-org
+ // platform-row and ADR-0105 D2 union semantics. Re-deriving either here
+ // would be a second, worse copy of the wall.
+ this.applyTenantScope(builder, object, options);
builder.select('*');
@@ -4335,6 +4361,19 @@ export class SqlDriver implements IDataDriver {
*/
async analyzeQuery(object: string, query: DriverQuery, options?: DriverOptions): Promise {
const builder = this.getBuilder(object, options);
+ // A PLAN, not rows — so this is a smaller fix than the one above, and it is
+ // made on its own merits rather than riding in on that one (#6792). It is
+ // the SAME defect #6577 fixed on this method one builder line lower: a plan
+ // is only worth reading if it explains the statement `find()` would
+ // actually run, and a missing tenant predicate is not a cosmetic
+ // difference — it changes selectivity and therefore which index the planner
+ // picks, so the EXPLAIN answers for a query nobody will execute.
+ // Measured on `main` at `6595262`, `tenantId: 'org_a'`:
+ // analyze -> select * from `os6792_account`
+ // find -> select * from `os6792_account`
+ // where (`organization_id` = ? or `organization_id` is null)
+ // order by `id` asc
+ this.applyTenantScope(builder, object, options);
if (query.fields) {
builder.select(query.fields);
@@ -6677,8 +6716,32 @@ export class SqlDriver implements IDataDriver {
*
* Without a tenantId the call is treated as an unscoped/admin path —
* keeps legacy callers, seed scripts, and cross-org tooling working.
- * This is the single chokepoint for read-side tenant isolation in the
- * SQL driver; every CRUD method routes through it.
+ *
+ * This is the single chokepoint for read-side tenant isolation in the SQL
+ * driver, and every read door routes through it — `findRows()` (what
+ * `find()`/`findOne()` use), `count()`, `aggregate()`, `distinct()`,
+ * `findWithWindowFunctions()`, `analyzeQuery()`/`explain()`, and the
+ * update/delete predicates and their readbacks. Write-side tenancy is a
+ * different mechanism and deliberately not this one: inserts stamp the
+ * column via {@link injectTenantOnInsert}, so the three `insert` builders
+ * (create / upsert / bulkCreate) reach `getBuilder` without coming here.
+ *
+ * ⚠️ That sentence used to be written as a claim — "every CRUD method routes
+ * through it" — and it was FALSE for as long as it had existed (#6792).
+ * Three doors built through `getBuilder` and never arrived:
+ * `findWithWindowFunctions` (ROWS — a caller passing `tenantId` got every
+ * tenant's rows), `analyzeQuery`/`explain` (a PLAN for a statement `find()`
+ * would not run), and `distinct` (every tenant's values for one column). The
+ * first two were filed; the third was found only because the invariant was
+ * finally MEASURED. Nothing had ever checked it, which is exactly how a
+ * docstring becomes the last place a wrong fact survives.
+ *
+ * So it is no longer a claim. `scripts/check-tenant-chokepoint.mjs`
+ * (`pnpm check:tenant-chokepoint`, wired into `.github/workflows/lint.yml`)
+ * re-derives it from the AST on every run: a method that builds through
+ * `getBuilder(object, options)` and lets that builder escape as a read must
+ * call this, or carry a written exemption. Edit this list and the gate will
+ * disagree with you — that is the point.
*/
protected applyTenantScope(
builder: Knex.QueryBuilder,
diff --git a/scripts/check-tenant-chokepoint.mjs b/scripts/check-tenant-chokepoint.mjs
new file mode 100644
index 0000000000..03aafa1b32
--- /dev/null
+++ b/scripts/check-tenant-chokepoint.mjs
@@ -0,0 +1,561 @@
+#!/usr/bin/env node
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Read-side tenant chokepoint guard (#6792, from #3724 / #6577).
+ *
+ * ## What it guards
+ *
+ * `SqlDriver.applyTenantScope()` is the single chokepoint for read-side tenant
+ * isolation in the SQL driver: it owns the `tenantId` early-out, the
+ * "object has no tenant field" early-out, the NULL-org platform-row rule
+ * (#2734) and the ADR-0105 D2 union posture (#3623). A read door that builds
+ * its own query and never arrives there is not "inconsistent" — it returns
+ * other tenants' data to a caller that asked to be scoped.
+ *
+ * So: **a method that builds through `this.getBuilder(object, options)` and
+ * lets that builder escape as a READ must call `this.applyTenantScope()` on
+ * it.** Write builders are exempt and the exemption is structural, not a
+ * name list — insert-side tenancy is a different mechanism
+ * (`injectTenantOnInsert` stamps the column), so a builder whose only escape
+ * is `.insert(...)` is correct without this call.
+ *
+ * ## Why the gate exists, and what it already caught
+ *
+ * The invariant was written down and never measured. `applyTenantScope`'s own
+ * docstring claimed "every CRUD method routes through it" — false, for as long
+ * as it had existed. Three doors built through `getBuilder` and never arrived:
+ *
+ * - `findWithWindowFunctions()` — the documented #4286 window door. Returns
+ * ROWS. Measured on `main` at `6595262` with two tenants seeded,
+ * `tenantId: 'org_a'` returned `[a1, a2, b1, b2, p1]` here against
+ * `[a1, a2, p1]` through `find()`. Cross-tenant read exposure.
+ * - `analyzeQuery()` / `explain()` — returns a PLAN. Compiled
+ * `select * from account` where `find()` sent the `organization_id`
+ * predicate: an EXPLAIN for a statement nobody will run.
+ * - `distinct()` — returns one column's VALUES for every tenant. **This one
+ * was in no card.** #6792 asserts the opposite — that the 13 call sites
+ * include `distinct` — and both the triage comment and two rounds of PM
+ * measurement inherited that sentence without re-deriving it. The 13th
+ * read site is `aggregate()`. It was found by running this gate.
+ *
+ * That is the case for the gate in one line: two of the three doors were found
+ * by a human reading the file, and the third was found by measuring. Nothing
+ * obliged the next one to route through the chokepoint, which is exactly how
+ * all three got out.
+ *
+ * ## Why the criterion is the BUILDER, not the signature
+ *
+ * #6792 sketches "every method taking `(object, …, options)` and returning
+ * rows". That criterion is the intuitive one and it is measurably too narrow —
+ * it misses two of the three real doors:
+ *
+ * - `distinct(object, field, filters?, options?)` takes no `query` at all.
+ * - `analyzeQuery(): Promise` does not return rows; it returns a plan,
+ * and its defect is that the plan describes an unscoped statement.
+ *
+ * Deciding "returns rows" from a return type needs dataflow the AST does not
+ * carry, and `Promise` is not a signal. The structural fact that actually
+ * defines a door is that it BUILDS one — `getBuilder` is the single constructor
+ * of every query this driver sends. So that is what is keyed on, and the check
+ * is sound rather than heuristic: every builder is classified, and one that
+ * cannot be classified is an error, never a default (#4690's family).
+ *
+ * ## Scope
+ *
+ * The `SqlDriver` family only — `driver-sql` plus the two subclasses that
+ * inherit this chokepoint (`driver-sqlite-wasm`, `driver-turso`). Scanning the
+ * subclasses is what stops a new door being added one layer down.
+ *
+ * `driver-memory` and `driver-mongodb` are outside it, and NOT because #5499
+ * froze them: they do not use this mechanism at all. Neither file contains
+ * `getBuilder` or `applyTenantScope` (mongodb enforces its own wall in
+ * `mongodb-tenancy-guard.ts`, a different mechanism with a different shape).
+ * There is therefore no verdict for this gate to force on a frozen driver and
+ * no DEBT row to record — a real absence, stated rather than left to inference.
+ *
+ * ## What is NOT covered, named rather than implied
+ *
+ * - A builder passed to a HELPER that applies the scope on the caller's
+ * behalf. The gate looks for `applyTenantScope(, …)` within the
+ * method; a helper indirection would read as unscoped and go red. That is
+ * the safe direction (it demands the call be visible where the door is),
+ * and if a helper is ever wanted the answer is an EXEMPT entry with a
+ * reason, not a looser rule.
+ * - Raw `this.knex(...)` construction that bypasses `getBuilder` entirely.
+ * Out of reach of this criterion by construction. Nothing in the family
+ * does it today on a read path; if that changes, this gate cannot see it,
+ * and the honest place to record that limit is here.
+ * - **WHERE the call sits.** This is the important one, and it is measured
+ * rather than assumed. The gate asserts the call EXISTS on that binding,
+ * never its position — so a call moved below `builder.toSQL()`, or below
+ * `await builder`, satisfies this gate while isolating nothing. Measured
+ * during #6792's reverse verification: with the scope call relocated after
+ * the statement was snapshotted and after the rows were fetched, this gate
+ * reported clean (19/19 bindings) while nine assertions in
+ * `sql-driver-tenant-scope-read-doors.test.ts` went red.
+ *
+ * That is the division of labour, not a defect to fix here: making the
+ * gate position-aware would have it re-implement knex's evaluation order
+ * from the AST, and be wrong in a new way. **The gate proves the call is
+ * there; only the fixture proves it works.** Neither is redundant, and a
+ * change that keeps this green must still keep that file green.
+ *
+ * node scripts/check-tenant-chokepoint.mjs [--self-test]
+ *
+ * Exit codes: 0 clean · 1 an unscoped read door · 2 the scan could not run
+ * (no class found, a builder it cannot classify, or a DISCOVERED floor missed).
+ */
+
+import { readFileSync, existsSync } from 'node:fs';
+import { join, relative } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import ts from 'typescript';
+
+const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..');
+
+/** The `SqlDriver` family — every class that inherits this chokepoint. */
+const SCAN_FILES = [
+ join('packages', 'drivers', 'driver-sql', 'src', 'sql-driver.ts'),
+ join('packages', 'drivers', 'driver-sqlite-wasm', 'src', 'sqlite-wasm-driver.ts'),
+ join('packages', 'drivers', 'driver-turso', 'src', 'turso-driver.ts'),
+];
+
+/**
+ * The floor a real scan must clear. A scan that silently stops matching reports
+ * "clean" while reading nothing (#4690), and this gate's whole value is that it
+ * is the only thing measuring the invariant. Set below today's count so
+ * ordinary edits do not trip it, and high enough that a broken matcher does.
+ */
+const DISCOVERED_FLOOR = 15;
+
+/**
+ * `'::::'` -> reason a reader can check.
+ *
+ * Empty by design. #6792 closed the last three unscoped read doors, so this
+ * starts from zero rather than from a ratchet. A new entry needs a reason,
+ * not a name — and "the layer above catches it" is not one: the chokepoint's
+ * own contract is what this asserts.
+ */
+const EXEMPT = Object.create(null);
+
+const BUILDER_FACTORY = 'getBuilder';
+const SCOPE_CALL = 'applyTenantScope';
+
+/** `this.(...)` — the only receiver these two ever have. */
+function isThisCall(node, name) {
+ return (
+ ts.isCallExpression(node) &&
+ ts.isPropertyAccessExpression(node.expression) &&
+ node.expression.expression.kind === ts.SyntaxKind.ThisKeyword &&
+ node.expression.name.text === name
+ );
+}
+
+/**
+ * Does this initializer build through `this.getBuilder(...)`? Unwraps the
+ * chained forms the driver really uses — `this.getBuilder(o, opts).where(...)`,
+ * `.whereIn(...)`, `.withSchema(...)` — so a chain is still recognised as one
+ * builder rather than passed over.
+ */
+function buildsThroughGetBuilder(node) {
+ let cur = node;
+ while (cur) {
+ if (isThisCall(cur, BUILDER_FACTORY)) return true;
+ if (ts.isCallExpression(cur)) { cur = cur.expression; continue; }
+ if (ts.isPropertyAccessExpression(cur)) { cur = cur.expression; continue; }
+ return false;
+ }
+ return false;
+}
+
+/** Every identifier-rooted `.` use inside `body`. */
+function memberUses(body, name) {
+ const members = new Set();
+ const walk = (node) => {
+ if (
+ ts.isPropertyAccessExpression(node) &&
+ ts.isIdentifier(node.expression) &&
+ node.expression.text === name
+ ) {
+ members.add(node.name.text);
+ }
+ ts.forEachChild(node, walk);
+ };
+ walk(body);
+ return members;
+}
+
+/** Is `this.applyTenantScope(, …)` called anywhere in `body`? */
+function isScoped(body, name) {
+ let found = false;
+ const walk = (node) => {
+ if (found) return;
+ if (isThisCall(node, SCOPE_CALL)) {
+ const first = node.arguments[0];
+ if (first && ts.isIdentifier(first) && first.text === name) {
+ found = true;
+ return;
+ }
+ }
+ ts.forEachChild(node, walk);
+ };
+ walk(body);
+ return found;
+}
+
+/**
+ * Classify every `getBuilder` binding in one source file.
+ *
+ * Exported so `--self-test` can drive it over synthetic sources in BOTH
+ * directions without needing the repo in any particular state — the half a
+ * green run over a clean tree cannot exercise at all.
+ */
+export function analyzeSource(fileName, text) {
+ const sf = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true);
+ const builders = [];
+ const unclassifiable = [];
+
+ const visitMethod = (methodName, body) => {
+ if (!body) return;
+
+ const walk = (node) => {
+ if (ts.isVariableDeclaration(node) && node.initializer && buildsThroughGetBuilder(node.initializer)) {
+ if (!ts.isIdentifier(node.name)) {
+ // A destructured builder. Nothing does this today and the binding
+ // cannot be tracked by name — refuse rather than skip.
+ unclassifiable.push({
+ method: methodName,
+ line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1,
+ why: 'a getBuilder() result bound by destructuring — cannot track the binding',
+ });
+ return;
+ }
+ const name = node.name.text;
+ const members = memberUses(body, name);
+ builders.push({
+ method: methodName,
+ binding: name,
+ line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1,
+ scoped: isScoped(body, name),
+ // Insert-side tenancy is injectTenantOnInsert's job, structurally.
+ write: members.has('insert'),
+ });
+ return;
+ }
+
+ // A getBuilder() call that is never bound to a name — an inline read this
+ // criterion cannot follow. Refuse; do not pass over.
+ if (isThisCall(node, BUILDER_FACTORY)) {
+ let p = node.parent;
+ while (p && (ts.isCallExpression(p) || ts.isPropertyAccessExpression(p))) p = p.parent;
+ if (p && !ts.isVariableDeclaration(p)) {
+ unclassifiable.push({
+ method: methodName,
+ line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1,
+ why: 'a getBuilder() result used inline, never bound — cannot tell a read from a write',
+ });
+ }
+ }
+
+ ts.forEachChild(node, walk);
+ };
+
+ walk(body);
+ };
+
+ const walkTop = (node) => {
+ if (ts.isClassDeclaration(node)) {
+ for (const member of node.members) {
+ if (
+ (ts.isMethodDeclaration(member) || ts.isPropertyDeclaration(member)) &&
+ member.name &&
+ ts.isIdentifier(member.name)
+ ) {
+ const body = ts.isMethodDeclaration(member) ? member.body : member.initializer;
+ visitMethod(member.name.text, body);
+ }
+ }
+ }
+ ts.forEachChild(node, walkTop);
+ };
+ walkTop(sf);
+
+ return { builders, unclassifiable };
+}
+
+/** The gate's verdict as a pure function of a classification. Self-testable. */
+export function violationsOf(file, { builders, unclassifiable }, exempt = EXEMPT) {
+ const problems = [];
+ for (const u of unclassifiable) {
+ problems.push({
+ fatal: true,
+ text: `${file}:${u.line} ${u.method}() — ${u.why}`,
+ });
+ }
+ for (const b of builders) {
+ if (b.scoped || b.write) continue;
+ if (exempt[`${file}::${b.method}::${b.binding}`]) continue;
+ problems.push({
+ fatal: false,
+ text:
+ `${file}:${b.line} ${b.method}() builds \`${b.binding}\` through ${BUILDER_FACTORY}() ` +
+ `and never calls this.${SCOPE_CALL}(${b.binding}, …)`,
+ });
+ }
+ return problems;
+}
+
+// ---------------------------------------------------------------------------
+// --self-test
+//
+// Every shape is proved to REPORT and its canonical counterpart proved to stay
+// SILENT. A gate that has only ever been green cannot be told apart from a gate
+// that matches nothing.
+
+const wrap = (body) => `class SqlDriver {\n${body}\n}`;
+
+function selfTest() {
+ const failures = [];
+ const assert = (cond, msg) => { if (!cond) failures.push(msg); };
+ const run = (src) => {
+ const a = analyzeSource('t.ts', wrap(src));
+ return { ...a, problems: violationsOf('t.ts', a) };
+ };
+
+ const reports = [
+ [
+ 'a new row-returning door — the #6792 shape itself',
+ `async findWithWindowFunctions(object, query, options) {
+ const builder = this.getBuilder(object, options);
+ builder.select('*');
+ return await builder;
+ }`,
+ ],
+ [
+ 'a plan door — returns no rows, still a read',
+ `async analyzeQuery(object, query, options) {
+ const builder = this.getBuilder(object, options);
+ const sql = builder.toSQL();
+ return { sql: sql.sql };
+ }`,
+ ],
+ [
+ 'a values door with no `query` parameter at all — what the signature criterion misses',
+ `async distinct(object, field, filters, options) {
+ const builder = this.getBuilder(object, options);
+ builder.distinct(field);
+ return await builder;
+ }`,
+ ],
+ [
+ 'a chained builder',
+ `async readOne(object, id, options) {
+ const builder = this.getBuilder(object, options).where('id', id);
+ return await builder;
+ }`,
+ ],
+ [
+ 'scoping the WRONG binding does not count as scoping this one',
+ `async two(object, options) {
+ const a = this.getBuilder(object, options);
+ const b = this.getBuilder(object, options);
+ this.applyTenantScope(a, object, options);
+ return [await a, await b];
+ }`,
+ ],
+ [
+ 'an update builder is a read predicate too — it must be scoped',
+ `async update(object, id, patch, options) {
+ const builder = this.getBuilder(object, options).where('id', id);
+ return await builder.update(patch);
+ }`,
+ ],
+ ];
+ for (const [name, src] of reports) {
+ const { problems } = run(src);
+ assert(problems.length >= 1, `expected a report for: ${name}`);
+ }
+
+ const silent = [
+ [
+ 'the canonical door — scoped beside getBuilder',
+ `async findRows(object, query, options) {
+ const b = this.getBuilder(object, options);
+ this.applyTenantScope(b, object, options);
+ return await b;
+ }`,
+ ],
+ [
+ 'scoped inside a nested closure, which is how findRows really spells it',
+ `async findRows(object, query, options) {
+ const buildBase = () => {
+ const b = this.getBuilder(object, options);
+ this.applyTenantScope(b, object, options);
+ return b;
+ };
+ return await buildBase();
+ }`,
+ ],
+ [
+ 'an INSERT builder — write-side tenancy is injectTenantOnInsert',
+ `async create(object, row, options) {
+ this.injectTenantOnInsert(object, row, options);
+ const builder = this.getBuilder(object, options);
+ return await builder.insert(row).returning('*');
+ }`,
+ ],
+ [
+ 'an upsert insert builder, conflict-merged',
+ `async upsert(object, row, options) {
+ const builder = this.getBuilder(object, options);
+ const insertion = builder.insert(row).onConflict(['id']);
+ await insertion.merge();
+ }`,
+ ],
+ [
+ 'a method that never builds at all',
+ `async disconnect() { await this.knex.destroy(); }`,
+ ],
+ [
+ 'scope applied after other builder calls — position is not the assertion',
+ `async count(object, query, options) {
+ const builder = this.getBuilder(object, options);
+ builder.count('* as c');
+ this.applyTenantScope(builder, object, options);
+ return await builder;
+ }`,
+ ],
+ ];
+ for (const [name, src] of silent) {
+ const { problems } = run(src);
+ assert(problems.length === 0, `expected NO report for: ${name} (got ${problems.map((p) => p.text).join('; ')})`);
+ }
+
+ // A builder the criterion cannot classify must ABORT, never pass.
+ {
+ const { problems } = run(
+ `async sneaky(object, options) { return await this.getBuilder(object, options).select('*'); }`,
+ );
+ assert(problems.some((p) => p.fatal), 'an unbound inline getBuilder() must be reported as fatal');
+ }
+
+ // The exemption channel is real, and it is keyed to one binding.
+ {
+ const src = `async door(object, options) {
+ const builder = this.getBuilder(object, options);
+ return await builder;
+ }`;
+ const a = analyzeSource('t.ts', wrap(src));
+ assert(violationsOf('t.ts', a).length === 1, 'the exemption fixture must report without an entry');
+ assert(
+ violationsOf('t.ts', a, { 't.ts::door::builder': 'because' }).length === 0,
+ 'an EXEMPT entry must silence exactly its own binding',
+ );
+ assert(
+ violationsOf('t.ts', a, { 't.ts::door::other': 'because' }).length === 1,
+ 'an EXEMPT entry for a different binding must NOT silence this one',
+ );
+ }
+
+ // The DISCOVERED floor must be able to fail. A matcher that stops matching
+ // is the failure mode this whole gate is protecting against.
+ {
+ const { builders } = run(`async nothing() { return 1; }`);
+ assert(builders.length === 0, 'the empty fixture must discover no builders');
+ }
+
+ if (failures.length > 0) {
+ console.error(`✗ check:tenant-chokepoint self-test (${failures.length} failure(s)):\n`);
+ for (const f of failures) console.error(` • ${f}`);
+ process.exit(1);
+ }
+ console.log(
+ `✓ check:tenant-chokepoint self-test: ${reports.length} reporting shape(s), ` +
+ `${silent.length} silent counterpart(s), fatal/exemption/floor channels proved in both directions.`,
+ );
+}
+
+// ---------------------------------------------------------------------------
+// main
+
+function main() {
+ const problems = [];
+ let discovered = 0;
+ let scannedFiles = 0;
+
+ for (const rel of SCAN_FILES) {
+ const abs = join(ROOT, rel);
+ if (!existsSync(abs)) {
+ console.error(
+ `check:tenant-chokepoint: ${rel} does not exist.\n` +
+ 'The scan set is stale — a driver was renamed or moved. Refusing to report clean\n' +
+ 'over a file set that no longer describes the SqlDriver family.',
+ );
+ process.exit(2);
+ }
+ scannedFiles += 1;
+ const analysis = analyzeSource(rel, readFileSync(abs, 'utf8'));
+ discovered += analysis.builders.length;
+ problems.push(...violationsOf(relative(ROOT, abs).replace(/\\/g, '/'), analysis));
+ }
+
+ if (discovered < DISCOVERED_FLOOR) {
+ console.error(
+ `check:tenant-chokepoint: discovered only ${discovered} ${BUILDER_FACTORY}() binding(s) across ` +
+ `${scannedFiles} file(s), below the floor of ${DISCOVERED_FLOOR}.\n` +
+ 'A scan that stops matching reports "clean" while reading nothing (#4690). Either the\n' +
+ 'driver was refactored away from getBuilder() — in which case this gate needs rewriting\n' +
+ 'against the new constructor — or the matcher is broken. Both are errors, not passes.',
+ );
+ process.exit(2);
+ }
+
+ const fatal = problems.filter((p) => p.fatal);
+ if (fatal.length > 0) {
+ console.error(`✗ check:tenant-chokepoint: ${fatal.length} builder(s) this gate cannot classify\n`);
+ for (const p of fatal) console.error(` • ${p.text}`);
+ console.error(
+ '\nA builder it cannot classify is an error, never a default. Bind the builder to a\n' +
+ 'local and apply the scope on it, or widen the criterion in\n' +
+ 'scripts/check-tenant-chokepoint.mjs deliberately.',
+ );
+ process.exit(2);
+ }
+
+ if (problems.length > 0) {
+ console.error(`✗ check:tenant-chokepoint: ${problems.length} unscoped read door(s)\n`);
+ for (const p of problems) console.error(` • ${p.text}`);
+ console.error(`
+\`applyTenantScope\` is the single chokepoint for read-side tenant isolation: it owns
+the tenantId early-out, the no-tenant-field early-out, the NULL-org platform-row rule
+(#2734) and the ADR-0105 D2 union posture (#3623). A read door that skips it returns
+OTHER TENANTS' data to a caller that asked to be scoped — measured, not theoretical
+(#6792: three doors, one of them documented with a runnable example).
+
+Add the call beside the getBuilder() line, as every other door does:
+
+ const builder = this.getBuilder(object, options);
+ this.applyTenantScope(builder, object, options);
+
+Do not re-derive the predicate locally — a bare equality silently drops NULL-org
+platform rows (#2734) and collapses the group posture to active-org reach (#3623).
+
+If a door genuinely must build unscoped, add it to EXEMPT in
+scripts/check-tenant-chokepoint.mjs with a reason a reader can check. "The layer
+above catches it" is not one: this asserts the chokepoint's own contract.`);
+ process.exit(1);
+ }
+
+ console.log(
+ `✓ check:tenant-chokepoint: ${discovered} ${BUILDER_FACTORY}() binding(s) across ${scannedFiles} ` +
+ 'file(s); every read builder routes through applyTenantScope(), every unscoped one is an insert.',
+ );
+}
+
+if (process.argv.includes('--self-test')) {
+ selfTest();
+ process.exit(0);
+}
+
+main();