Skip to content

Commit 0091d84

Browse files
feat(scripts): check-tenant-chokepoint refuses an IS NULL arm on a tenant column, and shipped texts stop describing the retired arm (18.x step 7, part 7z) (#24880)
Part of #24583 Clause-②: no (the gate refusal narrows what a contributor may write in three driver files; every other change is text. No accept set, served body, error code or runtime behaviour moves) This is step 7's closing part (7z). It leaves #24583 open for the parts still in flight: 7a-2, 7b-0 and 7b-1+7b-2. ## What changed ### 1. The D8 gate: `check-tenant-chokepoint` now refuses an `IS NULL` arm on a tenant column ADR-0131 D8 says "`check-tenant-chokepoint` refuses an `IS NULL` arm on a tenant column." Before this PR the gate did not do that. Its header and its failure text also still taught the retired NULL-org platform-row arm as the rule. - **Texts restated.** The header and the unscoped-door failure text now name the one predicate: `tenant_column = :tenant`, or `IN (:tenants)` under the group posture. - **Refusal implemented.** It scans every class member and every module-level function in the three SqlDriver-family files. - A tenant column reference is one of three things: - a local bound to `this.resolveTenantField(...)`, `computeTenantField` or `computeAndRecordTenantField`; - anything named `tenantField` (a local, a parameter or a member); - the literal `'organization_id'`. - Refused spellings: - `whereNull` / `orWhereNull` / `andWhereNull` on such a reference; - a `where` / `orWhere` / `andWhere` with a `null` comparand, two-argument or three-argument; - the object form with a null value; - a raw fragment (`whereRaw`, `orWhereRaw`, `andWhereRaw`, `havingRaw`, `raw`) whose literal text says `IS NULL` and that names the column by substitution, by binding or literally. - `IS NOT NULL` is never refused. - Tenant bindings are scoped to their outermost container. So the filter compiler's own `whereNull(field)` on a plain column stays silent. - **Exemptions: none.** `NULL_ARM_EXEMPT` is empty, and `main` needs no entry. - The `sys_audit_log.tenant_id` attribution partition that `LifecycleService` reads is a D7 field, not a D8 tenant column. It is also outside the scanned files. The header names it. - **Floor.** `TENANT_PREDICATE_FLOOR = 3` (4 on `main`). A matcher that stops recognising the tenant column exits 2 instead of reporting clean. - **Self-test.** A second battery, `D8 null-arm rule`, is pinned at 22 cases: 11 refused spellings, 6 silent counterparts, 2 floor-counter cases and 3 exemption-channel cases. The roster floor goes from 1 to 2. ### 2. Text drift Comments in 15 runtime files, plus one runtime warning, said in the present tense that the driver or engine still reads organization-less rows. Each is now correct. Where code was written against the old arm, the text says so. - spec `events.zod.ts`, `driver.zod.ts` - runtime `domains/packages.ts`. `sys_metadata` has no organization column (D6/D7). - plugin-security `organization-scope.ts`, `delegated-admin-gate.ts` - plugin-sharing `backfill-sys-record-share-organizations.ts` - plugin-webhooks `auto-enqueuer.ts` - objectql `platform-object-tenancy.ts`. This is a runtime `evidence` string. - service-automation `runtime-identity.ts`, `builtin/http-nodes.ts`. The latter is the runtime `[http]` warning. - service-messaging `http-outbox.ts`, `memory-http-outbox.ts`, `sql-http-outbox.ts` - service-storage `backfill-sys-file-organizations.ts`, `metadata-store.ts` ### 3. Docs - `deployment/cli.mdx` (the `os migrate duplicates` table) and `deployment/seed-tenancy-repair.mdx`: - The live condition is now described as the record of which counter minted the duplicates. It is no longer "about to mint more". Since 7b-3, the counter refuses an organization-less row on a tenant table wherever the organization object is registered. - The colliding rows "stay findable by this report". The old text said "visible". - `protocol/objectql/schema.mdx`: wherever the organization object is registered, a tenant table holds no organization-less row. On a single-organization deployment, rows are stamped with the Default Organization. - `unique/legacy-organization-composite`: the `os explain` text in `rule-explanations.ts` and the rule docblock in `data-model-rules.ts` now carry the "wherever the organization object is registered" hedge. ### 4. Spec TSDoc `SharingRuleRow` was described as a "Stored shape … 1-to-1 to sys_sharing_rule". It is now described as a stored row or a declared rule's projection, and `deploymentWide` is named as the one member with no column. Changeset: `.changeset/24583-7z-closing-d8-texts.md`, `patch` for the ten packages whose shipped text changed. No existing changeset is touched. ## Proof All runs below are on head `e5f47d90d` unless marked. That head is `main` 81120ca merged in with a merge commit. - **Gate self-test.** - `node scripts/check-tenant-chokepoint.mjs --self-test` exited 0: "6 reporting shape(s), 6 silent counterpart(s) … D8 null-arm rule: 11 refused spelling(s), 6 silent counterpart(s)." - **Ablation, on a scratch copy.** The copy is a git-initialised `git archive HEAD` of `scripts/` plus the three driver files. - `node scripts/ablation-replace.mjs` replaced `return builder.where(field, String(tenantId));` in `applyTenantScope` with a grouped callback that compiles `where(field, String(tenantId)).orWhereNull(field)`, the v17 arm. The anchor count went 1 to 0, the injection count was 1, and the blob changed from 206bca5 to f741fbd69db5. - Under that mutation the gate exited 1 with: "1 IS NULL arm(s) on a tenant column … sql-driver.ts:15664 applyTenantScope() — orWhereNull(tenant column)". - Restored: the blob equals HEAD (206bca5) and `git diff HEAD` is empty. The scratch copy was green (exit 0) before the mutation. - **Gate green on `main` plus this diff.** - `pnpm check:tenant-chokepoint` exited 0: "24 getBuilder() binding(s) across 3 file(s) … no IS NULL arm on a tenant column (ADR-0131 D8)." - Exemptions: none. - **Spec build and generated artifacts** (pre-merge head `cd794f6`). - `pnpm --filter @objectstack/spec build` exited 0. - `check:generated` exited 0: "All 14 generated artifacts are up to date". Nothing was regenerated. - **Spec suite** (pre-merge head): 703 files passed, 21360 tests passed. - **Lint suite** (pre-merge head): 150 files passed, 6988 tests passed. The first attempt failed on a missing `@objectstack/formula` dist, before its dependency closure was built. It was re-run green after `pnpm --filter '@objectstack/lint...' build`. - **Full build** on the merged head: `pnpm build --concurrency=2` passed 76/76 tasks. - **R1, a fresh boot** (`pnpm dev -- --fresh -p 38723`, merged head): `/api/v1/health` returned 200 and `POST /api/v1/auth/sign-in/email` returned 200. The boot log had zero `error` lines. The server was torn down by its PID. - **`node scripts/pm/dispatch-gates.mjs --commands`** derived 134 commands, the same list on the pre-merge and merged heads. - Each command was run with its exit code captured before any pipe. - 128 exited 0 on the first pass, on the pre-merge head `8c68d506`. - 6 were prerequisite refusals: `check-engine-split-ratio` (exit 2, shallow clone), `check-plugin-teardown-shape --self-test` (exit 3, shallow clone), and `check:skill-examples`, `check:dual-build-cjs-loads`, `check:i18n` and `check:lean-entry-closure` (exit 3, no build). - After `git fetch --shallow-since=2026-07-15` and the full build, all 6 re-ran on the merged head and exited 0. - `--ran` with `cmd :: exit N` records: "134 derived famil(ies) accounted for — 134 run, 0 NOT-MEASURED (a DERIVED zero …)". - **`node scripts/pm/check-governed-merges.mjs --branch claude/issue-24583-7z-closing`** exited 0: "0 of 23 path(s) hit the register … NOT governed". - `scripts/` is not a governed surface. The size is 741 changed lines, under 3000. - **Not measured locally** (left to CI): per-package `typecheck` (comment and string changes only), `test:repo`, and the CI-only jobs that `--commands` names. ## Acceptance notes Found along the way and left as they are. None is fixed here, because the claim's file surface is texts plus the gate. - `memory-http-outbox.ts` `redeliver()` still admits an organization-less row for every tenant: its predicate was written to mirror the v17 SQL term. The SQL driver no longer does this. The comment now says that the two implementations disagree; the code is unchanged. Reported to the seat. - `organizationOutOfWriteReach` (`service-storage/src/metadata-store.ts`) still answers "in reach" for an organization-less row, which D8 put out of reach. Under ADR-0131 a walled deployment holds no such row once certified. The comment says the branch predates D8. - `applySysRecordShareOrganizationBackfill` threads `tenantId`, so under D8 its update no longer reaches an organization-less row. No runtime path calls it; the 18.x ceremony's attribution fate stamps these rows now. The comment says so. - Test-file comments still mention the driver's "NULL-tolerant arm": `record-share-tenant-wall.test.ts` and `backfill-sys-record-share-organizations.test.ts`. They are not runtime files, so they were left. - `core/src/security/resolve-authz-context.ts` ~795 ("a null-org row is global") is an authorization reader. That is 7a-2's surface, so it is left to 7a-2. - `metadata-protocol/src/protocol.ts` still says `saveMetaItem` writes `organization_id = NULL` (~24069, ~29060), but `sys_metadata` has no organization column. This is a different drift class from the driver-reads-NULL one, and it is left. --- _Generated by [Claude Code](https://claude.ai/code/session_017ae4eMXWeERGrG6heTbwBX)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a8b44d4 commit 0091d84

24 files changed

Lines changed: 635 additions & 143 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/lint': patch
4+
'@objectstack/objectql': patch
5+
'@objectstack/runtime': patch
6+
'@objectstack/plugin-security': patch
7+
'@objectstack/plugin-sharing': patch
8+
'@objectstack/plugin-webhooks': patch
9+
'@objectstack/service-automation': patch
10+
'@objectstack/service-messaging': patch
11+
'@objectstack/service-storage': patch
12+
---
13+
14+
fix: no shipped text says the SQL driver reads organization-less rows, and the in-memory HTTP outbox no longer does
15+
16+
Clause-②: no (one fix and texts: the in-memory HTTP outbox's `redeliver()` now answers as `SqlHttpOutbox` already did; no accept set, served body or error code of a shipped composition moves)
17+
18+
ADR-0131 D8 retired the driver's `OR organization_id IS NULL` arm. Several shipped texts still described it as the live tenant predicate. They now state the current rule: the tenant term is `organization_id = :tenant`, or `IN (:tenants)` under the `group` posture, and nothing else. No tenant-scoped read reaches an organization-less row on a tenant table.
19+
20+
- **`@objectstack/service-messaging` — fix.** `MemoryHttpOutbox.redeliver()` no longer lets a tenant-scoped caller replay a row with no organization. It answers `RESOURCE_NOT_FOUND`, as `SqlHttpOutbox` does through the driver's tenant scope (ADR-0131 D8: no layer has a NULL arm). A caller with no tenant is still unscoped. No shipped composition constructs the in-memory outbox; the messaging plugin always builds `SqlHttpOutbox`. Code that embeds `MemoryHttpOutbox` directly sees the change.
21+
- **`@objectstack/service-automation`.** The `[http]` node's warning for a durable callout with no organization no longer says the `sys_http_delivery` row is "a global row every organization's redeliver door can reach". It now says no organization's redeliver door reaches that row.
22+
- **`@objectstack/objectql`.** The `sys_record_share` entry of the platform-object tenancy inventory carries an updated `evidence` text.
23+
- **`@objectstack/lint`.** `os explain unique/legacy-organization-composite` now says that a new row carries its organization wherever the organization object is registered. A composition that registers no organization object still writes rows whose organization column is NULL.
24+
- **`@objectstack/spec`.** The TSDoc of `SharingRuleRow` no longer calls it a 1-to-1 stored shape: `deploymentWide` has no `sys_sharing_rule` column. The TSDoc of `DriverOptions.tenantIds` and of the events `organizationId` member describe the predicate without a NULL arm.
25+
- **The other packages.** Code comments only.

‎content/docs/deployment/cli.mdx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1797,12 +1797,12 @@ this report reads. Half of what it can tell you does not survive that:
17971797
17981798
| Reported | Survives the repair? |
17991799
| :--- | :--- |
1800-
| **The duplicates themselves** — every value held across two partitions, with the id, organization and creation time of each holder | **Yes.** The repair deliberately refuses to adopt a row whose identifier is already taken in the destination partition, so those rows keep `organization_id = NULL` and stay visible |
1801-
| **The live condition** — an object still running a global counter beside an organization-scoped one, and therefore about to mint more duplicates | **No.** The repair merges the two counters and deletes the global one. Once that has happened, this line can never be produced again |
1800+
| **The duplicates themselves** — every value held across two partitions, with the id, organization and creation time of each holder | **Yes.** The repair deliberately refuses to adopt a row whose identifier is already taken in the destination partition, so those rows keep `organization_id = NULL` and stay findable by this report |
1801+
| **The live condition** — an object still holding a global counter beside an organization-scoped one: the record of which counter minted the duplicates above. Under 17.x it also forecast more; since the counter refuses an organization-less row on a tenant table wherever the organization object is registered, nothing mints from the global counter there | **No.** The repair merges the two counters and deletes the global one. Once that has happened, this line can never be produced again |
18021802
18031803
Running it afterwards is still worth doing — the inventory is what you act on,
1804-
and it is complete either way. What you cannot recover is the forward-looking
1805-
half.
1804+
and it is complete either way. What you cannot recover is the counter
1805+
evidence.
18061806
18071807
A deployment holding **more than one organization** is skipped by that repair
18081808
rather than guessed at: there is no derivable answer to which organization owns

‎content/docs/deployment/seed-tenancy-repair.mdx‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -302,10 +302,9 @@ Be precise about what is lost, because the two halves behave differently:
302302
| | Survives the repair? |
303303
| :--- | :--- |
304304
| The inventory of identifiers **already** minted twice | **Yes.** Colliding rows deliberately keep `organization_id = NULL`, so they remain in two partitions and remain findable. |
305-
| The **live condition** — an install still holding a `__global__` counter beside an organization-scoped one, i.e. *about to mint more* | **No.** The repair deletes that counter row. Once it has run, this condition can never be produced again. |
305+
| The **live condition** — an install still holding a `__global__` counter beside an organization-scoped one: the record of which counter minted the duplicates. Under 17.x it also forecast more; since the counter refuses an organization-less row on a tenant table wherever the organization object is registered, nothing mints from that counter there | **No.** The repair deletes that counter row. Once it has run, this condition can never be produced again. |
306306

307-
The live condition is the only forward-looking line in the report, and it is the
308-
perishable one. On a deployment you have not yet restarted since discovering the
307+
The live condition is the perishable line in the report. On a deployment you have not yet restarted since discovering the
309308
problem, run the report first.
310309

311310
`os migrate duplicates` writes no row: it boots read-only, declares itself out

‎content/docs/protocol/objectql/schema.mdx‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -606,9 +606,13 @@ stays valid indefinitely; `'organization'` is simply the preferred spelling in
606606
new code.
607607

608608
**The organization key part is NULL-safe.** It materializes as
609-
`COALESCE(organization_id, '__global__')`, so rows carrying no organization —
610-
platform rows, and *every* row on a single-organization deployment — form one
611-
platform bucket that is unique among itself. A plain `(organization_id, field)`
609+
`COALESCE(organization_id, '__global__')`, so rows carrying no organization
610+
form one bucket that is unique among itself. Wherever the organization object
611+
is registered, a tenant table holds no such row (ADR-0131: every write is
612+
stamped with its organization — on a single-organization deployment, the
613+
Default Organization); the bucket is for a composition that registers no
614+
organization object, and for residue an uncertified pre-18.x database still
615+
holds. A plain `(organization_id, field)`
612616
composite would enforce nothing on those rows, because SQL `UNIQUE` treats every
613617
`NULL` as distinct. The value stored in the column is still `NULL`; the sentinel
614618
exists only inside the index key, so `WHERE organization_id = '__global__'`

‎packages/lint/src/data-model-rules.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -594,7 +594,9 @@ export function lintUniqueDeclarations(objects: any[]): LocatedLintIssue[] {
594594
* UNIQUE is NULL-distinct, so on every row where the organization column is
595595
* NULL it enforces **nothing** (#5030, measured). A 17.x single-organization
596596
* deployment wrote *every* row that way; since ADR-0131 D9 a new row carries its
597-
* organization, so the gap now admits that residue. The `'organization'`
597+
* organization wherever the organization object is registered, so there the gap
598+
* now admits that residue (a composition that registers no organization object
599+
* still writes NULL-organization rows). The `'organization'`
598600
* respelling is what closes the hole: the driver makes the LISTED organization
599601
* column NULL-safe in place (`COALESCE(organization_id, '__global__')`), so the
600602
* NULL rows become one bucket that is unique among themselves.

‎packages/lint/src/rule-explanations.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2695,8 +2695,9 @@ const UNIQUE_LEGACY_ORGANIZATION_COMPOSITE_EXPLANATION: RuleExplanation = {
26952695
'predates the scope vocabulary (ADR-0120 S6). It reads as "unique per organization" but ' +
26962696
'materializes as a plain composite, and SQL UNIQUE is NULL-distinct: it enforces nothing on a ' +
26972697
'row whose organization column is NULL — every row a 17.x single-organization deployment ' +
2698-
'wrote. Since ADR-0131 D9 a new row carries its organization, so what the gap still admits is ' +
2699-
'that residue.',
2698+
'wrote. Since ADR-0131 D9 a new row carries its organization wherever the organization object ' +
2699+
'is registered, so what the gap still admits there is that residue; a composition that ' +
2700+
'registers no organization object still writes rows whose organization column is NULL.',
27002701
'Respelled `unique: \'organization\'`, the NULL rows become one bucket ' +
27012702
'(`COALESCE(organization_id, \'__global__\')`) that is unique among themselves, which is what ' +
27022703
'closes the hole. The respelling keeps `fields` exactly as they are: the driver makes the listed ' +

‎packages/objectql/src/tenancy/platform-object-tenancy.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -239,9 +239,9 @@ export const PLATFORM_OBJECT_TENANCY: Readonly<Record<string, PlatformObjectTena
239239
"carries the granting rule's organization, a direct grant the shared record's. The maintainer ordered " +
240240
'the rows written before it backfilled from the record they reference on 2026-09-02 ' +
241241
'(`backfill-sys-record-share-organizations.ts`). A grant table that cannot say which organization ' +
242-
'a grant belongs to is a defect, not a design: under a wall, a tenant-scoped read would AND ' +
243-
"plugin-security's strict `organization_id = :tenant` over the driver's NULL-tolerant arm and every " +
244-
'organization-less grant would silently disappear.',
242+
'a grant belongs to is a defect, not a design: under a wall, a tenant-scoped read compiles ' +
243+
'`organization_id = :tenant` at both layers, and every organization-less grant would silently ' +
244+
'disappear.',
245245
},
246246
};
247247

‎packages/plugins/plugin-security/src/delegated-admin-gate.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1296,9 +1296,9 @@ export class DelegatedAdminGate {
12961296
* `{ isSystem: true }`;
12971297
* 2. the candidates that come back are reduced to the caller's OWN row by
12981298
* {@link resolveOwnOrganizationRow}. That arm is load-bearing on its own:
1299-
* the driver's compatibility arm deliberately also returns
1300-
* organization-less rows, and a driver with no tenant scoping at all
1301-
* returns every organization's. Under a walled posture an
1299+
* a driver with no native tenant scoping returns every organization's
1300+
* rows, organization-less ones included. (The SQL driver's NULL arm,
1301+
* which also returned organization-less rows, is retired — ADR-0131 D8.) Under a walled posture an
13021302
* organization-less business unit is invalid state
13031303
* (`organization-scope.ts`), so neither class may answer a
13041304
* delegation boundary.

‎packages/plugins/plugin-security/src/organization-scope.ts‎

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717
*
1818
* Under a walled posture that row is invalid state. It was measured to be
1919
* unreadable by anyone: plugin-security's Layer 0 composes a STRICT
20-
* `organization_id = :tenant` and the middleware ANDs it over the driver's
21-
* `(organization_id = :tenant OR organization_id IS NULL)`, and the conjunction
22-
* of the two is the strict equality alone — so on a walled deployment every
20+
* `organization_id = :tenant` and the middleware ANDed it over the driver's
21+
* v17 predicate `(organization_id = :tenant OR organization_id IS NULL)`, and
22+
* the conjunction of the two is the strict equality alone — so on a walled deployment every
2323
* principal, at every rung, listed ZERO positions, permission sets and sharing
2424
* rules while the tables held rows.
2525
*
@@ -32,11 +32,14 @@
3232
*
3333
* An organization-less row is INVALID STATE under a walled posture — refuse or
3434
* warn loudly, never treat it as a platform-wide default. The older reading, in
35-
* which a NULL organization marked a platform row visible to every tenant,
36-
* survives only as DRIVER-LEVEL COMPATIBILITY BEHAVIOUR: `applyTenantScope`
37-
* still emits the `OR organization_id IS NULL` arm, and this module depends on
38-
* that arm being there — it is precisely how a per-organization pass can still
39-
* SEE a pre-fix organization-less row and therefore say something about it.
35+
* which a NULL organization marked a platform row visible to every tenant, is
36+
* retired at every layer: ADR-0131 D8 removed the driver's
37+
* `OR organization_id IS NULL` arm from `applyTenantScope`, and
38+
* `check-tenant-chokepoint` refuses it. A per-organization pass therefore no
39+
* longer SEES an organization-less row at all — the tenant predicate is
40+
* `organization_id = :tenant` and nothing else — and the 18.x upgrade ceremony
41+
* (ADR-0131 D10) attributes or retires such rows before a walled deployment
42+
* boots.
4043
*
4144
* ## What replaces a reap
4245
*
@@ -69,10 +72,11 @@
6972
* So the pass said so instead. Its guard (retired with the seeder) named the
7073
* rows and named the remedy, and — this is the load-bearing half — the pass
7174
* still CREATES the organization's own copy. The failure shape it exists to
72-
* prevent is the silent no-op: a tenant-threaded pass sees the pre-fix
73-
* organization-less row through the driver's compatibility arm, reads the name
74-
* as already represented, takes the update branch and creates nothing, leaving
75-
* the deployment exactly as broken as before while reporting success.
75+
* prevent is the silent no-op: a tenant-threaded pass that saw the pre-fix
76+
* organization-less row through the driver's v17 NULL arm (retired by ADR-0131
77+
* D8) read the name as already represented, took the update branch and created
78+
* nothing, leaving the deployment exactly as broken as before while reporting
79+
* success.
7680
* {@link resolveOwnOrganizationRow} is the one read that distinguishes "this
7781
* organization has its row" from "somebody's organization-less row is visible
7882
* here", and every seeder in this catalog routes through it.
@@ -111,13 +115,15 @@ export function rowOrganizationId(row: any): string | null {
111115
* Resolve THIS organization's own row for a name, out of what a tenant-scoped
112116
* read returned.
113117
*
114-
* A scoped read passes through `applyTenantScope`, whose compatibility arm
115-
* returns the caller's rows AND any organization-less ones. Those two are not
116-
* interchangeable and the seeders must never treat them as such:
118+
* A scoped read passes through `applyTenantScope`, which since ADR-0131 D8
119+
* returns the caller's rows only — the driver's NULL arm is retired. A row
120+
* set can still mix the two classes when it comes from a driver with no
121+
* native tenant scoping or from an unscoped read, and they are not
122+
* interchangeable; the seeders must never treat them as such:
117123
*
118124
* - a row stamped with `organizationId` is this organization's — update it;
119125
* - an organization-less row is a PRE-FIX residue that merely happens to be
120-
* visible here. Reading it as "already seeded" is the silent no-op this
126+
* in the row set. Reading it as "already seeded" is the silent no-op this
121127
* catalog exists to prevent, so it is reported separately and never returned
122128
* as the organization's own row.
123129
*

‎packages/plugins/plugin-sharing/src/backfill-sys-record-share-organizations.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,17 @@
1313
* grant the shared record's. It wrote nothing to existing rows, and on a WALLED
1414
* deployment that asymmetry is the cliff that commit pins:
1515
*
16-
* - the SQL driver's own tenant predicate is NULL-TOLERANT —
16+
* - the SQL driver's own tenant predicate was then NULL-TOLERANT —
1717
* `(organization_id = :tenantId OR organization_id IS NULL)` — so an
1818
* unstamped grant stayed readable by every bare-context reader;
1919
* - but Layer 0 (`plugin-security`'s `computeTenantLayer0Filter`) AND-composes
2020
* a STRICT `organization_id = <active org>` above it, and the conjunction is
2121
* the strict equality alone.
2222
*
23+
* (ADR-0131 D8 has since retired the driver's NULL arm: both layers now
24+
* compile the equality alone, so an unstamped grant is unreachable by every
25+
* tenant-scoped read.)
26+
*
2327
* ⇒ the day anything reads this table under a tenant context, every
2428
* organization-less grant becomes invisible — not refused, simply "this person
2529
* was never granted access". Forward-only stamping would split the table in
@@ -591,9 +595,12 @@ function finish(
591595
*
592596
* The derived organization is threaded as `tenantId` on the write context too,
593597
* beside the elevation: the shape #8844 prescribes for a system write and the
594-
* one the repaired writer uses. On this verb the driver's scope keeps the NULL
595-
* row in reach (`organization_id = ? OR IS NULL`, #2734), so the write lands
596-
* exactly on the row the plan named.
598+
* one the repaired writer uses. ⚠️ That shape predates ADR-0131 D8: it relied
599+
* on the driver's v17 scope `organization_id = ? OR IS NULL` keeping the NULL
600+
* row in reach. D8 retired that arm, so under the SQL driver a write threaded
601+
* with `tenantId` no longer reaches an organization-less row. No runtime path
602+
* calls this pass; the 18.x upgrade ceremony's attribution fate (ADR-0131
603+
* D10) is what stamps these rows now.
597604
*
598605
* A row whose write throws is RECORDED and the sweep continues: a driver
599606
* rejecting one row must not cost the other N-1 their repair, and a half-done

0 commit comments

Comments
 (0)