Skip to content

Commit 279fdcf

Browse 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>
1 parent c8f4c43 commit 279fdcf

9 files changed

Lines changed: 527 additions & 118 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/service-settings': patch
3+
---
4+
5+
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`.

‎packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,26 @@ describe(`[#24884] an 18.0.0 database (fixture from ${gaManifest.ref} @ ${gaMani
399399
expect(rowsOf(await res.json()).map((r) => String(r?.title))).toContain(`${FIXTURE_NAMES.task} — edited`);
400400
}, 240_000);
401401

402+
// [ADR-0131 D8] Its settings rows carry their organization, and no reach
403+
// reads a row written with none: they still answer, through the admin's
404+
// Setup door and for a process-wide reader that names no organization (the
405+
// email brand name, the anonymous runtime config), which reads as the one
406+
// organization D9 derives.
407+
it('[ADR-0131 D8] its settings still answer: through the Setup door, and for a reader that names no organization', async () => {
408+
expect(stack, 'HEAD is booted').toBeDefined();
409+
const adminTok = await stack!.signIn(FIXTURE_PEOPLE.admin.email, FIXTURE_PEOPLE.admin.password);
410+
const res = await stack!.raw('/api/settings/branding', { headers: { Authorization: `Bearer ${adminTok}` } });
411+
expect(res.status).toBe(200);
412+
const body = (await res.json()) as { data: { values: Record<string, { value: unknown; source: string }> } };
413+
expect(body.data.values.workspace_name).toMatchObject({ value: FIXTURE_NAMES.workspaceName, source: 'tenant' });
414+
415+
const settings = stack!.kernel.getService('settings') as {
416+
get(namespace: string, key: string, ctx?: object): Promise<{ value: unknown; source: string }>;
417+
};
418+
expect(await settings.get('branding', 'workspace_name', {})).toMatchObject({ value: FIXTURE_NAMES.workspaceName, source: 'tenant' });
419+
expect(await settings.get('localization', 'timezone', {})).toMatchObject({ value: 'UTC', source: 'tenant' });
420+
}, 120_000);
421+
402422
it('every 18.0.0 row of every reshaped table is still there, with its key columns', async () => {
403423
await stack?.stop();
404424
stack = undefined;

‎packages/services/service-settings/src/settings-getmany.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,9 @@ describe('[#10826] SettingsService.getMany', () => {
224224
// codec, which is why the fixture must already hold the decoded value.
225225
it('[#12172] resolves the LITERAL stored value — no doubled JSON encoding', async () => {
226226
const { svc } = await makeService();
227-
const ctx = { userId: 'u1', tenantId: 't1' };
227+
// No organization: the fixture's rows carry none, and an organization's
228+
// read does not fall back to them (ADR-0131 D8).
229+
const ctx = { userId: 'u1' };
228230
const many = await svc.getMany('localization', ['timezone', 'locale', 'currency'], ctx);
229231

230232
// Global row, user-scope key, no user/tenant override.

‎packages/services/service-settings/src/settings-organization-isolation.pin.test.ts‎

Lines changed: 117 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,21 @@
1919
* - identity: a tenant-scope or user-scope row carries the writing caller's
2020
* organization, and a write by one organization neither reads, nor
2121
* replaces, nor resets another's;
22-
* - the cascade: a caller's own organization row is preferred over a row
23-
* written with no organization, which stays every organization's fallback,
24-
* and the global rung is read by every organization;
22+
* - the cascade: a caller reads its own organization's row and no other — a
23+
* row written with no organization is no organization's fallback (ADR-0131
24+
* D8), under a wall and under `single` alike — and the global rung is read
25+
* by every organization;
2526
* - the lock pre-flight reads the same row the cascade does;
2627
* - the refusal: under a walled posture a tenant-scope write that names no
2728
* organization is refused whole, and writes nothing; under `single` it is
2829
* not;
29-
* - `single`: the default organization keeps its answers, including for a
30-
* process-wide reader that names no organization;
30+
* - `single`: a process-wide reader that names no organization reads and
31+
* writes as the one organization ADR-0131 D9 derives, so the default
32+
* organization keeps its answers and a second organization-less write
33+
* replaces the first instead of inserting beside it; where D9 derives none,
34+
* such a reader has no tenant rung and its tenant write is refused;
35+
* - a composition with no organization object (the lean embedding) keeps
36+
* storing and reading its rows with no organization;
3137
* - the posture is asked only for a caller that names no organization, on a
3238
* key below the global rung — and when it cannot be read, that read or
3339
* write fails rather than guessing.
@@ -43,7 +49,7 @@ import { ObjectQL } from '@objectstack/objectql';
4349
import { SysUser } from '@objectstack/platform-objects/identity';
4450
import { SysPlatformSetting, SysSetting } from '@objectstack/platform-objects/system';
4551
import type { TenancyPosture } from '@objectstack/spec/security';
46-
import { SettingsService } from './settings-service.js';
52+
import { SettingsService, type SettingsOrganizationSource } from './settings-service.js';
4753
import { wrapEngineAsSettingsEngine } from './settings-service-plugin.js';
4854
import { SettingsLockedError, type SettingsContext } from './settings-service.types.js';
4955

@@ -139,12 +145,30 @@ beforeEach(async () => {
139145
userId = String((user as any).id);
140146
});
141147

142-
/** A service bound to the real engine, under the given posture. */
143-
function settings(posture: TenancyPosture | undefined): SettingsService {
148+
/**
149+
* ADR-0131 D9's answers, as the plugin's source hands them to the service: the
150+
* one organization derived, none derivable, or no organization object at all.
151+
*/
152+
const derived = (organizationId: string): SettingsOrganizationSource =>
153+
async () => ({ kind: 'derived', organizationId });
154+
const NONE_DERIVABLE: SettingsOrganizationSource =
155+
async () => ({ kind: 'refuse', reason: 'no-organization', organizationCount: 0 });
156+
const NO_ORGANIZATION_OBJECT: SettingsOrganizationSource = async () => ({ kind: 'no-organization-object' });
157+
158+
/**
159+
* A service bound to the real engine, under the given posture and with the
160+
* given answer for a caller that names no organization — by default a
161+
* composition with no organization object, which is what this rig registers.
162+
*/
163+
function settings(
164+
posture: TenancyPosture | undefined,
165+
organization: SettingsOrganizationSource = NO_ORGANIZATION_OBJECT,
166+
): SettingsService {
144167
const svc = new SettingsService({ env: {} });
145168
svc.registerManifest(MANIFEST);
146169
svc.bindEngine(wrapEngineAsSettingsEngine(engine as any), undefined, {
147170
tenancyPosture: () => posture,
171+
organization,
148172
});
149173
return svc;
150174
}
@@ -221,24 +245,35 @@ describe('a tenant-scope value belongs to the organization that wrote it (walled
221245
}
222246
});
223247

224-
it('a row written with no organization is every organization\'s fallback, and an organization\'s own row is preferred over it', async () => {
248+
it('a row written with no organization answers no organization, and an organization\'s write lands beside it', async () => {
225249
await seed({ key: 'motto', scope: 'tenant', value: 'Legacy', organization_id: null });
250+
await seed({ key: 'theme', scope: 'user', user_id: userId, value: 'Legacy-dark', organization_id: null });
226251
const svc = settings('isolated');
227252

228253
for (const org of [ORG_A, ORG_B]) {
229254
const got = await svc.get(NS, 'motto', inOrg(org));
230-
expect(got.value).toBe('Legacy');
231-
expect(got.source).toBe('tenant');
255+
expect(got.value).toBe('none');
256+
expect(got.source).toBe('default');
257+
expect(got.cascadeChain?.some((e) => e.scope === 'tenant')).toBe(false);
258+
// The user rung likewise: the same user's organization-less row is not
259+
// their value in any organization.
260+
expect((await svc.getMany(NS, ['theme'], inOrg(org, { userId }))).theme.value).toBe('light');
232261
}
233262

234263
await svc.set(NS, 'motto', 'Alpha', inOrg(ORG_A));
235-
const own = await svc.get(NS, 'motto', inOrg(ORG_A));
236-
expect(own.value).toBe('Alpha');
237-
// ONE tenant entry in the chain — the preferred row, not both.
238-
expect(own.cascadeChain?.filter((e) => e.scope === 'tenant')).toHaveLength(1);
239-
expect((await svc.get(NS, 'motto', inOrg(ORG_B))).value).toBe('Legacy');
240-
// The organization-less row was not rewritten by the organization's write.
241-
expect(settingRows().find((r) => r.organization_id == null)?.value).toBe('Legacy');
264+
expect((await svc.get(NS, 'motto', inOrg(ORG_A))).value).toBe('Alpha');
265+
expect((await svc.get(NS, 'motto', inOrg(ORG_B))).source).toBe('default');
266+
// The organization's write did not find, and rewrite, the organization-less row.
267+
expect(
268+
settingRows().filter((r) => r.key === 'motto').map((r) => [r.organization_id ?? null, r.value]).sort(),
269+
).toEqual([[null, 'Legacy'], [ORG_A, 'Alpha']]);
270+
});
271+
272+
it('a lock on a row written with no organization locks no organization\'s write', async () => {
273+
await seed({ key: 'theme', scope: 'tenant', value: 'dark', organization_id: null, locked: true });
274+
const svc = settings('isolated');
275+
await expect(svc.set(NS, 'theme', 'blue', inOrg(ORG_A, { userId }))).resolves.toBeDefined();
276+
expect((await svc.get(NS, 'theme', inOrg(ORG_A, { userId }))).value).toBe('blue');
242277
});
243278

244279
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', () => {
283318
});
284319
}
285320

286-
it("is not refused under the 'single' posture, nor where no posture is reported", async () => {
321+
it("is not refused under the 'single' posture, nor where no posture is reported: it lands in the one organization D9 derives", async () => {
287322
for (const posture of ['single', undefined] as const) {
288-
const svc = settings(posture);
323+
const svc = settings(posture, derived(ORG_A));
289324
await svc.set(NS, 'motto', `free-${String(posture)}`, {});
290325
expect((await svc.get(NS, 'motto', {})).value).toBe(`free-${String(posture)}`);
326+
expect((await svc.get(NS, 'motto', inOrg(ORG_A))).value).toBe(`free-${String(posture)}`);
291327
}
292-
expect(settingRows().every((r) => r.organization_id == null)).toBe(true);
328+
// One row, the organization's: the second write found the first.
329+
expect(settingRows().map((r) => [r.organization_id, r.value])).toEqual([[ORG_A, 'free-undefined']]);
293330
});
294331

295-
it('under a walled posture, a reader that names no organization reads only the organization-less rows', async () => {
296-
const svc = settings('isolated');
297-
await svc.set(NS, 'motto', 'Alpha', inOrg(ORG_A));
298-
expect((await svc.get(NS, 'motto', {})).source).toBe('default');
299-
300-
await seed({ key: 'motto', scope: 'tenant', value: 'Legacy', organization_id: null });
301-
expect((await svc.get(NS, 'motto', {})).value).toBe('Legacy');
332+
it("is refused under 'single' where D9 derives no organization, and nothing is written", async () => {
333+
const svc = settings('single', NONE_DERIVABLE);
334+
const refusal = await svc.setMany(NS, { motto: 'Nobody' }, { userId }).then(() => null, (e) => e);
335+
expect(refusal?.code).toBe('SETTINGS_VALIDATION');
336+
expect(settingRows()).toHaveLength(0);
302337
});
338+
339+
for (const posture of ['isolated', 'group'] as const) {
340+
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 () => {
341+
const svc = settings(posture);
342+
await svc.set(NS, 'motto', 'Alpha', inOrg(ORG_A));
343+
await svc.set(NS, 'theme', 'dark', inOrg(ORG_A, { userId }));
344+
expect((await svc.get(NS, 'motto', {})).source).toBe('default');
345+
346+
await seed({ key: 'motto', scope: 'tenant', value: 'Legacy', organization_id: null });
347+
await seed({ key: 'theme', scope: 'user', user_id: userId, value: 'Legacy-dark', organization_id: null });
348+
const got = await svc.get(NS, 'motto', {});
349+
expect(got.source).toBe('default');
350+
expect(got.cascadeChain?.some((e) => e.scope === 'tenant')).toBe(false);
351+
expect((await svc.get(NS, 'theme', { userId })).source).toBe('default');
352+
expect((await svc.getNamespace(NS, { userId })).values.motto.source).toBe('default');
353+
});
354+
}
303355
});
304356

305357
describe('the posture is read only where it decides something', () => {
@@ -332,25 +384,44 @@ describe('the posture is read only where it decides something', () => {
332384
describe("posture 'single': the default organization keeps its answers", () => {
333385
const DEFAULT_ORG = 'org_default';
334386

335-
it('a value stored before rows carried an organization is still read by the default organization', async () => {
387+
it('a row stored with no organization is not the default organization\'s, for either reader', async () => {
336388
await seed({ key: 'motto', scope: 'tenant', value: 'Acme', organization_id: null });
337-
const svc = settings('single');
338-
const got = await svc.get(NS, 'motto', inOrg(DEFAULT_ORG));
339-
expect(got.value).toBe('Acme');
340-
expect(got.source).toBe('tenant');
389+
await seed({ key: 'theme', scope: 'user', user_id: userId, value: 'Acme-dark', organization_id: null });
390+
const svc = settings('single', derived(DEFAULT_ORG));
391+
for (const ctx of [inOrg(DEFAULT_ORG), {}]) {
392+
const got = await svc.get(NS, 'motto', ctx);
393+
expect(got.value).toBe('none');
394+
expect(got.source).toBe('default');
395+
expect((await svc.get(NS, 'theme', { ...ctx, userId })).value).toBe('light');
396+
}
341397
});
342398

343399
it('the default organization\'s write is read by itself and by a process-wide reader that names no organization', async () => {
344400
await seed({ key: 'motto', scope: 'tenant', value: 'Acme', organization_id: null });
345-
const svc = settings('single');
401+
const svc = settings('single', derived(DEFAULT_ORG));
346402
await svc.set(NS, 'motto', 'Acme Corp', inOrg(DEFAULT_ORG));
347403

348404
expect((await svc.get(NS, 'motto', inOrg(DEFAULT_ORG))).value).toBe('Acme Corp');
349405
expect((await svc.get(NS, 'motto', {})).value).toBe('Acme Corp');
350406
});
351407

408+
it('a process-wide writer that names no organization writes the default organization\'s row, and its second write replaces the first', async () => {
409+
const svc = settings('single', derived(DEFAULT_ORG));
410+
await svc.set(NS, 'motto', 'one', {});
411+
await svc.set(NS, 'motto', 'two', {});
412+
await svc.set(NS, 'theme', 'dark', { userId });
413+
414+
expect((await svc.get(NS, 'motto', {})).value).toBe('two');
415+
expect((await svc.get(NS, 'motto', inOrg(DEFAULT_ORG))).value).toBe('two');
416+
expect((await svc.get(NS, 'theme', inOrg(DEFAULT_ORG, { userId }))).value).toBe('dark');
417+
expect(settingRows().map((r) => [r.scope, r.organization_id, r.value]).sort()).toEqual([
418+
['tenant', DEFAULT_ORG, 'two'],
419+
['user', DEFAULT_ORG, 'dark'],
420+
]);
421+
});
422+
352423
it('the default organization\'s reset reads back the default for both readers', async () => {
353-
const svc = settings('single');
424+
const svc = settings('single', derived(DEFAULT_ORG));
354425
await svc.set(NS, 'motto', 'Acme Corp', inOrg(DEFAULT_ORG));
355426
await svc.runAction(NS, 'reset', null, inOrg(DEFAULT_ORG));
356427

@@ -359,6 +430,17 @@ describe("posture 'single': the default organization keeps its answers", () => {
359430
});
360431
});
361432

433+
describe('a composition with no organization object (the lean embedding, ADR-0131 D9 note)', () => {
434+
it('stores and reads its rows with no organization, which no organization reads', async () => {
435+
const svc = settings('single', NO_ORGANIZATION_OBJECT);
436+
await svc.set(NS, 'motto', 'Lean', {});
437+
await svc.set(NS, 'motto', 'Lean again', {});
438+
expect((await svc.get(NS, 'motto', {})).value).toBe('Lean again');
439+
expect(settingRows().map((r) => [r.organization_id ?? null, r.value])).toEqual([[null, 'Lean again']]);
440+
expect((await svc.get(NS, 'motto', inOrg(ORG_A))).source).toBe('default');
441+
});
442+
});
443+
362444
describe('the in-memory store keeps the same identity', () => {
363445
it('two organizations each read their own value with no engine bound', async () => {
364446
const svc = new SettingsService({ env: {} });

0 commit comments

Comments
 (0)