From 3c27c0a7662146e6f605ef404705b55d292869b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 01:13:04 +0000 Subject: [PATCH 1/5] fix(service-settings): a host that states no organization source reads as the organization D9 derives through the bound engine Claude-Session: https://claude.ai/code/session_014FFTcf6msoLbXiD6BR7wHW Co-authored-by: Claude --- .../service-settings/src/settings-service.ts | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index c4d068dfb4..cc76b668ec 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -41,7 +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 { resolveGuestOrganization, type SystemWriteOrganizationDecision } from '@objectstack/core'; import { SETTINGS_SECRET_MASK } from './settings-secret-redaction.js'; import { USER_OBJECT, assertUserReferenceResolves, registeredLabel } from './actor-reference.js'; import { @@ -200,8 +200,12 @@ export type SettingsTenancyPostureSource = () => * 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. + * Unset, the service asks the same predicate itself, through the engine it + * holds ({@link SettingsService.organizationReachOf}). A host that binds its own + * engine on a composition with no organization object states that with a + * source answering `{ kind: 'no-organization-object' }`. A service with no + * engine at all (the in-memory store) holds no organization's rows, and keeps + * that reading. */ export type SettingsOrganizationSource = () => Promise; @@ -868,8 +872,8 @@ export class SettingsService { 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}. + * bound with the engine. Unset, it is derived through the engine the service + * holds. See {@link organizationReachOf}. */ private organizationSource?: SettingsOrganizationSource; /** Change subscribers, optionally scoped to a namespace. */ @@ -916,7 +920,8 @@ export class SettingsService { /** * 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. + * composition with no organization object. Omitted, the service asks + * D9 through `engine` itself (see `organizationDecision`). */ organization?: SettingsOrganizationSource; }, @@ -1868,14 +1873,33 @@ export class SettingsService { const organizationId = callerOrganizationIdOf(ctx); if (organizationId !== null) return { kind: 'own', organizationId }; if (await this.organizationWallStands()) return UNOWNED; - const source = this.organizationSource; - if (!source) return { kind: 'no-organization-object' }; - const decision = await source(); + const decision = await this.organizationDecision(); if (decision.kind === 'derived') return { kind: 'own', organizationId: decision.organizationId }; if (decision.kind === 'no-organization-object') return { kind: 'no-organization-object' }; return UNOWNED; } + /** + * ADR-0131 D9's answer for a caller that names no organization where no wall + * stands: the bound source's when the host stated one, else the predicate + * asked here, through the engine this service holds. + * + * The second branch is the same call the plugin's source makes + * (`resolveGuestOrganization`, the one spelling of D9 for a caller with no + * organization of its own), so a host that binds its own engine, or hands + * one to the constructor, reads and writes as the organization the + * composition derives rather than as no organization. An engine that exposes + * no registry cannot say the organization object is absent, and the + * predicate reads that as registered: a host that means the lean composition + * says so with its source. No engine at all is the in-memory store, which + * holds no organization's rows. + */ + private async organizationDecision(): Promise { + if (this.organizationSource) return this.organizationSource(); + if (!this.engine) return { kind: 'no-organization-object' }; + return resolveGuestOrganization(this.engine, await this.tenancyPosture?.()); + } + /** * Does the tenancy posture IN FORCE wall organizations off from each other? * From 6925a174c0efe741cd7869f0160bbc050710a41d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 01:43:00 +0000 Subject: [PATCH 2/5] test(service-settings): self-binding suites state their composition's organization facts; pin the derivation The suites that bind their own engine now say what the composition holds: a double states the lean source at the bind site, and a suite running the real ObjectQL forwards that engine's registry onto the adapter. Pins: one organization through a registry-less adapter reads and writes as it; no organization object is loud on the real engine and zero organizations on a double; a stated lean source and no engine keep their readings. Claude-Session: https://claude.ai/code/session_014FFTcf6msoLbXiD6BR7wHW Co-authored-by: Claude --- .../25249-settings-self-bound-organization.md | 19 +++ .../settings-organization.testkit.ts | 28 ++++ .../services/service-settings/src/index.ts | 6 +- .../src/settings-getmany.test.ts | 3 +- .../src/settings-global-rung.test.ts | 3 +- .../settings-organization-reach.pin.test.ts | 123 +++++++++++++++++- .../src/settings-secret-rotation.test.ts | 14 +- .../src/settings-system-context.pin.test.ts | 11 +- .../src/settings-user-reference.pin.test.ts | 3 +- .../src/settings-user-rung-owner.pin.test.ts | 3 +- .../src/sys-secret-orphan-report.test.ts | 5 +- 11 files changed, 199 insertions(+), 19 deletions(-) create mode 100644 .changeset/25249-settings-self-bound-organization.md create mode 100644 packages/services/service-settings/src/__tests__/settings-organization.testkit.ts diff --git a/.changeset/25249-settings-self-bound-organization.md b/.changeset/25249-settings-self-bound-organization.md new file mode 100644 index 0000000000..ec6b8ca3d4 --- /dev/null +++ b/.changeset/25249-settings-self-bound-organization.md @@ -0,0 +1,19 @@ +--- +'@objectstack/service-settings': patch +--- + +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 + +Clause-②: no (narrowing) +Metadata-protocol: none + + + +**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. + +- 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. +- 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. + +**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. + +**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. diff --git a/packages/services/service-settings/src/__tests__/settings-organization.testkit.ts b/packages/services/service-settings/src/__tests__/settings-organization.testkit.ts new file mode 100644 index 0000000000..ed0ae86831 --- /dev/null +++ b/packages/services/service-settings/src/__tests__/settings-organization.testkit.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Test scaffolding: how a suite that binds its own engine states the + * composition's organization facts (#25249, ADR-0131 D9). + * + * A host that states no organization source has the service ask D9 itself, + * through the engine it holds — and an engine with no registry to ask is read + * the fail-closed way, as one that DOES register `sys_organization`. So a + * suite states it, one of two ways: + * + * - {@link LEAN_ORGANIZATION_SOURCE}, at the bind site, for a double: this + * composition registers no organization object. + * - {@link withEngineRegistry}, for a suite that runs the real ObjectQL behind + * the production adapter: the real registry answers, as it would through + * the raw engine the plugin hands D9. + */ + +import type { SettingsOrganizationSource } from '../settings-service.js'; +import type { SettingsEngine } from '../settings-service.types.js'; + +/** A lean composition's answer: no organization object is registered. */ +export const LEAN_ORGANIZATION_SOURCE: SettingsOrganizationSource = async () => ({ kind: 'no-organization-object' }); + +/** `wrapped`, carrying the real engine's registry, so D9's registry question reaches it. */ +export function withEngineRegistry(wrapped: E, engine: { registry: unknown }): E & { registry: unknown } { + return Object.assign(wrapped, { registry: engine.registry }); +} diff --git a/packages/services/service-settings/src/index.ts b/packages/services/service-settings/src/index.ts index dd0491a22b..9045abfaea 100644 --- a/packages/services/service-settings/src/index.ts +++ b/packages/services/service-settings/src/index.ts @@ -78,7 +78,11 @@ export { // because a host that binds its own engine (control-plane mocks, embedded // runtimes) has to be able to get the SAME ledger row rather than re-deriving // its shape; the action value and target-object constants travel with it for the -// same reason a pin must not spell them by hand. +// same reason a pin must not spell them by hand. [#25249] Such a host with no +// organization object declares it (`bindEngine`'s `organization` extra answering +// `no-organization-object`); left out, the service asks `sys_organization` through +// that engine, so its rows with no organization stop answering, or, where the engine +// does not know that object, the read throws `AuthzStoreUnavailableError`. export { buildConfigChangeAuditSink, CONFIG_CHANGE_ACTION, diff --git a/packages/services/service-settings/src/settings-getmany.test.ts b/packages/services/service-settings/src/settings-getmany.test.ts index d1c00ff43f..557bc67b65 100644 --- a/packages/services/service-settings/src/settings-getmany.test.ts +++ b/packages/services/service-settings/src/settings-getmany.test.ts @@ -22,6 +22,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { SettingsService } from './settings-service.js'; import { UnknownKeyError } from './settings-service.types.js'; +import { LEAN_ORGANIZATION_SOURCE } from './__tests__/settings-organization.testkit.js'; // WHERE-matcher gate: implement exactly the combinators the service emits and // THROW on the rest — a bare field-equality read of `$or` would silently match @@ -91,7 +92,7 @@ async function makeService(rows = ROWS) { const engine = makeEngine(rows); const svc = new SettingsService(); svc.registerManifest(MANIFEST); - svc.bindEngine(engine as any); + svc.bindEngine(engine as any, undefined, { organization: LEAN_ORGANIZATION_SOURCE }); return { svc, engine }; } diff --git a/packages/services/service-settings/src/settings-global-rung.test.ts b/packages/services/service-settings/src/settings-global-rung.test.ts index 0fe8e2c9ae..66752b8c03 100644 --- a/packages/services/service-settings/src/settings-global-rung.test.ts +++ b/packages/services/service-settings/src/settings-global-rung.test.ts @@ -31,6 +31,7 @@ import { SysPlatformSetting, SysSecret, SysSetting } from '@objectstack/platform import type { SettingsManifest } from '@objectstack/spec/system'; import { SettingsService } from './settings-service.js'; import { SettingsServicePlugin, wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; +import { withEngineRegistry } from './__tests__/settings-organization.testkit.js'; import { LocalCryptoProvider } from './local-crypto-provider.js'; import { SettingsLockedError } from './settings-service.types.js'; @@ -151,7 +152,7 @@ async function boot() { const cryptoProvider = new LocalCryptoProvider(); const svc = new SettingsService({ env: {}, - engine: wrapEngineAsSettingsEngine(engine as any), + engine: withEngineRegistry(wrapEngineAsSettingsEngine(engine as any), engine), cryptoProvider, secretStore: (new SettingsServicePlugin() as any).buildSecretStore(engine), }); 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 index 323afda544..41a83c4696 100644 --- a/packages/services/service-settings/src/settings-organization-reach.pin.test.ts +++ b/packages/services/service-settings/src/settings-organization-reach.pin.test.ts @@ -18,15 +18,21 @@ * 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. + * + * [#25249] The last section pins a host that binds its OWN engine and states + * no organization source: the service asks D9 itself, through the engine it + * holds, and an engine with no registry to ask is read as one that registers + * the organization object. */ import { describe, it, expect } from 'vitest'; -import { ObjectKernel } from '@objectstack/core'; +import { isAuthzStoreUnavailableError, 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'; +import { SettingsServicePlugin, wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; +import { SettingsService } from './settings-service.js'; +import { LEAN_ORGANIZATION_SOURCE } from './__tests__/settings-organization.testkit.js'; const SYS = { context: { isSystem: true } } as const; const NS = 'org_reach_fixture'; @@ -227,3 +233,114 @@ describe('a composition with no organization object (the lean embedding, ADR-013 expect(b.rows()).toEqual([['brand', null, 'Lean again']]); }); }); + +/** + * [#25249] A host that binds its own engine — a control-plane mock, an embedded + * runtime — through the production adapter, which carries no registry. No + * kernel, no plugin, no posture source (`single`). `organizations` is as in + * {@link boot}; `source` is the organization source the host states, if any. + */ +async function bootSelfBound(organizations: string[] | null, source?: typeof LEAN_ORGANIZATION_SOURCE) { + 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 service = new SettingsService({ env: {} }); + service.registerManifest(MANIFEST); + service.bindEngine(wrapEngineAsSettingsEngine(engine as any), undefined, source ? { organization: source } : undefined); + const seed = (row: Record) => { + const id = `seed_${memory.rowsOf('sys_setting').size + 1}`; + 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 }; +} + +/** + * The same host over a double: an `IDataEngine`-shaped object with no registry, + * whose `sys_organization` holds no rows, and one tenant row written with no + * organization. + */ +function selfBoundDouble(source?: typeof LEAN_ORGANIZATION_SOURCE) { + const stored: Array> = [ + { id: 'seed_1', namespace: NS, key: 'brand', scope: 'tenant', user_id: null, organization_id: null, value: 'Lean', encrypted: false, locked: false }, + ]; + const engine = { + async find(object: string, query: any) { + if (object !== 'sys_setting') return []; + const where = query?.where ?? {}; + const branches: Array> = Array.isArray(where.$or) ? where.$or : [where]; + const { $or: _or, ...rest } = where; + return stored.filter((r) => branches.some((b) => Object.entries({ ...rest, ...b }).every(([k, v]) => (r[k] ?? null) === (v ?? null)))); + }, + async insert(_object: string, data: Record) { stored.push({ ...data }); return { ...data }; }, + async update() { return 1; }, + }; + const service = new SettingsService({ env: {} }); + service.registerManifest(MANIFEST); + service.bindEngine(engine as any, undefined, source ? { organization: source } : undefined); + return { service }; +} + +describe('[#25249] a host that binds its own engine and states no organization source', () => { + it('(a) one organization: it reads and writes as that organization, through an adapter with no registry', async () => { + const b = await bootSelfBound([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', {})).value).toBe('Acme'); + expect((await b.service.get(NS, 'legacy', {})).source).toBe('default'); + await b.service.set(NS, 'brand', 'Acme two', {}); + expect(b.rows()).toEqual([['brand', ORG, 'Acme two'], ['legacy', null, 'Unowned']]); + }); + + it('(b) no organization object, real engine: the probe is refused as a failed read, loudly', async () => { + // Measured, not converted: the adapter cannot say the object is absent, so + // D9 asks `sys_organization`, and the engine refuses an unregistered name + // as "object not found" — which is not a missing table. + const b = await bootSelfBound(null); + const refusal = await b.service.get(NS, 'brand', {}).then(() => null, (e) => e); + expect(isAuthzStoreUnavailableError(refusal)).toBe(true); + expect(refusal.object).toBe('sys_organization'); + }); + + it('(b) no organization object, a double with no registry: zero organizations, so no tenant row and no tenant write', async () => { + const d = selfBoundDouble(); + expect((await d.service.get(NS, 'brand', {})).source).toBe('default'); + const refusal = await d.service.set(NS, 'brand', 'Nobody', {}).then(() => null, (e) => e); + expect(refusal?.code).toBe('SETTINGS_VALIDATION'); + }); +}); + +describe('[#25249] the readings a stated source and an absent engine keep', () => { + it('(c) a host that states the lean composition reads and writes the rows with no organization', async () => { + const d = selfBoundDouble(LEAN_ORGANIZATION_SOURCE); + expect((await d.service.get(NS, 'brand', {})).value).toBe('Lean'); + + const b = await bootSelfBound(null, LEAN_ORGANIZATION_SOURCE); + 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']]); + }); + + it('(d) no engine: the in-memory store keeps its rows with no organization', async () => { + const service = new SettingsService({ env: {} }); + service.registerManifest(MANIFEST); + await service.set(NS, 'brand', 'Memory', {}); + expect((await service.get(NS, 'brand', {})).value).toBe('Memory'); + expect(((service as any).memory as Array>).map((r) => r.organization_id ?? null)).toEqual([null]); + }); +}); diff --git a/packages/services/service-settings/src/settings-secret-rotation.test.ts b/packages/services/service-settings/src/settings-secret-rotation.test.ts index 1e8086f0d5..139fa7af69 100644 --- a/packages/services/service-settings/src/settings-secret-rotation.test.ts +++ b/packages/services/service-settings/src/settings-secret-rotation.test.ts @@ -81,6 +81,7 @@ import { SysPlatformSetting, SysSecret, SysSetting } from '@objectstack/platform import type { SettingsManifest } from '@objectstack/spec/system'; import { SettingsService } from './settings-service.js'; import { wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; +import { withEngineRegistry } from './__tests__/settings-organization.testkit.js'; import { LocalCryptoProvider } from './local-crypto-provider.js'; import { dropEchoedSecretMasks, SETTINGS_SECRET_MASK } from './settings-secret-redaction.js'; import type { SettingsEngine, SettingsSecretStore } from './settings-service.types.js'; @@ -306,11 +307,14 @@ async function boot(opts: { const logged: string[] = []; const svc = new SettingsService({ env: {}, - engine: opts.failVerificationRead - ? wrapEngineFailingPostUpdateReads(engine) - : opts.forwardContext === false - ? wrapEngineDroppingContext(engine) - : wrapEngineAsSettingsEngine(engine as any), + engine: withEngineRegistry( + opts.failVerificationRead + ? wrapEngineFailingPostUpdateReads(engine) + : opts.forwardContext === false + ? wrapEngineDroppingContext(engine) + : wrapEngineAsSettingsEngine(engine as any), + engine, + ), cryptoProvider: new LocalCryptoProvider(), secretStore: { ...baseStore, ...(opts.secretStoreOverrides ?? {}) }, logger: { error: (m) => { logged.push(m); } }, diff --git a/packages/services/service-settings/src/settings-system-context.pin.test.ts b/packages/services/service-settings/src/settings-system-context.pin.test.ts index f8622b497f..061901a7ed 100644 --- a/packages/services/service-settings/src/settings-system-context.pin.test.ts +++ b/packages/services/service-settings/src/settings-system-context.pin.test.ts @@ -22,6 +22,7 @@ import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objects import { SettingsService } from './settings-service.js'; import { SettingsServicePlugin, buildSettingAuditWriter, wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; import { LocalCryptoProvider } from './local-crypto-provider.js'; +import { LEAN_ORGANIZATION_SOURCE } from './__tests__/settings-organization.testkit.js'; type Call = { verb: 'find' | 'insert' | 'update' | 'delete'; object: string; context: unknown }; @@ -90,7 +91,7 @@ describe('[#21913] SettingsService engine calls carry the explicit system opt-in const { engine, calls, rows } = recordingEngine(); const svc = new SettingsService(); svc.registerManifest(MANIFEST); - svc.bindEngine(wrapEngineAsSettingsEngine(engine as any)); + svc.bindEngine(wrapEngineAsSettingsEngine(engine as any), undefined, { organization: LEAN_ORGANIZATION_SOURCE }); // loadRows (read path), then upsertRow's existence probe and insert branch. // (Its update branch already carried the opt-in before this change.) @@ -193,12 +194,12 @@ describe('[#21908] the sys_secret store and readStoredHandle carry the explicit it('a rotation: the secret writes, the row update and the verification read after it all pass isSystem', async () => { const { engine, calls } = recordingEngine(); - const svc = new SettingsService({ - env: {}, - engine: wrapEngineAsSettingsEngine(engine as any), + const svc = new SettingsService({ env: {} }); + svc.bindEngine(wrapEngineAsSettingsEngine(engine as any), undefined, { cryptoProvider: new LocalCryptoProvider(), secretStore: (new SettingsServicePlugin() as any).buildSecretStore(engine), - } as any); + organization: LEAN_ORGANIZATION_SOURCE, + }); svc.registerManifest(SECRET_MANIFEST); await svc.set('sms', 'twilio_auth_token', 'alpha'); diff --git a/packages/services/service-settings/src/settings-user-reference.pin.test.ts b/packages/services/service-settings/src/settings-user-reference.pin.test.ts index af14f030d0..41e8a07ec8 100644 --- a/packages/services/service-settings/src/settings-user-reference.pin.test.ts +++ b/packages/services/service-settings/src/settings-user-reference.pin.test.ts @@ -20,6 +20,7 @@ import { SysUser } from '@objectstack/platform-objects/identity'; import { SysPlatformSetting, SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; import { SettingsService } from './settings-service.js'; import { buildSettingAuditWriter, wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; +import { withEngineRegistry } from './__tests__/settings-organization.testkit.js'; const SYS = { context: { isSystem: true } } as const; const GHOST = 'usr_ghost_21913'; @@ -97,7 +98,7 @@ const MANIFEST = { function settings(): SettingsService { const svc = new SettingsService(); svc.registerManifest(MANIFEST); - svc.bindEngine(wrapEngineAsSettingsEngine(engine as any)); + svc.bindEngine(withEngineRegistry(wrapEngineAsSettingsEngine(engine as any), engine)); return svc; } 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 c2a8627ff1..d9bf8ee13d 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 @@ -26,6 +26,7 @@ import { describe, it, expect, vi } from 'vitest'; import { SettingsService } from './settings-service.js'; import { SettingsValidationError, type SettingsContext } from './settings-service.types.js'; +import { LEAN_ORGANIZATION_SOURCE } from './__tests__/settings-organization.testkit.js'; type Row = Record; @@ -99,7 +100,7 @@ function engineStack(seed: Row[], opts: { overAnswer?: boolean } = {}) { const engine = { find, insert, update: vi.fn(), delete: vi.fn(), count: vi.fn() }; const svc = new SettingsService({ env: {} }); svc.registerManifest(MANIFEST); - svc.bindEngine(engine as any); + svc.bindEngine(engine as any, undefined, { organization: LEAN_ORGANIZATION_SOURCE }); return { svc, wrote: () => insert.mock.calls.length + engine.update.mock.calls.length, diff --git a/packages/services/service-settings/src/sys-secret-orphan-report.test.ts b/packages/services/service-settings/src/sys-secret-orphan-report.test.ts index 8b38b0676f..7214f31c8c 100644 --- a/packages/services/service-settings/src/sys-secret-orphan-report.test.ts +++ b/packages/services/service-settings/src/sys-secret-orphan-report.test.ts @@ -48,6 +48,7 @@ import type { SettingsManifest } from '@objectstack/spec/system'; import type { CryptoHandle, ICryptoProvider } from '@objectstack/spec/contracts'; import { SettingsService } from './settings-service.js'; import { wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; +import { withEngineRegistry } from './__tests__/settings-organization.testkit.js'; import { LocalCryptoProvider } from './local-crypto-provider.js'; import type { SettingsEngine, SettingsSecretStore } from './settings-service.types.js'; import { @@ -274,10 +275,12 @@ async function boot(opts: { forwardContext?: boolean; withDelete?: boolean } = { const auditEntries: Array> = []; const svc = new SettingsService({ env: {}, - engine: + engine: withEngineRegistry( opts.forwardContext === false ? wrapEngineDroppingContext(engine) : wrapEngineAsSettingsEngine(engine as any), + engine, + ), cryptoProvider: new LocalCryptoProvider(), secretStore, auditWriter: { From e12143e69e469c220a23a889316733e2af44488c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 01:45:46 +0000 Subject: [PATCH 3/5] 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 --- .../25249-settings-self-bound-organization.md | 4 ++-- packages/services/service-settings/src/index.ts | 6 +++--- .../src/settings-organization-reach.pin.test.ts | 17 +++++++++++++++-- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/.changeset/25249-settings-self-bound-organization.md b/.changeset/25249-settings-self-bound-organization.md index ec6b8ca3d4..0ce237212b 100644 --- a/.changeset/25249-settings-self-bound-organization.md +++ b/.changeset/25249-settings-self-bound-organization.md @@ -12,8 +12,8 @@ Metadata-protocol: none **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. - 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. -- 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. +- 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. -**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. +**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. **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. diff --git a/packages/services/service-settings/src/index.ts b/packages/services/service-settings/src/index.ts index 9045abfaea..c972831fa1 100644 --- a/packages/services/service-settings/src/index.ts +++ b/packages/services/service-settings/src/index.ts @@ -80,9 +80,9 @@ export { // its shape; the action value and target-object constants travel with it for the // same reason a pin must not spell them by hand. [#25249] Such a host with no // organization object declares it (`bindEngine`'s `organization` extra answering -// `no-organization-object`); left out, the service asks `sys_organization` through -// that engine, so its rows with no organization stop answering, or, where the engine -// does not know that object, the read throws `AuthzStoreUnavailableError`. +// `no-organization-object`); left out, a tenant-rung read or write by a caller naming +// none is refused as organization-less, or raises `AuthzStoreUnavailableError` +// naming `sys_organization`. export { buildConfigChangeAuditSink, CONFIG_CHANGE_ACTION, 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 index 41a83c4696..0591950064 100644 --- a/packages/services/service-settings/src/settings-organization-reach.pin.test.ts +++ b/packages/services/service-settings/src/settings-organization-reach.pin.test.ts @@ -45,6 +45,7 @@ const MANIFEST = { specifiers: [ { key: 'brand', type: 'text', label: 'Brand', scope: 'tenant', default: 'none' }, { key: 'legacy', type: 'text', label: 'Legacy', scope: 'tenant', default: 'none' }, + { key: 'theme', type: 'text', label: 'Theme', scope: 'user', default: 'none' }, ], } as any; @@ -252,6 +253,8 @@ async function bootSelfBound(organizations: string[] | null, source?: typeof LEA for (const id of organizations ?? []) { await engine.insert('sys_organization', { id, name: id, slug: id }, SYS); } + // The user a user-rung write names, as the store already holds it. + memory.rowsOf('sys_user').set('usr_1', { id: 'usr_1', name: 'Ada', email: 'ada@example.test' }); const service = new SettingsService({ env: {} }); service.registerManifest(MANIFEST); service.bindEngine(wrapEngineAsSettingsEngine(engine as any), undefined, source ? { organization: source } : undefined); @@ -295,15 +298,25 @@ function selfBoundDouble(source?: typeof LEAN_ORGANIZATION_SOURCE) { } describe('[#25249] a host that binds its own engine and states no organization source', () => { - it('(a) one organization: it reads and writes as that organization, through an adapter with no registry', async () => { + it('(a) one organization: the tenant and user rungs read and write as that organization, through an adapter with no registry', async () => { const b = await bootSelfBound([ORG]); await b.seed({ key: 'brand', value: 'Acme', organization_id: ORG }); await b.seed({ key: 'legacy', value: 'Unowned', organization_id: null }); + await b.seed({ key: 'theme', value: 'Unowned dark', scope: 'user', user_id: 'usr_1', organization_id: null }); expect((await b.service.get(NS, 'brand', {})).value).toBe('Acme'); expect((await b.service.get(NS, 'legacy', {})).source).toBe('default'); + expect((await b.service.get(NS, 'theme', { userId: 'usr_1' })).source).toBe('default'); await b.service.set(NS, 'brand', 'Acme two', {}); - expect(b.rows()).toEqual([['brand', ORG, 'Acme two'], ['legacy', null, 'Unowned']]); + await b.service.set(NS, 'theme', 'Light', { userId: 'usr_1' }); + await b.service.set(NS, 'theme', 'Light two', { userId: 'usr_1' }); + + expect((await b.service.get(NS, 'brand', { tenantId: ORG })).value).toBe('Acme two'); + expect((await b.service.get(NS, 'theme', { userId: 'usr_1' })).value).toBe('Light two'); + expect((await b.service.get(NS, 'theme', { userId: 'usr_1', tenantId: ORG })).value).toBe('Light two'); + expect(b.rows()).toEqual([ + ['brand', ORG, 'Acme two'], ['legacy', null, 'Unowned'], ['theme', null, 'Unowned dark'], ['theme', ORG, 'Light two'], + ]); }); it('(b) no organization object, real engine: the probe is refused as a failed read, loudly', async () => { From 7813e9d7dac6508cbbd3e2801edc808166dac585 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 01:58:41 +0000 Subject: [PATCH 4/5] test(service-settings): the reach pin's double routes update through the engine's dispatch predicate Claude-Session: https://claude.ai/code/session_014FFTcf6msoLbXiD6BR7wHW Co-authored-by: Claude --- .../src/settings-organization-reach.pin.test.ts | 4 ++-- scripts/engine-double-contract.pinned.json | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) 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 index 0591950064..d8c911675f 100644 --- a/packages/services/service-settings/src/settings-organization-reach.pin.test.ts +++ b/packages/services/service-settings/src/settings-organization-reach.pin.test.ts @@ -27,7 +27,7 @@ import { describe, it, expect } from 'vitest'; import { isAuthzStoreUnavailableError, ObjectKernel } from '@objectstack/core'; -import { ObjectQL } from '@objectstack/objectql'; +import { assertEngineUpdateDispatch, ObjectQL } from '@objectstack/objectql'; import { SysOrganization, SysUser } from '@objectstack/platform-objects/identity'; import { SysPlatformSetting, SysSetting } from '@objectstack/platform-objects/system'; import { SettingsServicePlugin, wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; @@ -289,7 +289,7 @@ function selfBoundDouble(source?: typeof LEAN_ORGANIZATION_SOURCE) { return stored.filter((r) => branches.some((b) => Object.entries({ ...rest, ...b }).every(([k, v]) => (r[k] ?? null) === (v ?? null)))); }, async insert(_object: string, data: Record) { stored.push({ ...data }); return { ...data }; }, - async update() { return 1; }, + async update(_object: string, data: Record, options?: any) { assertEngineUpdateDispatch(data, options); return 1; }, }; const service = new SettingsService({ env: {} }); service.registerManifest(MANIFEST); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index d121f6c48d..84bc43879b 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -4711,6 +4711,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/services/service-settings/src/settings-organization-reach.pin.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/services/service-settings/src/settings-secret-rotation.test.ts", "verb": "update", From 1f6cbda31c1abf3b38ee84fe0314800105668bb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 02:02:53 +0000 Subject: [PATCH 5/5] test(service-settings): the reach pin's double holds the caller's limit Claude-Session: https://claude.ai/code/session_014FFTcf6msoLbXiD6BR7wHW Co-authored-by: Claude --- .../src/settings-organization-reach.pin.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index d8c911675f..9d0a195997 100644 --- a/packages/services/service-settings/src/settings-organization-reach.pin.test.ts +++ b/packages/services/service-settings/src/settings-organization-reach.pin.test.ts @@ -286,7 +286,8 @@ function selfBoundDouble(source?: typeof LEAN_ORGANIZATION_SOURCE) { const where = query?.where ?? {}; const branches: Array> = Array.isArray(where.$or) ? where.$or : [where]; const { $or: _or, ...rest } = where; - return stored.filter((r) => branches.some((b) => Object.entries({ ...rest, ...b }).every(([k, v]) => (r[k] ?? null) === (v ?? null)))); + const hits = stored.filter((r) => branches.some((b) => Object.entries({ ...rest, ...b }).every(([k, v]) => (r[k] ?? null) === (v ?? null)))); + return typeof query?.limit === 'number' ? hits.slice(0, query.limit) : hits; }, async insert(_object: string, data: Record) { stored.push({ ...data }); return { ...data }; }, async update(_object: string, data: Record, options?: any) { assertEngineUpdateDispatch(data, options); return 1; },