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 279fdcf
Browse filesBrowse the repository at this point in the historyBrowse files
fix(service-settings)!: no tenant- or user-rung settings read admits an organization-less row (ADR-0131 D8) (#25226)
Fixes#25202
## What
ADR-0131 D8 says no layer has a NULL arm, and D1 (as amended by record
`6328195425`) says nothing reads a NULL tenant value as an owner.
`SettingsService`'s organization reach still had a NULL arm:
- `own`: a caller that named an organization read that organization's
`sys_setting` rows **and** the rows written with no organization, as a
fallback for every organization.
- `organization-less` (no organization, walled): it read only the
organization-less rows.
- `unwalled` (no organization, `single`): it read every row.
This PR retires the NULL arm. Each reach now owns exactly one
organization:
| caller | reach | tenant/user rows read | tenant/user rows written |
|---|---|---|---|
| names organization X | `own(X)` | `organization_id = X` | X |
| names none, walled (`group`/`isolated`) | `unowned` | none (global
rung / default) | tenant write refused |
| names none, `single`, D9 derives X | `own(X)` | `organization_id = X`
| X |
| names none, `single`, D9 derives none (zero or several organizations)
| `unowned` | none | tenant write refused |
| names none, composition registers no organization object (lean
embedding; ruled kept, ADR-0131 D9 note) | `no-organization-object` |
`organization_id` NULL | NULL |
D9's answer comes from the predicate the engine already applies to a
system write: `resolveGuestOrganization` /
`resolveSystemWriteOrganization` (`@objectstack/core`).
`SettingsServicePlugin` binds it next to `tenancyPosture`. The
process-wide callers keep passing `{}`, and the service names the
organization for them: plugin-auth's email brand name and auth locale,
the anonymous `runtime/config` branding read, and
`resolveLocalizationContext` for a caller with no active organization.
**A live defect fixed on the way (same cause).** Under `single`, a
caller with no organization wrote its row with `organization_id` NULL.
The engine stamped the Default Organization on insert, but `upsertRow`'s
identity probe still asked for NULL. The second save of a key therefore
missed the first row and inserted a second one. Readers kept answering
the first value. On a SQL store the identity index refuses that insert,
so the save fails. Probe: `[[org_default,'one'],[org_default,'two']]`,
with every read answering `'one'`. The write now carries the
organization the reach owns.
## Measurement (step 1)
This was read on `main` `9c431bad37` before building. The full report
was prepared as an os-dev-report for #25202.
- **Kept by ruling?** No. D8 note `6325596117` names only storage's
`organizationOutOfWriteReach`. D13 lists the settings global rung, which
step 2 already moved. The one organization-less path a ruling keeps is
the lean embedding (#25133 letter A), and this PR keeps it.
- **Writable organization-less tenant/user row**, probed through the
real engine:
- fresh `single`: no. The engine derives the Default Organization.
- `group`/`isolated`: no. A tenant key is refused at the service. A user
key is refused by the engine (`ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`).
- 18.0.0-born store: no on a served composition.
- lean embedding: yes, by ruling.
- `--dev`: same as served, unless the auth tier is off, which takes the
lean path.
- `fixture-18.0.0`: 2 `sys_setting` rows, both owned by
`org_mvgqf77lj6oc60fk`; 0 organization-less. `fixture-18.0`: 2.
- **Ceremony inventory:** unchanged. `sys_setting` already has the
`attribution` fate (`organization-ownership-inventory.ts:660`). Under
`single` the ceremony gives such rows to the Default Organization.
Walled stores keep them NULL and reported, and those reported rows are
the population this narrows.
## Clause-② and Metadata-protocol
Clause-②: yes (narrowing)
Metadata-protocol: none
A reported organization-less tenant/user row stops answering every
organization's read.
- The changeset ships as `patch` with the BREAKING banner, the FROM/TO
and the remedy. ADR-0087 disposition: `not-required
(no-migration-prescription)`.
## Proof (head `576b8283`)
- **Pins:**
- `settings-organization-isolation.pin.test.ts` (rewritten): own /
walled / `single` / lean, tenant and user rungs, and the lock
pre-flight.
- `settings-organization-reach.pin.test.ts` (new): the real
`SettingsServicePlugin` on a real `ObjectKernel` + `ObjectQL`, with
`single` and one, zero or several organizations, `group`/`isolated`, and
lean. The source is the one the plugin wires.
- Upgrade harness, 18.0.0 leg: new test. The fixture's settings still
answer through `GET /api/settings/branding` and for a reader that names
no organization (`branding.workspace_name`, `localization.timezone`).
- **Ablation**, from the committed state, each restored with `git
checkout HEAD --`:
- A1, `own` admits NULL again: 8 pins red.
- A2, the plugin wires no organization source: 4 plugin pins red. The
harness's new 18.0.0 test is also red (`expected { value: 'ObjectStack',
… }`).
- A3, the write stores the caller's organization instead of the reach's:
3 pins red.
- **Package:** `pnpm --filter @objectstack/service-settings typecheck`
exit 0. vitest 58 files / 982 tests pass.
- **Downstream spot-check:** plugin-auth (4 settings files, 124 pass),
core (localization cache + authored-translation sync, 33 pass),
organizations membership-policy gate (32 pass).
- **Upgrade harness** (`upgrade-18x-harness.dogfood.test.ts`, both
fixture legs and the fresh store): 16/16 pass.
- R1: the fresh store boots and serves.
- R2: the 18.0 and 18.0.0 stores upgrade and keep their data.
- R3: the v17 trait is refused, then takes the ceremony.
- **R4 (walled):** the walled pins above. NOT MEASURED on a walled
dogfood boot locally; CI's dogfood lane covers it.
- **`node scripts/pm/dispatch-gates.mjs --commands`:** 72 derived; 70
exit 0. Two exit 3 (NOT MEASURED, prerequisites in this container):
- `check-plugin-teardown-shape --self-test` needs an unshallow clone.
- `check:dual-build-cjs-loads` needs the full build.
- `--ran` verdict: ✓ 72 accounted.
- **`check-governed-merges --branch`:** 0 of 9 paths governed. 645
changed lines (+527/−118). Not Tier H/S.
## Acceptance notes
- `audit.record`'s `tenantId` and the crypto context keep
`ctx.tenantId`. Only the row's organization and `auditWriter`'s
`organizationId` follow the derived organization.
- A user-scope write by a caller with no organization behind a wall is
still refused by the engine, not the service (unchanged).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01CUy2hdPMToZMsUtnAoshdP
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(service-settings)!: a tenant- or user-scope setting stored with no organization no longer answers every organization's read, and a caller with no organization reads and writes as the one organization the deployment derives
6
+
7
+
Clause-②: yes (narrowing)
8
+
Metadata-protocol: none
9
+
10
+
<!-- adr-0087: not-required (no-migration-prescription) Which stored `sys_setting` row answers a settings read changes. No key of any metadata schema is removed, renamed or re-shaped, so `objectstack migrate meta` has nothing to rewrite and no tombstone is needed. No export, type, error code or route is removed or renamed: `bindEngine` gains one optional input, and the plugin supplies it. The remedy for the rows this stops reading is the existing organization-ownership ceremony, stated below. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this read (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
11
+
12
+
**BREAKING** read narrowing (ADR-0131 D1 "nothing reads a NULL tenant value as an owner", and D8 "no layer has a NULL arm"). It changes which stored row a settings read takes, not what a metadata author can write, so it ships as `patch` with this banner.
13
+
14
+
- FROM: a `tenant`- or `user`-scope `sys_setting` row written with no organization was every organization's fallback. A caller that named an organization read its own row, or failing that the organization-less one. A caller that named none read only organization-less rows behind a wall (`group` / `isolated`). Under `single` it read every row, an organization's first. Under `single` such a caller also wrote with no organization, so its write's identity probe missed the row the engine stamped with the Default Organization. The second save of a key then inserted a second row beside the first. On a SQL store the identity index refuses that insert and the save fails. On a store with no such index the save was lost: every reader kept answering the first value.
15
+
- TO: a caller reads its organization's rows and no others. For a caller that names no organization, the deployment names one (ADR-0131 D9, the predicate the engine applies to a system write):
16
+
- Under `single` with one organization, the caller reads and writes as that organization. This covers the process-wide readers: plugin-auth's email brand name and auth locale, and the anonymous `runtime/config` branding read.
17
+
- Behind a wall, or under `single` with no organization or several, the caller has no tenant or user rung. Its read answers from the global rung or the default, and its tenant-scope write is refused (`SETTINGS_VALIDATION`, `constraint: { scope: 'tenant' }`).
18
+
- A composition that registers no organization object (a lean embedding, kept by ruling) still stores and reads its rows with no organization.
19
+
20
+
**Who is affected.** Only a store holding a tenant- or user-scope `sys_setting` row with a NULL `organization_id`. A fresh install holds none, nor does an 18.0.0-born store written through `os serve`: the measured 18.0.0 fixture holds two `sys_setting` rows, both owned. A v17 store holds none after `os migrate organization-ownership` under `single`, which gives such rows to the Default Organization. Under a walled posture the ceremony leaves such a row NULL and reports it, and from this release that row stops answering any organization.
21
+
22
+
**The remedy.** Run `os migrate organization-ownership --out PLAN`, review the reported `sys_setting` rows, and save each value again from inside the organization it belongs to (Setup → the settings page, or `PUT /api/settings/:namespace` with that organization active). A value meant for every organization belongs on a key declared at scope `global`.
it(`under the '${posture}' posture, a reader that names no organization reads no tenant or user row — neither an organization's nor one written with none`,async()=>{
0 commit comments