You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 7c2eab9
Browse filesBrowse the repository at this point in the historyBrowse 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>
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>`).
0 commit comments