Skip to content

Commit 9d60b42

Browse files
fix(service-settings)!: a host that binds its own engine and states no organization source reads and writes as the organization D9 derives (#25350)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuSQfvzuV2ZjD9aZ44eAhnVq)_ Fixes #25249 Clause-②: no (narrowing) Metadata-protocol: none Ruling-ref: 6355249103 (letter A). Why `Clause-②: no (narrowing)`: nothing is added to any accept set. `SettingsServiceOptions` and the `bindEngine` extras are unchanged. What narrows is which organization a host that binds its own engine, and states no organization source, reads and writes as. ## What changed `SettingsService.organizationReachOf` (`packages/services/service-settings/src/settings-service.ts`) now takes ADR-0131 D9's answer from `organizationDecision()`: 1. the stated `organization` source, when the host bound one (the plugin always does); 2. no engine at all (the in-memory store): `no-organization-object`, as before; 3. otherwise `resolveGuestOrganization(this.engine, posture)`, the same call the plugin's source makes, through the engine the service holds. There is no second predicate, no registry-sniffing arm, and no `catch` around the probe. An engine that exposes no `registry` (the plugin's own `wrapEngineAsSettingsEngine`, any hand-written `SettingsEngine`) cannot say the organization object is absent, so D9 reads it as registered and asks `sys_organization`. A lean self-binding host declares the composition at its bind site: `bindEngine(engine, audit, { organization: async () => ({ kind: 'no-organization-object' }) })`, or hands over an engine whose `registry.getObject` answers. The retired docblock sentence ("Unset … reads as a composition that registers no organization object") is rewritten on the source type, its field and the `bindEngine` extra. The `index.ts` host paragraph gains the declaration line and names the symptom of omitting it. No `docs/adr` edit. Also: a changeset (`@objectstack/service-settings` patch, BREAKING, FROM/TO, the one-line fix). ## Branch The harness assigned the branch `claude/project-thread-c4vx2u`, not the repo-convention `claude/issue-25249-…`. This note is for the Duplicate Fix Guard. The card's `Claim:` (6354964172) names this branch. ## Tests Premises P1–P4 of the ruling were re-read on the branch before the migration: `registryHoldsObject` answers `true` with no `registry.getObject` (core `resolve-authz-context.ts:1235`); `wrapEngineAsSettingsEngine` returns `find` / `insert` / `update` only and forwards `find` verbatim; the plugin's only `bindEngine` call states a source; the plugin is the only non-test constructor in the repo. All four hold. **Migration (45 tests in 7 files, red on the route alone).** Each suite now states its composition truthfully, through a shared testkit (`src/__tests__/settings-organization.testkit.ts`): - a suite over a double states `LEAN_ORGANIZATION_SOURCE` at its bind: `settings-getmany`, `settings-user-rung-owner.pin`, `settings-system-context.pin` (its constructor-engine case moves to `bindEngine` with the same crypto provider and secret store); - a suite running the real `ObjectQL` behind the production adapter forwards that engine's `registry` (`withEngineRegistry`), so D9 answers `no-organization-object` itself: `settings-secret-rotation`, `sys-secret-orphan-report`, `settings-global-rung`, `settings-user-reference.pin`. No default in the service, no stub `getObject`, no new input. The four construction sites outside the package pass `{ env }` and no engine; they are untouched. **Pins** (`settings-organization-reach.pin.test.ts`, new section): - (a) a registry-less adapter over a real engine holding one `sys_organization` row, no source: the tenant and user rungs read and write as that organization, and the rows with no organization do not answer; - (b) no organization object, no source: on the real engine, `AuthzStoreUnavailableError` naming `sys_organization` (pinned as measured; `isMissingTableError` not widened); on a double whose probe answers nothing, no tenant rung and the tenant write refused (`SETTINGS_VALIDATION`); - (c) a stated lean source: rows with no organization read and written, unchanged; - (d) no engine: the in-memory store unchanged; - (e) the existing plugin pins keep their assertions. Ablation (`scripts/ablation-replace.mjs` on the committed route): the engine branch of `organizationDecision` replaced by the old lean answer. (a) and both (b) pins went red; (c), (d) and the plugin pins (e) stayed green, the expected direction. Restored: blob equals HEAD, `git diff HEAD` empty. Runs on `e12143e6` (after merging `origin/main`): - `vitest run` in `packages/services/service-settings`: 58 files / 987 tests passed (982 before the new pins). - `pnpm --filter @objectstack/service-settings typecheck`: exit 0. - Derived gates, the full dogfood battery (`!` title) and the contract-review-tier review: reported on the card in the os-dev-report and on this PR. ## Out of scope Hosts outside this repo that bind `SettingsService` themselves (cloud control-plane mocks, embedded runtimes) are downstream of the published package. The changeset's FROM / TO and its one-line fix are the hand-off; no cloud card is filed from here. --- _Generated by [Claude Code](https://claude.ai/code/session_014FFTcf6msoLbXiD6BR7wHW)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_014FFTcf6msoLbXiD6BR7wHW --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 448374d commit 9d60b42

13 files changed

Lines changed: 252 additions & 29 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/service-settings': patch
3+
---
4+
5+
fix(service-settings)!: a host that binds its own engine and states no organization source reads and writes as the organization ADR-0131 D9 derives through that engine, not as no organization
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) Which organization a self-binding host's settings reads and writes take 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, option, error code or route is added, removed or renamed: `SettingsServiceOptions` and the `bindEngine` extras are unchanged, and `SettingsServicePlugin` already states its source. The remedy for a host this narrows is one line at its bind site, 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 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.
13+
14+
- 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`. 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.
16+
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.
18+
19+
**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.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Test scaffolding: how a suite that binds its own engine states the
5+
* composition's organization facts (#25249, ADR-0131 D9).
6+
*
7+
* A host that states no organization source has the service ask D9 itself,
8+
* through the engine it holds — and an engine with no registry to ask is read
9+
* the fail-closed way, as one that DOES register `sys_organization`. So a
10+
* suite states it, one of two ways:
11+
*
12+
* - {@link LEAN_ORGANIZATION_SOURCE}, at the bind site, for a double: this
13+
* composition registers no organization object.
14+
* - {@link withEngineRegistry}, for a suite that runs the real ObjectQL behind
15+
* the production adapter: the real registry answers, as it would through
16+
* the raw engine the plugin hands D9.
17+
*/
18+
19+
import type { SettingsOrganizationSource } from '../settings-service.js';
20+
import type { SettingsEngine } from '../settings-service.types.js';
21+
22+
/** A lean composition's answer: no organization object is registered. */
23+
export const LEAN_ORGANIZATION_SOURCE: SettingsOrganizationSource = async () => ({ kind: 'no-organization-object' });
24+
25+
/** `wrapped`, carrying the real engine's registry, so D9's registry question reaches it. */
26+
export function withEngineRegistry<E extends SettingsEngine>(wrapped: E, engine: { registry: unknown }): E & { registry: unknown } {
27+
return Object.assign(wrapped, { registry: engine.registry });
28+
}

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,11 @@ export {
7878
// because a host that binds its own engine (control-plane mocks, embedded
7979
// runtimes) has to be able to get the SAME ledger row rather than re-deriving
8080
// its shape; the action value and target-object constants travel with it for the
81-
// same reason a pin must not spell them by hand.
81+
// same reason a pin must not spell them by hand. [#25249] Such a host with no
82+
// organization object declares it (`bindEngine`'s `organization` extra answering
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`.
8286
export {
8387
buildConfigChangeAuditSink,
8488
CONFIG_CHANGE_ACTION,

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2323
import { SettingsService } from './settings-service.js';
2424
import { UnknownKeyError } from './settings-service.types.js';
25+
import { LEAN_ORGANIZATION_SOURCE } from './__tests__/settings-organization.testkit.js';
2526

2627
// WHERE-matcher gate: implement exactly the combinators the service emits and
2728
// THROW on the rest — a bare field-equality read of `$or` would silently match
@@ -91,7 +92,7 @@ async function makeService(rows = ROWS) {
9192
const engine = makeEngine(rows);
9293
const svc = new SettingsService();
9394
svc.registerManifest(MANIFEST);
94-
svc.bindEngine(engine as any);
95+
svc.bindEngine(engine as any, undefined, { organization: LEAN_ORGANIZATION_SOURCE });
9596
return { svc, engine };
9697
}
9798

‎packages/services/service-settings/src/settings-global-rung.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { SysPlatformSetting, SysSecret, SysSetting } from '@objectstack/platform
3131
import type { SettingsManifest } from '@objectstack/spec/system';
3232
import { SettingsService } from './settings-service.js';
3333
import { SettingsServicePlugin, wrapEngineAsSettingsEngine } from './settings-service-plugin.js';
34+
import { withEngineRegistry } from './__tests__/settings-organization.testkit.js';
3435
import { LocalCryptoProvider } from './local-crypto-provider.js';
3536
import { SettingsLockedError } from './settings-service.types.js';
3637

@@ -151,7 +152,7 @@ async function boot() {
151152
const cryptoProvider = new LocalCryptoProvider();
152153
const svc = new SettingsService({
153154
env: {},
154-
engine: wrapEngineAsSettingsEngine(engine as any),
155+
engine: withEngineRegistry(wrapEngineAsSettingsEngine(engine as any), engine),
155156
cryptoProvider,
156157
secretStore: (new SettingsServicePlugin() as any).buildSecretStore(engine),
157158
});

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

Lines changed: 135 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,21 @@
1818
* The callers this stands for read with an empty context: plugin-auth's email
1919
* brand name and boot-time locale, the anonymous runtime-config door's
2020
* branding read, and a signed-in caller with no active organization.
21+
*
22+
* [#25249] The last section pins a host that binds its OWN engine and states
23+
* no organization source: the service asks D9 itself, through the engine it
24+
* holds, and an engine with no registry to ask is read as one that registers
25+
* the organization object.
2126
*/
2227

2328
import { describe, it, expect } from 'vitest';
24-
import { ObjectKernel } from '@objectstack/core';
25-
import { ObjectQL } from '@objectstack/objectql';
29+
import { isAuthzStoreUnavailableError, ObjectKernel } from '@objectstack/core';
30+
import { assertEngineUpdateDispatch, ObjectQL } from '@objectstack/objectql';
2631
import { SysOrganization, SysUser } from '@objectstack/platform-objects/identity';
2732
import { SysPlatformSetting, SysSetting } from '@objectstack/platform-objects/system';
28-
import { SettingsServicePlugin } from './settings-service-plugin.js';
29-
import type { SettingsService } from './settings-service.js';
33+
import { SettingsServicePlugin, wrapEngineAsSettingsEngine } from './settings-service-plugin.js';
34+
import { SettingsService } from './settings-service.js';
35+
import { LEAN_ORGANIZATION_SOURCE } from './__tests__/settings-organization.testkit.js';
3036

3137
const SYS = { context: { isSystem: true } } as const;
3238
const NS = 'org_reach_fixture';
@@ -39,6 +45,7 @@ const MANIFEST = {
3945
specifiers: [
4046
{ key: 'brand', type: 'text', label: 'Brand', scope: 'tenant', default: 'none' },
4147
{ key: 'legacy', type: 'text', label: 'Legacy', scope: 'tenant', default: 'none' },
48+
{ key: 'theme', type: 'text', label: 'Theme', scope: 'user', default: 'none' },
4249
],
4350
} as any;
4451

@@ -227,3 +234,127 @@ describe('a composition with no organization object (the lean embedding, ADR-013
227234
expect(b.rows()).toEqual([['brand', null, 'Lean again']]);
228235
});
229236
});
237+
238+
/**
239+
* [#25249] A host that binds its own engine — a control-plane mock, an embedded
240+
* runtime — through the production adapter, which carries no registry. No
241+
* kernel, no plugin, no posture source (`single`). `organizations` is as in
242+
* {@link boot}; `source` is the organization source the host states, if any.
243+
*/
244+
async function bootSelfBound(organizations: string[] | null, source?: typeof LEAN_ORGANIZATION_SOURCE) {
245+
const engine = new ObjectQL();
246+
engine.registry.logLevel = 'silent';
247+
const memory = makeMemoryDriver();
248+
engine.registerDriver(memory.driver, true);
249+
await engine.init();
250+
const objects: any[] = [SysUser, SysSetting, SysPlatformSetting];
251+
if (organizations !== null) objects.push(SysOrganization);
252+
for (const o of objects) engine.registry.registerObject(o, '@objectstack/platform-objects');
253+
for (const id of organizations ?? []) {
254+
await engine.insert('sys_organization', { id, name: id, slug: id }, SYS);
255+
}
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' });
258+
const service = new SettingsService({ env: {} });
259+
service.registerManifest(MANIFEST);
260+
service.bindEngine(wrapEngineAsSettingsEngine(engine as any), undefined, source ? { organization: source } : undefined);
261+
const seed = (row: Record<string, unknown>) => {
262+
const id = `seed_${memory.rowsOf('sys_setting').size + 1}`;
263+
memory.rowsOf('sys_setting').set(id, {
264+
id, namespace: NS, scope: 'tenant', user_id: null, value_enc: null, encrypted: false,
265+
locked: false, locked_reason: null, ...row,
266+
});
267+
};
268+
const rows = () => [...memory.rowsOf('sys_setting').values()]
269+
.map((r) => [r.key, r.organization_id ?? null, r.value])
270+
.sort((a, b) => String(a[0]).localeCompare(String(b[0])));
271+
return { service, seed, rows };
272+
}
273+
274+
/**
275+
* The same host over a double: an `IDataEngine`-shaped object with no registry,
276+
* whose `sys_organization` holds no rows, and one tenant row written with no
277+
* organization.
278+
*/
279+
function selfBoundDouble(source?: typeof LEAN_ORGANIZATION_SOURCE) {
280+
const stored: Array<Record<string, unknown>> = [
281+
{ id: 'seed_1', namespace: NS, key: 'brand', scope: 'tenant', user_id: null, organization_id: null, value: 'Lean', encrypted: false, locked: false },
282+
];
283+
const engine = {
284+
async find(object: string, query: any) {
285+
if (object !== 'sys_setting') return [];
286+
const where = query?.where ?? {};
287+
const branches: Array<Record<string, unknown>> = Array.isArray(where.$or) ? where.$or : [where];
288+
const { $or: _or, ...rest } = where;
289+
const hits = stored.filter((r) => branches.some((b) => Object.entries({ ...rest, ...b }).every(([k, v]) => (r[k] ?? null) === (v ?? null))));
290+
return typeof query?.limit === 'number' ? hits.slice(0, query.limit) : hits;
291+
},
292+
async insert(_object: string, data: Record<string, unknown>) { stored.push({ ...data }); return { ...data }; },
293+
async update(_object: string, data: Record<string, unknown>, options?: any) { assertEngineUpdateDispatch(data, options); return 1; },
294+
};
295+
const service = new SettingsService({ env: {} });
296+
service.registerManifest(MANIFEST);
297+
service.bindEngine(engine as any, undefined, source ? { organization: source } : undefined);
298+
return { service };
299+
}
300+
301+
describe('[#25249] a host that binds its own engine and states no organization source', () => {
302+
it('(a) one organization: the tenant and user rungs read and write as that organization, through an adapter with no registry', async () => {
303+
const b = await bootSelfBound([ORG]);
304+
await b.seed({ key: 'brand', value: 'Acme', organization_id: ORG });
305+
await b.seed({ key: 'legacy', value: 'Unowned', organization_id: null });
306+
await b.seed({ key: 'theme', value: 'Unowned dark', scope: 'user', user_id: 'usr_1', organization_id: null });
307+
308+
expect((await b.service.get(NS, 'brand', {})).value).toBe('Acme');
309+
expect((await b.service.get(NS, 'legacy', {})).source).toBe('default');
310+
expect((await b.service.get(NS, 'theme', { userId: 'usr_1' })).source).toBe('default');
311+
await b.service.set(NS, 'brand', 'Acme two', {});
312+
await b.service.set(NS, 'theme', 'Light', { userId: 'usr_1' });
313+
await b.service.set(NS, 'theme', 'Light two', { userId: 'usr_1' });
314+
315+
expect((await b.service.get(NS, 'brand', { tenantId: ORG })).value).toBe('Acme two');
316+
expect((await b.service.get(NS, 'theme', { userId: 'usr_1' })).value).toBe('Light two');
317+
expect((await b.service.get(NS, 'theme', { userId: 'usr_1', tenantId: ORG })).value).toBe('Light two');
318+
expect(b.rows()).toEqual([
319+
['brand', ORG, 'Acme two'], ['legacy', null, 'Unowned'], ['theme', null, 'Unowned dark'], ['theme', ORG, 'Light two'],
320+
]);
321+
});
322+
323+
it('(b) no organization object, real engine: the probe is refused as a failed read, loudly', async () => {
324+
// Measured, not converted: the adapter cannot say the object is absent, so
325+
// D9 asks `sys_organization`, and the engine refuses an unregistered name
326+
// as "object not found" — which is not a missing table.
327+
const b = await bootSelfBound(null);
328+
const refusal = await b.service.get(NS, 'brand', {}).then(() => null, (e) => e);
329+
expect(isAuthzStoreUnavailableError(refusal)).toBe(true);
330+
expect(refusal.object).toBe('sys_organization');
331+
});
332+
333+
it('(b) no organization object, a double with no registry: zero organizations, so no tenant row and no tenant write', async () => {
334+
const d = selfBoundDouble();
335+
expect((await d.service.get(NS, 'brand', {})).source).toBe('default');
336+
const refusal = await d.service.set(NS, 'brand', 'Nobody', {}).then(() => null, (e) => e);
337+
expect(refusal?.code).toBe('SETTINGS_VALIDATION');
338+
});
339+
});
340+
341+
describe('[#25249] the readings a stated source and an absent engine keep', () => {
342+
it('(c) a host that states the lean composition reads and writes the rows with no organization', async () => {
343+
const d = selfBoundDouble(LEAN_ORGANIZATION_SOURCE);
344+
expect((await d.service.get(NS, 'brand', {})).value).toBe('Lean');
345+
346+
const b = await bootSelfBound(null, LEAN_ORGANIZATION_SOURCE);
347+
await b.service.set(NS, 'brand', 'Lean', {});
348+
await b.service.set(NS, 'brand', 'Lean again', {});
349+
expect((await b.service.get(NS, 'brand', {})).value).toBe('Lean again');
350+
expect(b.rows()).toEqual([['brand', null, 'Lean again']]);
351+
});
352+
353+
it('(d) no engine: the in-memory store keeps its rows with no organization', async () => {
354+
const service = new SettingsService({ env: {} });
355+
service.registerManifest(MANIFEST);
356+
await service.set(NS, 'brand', 'Memory', {});
357+
expect((await service.get(NS, 'brand', {})).value).toBe('Memory');
358+
expect(((service as any).memory as Array<Record<string, unknown>>).map((r) => r.organization_id ?? null)).toEqual([null]);
359+
});
360+
});

‎packages/services/service-settings/src/settings-secret-rotation.test.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ import { SysPlatformSetting, SysSecret, SysSetting } from '@objectstack/platform
8181
import type { SettingsManifest } from '@objectstack/spec/system';
8282
import { SettingsService } from './settings-service.js';
8383
import { wrapEngineAsSettingsEngine } from './settings-service-plugin.js';
84+
import { withEngineRegistry } from './__tests__/settings-organization.testkit.js';
8485
import { LocalCryptoProvider } from './local-crypto-provider.js';
8586
import { dropEchoedSecretMasks, SETTINGS_SECRET_MASK } from './settings-secret-redaction.js';
8687
import type { SettingsEngine, SettingsSecretStore } from './settings-service.types.js';
@@ -306,11 +307,14 @@ async function boot(opts: {
306307
const logged: string[] = [];
307308
const svc = new SettingsService({
308309
env: {},
309-
engine: opts.failVerificationRead
310-
? wrapEngineFailingPostUpdateReads(engine)
311-
: opts.forwardContext === false
312-
? wrapEngineDroppingContext(engine)
313-
: wrapEngineAsSettingsEngine(engine as any),
310+
engine: withEngineRegistry(
311+
opts.failVerificationRead
312+
? wrapEngineFailingPostUpdateReads(engine)
313+
: opts.forwardContext === false
314+
? wrapEngineDroppingContext(engine)
315+
: wrapEngineAsSettingsEngine(engine as any),
316+
engine,
317+
),
314318
cryptoProvider: new LocalCryptoProvider(),
315319
secretStore: { ...baseStore, ...(opts.secretStoreOverrides ?? {}) },
316320
logger: { error: (m) => { logged.push(m); } },

0 commit comments

Comments
 (0)