Skip to content
Merged
25 changes: 25 additions & 0 deletions .changeset/24583-7z-closing-d8-texts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@objectstack/spec': patch
'@objectstack/lint': patch
'@objectstack/objectql': patch
'@objectstack/runtime': patch
'@objectstack/plugin-security': patch
'@objectstack/plugin-sharing': patch
'@objectstack/plugin-webhooks': patch
'@objectstack/service-automation': patch
'@objectstack/service-messaging': patch
'@objectstack/service-storage': patch
---

fix: no shipped text says the SQL driver reads organization-less rows, and the in-memory HTTP outbox no longer does

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)

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.

- **`@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.
- **`@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.
- **`@objectstack/objectql`.** The `sys_record_share` entry of the platform-object tenancy inventory carries an updated `evidence` text.
- **`@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.
- **`@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.
- **The other packages.** Code comments only.
8 changes: 4 additions & 4 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1797,12 +1797,12 @@ this report reads. Half of what it can tell you does not survive that:

| Reported | Survives the repair? |
| :--- | :--- |
| **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 |
| **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 |
| **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 |
| **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 |

Running it afterwards is still worth doing — the inventory is what you act on,
and it is complete either way. What you cannot recover is the forward-looking
half.
and it is complete either way. What you cannot recover is the counter
evidence.

A deployment holding **more than one organization** is skipped by that repair
rather than guessed at: there is no derivable answer to which organization owns
Expand Down
5 changes: 2 additions & 3 deletions content/docs/deployment/seed-tenancy-repair.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -302,10 +302,9 @@ Be precise about what is lost, because the two halves behave differently:
| | Survives the repair? |
| :--- | :--- |
| The inventory of identifiers **already** minted twice | **Yes.** Colliding rows deliberately keep `organization_id = NULL`, so they remain in two partitions and remain findable. |
| 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. |
| 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. |

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

`os migrate duplicates` writes no row: it boots read-only, declares itself out
Expand Down
10 changes: 7 additions & 3 deletions content/docs/protocol/objectql/schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -606,9 +606,13 @@ stays valid indefinitely; `'organization'` is simply the preferred spelling in
new code.

**The organization key part is NULL-safe.** It materializes as
`COALESCE(organization_id, '__global__')`, so rows carrying no organization —
platform rows, and *every* row on a single-organization deployment — form one
platform bucket that is unique among itself. A plain `(organization_id, field)`
`COALESCE(organization_id, '__global__')`, so rows carrying no organization
form one bucket that is unique among itself. Wherever the organization object
is registered, a tenant table holds no such row (ADR-0131: every write is
stamped with its organization — on a single-organization deployment, the
Default Organization); the bucket is for a composition that registers no
organization object, and for residue an uncertified pre-18.x database still
holds. A plain `(organization_id, field)`
composite would enforce nothing on those rows, because SQL `UNIQUE` treats every
`NULL` as distinct. The value stored in the column is still `NULL`; the sentinel
exists only inside the index key, so `WHERE organization_id = '__global__'`
Expand Down
4 changes: 3 additions & 1 deletion packages/lint/src/data-model-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,9 @@ export function lintUniqueDeclarations(objects: any[]): LocatedLintIssue[] {
* UNIQUE is NULL-distinct, so on every row where the organization column is
* NULL it enforces **nothing** (#5030, measured). A 17.x single-organization
* deployment wrote *every* row that way; since ADR-0131 D9 a new row carries its
* organization, so the gap now admits that residue. The `'organization'`
* organization wherever the organization object is registered, so there the gap
* now admits that residue (a composition that registers no organization object
* still writes NULL-organization rows). The `'organization'`
* respelling is what closes the hole: the driver makes the LISTED organization
* column NULL-safe in place (`COALESCE(organization_id, '__global__')`), so the
* NULL rows become one bucket that is unique among themselves.
Expand Down
5 changes: 3 additions & 2 deletions packages/lint/src/rule-explanations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2695,8 +2695,9 @@ const UNIQUE_LEGACY_ORGANIZATION_COMPOSITE_EXPLANATION: RuleExplanation = {
'predates the scope vocabulary (ADR-0120 S6). It reads as "unique per organization" but ' +
'materializes as a plain composite, and SQL UNIQUE is NULL-distinct: it enforces nothing on a ' +
'row whose organization column is NULL — every row a 17.x single-organization deployment ' +
'wrote. Since ADR-0131 D9 a new row carries its organization, so what the gap still admits is ' +
'that residue.',
'wrote. Since ADR-0131 D9 a new row carries its organization wherever the organization object ' +
'is registered, so what the gap still admits there is that residue; a composition that ' +
'registers no organization object still writes rows whose organization column is NULL.',
'Respelled `unique: \'organization\'`, the NULL rows become one bucket ' +
'(`COALESCE(organization_id, \'__global__\')`) that is unique among themselves, which is what ' +
'closes the hole. The respelling keeps `fields` exactly as they are: the driver makes the listed ' +
Expand Down
6 changes: 3 additions & 3 deletions packages/objectql/src/tenancy/platform-object-tenancy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,9 @@ export const PLATFORM_OBJECT_TENANCY: Readonly<Record<string, PlatformObjectTena
"carries the granting rule's organization, a direct grant the shared record's. The maintainer ordered " +
'the rows written before it backfilled from the record they reference on 2026-09-02 ' +
'(`backfill-sys-record-share-organizations.ts`). A grant table that cannot say which organization ' +
'a grant belongs to is a defect, not a design: under a wall, a tenant-scoped read would AND ' +
"plugin-security's strict `organization_id = :tenant` over the driver's NULL-tolerant arm and every " +
'organization-less grant would silently disappear.',
'a grant belongs to is a defect, not a design: under a wall, a tenant-scoped read compiles ' +
'`organization_id = :tenant` at both layers, and every organization-less grant would silently ' +
'disappear.',
},
};

Expand Down
6 changes: 3 additions & 3 deletions packages/plugins/plugin-security/src/delegated-admin-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1296,9 +1296,9 @@ export class DelegatedAdminGate {
* `{ isSystem: true }`;
* 2. the candidates that come back are reduced to the caller's OWN row by
* {@link resolveOwnOrganizationRow}. That arm is load-bearing on its own:
* the driver's compatibility arm deliberately also returns
* organization-less rows, and a driver with no tenant scoping at all
* returns every organization's. Under a walled posture an
* a driver with no native tenant scoping returns every organization's
* rows, organization-less ones included. (The SQL driver's NULL arm,
* which also returned organization-less rows, is retired — ADR-0131 D8.) Under a walled posture an
* organization-less business unit is invalid state
* (`organization-scope.ts`), so neither class may answer a
* delegation boundary.
Expand Down
38 changes: 22 additions & 16 deletions packages/plugins/plugin-security/src/organization-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
*
* Under a walled posture that row is invalid state. It was measured to be
* unreadable by anyone: plugin-security's Layer 0 composes a STRICT
* `organization_id = :tenant` and the middleware ANDs it over the driver's
* `(organization_id = :tenant OR organization_id IS NULL)`, and the conjunction
* of the two is the strict equality alone — so on a walled deployment every
* `organization_id = :tenant` and the middleware ANDed it over the driver's
* v17 predicate `(organization_id = :tenant OR organization_id IS NULL)`, and
* the conjunction of the two is the strict equality alone — so on a walled deployment every
* principal, at every rung, listed ZERO positions, permission sets and sharing
* rules while the tables held rows.
*
Expand All @@ -32,11 +32,14 @@
*
* An organization-less row is INVALID STATE under a walled posture — refuse or
* warn loudly, never treat it as a platform-wide default. The older reading, in
* which a NULL organization marked a platform row visible to every tenant,
* survives only as DRIVER-LEVEL COMPATIBILITY BEHAVIOUR: `applyTenantScope`
* still emits the `OR organization_id IS NULL` arm, and this module depends on
* that arm being there — it is precisely how a per-organization pass can still
* SEE a pre-fix organization-less row and therefore say something about it.
* which a NULL organization marked a platform row visible to every tenant, is
* retired at every layer: ADR-0131 D8 removed the driver's
* `OR organization_id IS NULL` arm from `applyTenantScope`, and
* `check-tenant-chokepoint` refuses it. A per-organization pass therefore no
* longer SEES an organization-less row at all — the tenant predicate is
* `organization_id = :tenant` and nothing else — and the 18.x upgrade ceremony
* (ADR-0131 D10) attributes or retires such rows before a walled deployment
* boots.
*
* ## What replaces a reap
*
Expand Down Expand Up @@ -69,10 +72,11 @@
* So the pass said so instead. Its guard (retired with the seeder) named the
* rows and named the remedy, and — this is the load-bearing half — the pass
* still CREATES the organization's own copy. The failure shape it exists to
* prevent is the silent no-op: a tenant-threaded pass sees the pre-fix
* organization-less row through the driver's compatibility arm, reads the name
* as already represented, takes the update branch and creates nothing, leaving
* the deployment exactly as broken as before while reporting success.
* prevent is the silent no-op: a tenant-threaded pass that saw the pre-fix
* organization-less row through the driver's v17 NULL arm (retired by ADR-0131
* D8) read the name as already represented, took the update branch and created
* nothing, leaving the deployment exactly as broken as before while reporting
* success.
* {@link resolveOwnOrganizationRow} is the one read that distinguishes "this
* organization has its row" from "somebody's organization-less row is visible
* here", and every seeder in this catalog routes through it.
Expand Down Expand Up @@ -111,13 +115,15 @@ export function rowOrganizationId(row: any): string | null {
* Resolve THIS organization's own row for a name, out of what a tenant-scoped
* read returned.
*
* A scoped read passes through `applyTenantScope`, whose compatibility arm
* returns the caller's rows AND any organization-less ones. Those two are not
* interchangeable and the seeders must never treat them as such:
* A scoped read passes through `applyTenantScope`, which since ADR-0131 D8
* returns the caller's rows only — the driver's NULL arm is retired. A row
* set can still mix the two classes when it comes from a driver with no
* native tenant scoping or from an unscoped read, and they are not
* interchangeable; the seeders must never treat them as such:
*
* - a row stamped with `organizationId` is this organization's — update it;
* - an organization-less row is a PRE-FIX residue that merely happens to be
* visible here. Reading it as "already seeded" is the silent no-op this
* in the row set. Reading it as "already seeded" is the silent no-op this
* catalog exists to prevent, so it is reported separately and never returned
* as the organization's own row.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,17 @@
* grant the shared record's. It wrote nothing to existing rows, and on a WALLED
* deployment that asymmetry is the cliff that commit pins:
*
* - the SQL driver's own tenant predicate is NULL-TOLERANT —
* - the SQL driver's own tenant predicate was then NULL-TOLERANT —
* `(organization_id = :tenantId OR organization_id IS NULL)` — so an
* unstamped grant stayed readable by every bare-context reader;
* - but Layer 0 (`plugin-security`'s `computeTenantLayer0Filter`) AND-composes
* a STRICT `organization_id = <active org>` above it, and the conjunction is
* the strict equality alone.
*
* (ADR-0131 D8 has since retired the driver's NULL arm: both layers now
* compile the equality alone, so an unstamped grant is unreachable by every
* tenant-scoped read.)
*
* ⇒ the day anything reads this table under a tenant context, every
* organization-less grant becomes invisible — not refused, simply "this person
* was never granted access". Forward-only stamping would split the table in
Expand Down Expand Up @@ -591,9 +595,12 @@ function finish(
*
* The derived organization is threaded as `tenantId` on the write context too,
* beside the elevation: the shape #8844 prescribes for a system write and the
* one the repaired writer uses. On this verb the driver's scope keeps the NULL
* row in reach (`organization_id = ? OR IS NULL`, #2734), so the write lands
* exactly on the row the plan named.
* one the repaired writer uses. ⚠️ That shape predates ADR-0131 D8: it relied
* on the driver's v17 scope `organization_id = ? OR IS NULL` keeping the NULL
* row in reach. D8 retired that arm, so under the SQL driver a write threaded
* with `tenantId` no longer reaches an organization-less row. No runtime path
* calls this pass; the 18.x upgrade ceremony's attribution fate (ADR-0131
* D10) is what stamps these rows now.
*
* A row whose write throws is RECORDED and the sweep continues: a driver
* rejecting one row must not cost the other N-1 their repair, and a half-done
Expand Down
5 changes: 3 additions & 2 deletions packages/plugins/plugin-webhooks/src/auto-enqueuer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,8 @@ interface CachedSubscription {
* (`EnqueueHttpInput.organizationId`), which is what makes the
* cross-organization wall on `redeliver()` (#10740) actually exclude other
* tenants' rows: a row enqueued without it lands `organization_id = NULL`,
* the driver's global-row arm, visible to every organization. There is no
* a row of no organization that no tenant-scoped read reaches (ADR-0131
* D8 retired the driver's NULL arm). There is no
* request context to read here — the enqueuer runs fire-and-forget off the
* write path — so the subscription row is the one honest source. Absent
* when the row itself carries no organization (a `single`-posture install):
Expand Down Expand Up @@ -1223,7 +1224,7 @@ export class AutoEnqueuer {
// organization — the one honest tenant in scope on this
// fire-and-forget path (no request context exists here).
// Absent for an org-less subscription; the row then lands
// NULL, the global-row shape. A deployment-wide declared
// NULL, a row of no organization. A deployment-wide declared
// webhook takes the event's organization instead.
organizationId: sub.deploymentWide ? organization.organizationId : sub.organizationId,
// [#3946] Envelope keys are written LAST so the event payload
Expand Down
5 changes: 3 additions & 2 deletions packages/runtime/src/domains/packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2700,8 +2700,9 @@ _context: HttpProtocolContext,
// Read the just-published seed body. The protocol decides the scope,
// not this call site: [ADR-0131 D6] every metadata read is
// environment → code, so this read is env-wide by construction:
// `organization_id IS NULL`, the partition a workspace seed is stored
// in and the only one cold boot hydrates.
// `sys_metadata` carries no organization column (ADR-0131 D6/D7), so
// the environment's rows are the only partition there is — the one a
// workspace seed is stored in and cold boot hydrates.
//
// [commit 8744de9e9] This used to be a two-attempt org-then-env ladder, written
// when resolving the wrong scope here is what silently produced "0
Expand Down
Loading
Loading