Repository navigation
fix(service-settings)!: no tenant- or user-rung settings read admits an organization-less row (ADR-0131 D8) - #25226
Conversation
…ation-less row (ADR-0131 D8) Claude-Session: https://claude.ai/code/session_01CUy2hdPMToZMsUtnAoshdP Co-authored-by: Claude <noreply@anthropic.com>
…gle, lean (ADR-0131 D8/D9) Claude-Session: https://claude.ai/code/session_01CUy2hdPMToZMsUtnAoshdP Co-authored-by: Claude <noreply@anthropic.com>
…swer, organization-less readers included Claude-Session: https://claude.ai/code/session_01CUy2hdPMToZMsUtnAoshdP Co-authored-by: Claude <noreply@anthropic.com>
…es (ADR-0131 D8) Claude-Session: https://claude.ai/code/session_01CUy2hdPMToZMsUtnAoshdP Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a986561e0ada5c3f89e1ecc8525b568536e8c7d2 && git checkout a986561e0ada5c3f89e1ecc8525b568536e8c7d2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 123ff6f4e2faa4beffcf1875618c358bc1692c95 576b82834cd74ce0fc09692d4a58476d5ca822c5 && git checkout -B drift-repro 123ff6f4e2faa4beffcf1875618c358bc1692c95 && git merge --no-ff 576b82834cd74ce0fc09692d4a58476d5ca822c5
node scripts/docs-audit/affected-docs.mjs --json 123ff6f4e2faa4beffcf1875618c358bc1692c95
|
Contract reviewServed-tier: Inputs read: card #25202 body, its claim ( ① Derived judgments1. The reach, before and after — which 2. A caller with no organization, by posture. 3. Legitimate callers. plugin-auth's email brand name (:1102) reads 4. The write side. Head :2289: 5. Cross-organization leakage under a wall. None: 6. R1–R4. R1 (fresh store boots and serves), R2 (18.0 and 18.0.0 fixtures upgrade and keep their data, the 18.0.0 leg now including the settings-still-answer test) and R3 (the v17 trait is refused, takes the ceremony, boots) are the 7. Tests edited beside the pins. 8. 9. Gate verdicts on the head ( ② Semver levelChangeset
③ Boundary flags
Implemented-by: VERDICT: PASS |
|
Seat ACCEPT · epic PM Seat check
|
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'ssys_settingrows 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:
own(X)organization_id = Xgroup/isolated)unownedsingle, D9 derives Xown(X)organization_id = Xsingle, D9 derives none (zero or several organizations)unownedno-organization-objectorganization_idNULLD9's answer comes from the predicate the engine already applies to a system write:
resolveGuestOrganization/resolveSystemWriteOrganization(@objectstack/core).SettingsServicePluginbinds it next totenancyPosture. The process-wide callers keep passing{}, and the service names the organization for them: plugin-auth's email brand name and auth locale, the anonymousruntime/configbranding read, andresolveLocalizationContextfor 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 withorganization_idNULL. The engine stamped the Default Organization on insert, butupsertRow'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
main9c431bad37before building. The full report was prepared as an os-dev-report for #25202.6325596117names only storage'sorganizationOutOfWriteReach. D13 lists the settings global rung, which step 2 already moved. The one organization-less path a ruling keeps is the lean embedding ([Decision] A production os serve that composes no organization object (no auth secret, or no auth) still writes ownerless rows under --no-server: refuse that boot too, or refuse such writes in the engine everywhere #25133 letter A), and this PR keeps it.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).--dev: same as served, unless the auth tier is off, which takes the lean path.fixture-18.0.0: 2sys_settingrows, both owned byorg_mvgqf77lj6oc60fk; 0 organization-less.fixture-18.0: 2.sys_settingalready has theattributionfate (organization-ownership-inventory.ts:660). Undersinglethe 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.
patchwith the BREAKING banner, the FROM/TO and the remedy. ADR-0087 disposition:not-required (no-migration-prescription).Proof (head
576b8283)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 realSettingsServicePluginon a realObjectKernel+ObjectQL, withsingleand one, zero or several organizations,group/isolated, and lean. The source is the one the plugin wires.GET /api/settings/brandingand for a reader that names no organization (branding.workspace_name,localization.timezone).git checkout HEAD --:ownadmits NULL again: 8 pins red.expected { value: 'ObjectStack', … }).pnpm --filter @objectstack/service-settings typecheckexit 0. vitest 58 files / 982 tests pass.upgrade-18x-harness.dogfood.test.ts, both fixture legs and the fresh store): 16/16 pass.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-testneeds an unshallow clone.check:dual-build-cjs-loadsneeds the full build.--ranverdict: ✓ 72 accounted.check-governed-merges --branch: 0 of 9 paths governed. 645 changed lines (+527/−118). Not Tier H/S.Acceptance notes
audit.record'stenantIdand the crypto context keepctx.tenantId. Only the row's organization andauditWriter'sorganizationIdfollow the derived organization.🤖 Generated with Claude Code
https://claude.ai/code/session_01CUy2hdPMToZMsUtnAoshdP