From 1af51f2f64f618a1a27e849393849899b112f45f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 11:50:27 +0000 Subject: [PATCH 1/4] wip(service-settings): no tenant- or user-rung read admits an organization-less row (ADR-0131 D8) Claude-Session: https://claude.ai/code/session_01CUy2hdPMToZMsUtnAoshdP Co-authored-by: Claude --- .../src/settings-service-plugin.ts | 11 + .../service-settings/src/settings-service.ts | 193 +++++++++++------- 2 files changed, 127 insertions(+), 77 deletions(-) diff --git a/packages/services/service-settings/src/settings-service-plugin.ts b/packages/services/service-settings/src/settings-service-plugin.ts index 171c958f570..eef084c1802 100644 --- a/packages/services/service-settings/src/settings-service-plugin.ts +++ b/packages/services/service-settings/src/settings-service-plugin.ts @@ -18,6 +18,9 @@ import { // admission seam at once -- see `tenancyServiceResolver` below for the half // that stays this seam's own. classifyAdmissionTenancyPosture, + // [ADR-0131 D9] The organization a caller that names none acts in — the + // engine's own predicate, answered from the composition's facts. + resolveGuestOrganization, type TenancyPostureSource, type TenancyServiceResolver, } from '@objectstack/core'; @@ -332,6 +335,14 @@ export class SettingsServicePlugin implements Plugin { // it — so the service and the door it sits behind agree on whether // organizations are walled off. Read live at each use. tenancyPosture: () => this.resolveAdmissionTenancyPosture(ctx), + // [ADR-0131 D8/D9] The one organization a caller that names none + // reads and writes as where no wall stands — the process-wide + // readers (the email brand name, the boot-time locale) included — + // asked the way the engine asks it for a system write: derived at + // exactly one organization under `single`, none at zero or several, + // and no organization object in a lean composition. + organization: async () => + resolveGuestOrganization(engine, await this.resolveAdmissionTenancyPosture(ctx)), }, ); // Each namespace's `readPermission`, applied at the generic data diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index ae561682b08..c4d068dfb42 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -41,6 +41,7 @@ import { renderValidationMessage } from '@objectstack/spec/system'; // The one predicate every layer asks "does this posture wall organizations // off from each other?" with (`group` / `isolated`), never a re-spelled list. import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; +import type { SystemWriteOrganizationDecision } from '@objectstack/core'; import { SETTINGS_SECRET_MASK } from './settings-secret-redaction.js'; import { USER_OBJECT, assertUserReferenceResolves, registeredLabel } from './actor-reference.js'; import { @@ -151,30 +152,36 @@ function organizationOf(row: SettingsRow): string | null { /** * Which `sys_setting` rows a caller's `tenant` and `user` rungs may draw on. * - * - `own` — the caller names an organization: that organization's rows, plus - * the rows written with no organization. The cascade prefers the - * organization's own row ({@link SettingsService.preferredRow}); an - * organization-less row is only the fallback, the way the global rung is - * for every organization. - * - `organization-less` — no organization, under a posture that walls - * organizations off from each other (`group` / `isolated`): only the rows - * written with no organization. Another organization's row has no claim on - * a caller that is in none of them. - * - `unwalled` — no organization, under `single` (or with no posture source - * wired): every row, as before. A single-organization deployment has no - * boundary to keep, and its process-wide readers (an email brand name, a - * boot-time locale) read without naming the one organization they serve. + * [ADR-0131 D1/D8] A row with no organization is no organization's: no reach + * reads it as a fallback for an organization, and none reads another + * organization's row for a caller that names none. + * + * - `own` — that organization's rows, and only those. The organization is the + * caller's own, or — for a caller that names none where no wall stands — + * the one organization ADR-0131 D9's predicate derives + * ({@link SettingsOrganizationSource}): a process-wide reader (an email + * brand name, a boot-time locale) reads, and writes, as the organization it + * serves. + * - `no-organization-object` — the composition registers no organization + * object, so no organization can exist (the lean embedding, kept by the + * ruling ADR-0131's D9 note records): the rows written with no + * organization, the only rows such a composition can hold. + * - `unowned` — no organization to name: a caller that names none under a + * posture that walls organizations off from each other (`group` / + * `isolated`), or where D9 derives none (no organization yet, or several). + * No tenant or user rung at all; the global rung and the default answer. */ type OrganizationReach = | { kind: 'own'; organizationId: string } - | { kind: 'organization-less' } - | { kind: 'unwalled' }; + | { kind: 'no-organization-object' } + | { kind: 'unowned' }; /** - * The reach a read of a `global` key is made with: it has no tenant or user - * rung, so which organization's rows the load returns cannot change its answer. + * The reach of a caller with no organization to name — and the reach a read of + * a `global` key is made with: such a key has no tenant or user rung, so no + * reach could change its answer, and no row below the global rung is loaded. */ -const ANY_ORGANIZATION: OrganizationReach = Object.freeze({ kind: 'unwalled' as const }); +const UNOWNED: OrganizationReach = Object.freeze({ kind: 'unowned' as const }); /** * Where the service learns the tenancy posture IN FORCE — the `tenancy` @@ -186,6 +193,18 @@ export type SettingsTenancyPostureSource = () => | undefined | Promise; +/** + * Where the service learns which organization a caller that names none acts + * in, where no wall stands — ADR-0131 D9's predicate + * (`resolveSystemWriteOrganization`), answered from the composition's own + * facts. The plugin supplies it at bind time; it is asked only for such a + * caller, on a key below the global rung. + * + * Unset — a service constructed directly — reads as a composition that + * registers no organization object: nothing names an organization to it. + */ +export type SettingsOrganizationSource = () => Promise; + /** * The capability a namespace's read requires when its manifest declares no * `readPermission` — and therefore the one a row of a namespace with NO @@ -847,6 +866,12 @@ export class SettingsService { * deployment with no organization wall. See {@link organizationWallStands}. */ private tenancyPosture?: SettingsTenancyPostureSource; + /** + * The organization a caller that names none acts in where no wall stands, + * bound with the engine. Unset reads as a composition with no organization + * object. See {@link organizationReachOf}. + */ + private organizationSource?: SettingsOrganizationSource; /** Change subscribers, optionally scoped to a namespace. */ private readonly subscribers = new Set<{ ns?: string; @@ -884,10 +909,16 @@ export class SettingsService { cryptoProvider?: import('@objectstack/spec/contracts').ICryptoProvider; /** * The tenancy posture IN FORCE. Read when a caller names no - * organization: under a walled posture such a caller reads only the - * organization-less rows and may not write a `tenant` key at all. + * organization: under a walled posture such a caller has no tenant or + * user rung and may not write a `tenant` key at all. */ tenancyPosture?: SettingsTenancyPostureSource; + /** + * ADR-0131 D9's answer for a caller that names no organization where no + * wall stands: the one organization it reads and writes as, none, or a + * composition with no organization object. + */ + organization?: SettingsOrganizationSource; }, ): void { this.engine = engine; @@ -899,6 +930,7 @@ export class SettingsService { if (extras?.auditWriter) this.auditWriter = extras.auditWriter; if (extras?.cryptoProvider) this.cryptoProvider = extras.cryptoProvider; if (extras?.tenancyPosture) this.tenancyPosture = extras.tenancyPosture; + if (extras?.organization) this.organizationSource = extras.organization; // Notify subscribers that the persistent store is now available so // late-binders (e.g. AIServicePlugin's adapter rebuild on saved @@ -1590,7 +1622,7 @@ export class SettingsService { // A `global` key has no tenant or user rung, so no reach changes its // answer — and its readers (boot-time plugins, mostly) do not wait on the // posture for one. - const reach = scope === 'global' ? ANY_ORGANIZATION : await this.organizationReachOf(ctx); + const reach = scope === 'global' ? UNOWNED : await this.organizationReachOf(ctx); const rows = await this.loadRows(namespace, userId, reach); return this.resolveKeyFromRows(reg, key, scope, rows, userId, reach); } @@ -1697,7 +1729,7 @@ export class SettingsService { // when a key below the global rung needs it (see `get`). const reach = pending.some((p) => p.scope !== 'global') ? await this.organizationReachOf(ctx) - : ANY_ORGANIZATION; + : UNOWNED; const sets = await this.loadRowSets(namespace, groups, reach); const userRows = userKeys.length > 0 ? sets[0] : []; const otherRows = otherKeys.length > 0 ? sets[sets.length - 1] : []; @@ -1723,9 +1755,9 @@ export class SettingsService { * user rung at all. * * `reach` is the caller's organization reach ({@link organizationReachOf}). - * The tenant and user rungs each take ONE row, the caller organization's own - * when it has one ({@link preferredRow}) — never whichever row a positional - * `find` happens to meet first. + * The tenant and user rungs each take ONE row, the one the reach owns + * ({@link preferredRow}) — never whichever row a positional `find` happens + * to meet first. */ private async resolveKeyFromRows( reg: RegisteredManifest, @@ -1806,37 +1838,42 @@ export class SettingsService { /** * The ONE row a rung takes from its candidates (rows of one key on one rung, * already narrowed to the caller's owner for the user rung), by the caller's - * organization reach: + * organization reach ({@link OrganizationReach}): * - * - `own` — the caller organization's row; failing that, the row written - * with no organization. The organization is compared HERE, not trusted to - * the load's filter, the same way the user rung compares its owner. - * - `organization-less` — only a row written with no organization. - * - `unwalled` — a row an organization wrote, ahead of one written with - * none: on a single-organization deployment that is the one organization's - * current value, while an organization-less row predates its first write. + * - `own` — that organization's row, and no other: a row written with no + * organization is not its fallback (ADR-0131 D8). The organization is + * compared HERE, not trusted to the load's filter, the same way the user + * rung compares its owner. + * - `no-organization-object` — the row written with no organization. + * - `unowned` — none. * * Shared by the cascade walk and the lock pre-flight in {@link setMany}, so * the row that answers a read is the row whose lock refuses a write. */ private preferredRow(candidates: SettingsRow[], reach: OrganizationReach): SettingsRow | undefined { - if (reach.kind === 'own') { - return ( - candidates.find((r) => organizationOf(r) === reach.organizationId) ?? - candidates.find((r) => organizationOf(r) === null) - ); - } - if (reach.kind === 'organization-less') { - return candidates.find((r) => organizationOf(r) === null); - } - return candidates.find((r) => organizationOf(r) !== null) ?? candidates[0]; + return candidates.find((r) => SettingsService.withinReach(r, reach)); } - /** The caller's organization reach — see {@link OrganizationReach}. */ + /** + * The caller's organization reach — see {@link OrganizationReach}. + * + * A caller that names an organization is that organization's, under every + * posture. One that names none is `unowned` behind a wall; elsewhere it is + * whatever ADR-0131 D9's predicate answers ({@link SettingsOrganizationSource}): + * the one organization it derives, none (refused there, so `unowned` here), + * or a composition with no organization object. A source that THROWS is not + * caught, for the posture's reason (see {@link organizationWallStands}). + */ private async organizationReachOf(ctx: SettingsContext): Promise { const organizationId = callerOrganizationIdOf(ctx); if (organizationId !== null) return { kind: 'own', organizationId }; - return (await this.organizationWallStands()) ? { kind: 'organization-less' } : { kind: 'unwalled' }; + if (await this.organizationWallStands()) return UNOWNED; + const source = this.organizationSource; + if (!source) return { kind: 'no-organization-object' }; + const decision = await source(); + if (decision.kind === 'derived') return { kind: 'own', organizationId: decision.organizationId }; + if (decision.kind === 'no-organization-object') return { kind: 'no-organization-object' }; + return UNOWNED; } /** @@ -2091,11 +2128,11 @@ export class SettingsService { /** * The per-key entry {@link setMany} refuses a `tenant`-scoped key with when - * the caller names no organization under a walled posture. Same vocabulary - * as {@link ownerlessUserKeyError}, for the same reason one level up: a - * `tenant` row is one row per organization, and under a wall a row naming - * none belongs to no organization's settings — it would only ever be read as - * every organization's fallback, which is not what any caller asked to write. + * no organization owns the write — the caller names none under a walled + * posture, or where ADR-0131 D9 derives none. Same vocabulary as + * {@link ownerlessUserKeyError}, for the same reason one level up: a + * `tenant` row is one row per organization, and a row naming none belongs to + * no organization's settings — no reach reads it (ADR-0131 D8). */ private organizationlessTenantKeyError(reg: RegisteredManifest, key: string): FieldError { const spec = ((reg.manifest.specifiers ?? []) as Array>) @@ -2151,12 +2188,11 @@ export class SettingsService { this.assertPermitted(reg.manifest, 'write', ctx); // Pre-flight: reject the whole batch if any key is locked or unknown, is - // user-scoped while the caller names no user, or is tenant-scoped while the - // caller names no organization under a walled posture (both collected, - // refused below). + // user-scoped while the caller names no user, or is tenant-scoped while no + // organization owns the write (both collected, refused below). const callerUserId = callerUserIdOf(ctx); - const callerOrganizationId = callerOrganizationIdOf(ctx); - // The reach every load below reads with — resolved once for the batch, and + // The reach every load below reads with, and the organization every row + // below the global rung is stored for — resolved once for the batch, and // only when a key below the global rung asks for it: a `global` key has no // upper rung to be locked by and no organization to be stored for. let reach: OrganizationReach | undefined; @@ -2185,10 +2221,10 @@ export class SettingsService { continue; } reach ??= await this.organizationReachOf(ctx); - // Under a walled posture an organization-less caller reaches only the - // organization-less rows (see `organizationReachOf`), so `reach` already - // carries the posture answer: no second read of it per key. - if (scope === 'tenant' && callerOrganizationId === null && reach.kind === 'organization-less') { + // A caller with no organization to name — behind a wall, or where D9 + // derives none — is `unowned` (see `organizationReachOf`), so `reach` + // already carries the posture answer: no second read of it per key. + if (scope === 'tenant' && reach.kind === 'unowned') { ownerless.push(this.organizationlessTenantKeyError(reg, key)); continue; } @@ -2244,12 +2280,14 @@ export class SettingsService { // which has no organization and no user column (ADR-0131 D7, see // `rowIdentity`); user rows pin to the caller's user id, which the // pre-flight above guarantees is present. Tenant and user rows both - // carry the caller's organization, HERE: this write runs under the - // service's system context, which names no organization, so nothing - // downstream would stamp one — the row would land organization-less, in - // the one bucket every organization reads and writes. + // carry the organization the reach owns, HERE — the caller's, or the one + // D9 derives for a caller that names none: this write runs under the + // service's system context, which names no organization, so the row's + // identity probe (`upsertRow`) must name the organization the row lands + // in, or a second write misses the first and inserts beside it. Only a + // composition with no organization object stores a row with none. const userId = scope === 'user' ? callerUserId : null; - const organizationId = scope === 'global' ? null : callerOrganizationId; + const organizationId = scope !== 'global' && reach?.kind === 'own' ? reach.organizationId : null; const isEncrypted = reg.encryptedKeys.has(key); const isNull = rawValue === null || typeof rawValue === 'undefined'; @@ -2931,12 +2969,15 @@ export class SettingsService { ); } - /** Whether a `tenant` / `user` row is one the reach may draw on. */ + /** + * Whether a `tenant` / `user` row is one the reach may draw on — the one + * predicate the engine query, the in-memory filter and the rung's pick share. + */ private static withinReach(row: SettingsRow, reach: OrganizationReach): boolean { const organizationId = organizationOf(row); - if (reach.kind === 'own') return organizationId === null || organizationId === reach.organizationId; - if (reach.kind === 'organization-less') return organizationId === null; - return true; + if (reach.kind === 'own') return organizationId === reach.organizationId; + if (reach.kind === 'no-organization-object') return organizationId === null; + return false; } /** @@ -2967,6 +3008,8 @@ export class SettingsService { userId: string | null, reach: OrganizationReach, ): Promise { + // No organization to name: no tenant or user rung, so nothing to read. + if (reach.kind === 'unowned') return []; // A user-keyed load must still see the tenant rows (user_id NULL): // resolveKey's user→tenant→global cascade and the Phase-2 upper-scope lock // check both search THIS one result set, so a bare user_id equality starves @@ -2981,17 +3024,13 @@ export class SettingsService { // organization, so neither the driver's tenant scope nor the security // layer's organization wall applies to it — every organization's row came // back, and the cascade answered with whichever it met first. Each rung is - // crossed with the organizations the caller reaches (`OrganizationReach`): - // its own and the organization-less, or the organization-less alone. - const organizations: Array | null = - reach.kind === 'own' ? [reach.organizationId, null] - : reach.kind === 'organization-less' ? [null] - : null; + // crossed with the ONE organization the reach owns (`OrganizationReach`) — + // none at all only in a composition with no organization object, whose + // rows carry none (ADR-0131's D9 note). + const organization_id = reach.kind === 'own' ? reach.organizationId : null; const where: Record = { namespace, - $or: organizations === null - ? rungs - : rungs.flatMap((rung) => organizations.map((organization_id) => ({ ...rung, organization_id }))), + $or: rungs.map((rung) => ({ ...rung, organization_id })), }; // Bypass the tenant-scoping audit warning so loads work uniformly across // the tenant and user rungs without log noise; the organization scope is From 80bb8e8b46d8a17d6082aec4ba5b3a958cd9df1c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 11:59:27 +0000 Subject: [PATCH 2/4] =?UTF-8?q?test(service-settings):=20pin=20the=20organ?= =?UTF-8?q?ization=20reach=20=E2=80=94=20own,=20walled,=20single,=20lean?= =?UTF-8?q?=20(ADR-0131=20D8/D9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01CUy2hdPMToZMsUtnAoshdP Co-authored-by: Claude --- .../src/settings-getmany.test.ts | 4 +- ...ettings-organization-isolation.pin.test.ts | 152 +++++++++--- .../settings-organization-reach.pin.test.ts | 229 ++++++++++++++++++ .../src/settings-routes.test.ts | 9 +- .../src/settings-user-rung-owner.pin.test.ts | 5 +- 5 files changed, 358 insertions(+), 41 deletions(-) create mode 100644 packages/services/service-settings/src/settings-organization-reach.pin.test.ts diff --git a/packages/services/service-settings/src/settings-getmany.test.ts b/packages/services/service-settings/src/settings-getmany.test.ts index 03383ee886f..d1c00ff43f5 100644 --- a/packages/services/service-settings/src/settings-getmany.test.ts +++ b/packages/services/service-settings/src/settings-getmany.test.ts @@ -224,7 +224,9 @@ describe('[#10826] SettingsService.getMany', () => { // codec, which is why the fixture must already hold the decoded value. it('[#12172] resolves the LITERAL stored value — no doubled JSON encoding', async () => { const { svc } = await makeService(); - const ctx = { userId: 'u1', tenantId: 't1' }; + // No organization: the fixture's rows carry none, and an organization's + // read does not fall back to them (ADR-0131 D8). + const ctx = { userId: 'u1' }; const many = await svc.getMany('localization', ['timezone', 'locale', 'currency'], ctx); // Global row, user-scope key, no user/tenant override. diff --git a/packages/services/service-settings/src/settings-organization-isolation.pin.test.ts b/packages/services/service-settings/src/settings-organization-isolation.pin.test.ts index fcb1d4968c2..0057c4a2f04 100644 --- a/packages/services/service-settings/src/settings-organization-isolation.pin.test.ts +++ b/packages/services/service-settings/src/settings-organization-isolation.pin.test.ts @@ -19,15 +19,21 @@ * - identity: a tenant-scope or user-scope row carries the writing caller's * organization, and a write by one organization neither reads, nor * replaces, nor resets another's; - * - the cascade: a caller's own organization row is preferred over a row - * written with no organization, which stays every organization's fallback, - * and the global rung is read by every organization; + * - the cascade: a caller reads its own organization's row and no other — a + * row written with no organization is no organization's fallback (ADR-0131 + * D8), under a wall and under `single` alike — and the global rung is read + * by every organization; * - the lock pre-flight reads the same row the cascade does; * - the refusal: under a walled posture a tenant-scope write that names no * organization is refused whole, and writes nothing; under `single` it is * not; - * - `single`: the default organization keeps its answers, including for a - * process-wide reader that names no organization; + * - `single`: a process-wide reader that names no organization reads and + * writes as the one organization ADR-0131 D9 derives, so the default + * organization keeps its answers and a second organization-less write + * replaces the first instead of inserting beside it; where D9 derives none, + * such a reader has no tenant rung and its tenant write is refused; + * - a composition with no organization object (the lean embedding) keeps + * storing and reading its rows with no organization; * - the posture is asked only for a caller that names no organization, on a * key below the global rung — and when it cannot be read, that read or * write fails rather than guessing. @@ -43,7 +49,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SysUser } from '@objectstack/platform-objects/identity'; import { SysPlatformSetting, SysSetting } from '@objectstack/platform-objects/system'; import type { TenancyPosture } from '@objectstack/spec/security'; -import { SettingsService } from './settings-service.js'; +import { SettingsService, type SettingsOrganizationSource } from './settings-service.js'; import { wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; import { SettingsLockedError, type SettingsContext } from './settings-service.types.js'; @@ -139,12 +145,30 @@ beforeEach(async () => { userId = String((user as any).id); }); -/** A service bound to the real engine, under the given posture. */ -function settings(posture: TenancyPosture | undefined): SettingsService { +/** + * ADR-0131 D9's answers, as the plugin's source hands them to the service: the + * one organization derived, none derivable, or no organization object at all. + */ +const derived = (organizationId: string): SettingsOrganizationSource => + async () => ({ kind: 'derived', organizationId }); +const NONE_DERIVABLE: SettingsOrganizationSource = + async () => ({ kind: 'refuse', reason: 'no-organization', organizationCount: 0 }); +const NO_ORGANIZATION_OBJECT: SettingsOrganizationSource = async () => ({ kind: 'no-organization-object' }); + +/** + * A service bound to the real engine, under the given posture and with the + * given answer for a caller that names no organization — by default a + * composition with no organization object, which is what this rig registers. + */ +function settings( + posture: TenancyPosture | undefined, + organization: SettingsOrganizationSource = NO_ORGANIZATION_OBJECT, +): SettingsService { const svc = new SettingsService({ env: {} }); svc.registerManifest(MANIFEST); svc.bindEngine(wrapEngineAsSettingsEngine(engine as any), undefined, { tenancyPosture: () => posture, + organization, }); return svc; } @@ -221,24 +245,35 @@ describe('a tenant-scope value belongs to the organization that wrote it (walled } }); - it('a row written with no organization is every organization\'s fallback, and an organization\'s own row is preferred over it', async () => { + it('a row written with no organization answers no organization, and an organization\'s write lands beside it', async () => { await seed({ key: 'motto', scope: 'tenant', value: 'Legacy', organization_id: null }); + await seed({ key: 'theme', scope: 'user', user_id: userId, value: 'Legacy-dark', organization_id: null }); const svc = settings('isolated'); for (const org of [ORG_A, ORG_B]) { const got = await svc.get(NS, 'motto', inOrg(org)); - expect(got.value).toBe('Legacy'); - expect(got.source).toBe('tenant'); + expect(got.value).toBe('none'); + expect(got.source).toBe('default'); + expect(got.cascadeChain?.some((e) => e.scope === 'tenant')).toBe(false); + // The user rung likewise: the same user's organization-less row is not + // their value in any organization. + expect((await svc.getMany(NS, ['theme'], inOrg(org, { userId }))).theme.value).toBe('light'); } await svc.set(NS, 'motto', 'Alpha', inOrg(ORG_A)); - const own = await svc.get(NS, 'motto', inOrg(ORG_A)); - expect(own.value).toBe('Alpha'); - // ONE tenant entry in the chain — the preferred row, not both. - expect(own.cascadeChain?.filter((e) => e.scope === 'tenant')).toHaveLength(1); - expect((await svc.get(NS, 'motto', inOrg(ORG_B))).value).toBe('Legacy'); - // The organization-less row was not rewritten by the organization's write. - expect(settingRows().find((r) => r.organization_id == null)?.value).toBe('Legacy'); + expect((await svc.get(NS, 'motto', inOrg(ORG_A))).value).toBe('Alpha'); + expect((await svc.get(NS, 'motto', inOrg(ORG_B))).source).toBe('default'); + // The organization's write did not find, and rewrite, the organization-less row. + expect( + settingRows().filter((r) => r.key === 'motto').map((r) => [r.organization_id ?? null, r.value]).sort(), + ).toEqual([[null, 'Legacy'], [ORG_A, 'Alpha']]); + }); + + it('a lock on a row written with no organization locks no organization\'s write', async () => { + await seed({ key: 'theme', scope: 'tenant', value: 'dark', organization_id: null, locked: true }); + const svc = settings('isolated'); + await expect(svc.set(NS, 'theme', 'blue', inOrg(ORG_A, { userId }))).resolves.toBeDefined(); + expect((await svc.get(NS, 'theme', inOrg(ORG_A, { userId }))).value).toBe('blue'); }); it('a user-scope value carries the organization too: the same user reads it only in the organization it was set in', async () => { @@ -283,23 +318,40 @@ describe('a tenant-scope write that names no organization', () => { }); } - it("is not refused under the 'single' posture, nor where no posture is reported", async () => { + it("is not refused under the 'single' posture, nor where no posture is reported: it lands in the one organization D9 derives", async () => { for (const posture of ['single', undefined] as const) { - const svc = settings(posture); + const svc = settings(posture, derived(ORG_A)); await svc.set(NS, 'motto', `free-${String(posture)}`, {}); expect((await svc.get(NS, 'motto', {})).value).toBe(`free-${String(posture)}`); + expect((await svc.get(NS, 'motto', inOrg(ORG_A))).value).toBe(`free-${String(posture)}`); } - expect(settingRows().every((r) => r.organization_id == null)).toBe(true); + // One row, the organization's: the second write found the first. + expect(settingRows().map((r) => [r.organization_id, r.value])).toEqual([[ORG_A, 'free-undefined']]); }); - it('under a walled posture, a reader that names no organization reads only the organization-less rows', async () => { - const svc = settings('isolated'); - await svc.set(NS, 'motto', 'Alpha', inOrg(ORG_A)); - expect((await svc.get(NS, 'motto', {})).source).toBe('default'); - - await seed({ key: 'motto', scope: 'tenant', value: 'Legacy', organization_id: null }); - expect((await svc.get(NS, 'motto', {})).value).toBe('Legacy'); + it("is refused under 'single' where D9 derives no organization, and nothing is written", async () => { + const svc = settings('single', NONE_DERIVABLE); + const refusal = await svc.setMany(NS, { motto: 'Nobody' }, { userId }).then(() => null, (e) => e); + expect(refusal?.code).toBe('SETTINGS_VALIDATION'); + expect(settingRows()).toHaveLength(0); }); + + for (const posture of ['isolated', 'group'] as const) { + 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 () => { + const svc = settings(posture); + await svc.set(NS, 'motto', 'Alpha', inOrg(ORG_A)); + await svc.set(NS, 'theme', 'dark', inOrg(ORG_A, { userId })); + expect((await svc.get(NS, 'motto', {})).source).toBe('default'); + + await seed({ key: 'motto', scope: 'tenant', value: 'Legacy', organization_id: null }); + await seed({ key: 'theme', scope: 'user', user_id: userId, value: 'Legacy-dark', organization_id: null }); + const got = await svc.get(NS, 'motto', {}); + expect(got.source).toBe('default'); + expect(got.cascadeChain?.some((e) => e.scope === 'tenant')).toBe(false); + expect((await svc.get(NS, 'theme', { userId })).source).toBe('default'); + expect((await svc.getNamespace(NS, { userId })).values.motto.source).toBe('default'); + }); + } }); describe('the posture is read only where it decides something', () => { @@ -332,25 +384,44 @@ describe('the posture is read only where it decides something', () => { describe("posture 'single': the default organization keeps its answers", () => { const DEFAULT_ORG = 'org_default'; - it('a value stored before rows carried an organization is still read by the default organization', async () => { + it('a row stored with no organization is not the default organization\'s, for either reader', async () => { await seed({ key: 'motto', scope: 'tenant', value: 'Acme', organization_id: null }); - const svc = settings('single'); - const got = await svc.get(NS, 'motto', inOrg(DEFAULT_ORG)); - expect(got.value).toBe('Acme'); - expect(got.source).toBe('tenant'); + await seed({ key: 'theme', scope: 'user', user_id: userId, value: 'Acme-dark', organization_id: null }); + const svc = settings('single', derived(DEFAULT_ORG)); + for (const ctx of [inOrg(DEFAULT_ORG), {}]) { + const got = await svc.get(NS, 'motto', ctx); + expect(got.value).toBe('none'); + expect(got.source).toBe('default'); + expect((await svc.get(NS, 'theme', { ...ctx, userId })).value).toBe('light'); + } }); it('the default organization\'s write is read by itself and by a process-wide reader that names no organization', async () => { await seed({ key: 'motto', scope: 'tenant', value: 'Acme', organization_id: null }); - const svc = settings('single'); + const svc = settings('single', derived(DEFAULT_ORG)); await svc.set(NS, 'motto', 'Acme Corp', inOrg(DEFAULT_ORG)); expect((await svc.get(NS, 'motto', inOrg(DEFAULT_ORG))).value).toBe('Acme Corp'); expect((await svc.get(NS, 'motto', {})).value).toBe('Acme Corp'); }); + it('a process-wide writer that names no organization writes the default organization\'s row, and its second write replaces the first', async () => { + const svc = settings('single', derived(DEFAULT_ORG)); + await svc.set(NS, 'motto', 'one', {}); + await svc.set(NS, 'motto', 'two', {}); + await svc.set(NS, 'theme', 'dark', { userId }); + + expect((await svc.get(NS, 'motto', {})).value).toBe('two'); + expect((await svc.get(NS, 'motto', inOrg(DEFAULT_ORG))).value).toBe('two'); + expect((await svc.get(NS, 'theme', inOrg(DEFAULT_ORG, { userId }))).value).toBe('dark'); + expect(settingRows().map((r) => [r.scope, r.organization_id, r.value]).sort()).toEqual([ + ['tenant', DEFAULT_ORG, 'two'], + ['user', DEFAULT_ORG, 'dark'], + ]); + }); + it('the default organization\'s reset reads back the default for both readers', async () => { - const svc = settings('single'); + const svc = settings('single', derived(DEFAULT_ORG)); await svc.set(NS, 'motto', 'Acme Corp', inOrg(DEFAULT_ORG)); await svc.runAction(NS, 'reset', null, inOrg(DEFAULT_ORG)); @@ -359,6 +430,17 @@ describe("posture 'single': the default organization keeps its answers", () => { }); }); +describe('a composition with no organization object (the lean embedding, ADR-0131 D9 note)', () => { + it('stores and reads its rows with no organization, which no organization reads', async () => { + const svc = settings('single', NO_ORGANIZATION_OBJECT); + await svc.set(NS, 'motto', 'Lean', {}); + await svc.set(NS, 'motto', 'Lean again', {}); + expect((await svc.get(NS, 'motto', {})).value).toBe('Lean again'); + expect(settingRows().map((r) => [r.organization_id ?? null, r.value])).toEqual([[null, 'Lean again']]); + expect((await svc.get(NS, 'motto', inOrg(ORG_A))).source).toBe('default'); + }); +}); + describe('the in-memory store keeps the same identity', () => { it('two organizations each read their own value with no engine bound', async () => { const svc = new SettingsService({ env: {} }); diff --git a/packages/services/service-settings/src/settings-organization-reach.pin.test.ts b/packages/services/service-settings/src/settings-organization-reach.pin.test.ts new file mode 100644 index 00000000000..323afda5448 --- /dev/null +++ b/packages/services/service-settings/src/settings-organization-reach.pin.test.ts @@ -0,0 +1,229 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A caller that names no organization reads and writes the `sys_setting` rows + * of the organization the COMPOSITION names for it — never a row written with + * no organization as a fallback (ADR-0131 D8), and never a guess. + * + * `settings-organization-isolation.pin.test.ts` pins the service's reach over + * each answer ADR-0131 D9 can give, handed in directly. This file pins where + * the answer comes from in a deployment: the REAL `SettingsServicePlugin` + * production sequence (`init` → `start` → the `kernel:ready` hook) on a REAL + * `ObjectKernel`, binding a REAL `ObjectQL` engine with its `tenancy` service, + * so the organization source is the one the plugin wires — the engine's own D9 + * predicate over the composition's facts. Only the driver is a Map; it stamps + * the organization the engine threads to it, as the SQL driver's + * `injectTenantOnInsert` does. + * + * The callers this stands for read with an empty context: plugin-auth's email + * brand name and boot-time locale, the anonymous runtime-config door's + * branding read, and a signed-in caller with no active organization. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQL } from '@objectstack/objectql'; +import { SysOrganization, SysUser } from '@objectstack/platform-objects/identity'; +import { SysPlatformSetting, SysSetting } from '@objectstack/platform-objects/system'; +import { SettingsServicePlugin } from './settings-service-plugin.js'; +import type { SettingsService } from './settings-service.js'; + +const SYS = { context: { isSystem: true } } as const; +const NS = 'org_reach_fixture'; +const ORG = 'org_default'; + +const MANIFEST = { + namespace: NS, + label: 'Organization reach fixture', + scope: 'tenant', + specifiers: [ + { key: 'brand', type: 'text', label: 'Brand', scope: 'tenant', default: 'none' }, + { key: 'legacy', type: 'text', label: 'Legacy', scope: 'tenant', default: 'none' }, + ], +} as any; + +/** A driver over plain Maps that stamps the threaded organization on insert. */ +function makeMemoryDriver() { + const store = new Map>>(); + let nextId = 0; + const rowsOf = (object: string) => { + let s = store.get(object); + if (!s) { s = new Map(); store.set(object, s); } + return s; + }; + const matches = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + return Object.entries(where).every(([k, v]) => { + if (k === '$or') return (v as any[]).some((b) => matches(row, b)); + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + if (v !== null && typeof v === 'object') throw new Error(`fake driver: unsupported condition on '${k}'`); + return (row[k] ?? null) === (v ?? null); + }); + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + const hits = [...rowsOf(object).values()].filter((r) => matches(r, ast?.where)); + const page = typeof ast?.limit === 'number' ? hits.slice(0, ast.limit) : hits; + return page.map((r) => ({ ...r })); + }, + async findOne(object: string, ast: any) { + for (const r of rowsOf(object).values()) if (matches(r, ast?.where)) return { ...r }; + return null; + }, + async create(object: string, data: Record, options?: any) { + nextId += 1; + // `injectTenantOnInsert`'s rule: fill an absent tenant column from the + // call's organization; never overwrite one the row carries. + const stamped = object === 'sys_setting' && options?.tenantId && (data.organization_id ?? null) === null + ? { ...data, organization_id: String(options.tenantId) } + : data; + const row = { ...stamped, id: (stamped.id as string) ?? `row_${nextId}` }; + rowsOf(object).set(row.id as string, row); + return { ...row }; + }, + async update(object: string, id: string, data: Record) { + const s = rowsOf(object); + const cur = s.get(id); + if (!cur) return null; + const next = { ...cur, ...data, id }; + s.set(id, next); + return { ...next }; + }, + async updateMany(object: string, ast: any, data: Record) { + const hits = await this.find(object, ast); + const s = rowsOf(object); + for (const r of hits) s.set(r.id as string, { ...s.get(r.id as string), ...data, id: r.id }); + return hits.length; + }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async syncSchema() {}, async dropTable() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, rowsOf }; +} + +/** + * Boot the plugin over a real engine. `organizations` is the composition's + * organization fact: `null` registers no organization object (the lean + * embedding); an array registers it and holds those organizations. + */ +async function boot(posture: 'single' | 'group' | 'isolated', organizations: string[] | null) { + const engine = new ObjectQL(); + engine.registry.logLevel = 'silent'; + const memory = makeMemoryDriver(); + engine.registerDriver(memory.driver, true); + await engine.init(); + const objects: any[] = [SysUser, SysSetting, SysPlatformSetting]; + if (organizations !== null) objects.push(SysOrganization); + for (const o of objects) engine.registry.registerObject(o, '@objectstack/platform-objects'); + for (const id of organizations ?? []) { + await engine.insert('sys_organization', { id, name: id, slug: id }, SYS); + } + + const kernel = new ObjectKernel({ skipSystemValidation: true, gracefulShutdown: false } as any); + kernel.registerService('tenancy', { posture }); + kernel.registerService('objectql', engine); + const getService = kernel.getService.bind(kernel); + let readyHook: (() => Promise) | undefined; + const ctx: any = { + logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, + registerService: kernel.registerService.bind(kernel), + getService, + hook: (event: string, fn: () => Promise) => { if (event === 'kernel:ready') readyHook = fn; }, + getKernel: () => kernel, + }; + const plugin = new SettingsServicePlugin({ env: {}, registerRoutes: false, manifests: [MANIFEST], actionHandlers: {} } as any); + await plugin.init(ctx); + await plugin.start(ctx); + await readyHook!(); + + const service = getService('settings') as SettingsService; + /** + * A row as an earlier writer left it, put straight into the store: through + * the engine, a row with no organization would be stamped with the derived + * one, which is the very write this file is not about. + */ + let seeded = 0; + const seed = async (row: Record) => { + seeded += 1; + const id = `seed_${seeded}`; + memory.rowsOf('sys_setting').set(id, { + id, namespace: NS, scope: 'tenant', user_id: null, value_enc: null, encrypted: false, + locked: false, locked_reason: null, ...row, + }); + }; + const rows = () => [...memory.rowsOf('sys_setting').values()] + .map((r) => [r.key, r.organization_id ?? null, r.value]) + .sort((a, b) => String(a[0]).localeCompare(String(b[0]))); + return { service, seed, rows }; +} + +describe("posture 'single', one organization: a process-wide reader is that organization", () => { + it('reads the organization\'s row, and not a row written with no organization', async () => { + const b = await boot('single', [ORG]); + await b.seed({ key: 'brand', value: 'Acme', organization_id: ORG }); + // A row with no organization, as a pre-attribution writer left it — the + // shape the ceremony reports rather than guesses an owner for. + await b.seed({ key: 'legacy', value: 'Unowned', organization_id: null }); + + expect((await b.service.get(NS, 'brand', {})).value).toBe('Acme'); + expect((await b.service.getMany(NS, ['brand', 'legacy'], {})).legacy).toMatchObject({ value: 'none', source: 'default' }); + expect((await b.service.get(NS, 'legacy', { tenantId: ORG })).source).toBe('default'); + }); + + it('writes the organization\'s row, and its second write replaces the first', async () => { + const b = await boot('single', [ORG]); + await b.service.set(NS, 'brand', 'one', {}); + await b.service.set(NS, 'brand', 'two', {}); + + expect((await b.service.get(NS, 'brand', {})).value).toBe('two'); + expect((await b.service.get(NS, 'brand', { tenantId: ORG })).value).toBe('two'); + expect(b.rows()).toEqual([['brand', ORG, 'two']]); + }); +}); + +describe("posture 'single' where no organization can be derived", () => { + for (const [label, organizations] of [['no organization yet', []], ['several organizations', ['org_a', 'org_b']]] as const) { + it(`${label}: no tenant rung, and the tenant write is refused`, async () => { + const b = await boot('single', [...organizations]); + for (const org of organizations) await b.seed({ key: 'brand', value: `brand-${org}`, organization_id: org }); + await b.seed({ key: 'legacy', value: 'Unowned', organization_id: null }); + + expect((await b.service.get(NS, 'brand', {})).source).toBe('default'); + expect((await b.service.get(NS, 'legacy', {})).source).toBe('default'); + const refusal = await b.service.set(NS, 'brand', 'Nobody', {}).then(() => null, (e) => e); + expect(refusal?.code).toBe('SETTINGS_VALIDATION'); + }); + } +}); + +describe('a walled posture', () => { + for (const posture of ['group', 'isolated'] as const) { + it(`'${posture}': a reader that names no organization reads no tenant row`, async () => { + const b = await boot(posture, [ORG]); + await b.seed({ key: 'brand', value: 'Acme', organization_id: ORG }); + await b.seed({ key: 'legacy', value: 'Unowned', organization_id: null }); + + expect((await b.service.get(NS, 'brand', {})).source).toBe('default'); + expect((await b.service.get(NS, 'legacy', {})).source).toBe('default'); + // Control: the organization reads its own row, and still not the unowned one. + expect((await b.service.get(NS, 'brand', { tenantId: ORG })).value).toBe('Acme'); + expect((await b.service.get(NS, 'legacy', { tenantId: ORG })).source).toBe('default'); + }); + } +}); + +describe('a composition with no organization object (the lean embedding, ADR-0131 D9 note)', () => { + it('stores its rows with no organization and reads them back', async () => { + const b = await boot('single', null); + await b.service.set(NS, 'brand', 'Lean', {}); + await b.service.set(NS, 'brand', 'Lean again', {}); + + expect((await b.service.get(NS, 'brand', {})).value).toBe('Lean again'); + expect(b.rows()).toEqual([['brand', null, 'Lean again']]); + }); +}); diff --git a/packages/services/service-settings/src/settings-routes.test.ts b/packages/services/service-settings/src/settings-routes.test.ts index 60c5f0743c2..f0db599281f 100644 --- a/packages/services/service-settings/src/settings-routes.test.ts +++ b/packages/services/service-settings/src/settings-routes.test.ts @@ -746,9 +746,10 @@ async function makeRetiredFormatStack() { registerSettingsRoutes(http, svc, { contextFromRequest: adminProvider }); /** The store's own rows for the four keys, read the way every resolve reads them. */ const storedFormatRows = async () => { - // The admin context names no organization and this service reports no - // posture, so every resolve reads with the `unwalled` reach. - const rows = (await (svc as any).loadRows('localization', null, { kind: 'unwalled' })) as Array<{ key: string; value: unknown }>; + // The admin context names no organization, and this service is wired no + // organization source — a composition with no organization object — so + // every resolve reads with the `no-organization-object` reach. + const rows = (await (svc as any).loadRows('localization', null, { kind: 'no-organization-object' })) as Array<{ key: string; value: unknown }>; return Object.fromEntries(rows.filter((r) => r.key in RETIRED_FORMAT_ROWS).map((r) => [r.key, r.value])); }; return { svc, http, storedFormatRows }; @@ -840,7 +841,7 @@ async function makeRetiredBrandingStack() { registerSettingsRoutes(http, svc, { contextFromRequest: adminProvider }); /** The store's own rows for the two keys, read the way every resolve reads them. */ const storedRetiredRows = async () => { - const rows = (await (svc as any).loadRows('branding', null, { kind: 'unwalled' })) as Array<{ key: string; value: unknown }>; + const rows = (await (svc as any).loadRows('branding', null, { kind: 'no-organization-object' })) as Array<{ key: string; value: unknown }>; return Object.fromEntries(rows.filter((r) => r.key in RETIRED_BRANDING_ROWS).map((r) => [r.key, r.value])); }; return { http, storedRetiredRows }; diff --git a/packages/services/service-settings/src/settings-user-rung-owner.pin.test.ts b/packages/services/service-settings/src/settings-user-rung-owner.pin.test.ts index 5ef1bc76a23..c2a8627ff14 100644 --- a/packages/services/service-settings/src/settings-user-rung-owner.pin.test.ts +++ b/packages/services/service-settings/src/settings-user-rung-owner.pin.test.ts @@ -201,7 +201,10 @@ describe('the user rung answers only its owner', () => { it('control: tenant and global keys resolve the same with or without a user id', async () => { const { svc } = stack([tenantRow('density', 'compact'), globalRow('banner', 'maintenance')]); - for (const ctx of [{}, { tenantId: 'org_1' }, { userId: USER_A }] as SettingsContext[]) { + // The rows carry no organization — the composition here wires no + // organization source, the lean shape — so no context names one: an + // organization's read does not fall back to them (ADR-0131 D8). + for (const ctx of [{}, { userId: USER_A }] as SettingsContext[]) { expect(await svc.get(NS, 'density', ctx)).toMatchObject({ value: 'compact', source: 'tenant' }); expect(await svc.get(NS, 'banner', ctx)).toMatchObject({ value: 'maintenance', source: 'global' }); } From e43b92cbfb1c915c12e4147f7967fc9fa3db346a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 12:14:35 +0000 Subject: [PATCH 3/4] test(dogfood): the 18.0.0 harness leg pins that its settings still answer, organization-less readers included Claude-Session: https://claude.ai/code/session_01CUy2hdPMToZMsUtnAoshdP Co-authored-by: Claude --- .../test/upgrade-18x-harness.dogfood.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts b/packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts index 0e4e95dd6c9..a176f07e402 100644 --- a/packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts +++ b/packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts @@ -399,6 +399,26 @@ describe(`[#24884] an 18.0.0 database (fixture from ${gaManifest.ref} @ ${gaMani expect(rowsOf(await res.json()).map((r) => String(r?.title))).toContain(`${FIXTURE_NAMES.task} — edited`); }, 240_000); + // [ADR-0131 D8] Its settings rows carry their organization, and no reach + // reads a row written with none: they still answer, through the admin's + // Setup door and for a process-wide reader that names no organization (the + // email brand name, the anonymous runtime config), which reads as the one + // organization D9 derives. + it('[ADR-0131 D8] its settings still answer: through the Setup door, and for a reader that names no organization', async () => { + expect(stack, 'HEAD is booted').toBeDefined(); + const adminTok = await stack!.signIn(FIXTURE_PEOPLE.admin.email, FIXTURE_PEOPLE.admin.password); + const res = await stack!.raw('/api/settings/branding', { headers: { Authorization: `Bearer ${adminTok}` } }); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { values: Record } }; + expect(body.data.values.workspace_name).toMatchObject({ value: FIXTURE_NAMES.workspaceName, source: 'tenant' }); + + const settings = stack!.kernel.getService('settings') as { + get(namespace: string, key: string, ctx?: object): Promise<{ value: unknown; source: string }>; + }; + expect(await settings.get('branding', 'workspace_name', {})).toMatchObject({ value: FIXTURE_NAMES.workspaceName, source: 'tenant' }); + expect(await settings.get('localization', 'timezone', {})).toMatchObject({ value: 'UTC', source: 'tenant' }); + }, 120_000); + it('every 18.0.0 row of every reshaped table is still there, with its key columns', async () => { await stack?.stop(); stack = undefined; From 576b82834cd74ce0fc09692d4a58476d5ca822c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 12:15:21 +0000 Subject: [PATCH 4/4] =?UTF-8?q?chore(changeset):=20service-settings=20?= =?UTF-8?q?=E2=80=94=20the=20organization-less=20rung=20retires=20(ADR-013?= =?UTF-8?q?1=20D8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01CUy2hdPMToZMsUtnAoshdP Co-authored-by: Claude --- ...5202-settings-no-organization-less-rung.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .changeset/25202-settings-no-organization-less-rung.md diff --git a/.changeset/25202-settings-no-organization-less-rung.md b/.changeset/25202-settings-no-organization-less-rung.md new file mode 100644 index 00000000000..90b04258633 --- /dev/null +++ b/.changeset/25202-settings-no-organization-less-rung.md @@ -0,0 +1,22 @@ +--- +'@objectstack/service-settings': patch +--- + +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 + +Clause-②: yes (narrowing) +Metadata-protocol: none + + + +**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. + +- 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. +- 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): + - 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. + - 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' }`). + - A composition that registers no organization object (a lean embedding, kept by ruling) still stores and reads its rows with no organization. + +**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. + +**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`.