Skip to content

Commit e12143e

Browse files
committed
test(service-settings): pin the user rung of a self-binding host's derived organization; name the omission's symptom
Claude-Session: https://claude.ai/code/session_014FFTcf6msoLbXiD6BR7wHW Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5f4a1ed commit e12143e

3 files changed

Lines changed: 20 additions & 7 deletions

File tree

‎.changeset/25249-settings-self-bound-organization.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ Metadata-protocol: none
1212
**BREAKING** read narrowing (ADR-0131 D8/D9, follow-up to the organization-less rung fix). It changes which organization a host's settings reads take, not what a metadata author can write, so it ships as `patch` with this banner.
1313

1414
- FROM: a host that bound its own engine (`new SettingsService({ engine })` or `bindEngine(engine)`) and stated no `organization` source read and wrote, for a caller that names no organization, as a composition with no organization object: its rows with no organization answered, and its writes stored none.
15-
- TO: such a host's service asks D9 itself, through the engine it holds (`resolveGuestOrganization`, the call the plugin's source makes). Under `single` with one organization it reads and writes as that organization; at none or several it has no tenant or user rung. An engine that exposes no `registry` cannot say the organization object is absent, so D9 asks `sys_organization`: an engine that does not know that object refuses the read and the settings call throws `AuthzStoreUnavailableError`. No engine at all (the in-memory store) and `SettingsServicePlugin` are unchanged.
15+
- TO: such a host's service asks D9 itself, through the engine it holds (`resolveGuestOrganization`, the call the plugin's source makes). Under `single` with one organization it reads and writes as that organization; at none or several it has no tenant or user rung. An engine that exposes no `registry` cannot say the organization object is absent, so D9 asks `sys_organization`. Over a store with no such object, a tenant-rung read or write by a caller naming none is then refused as organization-less (reads answer from the global rung or the default, tenant writes are refused with `SETTINGS_VALIDATION`), or raises `AuthzStoreUnavailableError` naming `sys_organization` where the engine does not know that object. No engine at all (the in-memory store), a stated source, an engine whose `registry.getObject` answers, and `SettingsServicePlugin` (`os serve`, `os dev`) are unchanged.
1616

17-
**Who is affected.** Only a host that binds its own engine on a composition with no organization object and states no source: a control-plane mock or an embedded runtime. A deployment booted through `SettingsServicePlugin` is not.
17+
**Who is affected.** Only a host that binds its own engine and states no source: a control-plane mock or an embedded runtime. On a composition with an organization object it now reads and writes the derived organization's rows, and its rows with no organization stop answering. On one without, it meets the refusal above. A deployment booted through `SettingsServicePlugin` is not affected.
1818

1919
**The fix.** State the composition at the bind site: `service.bindEngine(engine, audit, { organization: async () => ({ kind: 'no-organization-object' }) })` (a host that hands its engine to the constructor binds it this way instead), or hand over an engine whose `registry.getObject` can answer.

‎packages/services/service-settings/src/index.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,9 @@ export {
8080
// its shape; the action value and target-object constants travel with it for the
8181
// same reason a pin must not spell them by hand. [#25249] Such a host with no
8282
// organization object declares it (`bindEngine`'s `organization` extra answering
83-
// `no-organization-object`); left out, the service asks `sys_organization` through
84-
// that engine, so its rows with no organization stop answering, or, where the engine
85-
// does not know that object, the read throws `AuthzStoreUnavailableError`.
83+
// `no-organization-object`); left out, a tenant-rung read or write by a caller naming
84+
// none is refused as organization-less, or raises `AuthzStoreUnavailableError`
85+
// naming `sys_organization`.
8686
export {
8787
buildConfigChangeAuditSink,
8888
CONFIG_CHANGE_ACTION,

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const MANIFEST = {
4545
specifiers: [
4646
{ key: 'brand', type: 'text', label: 'Brand', scope: 'tenant', default: 'none' },
4747
{ key: 'legacy', type: 'text', label: 'Legacy', scope: 'tenant', default: 'none' },
48+
{ key: 'theme', type: 'text', label: 'Theme', scope: 'user', default: 'none' },
4849
],
4950
} as any;
5051

@@ -252,6 +253,8 @@ async function bootSelfBound(organizations: string[] | null, source?: typeof LEA
252253
for (const id of organizations ?? []) {
253254
await engine.insert('sys_organization', { id, name: id, slug: id }, SYS);
254255
}
256+
// The user a user-rung write names, as the store already holds it.
257+
memory.rowsOf('sys_user').set('usr_1', { id: 'usr_1', name: 'Ada', email: 'ada@example.test' });
255258
const service = new SettingsService({ env: {} });
256259
service.registerManifest(MANIFEST);
257260
service.bindEngine(wrapEngineAsSettingsEngine(engine as any), undefined, source ? { organization: source } : undefined);
@@ -295,15 +298,25 @@ function selfBoundDouble(source?: typeof LEAN_ORGANIZATION_SOURCE) {
295298
}
296299

297300
describe('[#25249] a host that binds its own engine and states no organization source', () => {
298-
it('(a) one organization: it reads and writes as that organization, through an adapter with no registry', async () => {
301+
it('(a) one organization: the tenant and user rungs read and write as that organization, through an adapter with no registry', async () => {
299302
const b = await bootSelfBound([ORG]);
300303
await b.seed({ key: 'brand', value: 'Acme', organization_id: ORG });
301304
await b.seed({ key: 'legacy', value: 'Unowned', organization_id: null });
305+
await b.seed({ key: 'theme', value: 'Unowned dark', scope: 'user', user_id: 'usr_1', organization_id: null });
302306

303307
expect((await b.service.get(NS, 'brand', {})).value).toBe('Acme');
304308
expect((await b.service.get(NS, 'legacy', {})).source).toBe('default');
309+
expect((await b.service.get(NS, 'theme', { userId: 'usr_1' })).source).toBe('default');
305310
await b.service.set(NS, 'brand', 'Acme two', {});
306-
expect(b.rows()).toEqual([['brand', ORG, 'Acme two'], ['legacy', null, 'Unowned']]);
311+
await b.service.set(NS, 'theme', 'Light', { userId: 'usr_1' });
312+
await b.service.set(NS, 'theme', 'Light two', { userId: 'usr_1' });
313+
314+
expect((await b.service.get(NS, 'brand', { tenantId: ORG })).value).toBe('Acme two');
315+
expect((await b.service.get(NS, 'theme', { userId: 'usr_1' })).value).toBe('Light two');
316+
expect((await b.service.get(NS, 'theme', { userId: 'usr_1', tenantId: ORG })).value).toBe('Light two');
317+
expect(b.rows()).toEqual([
318+
['brand', ORG, 'Acme two'], ['legacy', null, 'Unowned'], ['theme', null, 'Unowned dark'], ['theme', ORG, 'Light two'],
319+
]);
307320
});
308321

309322
it('(b) no organization object, real engine: the probe is refused as a failed read, loudly', async () => {

0 commit comments

Comments
 (0)