diff --git a/.changeset/15207-settings-global-rung-platform-setting.md b/.changeset/15207-settings-global-rung-platform-setting.md new file mode 100644 index 00000000000..80fbf089e55 --- /dev/null +++ b/.changeset/15207-settings-global-rung-platform-setting.md @@ -0,0 +1,28 @@ +--- +'@objectstack/platform-objects': minor +'@objectstack/service-settings': minor +'@objectstack/spec': minor +'@objectstack/cli': minor +--- + +feat(service-settings,platform-objects)!: the settings cascade's global rung moves to the tenant-less `sys_platform_setting`, and `sys_setting.scope` no longer declares `global` (ADR-0131 D7) + +Clause-②: yes (narrowing) + + + +**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes (Changesets pre mode is not on yet). + +A settings value for a key declared at `global` scope is a deployment-wide value: the mail transport, the SMS, storage, AI and knowledge providers, auth policy, the lifecycle retention defaults. It used to be a `scope: 'global'` row of `sys_setting`, a tenant-scoped table, where the injected `organization_id` column only ever held NULL and a walled posture hid the row from every reader. ADR-0131 D7 moves the rung out: it is now stored in a new platform object, `sys_platform_setting`. + +- **`sys_platform_setting`** (registered by the settings service, beside `sys_setting`): one row per `(namespace, key)` for the deployment, with the `value`, `value_enc`, `encrypted`, `locked`, `locked_reason` and `updated_by` columns of a settings row, and no `scope`, `user_id` or `organization_id` (`systemFields: { tenant: false }`). It is governed by object permission: a generic data read needs `manage_platform_settings` (`requiredPermissions`), which platform administrators hold. Writes go only through the settings door. +- **`SettingsService`** writes a global-scope key to `sys_platform_setting` and reads the cascade's global rung from there alone. Its `sys_setting` reads exclude `scope = 'global'`, so there is one source per rung. The cascade order, the lock semantics, `SpecifierScope` and the `source: 'global'` resolution value are unchanged. A global-scope change's `config_change` audit row names `sys_platform_setting` (`CONFIG_CHANGE_GLOBAL_OBJECT_NAME`); tenant- and user-scope changes still name `sys_setting`. +- **`sys_setting.scope`** no longer declares the `global` option: no write produces such a row. `sys_setting_audit.scope` keeps it, because a global-scope change is still audited there. +- **`os secret orphans` / `os secret rewrap`**: the settings family of the `sys_secret` reference union now reads both `sys_setting.value_enc` and `sys_platform_setting.value_enc`. Without that, every credential held at the global rung would read as unreferenced and be swept. An unreadable `sys_platform_setting` gaps the family, which refuses deletion. + +**What moves for consumers.** + +- **Existing databases — nothing moves automatically** (ADR-0131 D14). A `sys_setting` row at `scope = 'global'` is no longer read; until the v18 upgrade ceremony moves it, that key answers from its next rung or the manifest default. The ceremony moves each such row to `sys_platform_setting` by namespace and key, its `value_enc` handle included. An encrypted value needs no re-encryption: the ciphertext's associated data binds the settings scope, namespace and key, never the holding object or an organization. +- **Authored references.** A filter, list-view column or seed that names `scope = 'global'` on `sys_setting` matches nothing: point it at `sys_platform_setting`, which has no `scope` column. +- **Generic data reads of the global values.** Read `sys_platform_setting`; it needs `manage_platform_settings`. The settings door (`/api/settings/:namespace`) is unchanged and keeps applying each manifest's own read and write capability. +- **Kernels that register the settings objects by hand.** Register `SysPlatformSetting` (`@objectstack/platform-objects/system`) beside `SysSetting`; `SettingsServicePlugin` already does. The service reads both on every resolution, so a kernel missing one fails the read loudly rather than answering a cascade with a rung missing. diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 2fb5784a496..740fa04e643 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **54 of the 234 sites are spelled that way**. A +forwarding shim cannot, and **52 of the 234 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -150,8 +150,8 @@ now **0**: nothing on this surface threads a context that provably lacks the fla **"No tenant context" counted sites it had not read.** An options argument the walker could not parse was folded into the same bucket as one it had read and -found empty. That published **62 sites "carrying no tenant context at all"** -when 8 said so and 54 were simply unread — an over-claim in the *alarming* +found empty. That published **60 sites "carrying no tenant context at all"** +when 8 said so and 52 were simply unread — an over-claim in the *alarming* direction, on the very figure this page tells other cards to cite. `carries` is now three-valued, and an unreadable argument can never contribute to the provable count. @@ -228,10 +228,10 @@ cannot read, and they are neither in nor out. | …whose object name is chosen at run time | 80 | | …against an object with tenancy ENABLED | 153 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 172 | +| threading a tenant context | 174 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | -| options argument UNREADABLE — may or may not carry one | 54 | +| options argument UNREADABLE — may or may not carry one | 52 | | …of those, against a decidably tenancy-enabled object | 31 | | threading a decidably ELEVATED (`isSystem`) context | 123 | | threading a context that is decidably NOT elevated | 0 | @@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-08 at `7f9500afd`. +Measured on 2026-10-08 at `0328884e5`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 615 | +| tracked non-test sources scanned | 617 | | engine-shaped types recognised | 70 | -| declared objects in the registry | 116 | +| declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 160 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index 1bac8f1ce75..4589c664314 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -38,10 +38,10 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Object name chosen at run time | 80 | | Against a tenancy-enabled object | 153 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 172 | +| Threading a tenant context | 174 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | -| Options argument unreadable | 54 | +| Options argument unreadable | 52 | | …and decidably tenancy-enabled | 31 | | Threading a decidably elevated context | 123 | | Threading a decidably non-elevated context | 0 | @@ -90,13 +90,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-08 at `7f9500afd`. +Measured on 2026-10-08 at `0328884e5`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 615 | +| tracked non-test sources scanned | 617 | | engine-shaped types recognised | 70 | -| declared objects in the registry | 116 | +| declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 160 | ## Every site @@ -239,8 +239,8 @@ Measured on 2026-10-08 at `7f9500afd`. | `packages/services/service-settings/src/settings-service-plugin.ts` | `insert` | `sys_secret` | enabled | elevated | 1 | | `packages/services/service-settings/src/settings-service-plugin.ts` | `update` | `sys_secret` | enabled | elevated | 1 | | `packages/services/service-settings/src/settings-service-plugin.ts` | `insert` | `sys_setting_audit` | enabled | elevated | 1 | -| `packages/services/service-settings/src/settings-service.ts` | `insert` | `this.objectName` | undecidable | options unreadable | 1 | -| `packages/services/service-settings/src/settings-service.ts` | `update` | `this.objectName` | undecidable | options unreadable | 1 | +| `packages/services/service-settings/src/settings-service.ts` | `insert` | `object` | undecidable | context, elevation undecidable | 1 | +| `packages/services/service-settings/src/settings-service.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 | | `packages/services/service-storage/src/attachment-lifecycle.ts` | `update` | `sys_file` | enabled | elevated | 3 | | `packages/services/service-storage/src/backfill-file-references.ts` | `update` | `object` | undecidable | options unreadable | 1 | | `packages/services/service-storage/src/backfill-file-references.ts` | `update` | `object` | undecidable | elevated | 1 | diff --git a/packages/cli/src/commands/secret/rewrap.guards.test.ts b/packages/cli/src/commands/secret/rewrap.guards.test.ts index c1b2aefdf90..d5d05fc74c8 100644 --- a/packages/cli/src/commands/secret/rewrap.guards.test.ts +++ b/packages/cli/src/commands/secret/rewrap.guards.test.ts @@ -108,6 +108,10 @@ function wireBoot( if (object === 'sys_setting') { return { async find() { harness.reads.push('sys_setting'); return rows.settings.map((r) => ({ ...r })); } }; } + // [ADR-0131 D7] The settings family's second holder: the global rung's store. + if (object === 'sys_platform_setting') { + return { async find() { harness.reads.push('sys_platform_setting'); return []; } }; + } if (object === 'sys_metadata') return { async find() { harness.reads.push('sys_metadata'); return []; } }; return undefined; }, @@ -247,7 +251,7 @@ describe('os secret rewrap — guards that stop a run before any row is opened o }, 60_000); it('a dry run over tables the boot measured absent reads none of them, and reports empty work', async () => { - const h = wireBoot(freshRows(), { absent: ['sys_secret', 'sys_setting', 'sys_metadata'] }); + const h = wireBoot(freshRows(), { absent: ['sys_secret', 'sys_setting', 'sys_platform_setting', 'sys_metadata'] }); const { payload, exitCode } = await run(['--no-declared-datasources']); // "Not asked": a table that does not exist holds nothing, so no read is issued. diff --git a/packages/cli/src/utils/secret-reference-union.test.ts b/packages/cli/src/utils/secret-reference-union.test.ts index b582336c586..0b710a3211e 100644 --- a/packages/cli/src/utils/secret-reference-union.test.ts +++ b/packages/cli/src/utils/secret-reference-union.test.ts @@ -161,6 +161,15 @@ const sysSettingObject = { ), }; +/** [ADR-0131 D7] The global rung's store: no `scope`, no `user_id`. */ +const sysPlatformSettingObject = { + name: 'sys_platform_setting', + label: 'Platform Setting', + fields: Object.fromEntries( + ['id', 'namespace', 'key', 'value', 'value_enc'].map((f) => [f, textField(f)]), + ), +}; + const sysMetadataObject = { name: 'sys_metadata', label: 'Metadata', @@ -205,7 +214,7 @@ async function buildRuntime() { // `packageId` is required by the built declaration this package resolves // (`registerObject(schema, packageId, …)`); the engine's own in-package tests // reach a source signature that defaults it. - for (const object of [sysSecretObject, sysSettingObject, sysMetadataObject, smtpObject]) { + for (const object of [sysSecretObject, sysSettingObject, sysPlatformSettingObject, sysMetadataObject, smtpObject]) { engine.registry.registerObject(object as never, TEST_PACKAGE_ID); } @@ -329,7 +338,7 @@ describe('the premise: the shipped settings-scoped classifier calls a LIVE crede }); }); -describe('family 1 — settings (`sys_setting.value_enc`)', () => { +describe('family 1 — settings (`sys_setting.value_enc` and `sys_platform_setting.value_enc`)', () => { let rt: Runtime; beforeEach(async () => { rt = await buildRuntime(); }); @@ -363,6 +372,41 @@ describe('family 1 — settings (`sys_setting.value_enc`)', () => { expect(union.complete).toBe(false); expect(union.gaps.map((g) => g.family)).toContain('settings'); }); + + // [ADR-0131 D7] The global rung moved to `sys_platform_setting`, and the + // deployment-wide values it holds are exactly the provider credentials (mail, + // SMS, storage, AI). A union over `sys_setting` alone reads their handles as + // unreferenced, and the sweep deletes the credential in force. + it('names a handle held ONLY by sys_platform_setting.value_enc — the global rung', async () => { + const handle = await rt.crypto.encrypt('relay-api-key', { scope: 'settings', namespace: 'smtp', key: 'password' }); + rt.store.seed('sys_secret', { + id: handle.id, namespace: 'smtp', key: 'password', kms_key_id: handle.kmsKeyId, + alg: handle.alg, version: handle.version, ciphertext: handle.ciphertext, + }); + rt.store.seed('sys_platform_setting', { id: 'ps_1', namespace: 'smtp', key: 'password', value_enc: handle.id }); + // Anti-vacuity: no `sys_setting` row names it, so only the new holder can. + expect(rt.store.rowsOf('sys_setting').some((r) => r.value_enc === handle.id)).toBe(false); + + const union = await collect(rt); + assertSecretReferenceUnionComplete(union); + expect(union.handleIds.has(handle.id)).toBe(true); + const refs = union.references.filter((r) => r.handleId === handle.id); + expect(refs).toHaveLength(1); + expect(refs[0].family).toBe('settings'); + expect(refs[0].holder).toBe('sys_platform_setting(namespace=smtp,key=password)'); + }); + + it('an unreadable sys_platform_setting gaps the WHOLE settings family', async () => { + rt.store.failReadsOf('sys_platform_setting', new Error('relation does not exist')); + const result = await collectSettingsSecretReferences(rt.engine); + expect(result.status).toBe('gap'); + expect(result.status === 'gap' && result.reason).toContain('sys_platform_setting'); + expect(result.status === 'gap' && result.reason).toContain('relation does not exist'); + + const union = await collect(rt); + expect(union.complete).toBe(false); + expect(union.gaps.map((g) => g.family)).toContain('settings'); + }); }); describe('family 2 — the engine secret-field channel (`secret:` on a business row)', () => { diff --git a/packages/cli/src/utils/secret-reference-union.ts b/packages/cli/src/utils/secret-reference-union.ts index dc6bf38080d..1081f4d14b4 100644 --- a/packages/cli/src/utils/secret-reference-union.ts +++ b/packages/cli/src/utils/secret-reference-union.ts @@ -8,7 +8,8 @@ * `id` field's own description): * * 1. **settings** — `SettingsService` stores a bare `sec_…` handle in - * `sys_setting.value_enc`; + * `sys_setting.value_enc`, or `sys_platform_setting.value_enc` for a + * global-scope key (ADR-0131 D7); * 2. **object-field** — the engine's `secret`-typed field channel stores * `secret:` on an arbitrary business row, on every `secret` field of * every REGISTERED object, tenant-authored ones included; @@ -333,7 +334,21 @@ const describeCause = (err: unknown): string => err instanceof Error ? `${err.name}: ${err.message}` : String(err); /** - * Family 1 — handles held in `sys_setting.value_enc`. + * The settings producer's holder objects: every store `SettingsService` keeps a + * `value_enc` in. `sys_setting` holds the tenant and user rungs, and + * `sys_platform_setting` the global rung (ADR-0131 D7) — the deployment-wide + * values, which are exactly the ones that carry provider credentials (mail, + * SMS, storage, AI, knowledge). + * + * ⛔ Both are read, always. A union over `sys_setting` alone reads every handle + * a global-scope secret holds as unreferenced, and the sweep then deletes the + * credential in force — with no record afterwards of which one it was. + */ +const SETTINGS_HOLDER_OBJECTS = ['sys_setting', 'sys_platform_setting'] as const; + +/** + * Family 1 — handles held in the settings stores' `value_enc` + * ({@link SETTINGS_HOLDER_OBJECTS}). * * `value_enc` also carries LEGACY INLINE ciphertext on rows written before the * Phase-3 split, and such a row references no `sys_secret` row at all. The @@ -341,6 +356,9 @@ const describeCause = (err: unknown): string => * restated: treating inline ciphertext as a handle would inject a phantom id * into the union, and restating the `sec_` prefix is how the two spellings * would drift apart later. + * + * Either holder that cannot be read gaps the WHOLE family: a holder missing + * from the union is a set of live handles the sweep would read as orphans. */ export async function collectSettingsSecretReferences( engine: SecretReferenceEngineLike, @@ -348,40 +366,47 @@ export async function collectSettingsSecretReferences( const family: SecretReferenceFamily = 'settings'; const references: SecretReference[] = []; - const driver = engine.getDriverForObject('sys_setting'); - if (!driver) { - return { - family, - status: 'gap', - reason: - 'no driver resolves for `sys_setting`, so the settings producer\'s holder column could ' - + 'not be read (is the settings subsystem registered on this runtime?)', - references, - }; - } + for (const holder of SETTINGS_HOLDER_OBJECTS) { + const driver = engine.getDriverForObject(holder); + if (!driver) { + return { + family, + status: 'gap', + reason: + `no driver resolves for \`${holder}\`, so the settings producer's holder column could ` + + 'not be read (is the settings subsystem registered on this runtime?)', + references, + }; + } - let result: unknown; - try { - result = await driver.find('sys_setting', { fields: ['namespace', 'key', 'scope', 'user_id', 'value_enc'] }); - } catch (err) { - return { - family, - status: 'gap', - reason: `reading \`sys_setting\` threw — ${describeCause(err)}`, - references, - }; - } + // `sys_platform_setting` has no `scope` and no `user_id`: one row per + // `(namespace, key)` for the deployment. + const fields = holder === 'sys_setting' + ? ['namespace', 'key', 'scope', 'user_id', 'value_enc'] + : ['namespace', 'key', 'value_enc']; + let result: unknown; + try { + result = await driver.find(holder, { fields }); + } catch (err) { + return { + family, + status: 'gap', + reason: `reading \`${holder}\` threw — ${describeCause(err)}`, + references, + }; + } - for (const row of rowsOf(result)) { - const value = row.value_enc; - if (!isSecretHandle(value)) continue; // unset, or legacy inline ciphertext - references.push({ - handleId: value, - family, - holder: `sys_setting(namespace=${String(row.namespace)},key=${String(row.key)}` - + `${row.scope == null ? '' : `,scope=${String(row.scope)}`}` - + `${row.user_id == null ? '' : `,user_id=${String(row.user_id)}`})`, - }); + for (const row of rowsOf(result)) { + const value = row.value_enc; + if (!isSecretHandle(value)) continue; // unset, or legacy inline ciphertext + references.push({ + handleId: value, + family, + holder: `${holder}(namespace=${String(row.namespace)},key=${String(row.key)}` + + `${row.scope == null ? '' : `,scope=${String(row.scope)}`}` + + `${row.user_id == null ? '' : `,user_id=${String(row.user_id)}`})`, + }); + } } return { family, status: 'enumerated', references }; diff --git a/packages/cli/src/utils/sys-secret-orphan-sweep.test.ts b/packages/cli/src/utils/sys-secret-orphan-sweep.test.ts index c615e450bf1..fbc0e9a12eb 100644 --- a/packages/cli/src/utils/sys-secret-orphan-sweep.test.ts +++ b/packages/cli/src/utils/sys-secret-orphan-sweep.test.ts @@ -150,6 +150,15 @@ const sysSettingObject = { ), }; +/** [ADR-0131 D7] The global rung's store, the settings family's second holder. */ +const sysPlatformSettingObject = { + name: 'sys_platform_setting', + label: 'Platform Setting', + fields: Object.fromEntries( + ['id', 'namespace', 'key', 'value', 'value_enc'].map((f) => [f, textField(f)]), + ), +}; + const sysMetadataObject = { name: 'sys_metadata', label: 'Metadata', @@ -194,7 +203,7 @@ async function buildRuntime() { const engine = new ObjectQL(); engine.registerDriver(store.driver as never, true); await engine.init(); - for (const object of [sysSecretObject, sysSettingObject, sysMetadataObject, smtpObject]) { + for (const object of [sysSecretObject, sysSettingObject, sysPlatformSettingObject, sysMetadataObject, smtpObject]) { engine.registry.registerObject(object as never, TEST_PACKAGE_ID); } @@ -427,6 +436,31 @@ describe('the classes the ruling puts out of reach', () => { expect(plan.deletable).toEqual([rt.orphanHandleId]); }); + // [ADR-0131 D7] The global rung's values moved to `sys_platform_setting`. + // A credential held ONLY there is attributable to a declared encrypted + // specifier and unreferenced by `sys_setting` — the exact shape a sweep that + // reads one settings holder deletes, losing the credential in force. + it('a handle held ONLY by sys_platform_setting.value_enc is REFERENCED, never deletable', async () => { + const globalHandle = await rt.crypto.encrypt('relay-api-key', { scope: 'settings', namespace: 'smtp', key: 'password' }); + rt.store.seed('sys_secret', { + id: globalHandle.id, namespace: 'smtp', key: 'password', + kms_key_id: globalHandle.kmsKeyId, alg: globalHandle.alg, + version: globalHandle.version, ciphertext: globalHandle.ciphertext, + }); + rt.store.seed('sys_platform_setting', { id: 'ps_1', namespace: 'smtp', key: 'password', value_enc: globalHandle.id }); + // Anti-vacuity: no `sys_setting` row names it, and the pair is attributable. + expect(settingRowsOf(rt).some((r: any) => r.value_enc === globalHandle.id)).toBe(false); + + const { union, plan } = await planOver(rt, []); + expect(union.complete).toBe(true); + const row = plan.rows.find((r) => r.id === globalHandle.id); + expect(row?.attributable).toBe(true); + expect(row?.decision).toBe('referenced'); + expect(plan.deletable).not.toContain(globalHandle.id); + // Positive control: the same run still deletes the genuine orphan. + expect(plan.deletable).toEqual([rt.orphanHandleId]); + }); + it('a LEGACY INLINE sibling is withheld — the #8063 guard in the opposite direction', async () => { const inlineHandle = await rt.crypto.encrypt('older-inline', { scope: 'settings', namespace: 'smtp', key: 'inline_legacy' }); rt.store.seed('sys_secret', { diff --git a/packages/cli/src/utils/sys-secret-rewrap.test.ts b/packages/cli/src/utils/sys-secret-rewrap.test.ts index 79c11e0184a..e663a0da6ba 100644 --- a/packages/cli/src/utils/sys-secret-rewrap.test.ts +++ b/packages/cli/src/utils/sys-secret-rewrap.test.ts @@ -160,6 +160,8 @@ const sysSecretObject = objectOf('sys_secret', ['id', 'namespace', 'key', 'kms_k version: { name: 'version', label: 'version', type: 'number' as const }, }); const sysSettingObject = objectOf('sys_setting', ['id', 'namespace', 'key', 'scope', 'user_id', 'value', 'value_enc']); +// [ADR-0131 D7] The global rung's store, the settings family's second holder. +const sysPlatformSettingObject = objectOf('sys_platform_setting', ['id', 'namespace', 'key', 'value', 'value_enc']); const sysMetadataObject = objectOf('sys_metadata', ['id', 'name', 'type', 'scope', 'metadata', 'state']); /** A business object with a `secret` field: family 2's holder. */ const vaultObject = objectOf('vault_entry', ['id', 'label'], { @@ -203,7 +205,7 @@ async function buildRuntime(opts: BuildOptions = {}) { const engine = new ObjectQL(); engine.registerDriver(store.driver as never, true); await engine.init(); - for (const object of [sysSecretObject, sysSettingObject, sysMetadataObject, vaultObject]) { + for (const object of [sysSecretObject, sysSettingObject, sysPlatformSettingObject, sysMetadataObject, vaultObject]) { engine.registry.registerObject(object as never, TEST_PACKAGE_ID); } const provider = new LocalCryptoProvider({ key: KEY }); diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 8761a43162b..2a2009d1329 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -3257,7 +3257,6 @@ export const enObjects: NonNullable = { label: "Scope", help: "Which layer of the config-resolution hierarchy this row belongs to.", options: { - global: "Global", tenant: "Tenant", user: "User" } @@ -3276,7 +3275,7 @@ export const enObjects: NonNullable = { }, locked: { label: "Locked", - help: "When true, lower-scope rows cannot override this value; writes against lower scopes return 409. Used by platform administrators to pin a global value for all tenants (Phase 2 cascade)." + help: "When true, lower-scope rows cannot override this value; writes against lower scopes return 409. A tenant row pins its value for that tenant's users (Phase 2 cascade)." }, locked_reason: { label: "Lock Reason", diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 371b48793ec..d3ec5a0d72f 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -3257,7 +3257,6 @@ export const esESObjects: NonNullable = { label: "Ámbito", help: "Capa de la jerarquía de resolución de configuración a la que pertenece esta fila.", options: { - global: "Global", tenant: "Inquilino", user: "Usuario" } @@ -3276,7 +3275,7 @@ export const esESObjects: NonNullable = { }, locked: { label: "Bloqueado", - help: "Si es verdadero, las filas de ámbitos inferiores no pueden sobrescribir este valor; las escrituras contra ámbitos inferiores devuelven 409. Lo utilizan los administradores de la plataforma para fijar un valor global para todos los tenants (cascada Fase 2)." + help: "Si es verdadero, las filas de ámbitos inferiores no pueden sobrescribir este valor; las escrituras contra ámbitos inferiores devuelven 409. Una fila de tenant fija su valor para los usuarios de ese tenant (cascada Fase 2)." }, locked_reason: { label: "Motivo del bloqueo", diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts index bf49df2e212..b04db6e5d77 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts @@ -53,7 +53,6 @@ export const esESGeneratedSourceHashes: Readonly> = { "objects.sys_scim_subject.fields.id.label": "00b0385c9c152888", "objects.sys_scim_user.fields.id.label": "00b0385c9c152888", "objects.sys_secret.fields.id.label": "00b0385c9c152888", - "objects.sys_setting.fields.scope.options.global": "5e377106508d2ecd", "objects.sys_setting_audit.fields.actor_id.label": "b155813f8a7f06e3", "objects.sys_setting_audit.fields.id.label": "00b0385c9c152888", "objects.sys_setting_audit.fields.scope.options.global": "5e377106508d2ecd", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 24024be4d33..cb89f89dbbf 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -3257,7 +3257,6 @@ export const jaJPObjects: NonNullable = { label: "スコープ", help: "この行が属する設定解決階層のレイヤー。", options: { - global: "グローバル", tenant: "テナント", user: "ユーザー" } @@ -3276,7 +3275,7 @@ export const jaJPObjects: NonNullable = { }, locked: { label: "ロック済み", - help: "true の場合、下位スコープの行はこの値を上書きできず、下位スコープへの書き込みは 409 を返します。プラットフォーム管理者がすべてのテナントにグローバル値を固定するために使用します(フェーズ 2 カスケード)。" + help: "true の場合、下位スコープの行はこの値を上書きできず、下位スコープへの書き込みは 409 を返します。テナント行は、そのテナントのユーザーに対して値を固定します(フェーズ 2 カスケード)。" }, locked_reason: { label: "ロック理由", diff --git a/packages/platform-objects/src/apps/translations/objects-es-es-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/objects-es-es-echo-decisions.test.ts index b696d5865e9..fe7b24bc5a3 100644 --- a/packages/platform-objects/src/apps/translations/objects-es-es-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/objects-es-es-echo-decisions.test.ts @@ -51,7 +51,9 @@ // Since that walk, the seven `sys_account._actions.link_social` provider-brand // rows (Google through Discord) left this ledger together with the action, // retired under ADR-0049 enforce-or-remove (#21849): the bundle no longer -// carries those leaves, so the ledger below holds 47 echoes. +// carries those leaves, so the ledger held 47 echoes. Then the `global` option +// of `sys_setting.scope` left with the settings cascade's global rung +// (ADR-0131 D7): the ledger below holds 46. // // ## What this file deliberately does NOT assert // @@ -178,7 +180,6 @@ const DECISIONS: readonly Decision[] = [ echo('sys_notification.fields.actor_id.label', 'Actor', SAME_WORD_ACTOR), echo('sys_metadata_audit.fields.actor.label', 'Actor', SAME_WORD_ACTOR), echo('sys_setting_audit.fields.actor_id.label', 'Actor', SAME_WORD_ACTOR), - echo('sys_setting.fields.scope.options.global', 'Global', SAME_WORD_GLOBAL), echo('sys_setting_audit.fields.scope.options.global', 'Global', SAME_WORD_GLOBAL), echo('sys_email_template.fields.variables_json.label', 'Variables (JSON)', SAME_WORD_VARIABLES), @@ -237,10 +238,13 @@ function undeclaredEchoes(rows: readonly Decision[]): string[] { } describe('#20493 es-ES — the ledger itself (controls before verdicts)', () => { - it('is the size it claims: 3 pinned translations and 47 declared echoes, no path twice', () => { - expect(DECISIONS.length).toBe(50); + // [ADR-0131 D7] 47 → 46 echoes: `sys_setting.scope` no longer declares the + // `global` option (the rung moved to `sys_platform_setting`), so its leaf left + // the catalog with it. `sys_setting_audit.scope`'s `global` option stays. + it('is the size it claims: 3 pinned translations and 46 declared echoes, no path twice', () => { + expect(DECISIONS.length).toBe(49); expect(DECISIONS.filter((d) => d.verdict === 'translate').length).toBe(3); - expect(DECISIONS.filter((d) => d.verdict === 'echo').length).toBe(47); + expect(DECISIONS.filter((d) => d.verdict === 'echo').length).toBe(46); expect(new Set(DECISIONS.map((d) => d.path)).size).toBe(DECISIONS.length); }); diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 5e96b3926aa..442200a3247 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -3257,7 +3257,6 @@ export const zhCNObjects: NonNullable = { label: "范围", help: "该记录所属的配置解析层级。", options: { - global: "全局", tenant: "租户", user: "用户" } @@ -3276,7 +3275,7 @@ export const zhCNObjects: NonNullable = { }, locked: { label: "已锁定", - help: "为 true 时,较低范围的记录无法覆盖该值;对较低范围的写入会返回 409。平台管理员可借此为所有租户固定全局值(Phase 2 级联)。" + help: "为 true 时,较低范围的记录无法覆盖该值;对较低范围的写入会返回 409。租户行借此为该租户的用户固定其值(Phase 2 级联)。" }, locked_reason: { label: "锁定原因", diff --git a/packages/platform-objects/src/system/index.ts b/packages/platform-objects/src/system/index.ts index 1057bac92fa..81886729fe2 100644 --- a/packages/platform-objects/src/system/index.ts +++ b/packages/platform-objects/src/system/index.ts @@ -11,6 +11,8 @@ */ export { SysSetting } from './sys-setting.object.js'; +// [ADR-0131 D7] The settings cascade's global rung, tenant-less. +export { SysPlatformSetting } from './sys-platform-setting.object.js'; export { SysSecret } from './sys-secret.object.js'; export { SysMetadataActivation } from './sys-metadata-activation.object.js'; export { SysSettingAudit } from './sys-setting-audit.object.js'; diff --git a/packages/platform-objects/src/system/sys-platform-setting.object.test.ts b/packages/platform-objects/src/system/sys-platform-setting.object.test.ts new file mode 100644 index 00000000000..8444b41989d --- /dev/null +++ b/packages/platform-objects/src/system/sys-platform-setting.object.test.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { resolveInjectedSystemColumns } from '@objectstack/spec/data'; +import { PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; +import { SysPlatformSetting } from './sys-platform-setting.object.js'; +import { SysSetting } from './sys-setting.object.js'; + +/** + * [ADR-0131 D7] `sys_platform_setting` — the settings cascade's global rung — + * carries no organization column, and reads of it are platform-only. + * + * The column is asserted through the injection plan, not through `fields`: the + * tenant column is INJECTED at registration, so an object that merely omits the + * field would still get it. `resolveInjectedSystemColumns` is the derivation + * `applySystemFields` consumes, so it is the authority. The per-object control + * (the same declaration with the opt-out removed gets the column) shows the + * predicate able to answer `true`. + */ +describe('[ADR-0131 D7] sys_platform_setting: no organization column, platform-only reads', () => { + it('carries no tenant column — neither injected nor declared', () => { + const plan = resolveInjectedSystemColumns(SysPlatformSetting); + expect(plan.tenant).toBe(false); + expect([...plan.names]).not.toContain('organization_id'); + expect(Object.keys(SysPlatformSetting.fields ?? {})).not.toContain('organization_id'); + // Anti-vacuity: the plan still injects the audit family, so an empty name + // set cannot pass the assertions above by saying nothing. + expect([...plan.names]).toEqual(expect.arrayContaining(['id', 'created_at'])); + }); + + it('opts out through `systemFields.tenant`, and the opt-out is what removes the column', () => { + expect((SysPlatformSetting as { systemFields?: unknown }).systemFields).toEqual({ tenant: false }); + expect((SysPlatformSetting as { tenancy?: unknown }).tenancy).toBeUndefined(); + const { systemFields: _optOut, ...withoutOptOut } = SysPlatformSetting as Record; + expect(resolveInjectedSystemColumns(withoutOptOut).tenant).toBe(true); + }); + + it('gates every read on the platform-only capability, which no tenant administrator holds by role', () => { + expect((SysPlatformSetting as { requiredPermissions?: unknown }).requiredPermissions).toEqual([ + 'manage_platform_settings', + ]); + const cap = PLATFORM_CAPABILITIES.find((c) => c.name === 'manage_platform_settings'); + expect(cap?.scope).toBe('platform'); + }); + + it('is keyed (namespace, key) for the whole deployment, with no rung or user column', () => { + const fields = SysPlatformSetting.fields ?? {}; + expect(fields).not.toHaveProperty('scope'); + expect(fields).not.toHaveProperty('user_id'); + expect(SysPlatformSetting.indexes).toEqual( + expect.arrayContaining([expect.objectContaining({ fields: ['namespace', 'key'], unique: 'global' })]), + ); + }); + + it("holds the value and encryption columns of sys_setting's rows, with the same read-only posture", () => { + const fields = SysPlatformSetting.fields as Record; + const legacy = SysSetting.fields as Record; + for (const name of ['namespace', 'key', 'value', 'encrypted', 'locked', 'locked_reason', 'value_enc', 'updated_by']) { + expect(fields[name]?.type, name).toBe(legacy[name]?.type); + expect(Boolean(fields[name]?.readonly), `${name}.readonly`).toBe(Boolean(legacy[name]?.readonly)); + } + // The two columns only the settings service may write. + expect(fields.value_enc?.readonly).toBe(true); + expect(fields.updated_by?.readonly).toBe(true); + }); + + it('control: sys_setting, which keeps the tenant and user rungs, keeps the column', () => { + const plan = resolveInjectedSystemColumns(SysSetting); + expect(plan.tenant).toBe(true); + expect([...plan.names]).toContain('organization_id'); + }); + + it('is generic-API read-only: writes go through the settings door', () => { + expect(SysPlatformSetting.managedBy).toBe('engine-owned'); + expect((SysPlatformSetting.enable as { apiMethods?: unknown })?.apiMethods).toEqual(['get', 'list']); + }); +}); diff --git a/packages/platform-objects/src/system/sys-platform-setting.object.ts b/packages/platform-objects/src/system/sys-platform-setting.object.ts new file mode 100644 index 00000000000..3da60fe30e7 --- /dev/null +++ b/packages/platform-objects/src/system/sys-platform-setting.object.ts @@ -0,0 +1,150 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_platform_setting — the settings cascade's GLOBAL rung (ADR-0131 D7). + * + * One row per `(namespace, key)` for the whole deployment: the value an + * operator changes at runtime, in Setup, without a restart (mail transport, SMS + * and storage providers, AI and knowledge endpoints, auth policy, the lifecycle + * retention defaults). It used to be the `scope: 'global'` limb of `sys_setting`, + * a tenant-scoped object, where the injected organization column only ever held + * NULL and a walled posture hid it from every reader. ADR-0131 D7 moved it out: + * the global rung leaves the tenant-scoped table, and a tenant-less object holds + * only values an operator must change without a restart. + * + * ## No organization column, governed by object permission + * + * `systemFields: { tenant: false }` — no writer attributes a row to an + * organization. The only writer is `SettingsService.setMany` for a key whose + * declared scope is `global`, under a system context and with a row that names + * no organization. With no column there is no tenant wall, so who may read is + * object permission (D7): the platform-only capability below. Every manifest + * whose keys land here already requires the same capability to read or write + * through the settings door, so the door and the table agree. + * + * ## Resolution + * + * `SettingsService` reads this object as the cascade's global rung and nothing + * else does: env > global (this object) > tenant > user (`sys_setting`) > + * manifest default. `sys_setting` no longer stores a global row, and the service + * reads none from it — there is one source per rung. + * + * Encryption: as on `sys_setting`, a row with `encrypted = true` keeps the + * `sys_secret` handle in `value_enc` and leaves `value` null. The handle's + * ciphertext is bound to the settings producer's `(namespace, key)` (ADR-0128 + * D1), never to the holder object or an organization, so a handle moved here + * from a `sys_setting` row opens unchanged. + * + * managedBy: 'engine-owned' — reads are open to the capability holder for a + * diagnostic grid; every write flows through the settings door. + * + * See ADR-0007 (Settings Manifest + K/V Store + Resolver) and ADR-0131 D7. + * + * @namespace sys + */ +export const SysPlatformSetting = ObjectSchema.create({ + name: 'sys_platform_setting', + label: 'Platform Setting', + pluralLabel: 'Platform Settings', + icon: 'sliders', + isSystem: true, + managedBy: 'engine-owned', + // [ADR-0131 D7] Deployment-level runtime settings: NO tenant column, and + // reads are platform-only. + systemFields: { tenant: false }, + requiredPermissions: ['manage_platform_settings'], + description: 'Deployment-wide settings values: the global rung of the settings cascade.', + displayNameField: 'key', + nameField: 'key', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) + titleFormat: '{namespace}.{key}', + highlightFields: ['namespace', 'key', 'updated_at'], + + fields: { + id: Field.text({ + label: 'Setting ID', + required: true, + readonly: true, + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + }), + + namespace: Field.text({ + label: 'Namespace', + required: true, + maxLength: 64, + description: 'Manifest namespace (e.g. mail, storage, ai).', + }), + + key: Field.text({ + label: 'Key', + required: true, + maxLength: 128, + description: 'Specifier key inside the namespace (snake_case).', + }), + + value: Field.json({ + label: 'Value', + description: 'JSON-encoded value. Null when encrypted=true (see value_enc).', + }), + + encrypted: Field.boolean({ + label: 'Encrypted', + defaultValue: false, + description: 'When true, the value is stored encrypted-at-rest in value_enc; value column is null.', + }), + + locked: Field.boolean({ + label: 'Locked', + defaultValue: false, + description: + 'When true, tenant and user rows cannot override this value; writes against those scopes return 409.', + }), + + locked_reason: Field.text({ + label: 'Lock Reason', + description: 'Human-readable explanation surfaced in the UI tooltip when locked=true.', + }), + + value_enc: Field.text({ + label: 'Encrypted Value', + readonly: true, + description: 'Handle of the sys_secret row holding the ciphertext. Set only when encrypted=true.', + }), + + updated_by: Field.lookup('sys_user', { + label: 'Updated By', + readonly: true, + description: 'Last actor who wrote this row through the settings service.', + }), + }, + + indexes: [ + // The row identity, and the read path: one value per key for the whole + // deployment. Spelled `'global'` explicitly (ADR-0120 D1) — there is no + // organization column to scope by, and both key parts are NOT NULL, so the + // NULL-distinct hole `sys_setting`'s identity needed a runtime index for + // does not exist here. + { fields: ['namespace', 'key'], unique: 'global' }, + ], + + enable: { + trackHistory: false, + searchable: false, + apiEnabled: true, + // Reads for a diagnostic grid; writes go through /api/settings/:namespace + // so the resolver, the lock check and both audit ledgers run. + apiMethods: ['get', 'list'], + }, +}); diff --git a/packages/platform-objects/src/system/sys-setting.object.ts b/packages/platform-objects/src/system/sys-setting.object.ts index 91c6c30bd96..dccd56b65c1 100644 --- a/packages/platform-objects/src/system/sys-setting.object.ts +++ b/packages/platform-objects/src/system/sys-setting.object.ts @@ -5,10 +5,12 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; /** * sys_setting — Generic K/V store backing the SettingsManifest contract * - * Single physical table that holds *every* value for *every* settings + * The physical table that holds the TENANT and USER rungs of every settings * namespace declared by a `SettingsManifest`. Plugins MUST NOT define * per-namespace tables (e.g. `sys_mail_config`); they declare a manifest - * and the value persists here. + * and the value persists here — or, for a key declared `scope: 'global'`, in + * the tenant-less `sys_platform_setting` (ADR-0131 D7: the deployment-level + * rung left this tenant-scoped table). * * Row identity: (organization_id, namespace, key, scope, user_id?). * @@ -20,7 +22,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * * Resolution order (handled by `SettingsService.get`): * 1. process.env override (source='env', locked=true) - * 2. sys_setting WHERE scope='global' (source='global') + * 2. sys_platform_setting (source='global') * 3. sys_setting WHERE scope='tenant' (source='tenant') * 4. sys_setting WHERE scope='user' (source='user') * 5. manifest specifier.default (source='default') @@ -125,18 +127,21 @@ export const SysSetting = ObjectSchema.create({ description: 'Specifier key inside the namespace (snake_case).', }), - // The option list is the storage-side mirror of `SpecifierScopeSchema` - // (`packages/spec/src/system/settings-manifest.zod.ts`), which is the - // reference truth for the cascade's layers. Keep the two in step — - // `sys-setting.scope-options.test.ts` pins the parity, and the sibling - // audit object (`sys_setting_audit.scope`) mirrors the same three. - // A fourth option lived here declaring `runtime` (#6036): the spec enum - // never accepted it, `SettingsService` never mentioned it, and no write - // path could produce such a row — a declared-but-unenforced value domain - // of exactly the ADR-0049 kind. Removed rather than implemented. + // The option list is the storage-side mirror of the cascade layers THIS + // table stores: `SpecifierScopeSchema` + // (`packages/spec/src/system/settings-manifest.zod.ts`) minus `global`. + // `sys-setting.scope-options.test.ts` pins the parity. The sibling audit + // object (`sys_setting_audit.scope`) keeps all three, because the audit + // writer records a global-scope change too. + // `global` left with the rung itself (ADR-0131 D7): a global value is + // written to `sys_platform_setting`, no writer produces a `global` row + // here, and the resolver excludes one from its reads — so the option was a + // declared value no write could reach. A row a pre-v18 database still + // holds at `global` is moved by the v18 upgrade ceremony (ADR-0131 D14). + // A fourth option once lived here declaring `runtime` (#6036) and was + // removed for the same reason. scope: Field.select( [ - { label: 'Global', value: 'global' }, { label: 'Tenant', value: 'tenant' }, { label: 'User', value: 'user' }, ], @@ -169,7 +174,7 @@ export const SysSetting = ObjectSchema.create({ defaultValue: false, description: 'When true, lower-scope rows cannot override this value; writes against lower scopes return 409. ' + - 'Used by platform administrators to pin a global value for all tenants (Phase 2 cascade).', + 'A tenant row pins its value for that tenant\'s users (Phase 2 cascade).', }), locked_reason: Field.text({ @@ -228,10 +233,12 @@ export const SysSetting = ObjectSchema.create({ // duplicates this hole permits. // // The organization key part is NULL-safe (`COALESCE(organization_id, - // '__global__')`, ADR-0120 D3), which is what preserves the `scope='global'` - // LAYER: platform rows carry no organization, so they share one bucket and - // stay unique among themselves — the installation-wide platform default the - // resolver reads at rung 2 survives, without the whole index being global. + // '__global__')`, ADR-0120 D3): a row that carries no organization shares + // one bucket and stays unique among its peers, without the whole index + // being global. That bucket was the `scope='global'` LAYER's until the rung + // moved to `sys_platform_setting` (ADR-0131 D7); it now holds tenant/user + // rows that carry no organization, and the `global` rows a pre-v18 + // database keeps until the v18 ceremony moves them. { fields: ['namespace', 'key', 'scope', 'user_id'], unique: 'organization' }, // Common range read: full namespace dump for SettingsService.getNamespace. { fields: ['namespace', 'scope'], unique: false }, diff --git a/packages/platform-objects/src/system/sys-setting.organization-unique.test.ts b/packages/platform-objects/src/system/sys-setting.organization-unique.test.ts index 72b01289020..f9259d0bd08 100644 --- a/packages/platform-objects/src/system/sys-setting.organization-unique.test.ts +++ b/packages/platform-objects/src/system/sys-setting.organization-unique.test.ts @@ -134,8 +134,11 @@ describe('sys_setting — declared uniqueness is organization-scoped (#8555)', ( expect([...layers].sort()).toEqual(['global', 'tenant', 'user']); expect(layers).not.toContain('organization_id'); + // [ADR-0131 D7] This table stores every layer but `global`, which moved to + // the tenant-less `sys_platform_setting`; the remaining layers are the + // spec's, in the spec's order. const scopeField = (SysSetting.fields as any).scope; - expect(scopeField.options.map((o: any) => o.value)).toEqual([...layers]); + expect(scopeField.options.map((o: any) => o.value)).toEqual(layers.filter((l) => l !== 'global')); // …and the unique key lists `scope` as an ordinary key column, so it // partitions the ladder, not the tenants. expect((uniqueIndexes[0] as any).fields).toContain('scope'); diff --git a/packages/platform-objects/src/system/sys-setting.scope-options.test.ts b/packages/platform-objects/src/system/sys-setting.scope-options.test.ts index ec510db9ab4..7930152bcd0 100644 --- a/packages/platform-objects/src/system/sys-setting.scope-options.test.ts +++ b/packages/platform-objects/src/system/sys-setting.scope-options.test.ts @@ -13,6 +13,11 @@ // than to a hand-copied literal that would need editing on both sides anyway. // A future fourth cascade layer therefore lands here as a red test, not as a // silent re-divergence. +// +// [ADR-0131 D7] Since the global rung moved to the tenant-less +// `sys_platform_setting`, this table stores the cascade's OTHER layers: its +// option list is the spec enum minus `global`, and that difference is pinned +// as such. The audit trail still records every layer, `global` included. import { describe, expect, it } from 'vitest'; import { SpecifierScopeSchema } from '@objectstack/spec/system'; import { SysSetting } from './sys-setting.object.js'; @@ -27,8 +32,14 @@ function optionValues(object: unknown, field: string): string[] { } describe('sys_setting.scope — value domain (#6036)', () => { - it('declares exactly the cascade layers the resolver walks', () => { - expect(optionValues(SysSetting, 'scope')).toEqual(['global', 'tenant', 'user']); + it('declares exactly the cascade layers this table stores', () => { + expect(optionValues(SysSetting, 'scope')).toEqual(['tenant', 'user']); + }); + + it('[ADR-0131 D7] does not declare `global` — that rung is stored in sys_platform_setting', () => { + // Its own case because re-adding the option breaks nothing at runtime: + // the service never writes such a row and excludes one from its reads. + expect(optionValues(SysSetting, 'scope')).not.toContain('global'); }); it('does not declare a `runtime` layer', () => { @@ -38,17 +49,23 @@ describe('sys_setting.scope — value domain (#6036)', () => { expect(optionValues(SysSetting, 'scope')).not.toContain('runtime'); }); - it('matches SpecifierScopeSchema — the reference truth for the cascade', () => { + it('matches SpecifierScopeSchema minus the global rung — the reference truth for the cascade', () => { // Set-compare: the spec enum is the authority on which layers exist, the - // object definition is the storage mirror. Either side growing alone is - // the #6036 defect, in whichever direction it happens next. + // object definition is the storage mirror of the layers stored HERE. Either + // side growing alone is the #6036 defect, in whichever direction it + // happens next. expect([...optionValues(SysSetting, 'scope')].sort()).toEqual( - [...SpecifierScopeSchema.options].sort(), + SpecifierScopeSchema.options.filter((s) => s !== 'global').sort(), ); }); - it('agrees with the audit trail object, which records the same layers', () => { - expect(optionValues(SysSettingAudit, 'scope')).toEqual(optionValues(SysSetting, 'scope')); + it('the audit trail object records EVERY layer — the global rung\'s changes included', () => { + // `buildSettingAuditWriter` writes the changed key's scope, and a + // global-scope change is still a change: the audit option list is the + // full spec enum, not this table's subset. + expect([...optionValues(SysSettingAudit, 'scope')].sort()).toEqual( + [...SpecifierScopeSchema.options].sort(), + ); }); it('keeps `tenant` as the default, and the default is a declared option', () => { diff --git a/packages/services/service-settings/src/config-change-audit.test.ts b/packages/services/service-settings/src/config-change-audit.test.ts index 8f34bff5c23..379aa6fa79c 100644 --- a/packages/services/service-settings/src/config-change-audit.test.ts +++ b/packages/services/service-settings/src/config-change-audit.test.ts @@ -40,7 +40,7 @@ import { describe, expect, it, vi } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; -import { SysSecret, SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; +import { SysPlatformSetting, SysSecret, SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; import type { SettingsManifest } from '@objectstack/spec/system'; import type { IHttpRequest, IHttpResponse, IHttpServer, RouteHandler } from '@objectstack/spec/contracts'; import { @@ -51,6 +51,7 @@ import { import { buildConfigChangeAuditSink, CONFIG_CHANGE_ACTION, + CONFIG_CHANGE_GLOBAL_OBJECT_NAME, CONFIG_CHANGE_OBJECT_NAME, } from './config-change-audit.js'; import { registerSettingsRoutes } from './settings-routes.js'; @@ -122,6 +123,9 @@ function makeMemoryDriver() { const matches = (row: Record, where: any): boolean => { if (!where || typeof where !== 'object') return true; return Object.entries(where).every(([k, v]) => { + // `$or` is the one combinator the settings reads emit (ADR-0131 D7: every + // `sys_setting` read names its rungs); anything else still refuses. + if (k === '$or') return (v as any[]).some((b) => matches(row, b)); if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return (row[k] ?? null) === (v ?? null); }); @@ -227,6 +231,7 @@ async function bootPlugin(opts: BootOptions = {}) { engine.registerDriver(driver, true); await engine.init(); engine.registry.registerObject(SysSetting as any, OWNER_PACKAGE); + engine.registry.registerObject(SysPlatformSetting as any, OWNER_PACKAGE); engine.registry.registerObject(SysSettingAudit as any, OWNER_PACKAGE); // [#21516] A secret-typed setting writes through `sys_secret`; the engine // refuses a name its registry does not hold, so the harness registers it as @@ -306,8 +311,14 @@ async function bootPlugin(opts: BootOptions = {}) { ledgerOpts: () => ledgerInserts.map((i) => i.opts), /** REAL `sys_setting_audit` rows, read out of the driver's store. */ settingAuditRows: () => [...rowsOf('sys_setting_audit').values()], - /** REAL `sys_setting` rows. */ - settingRows: () => [...rowsOf('sys_setting').values()], + /** + * REAL settings rows of this file's manifest. It is `scope: 'global'`, so + * they are `sys_platform_setting` rows — the global rung's own store + * (ADR-0131 D7) — and `sys_setting` stays empty. + */ + settingRows: () => [...rowsOf('sys_platform_setting').values()], + /** REAL `sys_setting` rows — the tenant and user rungs. */ + scopedSettingRows: () => [...rowsOf('sys_setting').values()], logged, /** [#18368] Every captured line with the channel it arrived on. */ loggedAt, @@ -358,7 +369,11 @@ describe('#8145 — a settings write reaches sys_audit_log as `config_change`', expect(rows).toHaveLength(1); expect(rows[0].action).toBe(CONFIG_CHANGE_ACTION); expect(rows[0].action).toBe('config_change'); - expect(rows[0].object_name).toBe(CONFIG_CHANGE_OBJECT_NAME); + // [ADR-0131 D7] This manifest is global, so the row names the global + // rung's own store; a tenant-scope change names `sys_setting` (pinned below). + expect(rows[0].object_name).toBe(CONFIG_CHANGE_GLOBAL_OBJECT_NAME); + expect(rows[0].object_name).toBe('sys_platform_setting'); + expect(boot.scopedSettingRows()).toHaveLength(0); // A settings row has no single id — the composite key is in `metadata`. expect(rows[0].record_id).toBeNull(); // Attribution: both channels, per ADR-0014 D2. @@ -385,6 +400,22 @@ describe('#8145 — a settings write reaches sys_audit_log as `config_change`', }); }); + it('[ADR-0131 D7] a TENANT-scope change names `sys_setting`, where its row landed', async () => { + const boot = await bootPlugin({ userId: 'usr_admin', tenantId: 'org_1' }); + boot.service.registerManifest({ ...manifest, namespace: 'branding_tenant_test', scope: 'tenant' }); + + await boot.service.setMany('branding_tenant_test', { workspace_name: 'ObjectStack' }, boot.writeCtx); + + const rows = boot.ledgerRows(); + expect(rows).toHaveLength(1); + expect(rows[0].object_name).toBe(CONFIG_CHANGE_OBJECT_NAME); + expect(rows[0].object_name).toBe('sys_setting'); + // …and the two stores agree with the ledger: the row is a `sys_setting` + // row, and the global rung's store took nothing. + expect(boot.scopedSettingRows()).toHaveLength(1); + expect(boot.settingRows()).toHaveLength(0); + }); + it('records one row per CHANGED KEY, not one per request', async () => { const boot = await bootPlugin({ userId: 'usr_admin' }); await boot.service.setMany( @@ -581,6 +612,7 @@ describe('#8145 — a refused write emits NO config_change row', () => { engine.registerDriver(driver, true); await engine.init(); engine.registry.registerObject(SysSetting as any, OWNER_PACKAGE); + engine.registry.registerObject(SysPlatformSetting as any, OWNER_PACKAGE); engine.registry.registerObject(SysSettingAudit as any, OWNER_PACKAGE); // [#21516] A secret-typed setting writes through `sys_secret`; the engine // refuses a name its registry does not hold, so the harness registers it as diff --git a/packages/services/service-settings/src/config-change-audit.ts b/packages/services/service-settings/src/config-change-audit.ts index 2f9535bb6c7..f0cae0a1e68 100644 --- a/packages/services/service-settings/src/config-change-audit.ts +++ b/packages/services/service-settings/src/config-change-audit.ts @@ -101,6 +101,17 @@ import type { SettingsAuditSink, SettingsDiagnosticsLogger } from './settings-se /** The object this service records platform configuration changes against. */ export const CONFIG_CHANGE_OBJECT_NAME = 'sys_setting'; +/** + * The object a change on the GLOBAL rung is recorded against (ADR-0131 D7). + * + * A global-scope value is stored in the tenant-less `sys_platform_setting`, not + * in `sys_setting`, so its ledger row names the object the value actually + * changed in. Tenant- and user-scope changes keep + * {@link CONFIG_CHANGE_OBJECT_NAME}. Published beside it for the same reason: + * a host reproducing the row must not spell either by hand. + */ +export const CONFIG_CHANGE_GLOBAL_OBJECT_NAME = 'sys_platform_setting'; + /** * The `sys_audit_log.action` value settings writes carry. * @@ -295,9 +306,10 @@ export function buildConfigChangeAuditSink( // The first-class principal label (ADR-0014 D2): a user id, a service // principal, or null for an in-process/boot write. actor, - object_name: CONFIG_CHANGE_OBJECT_NAME, + object_name: entry.scope === 'global' ? CONFIG_CHANGE_GLOBAL_OBJECT_NAME : CONFIG_CHANGE_OBJECT_NAME, // A settings write has no single record id: `sys_setting` is keyed on - // the composite `(namespace, key, scope, user_id)`. Null is the honest + // the composite `(namespace, key, scope, user_id)` and + // `sys_platform_setting` on `(namespace, key)`. Null is the honest // answer and the shape `plugin-auth`'s run-level `import` row already // uses; WHICH setting changed is in `metadata` and `new_value`. record_id: null, diff --git a/packages/services/service-settings/src/index.ts b/packages/services/service-settings/src/index.ts index 5757c9bb5b1..dd0491a22b9 100644 --- a/packages/services/service-settings/src/index.ts +++ b/packages/services/service-settings/src/index.ts @@ -83,6 +83,7 @@ export { buildConfigChangeAuditSink, CONFIG_CHANGE_ACTION, CONFIG_CHANGE_OBJECT_NAME, + CONFIG_CHANGE_GLOBAL_OBJECT_NAME, } from './config-change-audit.js'; export { registerSettingsRoutes, diff --git a/packages/services/service-settings/src/manifest.test.ts b/packages/services/service-settings/src/manifest.test.ts index 920a47ca0cc..ef2b40a6c60 100644 --- a/packages/services/service-settings/src/manifest.test.ts +++ b/packages/services/service-settings/src/manifest.test.ts @@ -4,8 +4,15 @@ import { describe, it, expect } from 'vitest'; import { settingsObjects } from './manifest.js'; describe('service-settings manifest objects (#4270)', () => { - it('owns exactly the K/V store and its audit trail', () => { - expect(settingsObjects.map((o: any) => o.name)).toEqual(['sys_setting', 'sys_setting_audit']); + it('owns exactly the K/V store, its global rung and its audit trail', () => { + // [ADR-0131 D7] `sys_platform_setting` is the cascade's global rung. It + // registers WITH `sys_setting` because the resolver reads both on every + // resolution. + expect(settingsObjects.map((o: any) => o.name)).toEqual([ + 'sys_setting', + 'sys_platform_setting', + 'sys_setting_audit', + ]); }); /** diff --git a/packages/services/service-settings/src/manifest.ts b/packages/services/service-settings/src/manifest.ts index 49651ab89e9..3842426f5cc 100644 --- a/packages/services/service-settings/src/manifest.ts +++ b/packages/services/service-settings/src/manifest.ts @@ -1,12 +1,19 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; +import { SysPlatformSetting, SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; export const SETTINGS_PLUGIN_ID = 'com.objectstack.service.settings'; export const SETTINGS_PLUGIN_VERSION = '0.1.0'; /** - * Objects owned by service-settings: the K/V store and its audit trail. + * Objects owned by service-settings: the K/V store, its tenant-less global + * rung, and the audit trail. + * + * [ADR-0131 D7] `sys_platform_setting` holds the cascade's global rung — one + * row per `(namespace, key)` for the deployment, no organization column — and + * `sys_setting` holds the tenant and user rungs. They register together because + * the resolver reads both on every resolution: a kernel with one and not the + * other would answer a cascade with a rung missing. * * `sys_secret` is deliberately NOT here (#4270): its producers span domains * (encrypted settings here, the engine's `secret`-field encryption, the @@ -15,7 +22,7 @@ export const SETTINGS_PLUGIN_VERSION = '0.1.0'; * infrastructure, present with or without this service (cf. `sys_migration`, * #4243). This service remains a producer/consumer via its secret store. */ -export const settingsObjects: any[] = [SysSetting, SysSettingAudit]; +export const settingsObjects: any[] = [SysSetting, SysPlatformSetting, SysSettingAudit]; /** Manifest header shared by compile-time config and runtime registration. */ export const settingsPluginManifestHeader = { diff --git a/packages/services/service-settings/src/settings-admission-tenancy-posture.test.ts b/packages/services/service-settings/src/settings-admission-tenancy-posture.test.ts index dcc0a94bd8a..a5e96ec45bb 100644 --- a/packages/services/service-settings/src/settings-admission-tenancy-posture.test.ts +++ b/packages/services/service-settings/src/settings-admission-tenancy-posture.test.ts @@ -63,7 +63,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; -import { SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; +import { SysPlatformSetting, SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; import type { SettingsManifest } from '@objectstack/spec/system'; import type { IHttpRequest, IHttpResponse, IHttpServer, RouteHandler } from '@objectstack/spec/contracts'; import { @@ -203,6 +203,9 @@ function makeMemoryDriver() { const matches = (row: Record, where: any): boolean => { if (!where || typeof where !== 'object') return true; return Object.entries(where).every(([k, v]) => { + // `$or` is the one combinator the settings reads emit (ADR-0131 D7: every + // `sys_setting` read names its rungs); anything else still refuses. + if (k === '$or') return (v as any[]).some((b) => matches(row, b)); if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return (row[k] ?? null) === (v ?? null); }); @@ -296,7 +299,11 @@ type Tenancy = interface Mounted { http: MockHttp; - /** REAL `sys_setting` rows, read out of the driver's own store. */ + /** + * REAL settings rows of this file's manifest, read out of the driver's own + * store. The manifest is `scope: 'global'`, so the store is the global rung's + * `sys_platform_setting` (ADR-0131 D7). + */ settingRows: () => Record[]; /** REAL `sys_setting_audit` rows — where this seam's `tenantId` lands. */ auditRows: () => Record[]; @@ -315,6 +322,7 @@ async function mount(tenancy: Tenancy): Promise { engine.registerDriver(driver, true); await engine.init(); engine.registry.registerObject(SysSetting as any, OWNER_PACKAGE); + engine.registry.registerObject(SysPlatformSetting as any, OWNER_PACKAGE); engine.registry.registerObject(SysSettingAudit as any, OWNER_PACKAGE); // The permission tables belong to plugins this package must not depend on, @@ -387,7 +395,7 @@ async function mount(tenancy: Tenancy): Promise { return { http, - settingRows: () => [...rowsOf('sys_setting').values()], + settingRows: () => [...rowsOf('sys_platform_setting').values()], auditRows: () => [...rowsOf('sys_setting_audit').values()], contextFromRequest: captured.contextFromRequest!, }; diff --git a/packages/services/service-settings/src/settings-crypto-fail-closed.test.ts b/packages/services/service-settings/src/settings-crypto-fail-closed.test.ts index 3198fc9f352..d6ed7ac8bb5 100644 --- a/packages/services/service-settings/src/settings-crypto-fail-closed.test.ts +++ b/packages/services/service-settings/src/settings-crypto-fail-closed.test.ts @@ -40,7 +40,7 @@ import { describe, expect, it } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; -import { SysSetting } from '@objectstack/platform-objects/system'; +import { SysPlatformSetting, SysSetting } from '@objectstack/platform-objects/system'; import type { SettingsManifest } from '@objectstack/spec/system'; import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; import { SettingsService } from './settings-service.js'; @@ -94,6 +94,9 @@ function makeMemoryDriver() { const matches = (row: Record, where: any): boolean => { if (!where || typeof where !== 'object') return true; return Object.entries(where).every(([k, v]) => { + // `$or` is the one combinator the settings reads emit (ADR-0131 D7: every + // `sys_setting` read names its rungs); anything else still refuses. + if (k === '$or') return (v as any[]).some((b) => matches(row, b)); if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return (row[k] ?? null) === (v ?? null); }); @@ -166,6 +169,7 @@ async function bootProviderless(opts: { crypto?: CryptoAdapter } = {}) { engine.registerDriver(driver, true); await engine.init(); engine.registry.registerObject(SysSetting as any, OWNER_PACKAGE); + engine.registry.registerObject(SysPlatformSetting as any, OWNER_PACKAGE); const logged: string[] = []; const svc = new SettingsService({ @@ -176,7 +180,9 @@ async function bootProviderless(opts: { crypto?: CryptoAdapter } = {}) { }); svc.registerManifest(manifest); - const settingRows = () => [...rowsOf('sys_setting').values()]; + // `crypto_ns` is `scope: 'global'`: its rows are the global rung's, stored in + // `sys_platform_setting` (ADR-0131 D7). + const settingRows = () => [...rowsOf('sys_platform_setting').values()]; const rowFor = (key: string) => settingRows().find((r) => r.key === key); return { svc, settingRows, rowFor, logged }; } diff --git a/packages/services/service-settings/src/settings-engine-bind-window.test.ts b/packages/services/service-settings/src/settings-engine-bind-window.test.ts index 394bbe4419f..be2318ba021 100644 --- a/packages/services/service-settings/src/settings-engine-bind-window.test.ts +++ b/packages/services/service-settings/src/settings-engine-bind-window.test.ts @@ -55,7 +55,7 @@ import { describe, expect, it } from 'vitest'; import { LiteKernel } from '@objectstack/core'; import type { Plugin, PluginContext } from '@objectstack/core'; import { ObjectQL } from '@objectstack/objectql'; -import { SysSecret, SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; +import { SysPlatformSetting, SysSecret, SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; import type { SettingsManifest } from '@objectstack/spec/system'; import { SettingsService } from './settings-service.js'; import { SettingsServicePlugin, wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; @@ -93,6 +93,9 @@ function makeMemoryDriver() { const matches = (row: Record, where: any): boolean => { if (!where || typeof where !== 'object') return true; return Object.entries(where).every(([k, v]) => { + // `$or` is the one combinator the settings reads emit (ADR-0131 D7: every + // `sys_setting` read names its rungs); anything else still refuses. + if (k === '$or') return (v as any[]).some((b) => matches(row, b)); if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return (row[k] ?? null) === (v ?? null); }); @@ -232,7 +235,7 @@ async function bootKernel(opts: { withEngine?: boolean } = {}) { // [#21516] The settings service writes its audit row through // `sys_setting_audit`; the engine refuses a name its registry does not hold, // so the harness registers it beside the two it already did, as a boot does. - for (const o of [SysSetting, SysSecret, SysSettingAudit]) engine.registry.registerObject(o as any, OWNER_PACKAGE); + for (const o of [SysSetting, SysPlatformSetting, SysSecret, SysSettingAudit]) engine.registry.registerObject(o as any, OWNER_PACKAGE); const probe = new ReadyHookFromInitPlugin(); const kernel = new LiteKernel({ logger: { level: 'error' } as never }); @@ -247,7 +250,9 @@ async function bootKernel(opts: { withEngine?: boolean } = {}) { const svc = kernel.getService('settings'); return { kernel, engine, svc, probe, rowsOf, - settingRows: () => [...rowsOf('sys_setting').values()], + // The probe manifests are `scope: 'global'`, so their rows are the global + // rung's: `sys_platform_setting` (ADR-0131 D7). + settingRows: () => [...rowsOf('sys_platform_setting').values()], auditRows: () => [...rowsOf('sys_setting_audit').values()], }; } @@ -310,7 +315,7 @@ describe('the pre-bind window refuses a write instead of resolving it', () => { // --------------------------------------------------------------------------- describe('after bind, writes behave exactly as before', () => { - it('the identical write lands a real `sys_setting` row and a real audit row', async () => { + it('the identical write lands a real settings row (in `sys_platform_setting`, the global rung) and a real audit row', async () => { const { svc, settingRows, auditRows } = await bootKernel(); svc.registerManifest(probeManifest); @@ -322,9 +327,11 @@ describe('after bind, writes behave exactly as before', () => { expect(rows[0]).toMatchObject({ namespace: 'receipt_probe', key: 'last_run', - scope: 'global', value: 'written-after-boot', }); + // The global rung's store has no `scope` or `user_id` column to write. + expect(rows[0]).not.toHaveProperty('scope'); + expect(rows[0]).not.toHaveProperty('user_id'); expect(auditRows()).toHaveLength(1); }); }); @@ -371,7 +378,7 @@ describe('engine-less callers outside the window observe nothing new', () => { // [#21516] The settings service writes its audit row through // `sys_setting_audit`; the engine refuses a name its registry does not hold, // so the harness registers it beside the two it already did, as a boot does. - for (const o of [SysSetting, SysSecret, SysSettingAudit]) engine.registry.registerObject(o as any, OWNER_PACKAGE); + for (const o of [SysSetting, SysPlatformSetting, SysSecret, SysSettingAudit]) engine.registry.registerObject(o as any, OWNER_PACKAGE); const svc = new SettingsService({ env: {}, engineBindPending: true }); svc.registerManifest(probeManifest); diff --git a/packages/services/service-settings/src/settings-getmany.test.ts b/packages/services/service-settings/src/settings-getmany.test.ts index 3b96288afe9..a57b053d166 100644 --- a/packages/services/service-settings/src/settings-getmany.test.ts +++ b/packages/services/service-settings/src/settings-getmany.test.ts @@ -14,8 +14,9 @@ * deep-equals what per-key `get()` returns — same value, same source, same * lock, same cascadeChain — across env overrides, scope mixes, and the * unknown-key refusal. Plus the read-count contract itself, measured at the - * engine: same-scope keys collapse to ONE find, mixed scopes to TWO, and a - * fully env-overridden set to ZERO. + * engine: same-scope keys collapse to ONE `sys_setting` find, mixed scopes to + * TWO, and a fully env-overridden set to ZERO — with the global rung's own + * store (`sys_platform_setting`, ADR-0131 D7) read once per call on top. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -35,9 +36,14 @@ function matches(row: Record, where: Record): }); } -function makeEngine(rows: Array>) { - const find = vi.fn(async (_obj: string, opts: any) => - rows.filter((r) => matches(r, opts?.where ?? {})), +// [ADR-0131 D7] Rows live in the object that stores their rung: the global rung +// in `sys_platform_setting`, the tenant and user rungs in `sys_setting`. The +// double answers each object from its own rows, so a read aimed at the wrong +// store finds nothing. +type Stores = Record>>; +function makeEngine(stores: Stores) { + const find = vi.fn(async (obj: string, opts: any) => + (stores[obj] ?? []).filter((r) => matches(r, opts?.where ?? {})), ); return { find, @@ -45,6 +51,10 @@ function makeEngine(rows: Array>) { }; } +/** Finds the engine received on `object`. */ +const findsOn = (engine: ReturnType, object: string) => + engine.find.mock.calls.filter(([o]) => o === object).length; + const MANIFEST = { namespace: 'localization', label: 'Localization', @@ -64,11 +74,15 @@ const MANIFEST = { // below skips that codec and returns rows exactly as written, so anything // spelled '"America/New_York"' here would resolve one JSON encoding deep. // (Matches the sibling fixture in `settings-loadrows-scope.test.ts`.) -const ROWS = [ - { namespace: 'localization', key: 'timezone', scope: 'global', value: 'America/New_York', user_id: null }, - { namespace: 'localization', key: 'locale', scope: 'user', value: 'zh-CN', user_id: 'u1' }, - { namespace: 'localization', key: 'currency', scope: 'tenant', value: 'USD', user_id: null }, -]; +const ROWS: Stores = { + sys_platform_setting: [ + { namespace: 'localization', key: 'timezone', value: 'America/New_York' }, + ], + sys_setting: [ + { namespace: 'localization', key: 'locale', scope: 'user', value: 'zh-CN', user_id: 'u1' }, + { namespace: 'localization', key: 'currency', scope: 'tenant', value: 'USD', user_id: null }, + ], +}; async function makeService(rows = ROWS) { const engine = makeEngine(rows); @@ -95,19 +109,25 @@ describe('[#10826] SettingsService.getMany', () => { } }); - it('collapses same-namespace reads: mixed scopes → TWO engine finds, not one per key', async () => { + it('collapses same-namespace reads: mixed scopes → TWO sys_setting finds, not one per key', async () => { const { svc, engine } = await makeService(); engine.find.mockClear(); await svc.getMany('localization', ['timezone', 'locale', 'currency'], { userId: 'u1' }); - // user-scoped keys share one load, the tenant-scoped key the other. - expect(engine.find).toHaveBeenCalledTimes(2); + // user-scoped keys share one load, the tenant-scoped key the other… + expect(findsOn(engine, 'sys_setting')).toBe(2); + // …and the global rung, which no grouping argument changes, is read ONCE + // for both groups (ADR-0131 D7), not once per group. + expect(findsOn(engine, 'sys_platform_setting')).toBe(1); + expect(engine.find).toHaveBeenCalledTimes(3); }); - it('same-scope keys → ONE engine find', async () => { + it('same-scope keys → ONE sys_setting find, plus the global rung once', async () => { const { svc, engine } = await makeService(); engine.find.mockClear(); await svc.getMany('localization', ['timezone', 'locale'], { userId: 'u1' }); - expect(engine.find).toHaveBeenCalledTimes(1); + expect(findsOn(engine, 'sys_setting')).toBe(1); + expect(findsOn(engine, 'sys_platform_setting')).toBe(1); + expect(engine.find).toHaveBeenCalledTimes(2); }); it('an env-overridden key answers without any row load, exactly like get()', async () => { @@ -177,7 +197,8 @@ describe('[#10826] SettingsService.getMany', () => { // and it no longer costs one load per key engine.find.mockClear(); await svc.getNamespace('localization', ctx); - expect(engine.find.mock.calls.length).toBeLessThanOrEqual(2); + expect(findsOn(engine, 'sys_setting')).toBeLessThanOrEqual(2); + expect(findsOn(engine, 'sys_platform_setting')).toBe(1); }); // [#12172] The ENCODING pin — the one assertion in this file that reads a diff --git a/packages/services/service-settings/src/settings-global-rung.test.ts b/packages/services/service-settings/src/settings-global-rung.test.ts new file mode 100644 index 00000000000..0fe8e2c9ae5 --- /dev/null +++ b/packages/services/service-settings/src/settings-global-rung.test.ts @@ -0,0 +1,301 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D7] The settings cascade's GLOBAL rung lives in the tenant-less + * `sys_platform_setting`, and `SettingsService` reads and writes it there. + * + * The card's acceptance — "the settings cascade resolves deployment values from + * the new source" — pinned over a real `ObjectQL` engine and the real platform + * objects, through the real `IDataEngine → SettingsEngine` adapter the plugin + * binds. Each case names the store it reads back, so a value answered from the + * wrong table cannot pass: + * + * - a global write lands in `sys_platform_setting`, keyed `(namespace, key)`, + * and `sys_setting` takes nothing; + * - the resolver's global rung is read from `sys_platform_setting`; + * - the rank table is unchanged: the global rung still outranks the tenant and + * user rungs, which still answer when it is empty; + * - ⛔ no dual read: a `scope = 'global'` row a pre-v18 database still holds in + * `sys_setting` is not a rung (ADR-0131 D14: the v18 ceremony moves it), with + * the same row at `scope = 'tenant'` as the control that the read can see it; + * - an encrypted global value keeps its handle in `sys_platform_setting.value_enc`, + * opens, and a rotation reaps the retired handle only after re-reading THAT + * row; and a handle sealed by any settings row opens from this store, because + * the ADR-0128 AAD binds the producer's `(namespace, key)` and neither the + * holder object nor an organization. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SysPlatformSetting, SysSecret, SysSetting } from '@objectstack/platform-objects/system'; +import type { SettingsManifest } from '@objectstack/spec/system'; +import { SettingsService } from './settings-service.js'; +import { SettingsServicePlugin, wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; +import { LocalCryptoProvider } from './local-crypto-provider.js'; +import { SettingsLockedError } from './settings-service.types.js'; + +const OWNER_PACKAGE = 'com.objectstack.test.settings-global-rung'; + +/** A deployment-level manifest, as the shipped `mail` / `storage` / `ai` ones are. */ +const GLOBAL_MANIFEST: SettingsManifest = { + namespace: 'relay_probe', + version: 1, + label: 'Relay (global)', + scope: 'global', + readPermission: 'manage_platform_settings', + writePermission: 'manage_platform_settings', + specifiers: [ + { type: 'text', key: 'host', label: 'Host', required: false, default: 'localhost' }, + { type: 'password', key: 'api_key', label: 'API key', required: false, encrypted: true }, + ], +}; + +/** A per-user key, so every rung of the cascade can hold a row for it. */ +const USER_MANIFEST: SettingsManifest = { + namespace: 'prefs_probe', + version: 1, + label: 'Prefs (user)', + scope: 'user', + readPermission: 'setup.access', + writePermission: 'setup.write', + specifiers: [{ type: 'text', key: 'theme', label: 'Theme', required: false, default: 'system' }], +}; + +/** A driver over plain Maps — the verbs the settings path reaches through `ObjectQL`. */ +function makeMemoryDriver() { + const store = new Map>>(); + let nextId = 0; + const copy = (r: Record) => ({ ...r }); + const rowsOf = (object: string) => { + let s = store.get(object); + if (!s) { s = new Map(); store.set(object, s); } + return s; + }; + // `$or` is the one combinator the settings reads emit; anything else refuses, + // so a predicate this double cannot express never reads as "no such row". + const matches = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + return Object.entries(where).every(([k, v]) => { + if (k === '$or') return (v as any[]).some((b) => matches(row, b)); + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return (row[k] ?? null) === (v ?? null); + }); + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + // The caller's bound by PRESENCE, after the filter and before the copy. + const hits = [...rowsOf(object).values()].filter((r) => matches(r, ast?.where)); + const page = typeof ast?.limit === 'number' ? hits.slice(0, ast.limit) : hits; + return page.map(copy); + }, + async findOne(object: string, ast: any) { + for (const r of rowsOf(object).values()) if (matches(r, ast?.where)) return copy(r); + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const id = (data.id as string) ?? `row_${nextId}`; + const row = { ...data, id }; + rowsOf(object).set(id, row); + return copy(row); + }, + async update(object: string, id: string, data: Record) { + const s = rowsOf(object); + const cur = s.get(id); + if (!cur) return null; + const next = { ...cur, ...data, id }; + s.set(id, next); + return copy(next); + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + return id && rowsOf(object).has(id) ? this.update(object, id, data) : this.create(object, data); + }, + async delete(object: string, id: string) { return rowsOf(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, + async bulkDelete() {}, + async updateMany(object: string, ast: any, data: Record) { + const rows = await this.find(object, ast); + const s = rowsOf(object); + for (const r of rows) s.set(r.id as string, { ...s.get(r.id as string), ...data, id: r.id }); + return rows.length; + }, + async deleteMany(object: string, ast: any) { + const rows = await this.find(object, ast); + for (const r of rows) rowsOf(object).delete(r.id as string); + return rows.length; + }, + async syncSchema() {}, async dropTable() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, rowsOf }; +} + +/** The pieces the running server binds: real engine, real objects, real adapter and secret store. */ +async function boot() { + const engine = new ObjectQL(); + const { driver, rowsOf } = makeMemoryDriver(); + engine.registerDriver(driver, true); + await engine.init(); + for (const o of [SysSetting, SysPlatformSetting, SysSecret]) { + engine.registry.registerObject(o as any, OWNER_PACKAGE); + } + const cryptoProvider = new LocalCryptoProvider(); + const svc = new SettingsService({ + env: {}, + engine: wrapEngineAsSettingsEngine(engine as any), + cryptoProvider, + secretStore: (new SettingsServicePlugin() as any).buildSecretStore(engine), + }); + svc.registerManifest(GLOBAL_MANIFEST); + svc.registerManifest(USER_MANIFEST); + const rows = (object: string) => [...rowsOf(object).values()]; + /** Seed a row straight into a store, as a database already holds it. */ + const seed = (object: string, row: Record) => + rowsOf(object).set(String(row.id), { encrypted: false, locked: false, value_enc: null, ...row }); + return { svc, engine, cryptoProvider, rows, seed }; +} + +describe('[ADR-0131 D7] the global rung is stored in sys_platform_setting', () => { + it('a global write lands in sys_platform_setting, keyed (namespace, key) — and sys_setting takes nothing', async () => { + const { svc, rows } = await boot(); + + await svc.set('relay_probe', 'host', 'smtp.example.com', { userId: 'usr_admin', tenantId: 'org_1' }); + await svc.set('relay_probe', 'host', 'smtp2.example.com', { userId: 'usr_admin', tenantId: 'org_1' }); + + // One row for the deployment, updated in place on the second write. + const platform = rows('sys_platform_setting'); + expect(platform).toHaveLength(1); + expect(platform[0]).toMatchObject({ namespace: 'relay_probe', key: 'host', value: 'smtp2.example.com' }); + // The store has no rung column, no user column and no organization: the + // writer's tenant context did not attribute the deployment value to it. + expect(platform[0]).not.toHaveProperty('scope'); + expect(platform[0]).not.toHaveProperty('user_id'); + expect(platform[0].organization_id ?? null).toBeNull(); + // The tenant/user store took nothing. + expect(rows('sys_setting')).toEqual([]); + }); + + it('the cascade resolves the global rung FROM sys_platform_setting', async () => { + const { svc, seed } = await boot(); + seed('sys_platform_setting', { id: 'ps_1', namespace: 'relay_probe', key: 'host', value: 'relay.internal' }); + + const got = await svc.get('relay_probe', 'host'); + expect(got.value).toBe('relay.internal'); + expect(got.source).toBe('global'); + expect(got.cascadeChain?.[0]).toMatchObject({ scope: 'global', value: 'relay.internal', effective: true }); + }); + + it('a lock on the global rung still refuses a lower-rung write (the lock check reads the new store)', async () => { + const { svc, seed, rows } = await boot(); + seed('sys_platform_setting', { + id: 'ps_1', namespace: 'prefs_probe', key: 'theme', value: 'dark', + locked: true, locked_reason: 'Platform policy: one theme.', + }); + + const err = await svc.set('prefs_probe', 'theme', 'light', { userId: 'u1' }).then(() => null, (e: unknown) => e); + expect(err).toBeInstanceOf(SettingsLockedError); + expect((err as SettingsLockedError).code).toBe('SETTINGS_LOCKED'); + expect(rows('sys_setting')).toEqual([]); + }); +}); + +describe('[ADR-0131 D7] the rank table is unchanged — only the global rung moved', () => { + it('the global rung outranks the tenant and user rungs, which answer once it is empty', async () => { + const { svc, seed } = await boot(); + seed('sys_setting', { id: 'st_t', namespace: 'prefs_probe', key: 'theme', scope: 'tenant', user_id: null, value: 'tenant-theme' }); + seed('sys_setting', { id: 'st_u', namespace: 'prefs_probe', key: 'theme', scope: 'user', user_id: 'u1', value: 'user-theme' }); + + // No global row: the tenant and user rungs answer, in the order they did. + let got = await svc.get('prefs_probe', 'theme', { userId: 'u1' }); + expect(got.cascadeChain?.map((e) => e.scope)).toEqual(['tenant', 'user', 'default']); + expect(got.source).toBe('tenant'); + + // A global row, from its own store, takes rank 1 — above both. + seed('sys_platform_setting', { id: 'ps_1', namespace: 'prefs_probe', key: 'theme', value: 'global-theme' }); + got = await svc.get('prefs_probe', 'theme', { userId: 'u1' }); + expect(got.cascadeChain?.map((e) => e.scope)).toEqual(['global', 'tenant', 'user', 'default']); + expect(got.value).toBe('global-theme'); + expect(got.source).toBe('global'); + }); +}); + +describe('[ADR-0131 D14] no dual read: sys_setting is not a second source for the global rung', () => { + it('a scope=global row still in sys_setting is NOT read — the control row at scope=tenant is', async () => { + const { svc, seed } = await boot(); + // The shape a pre-v18 database holds until the v18 ceremony moves it. The + // key is user-declared, so every rung — global, tenant, user — is consulted. + seed('sys_setting', { id: 'legacy', namespace: 'prefs_probe', key: 'theme', scope: 'global', user_id: null, value: 'legacy-theme' }); + + const got = await svc.get('prefs_probe', 'theme', { userId: 'u1' }); + expect(got.value).toBe('system'); + expect(got.source).toBe('default'); + expect(got.cascadeChain?.map((e) => e.scope)).toEqual(['default']); + + // Control: the identical row one rung down IS visible to the same read, + // so the silence above is the exclusion and not a read that sees nothing. + seed('sys_setting', { id: 'legacy', namespace: 'prefs_probe', key: 'theme', scope: 'tenant', user_id: null, value: 'legacy-theme' }); + const control = await svc.get('prefs_probe', 'theme', { userId: 'u1' }); + expect(control.value).toBe('legacy-theme'); + expect(control.source).toBe('tenant'); + }); + + it('a global-scope key never consults sys_setting at all — the legacy row is not its value either', async () => { + const { svc, seed } = await boot(); + seed('sys_setting', { id: 'legacy', namespace: 'relay_probe', key: 'host', scope: 'global', user_id: null, value: 'legacy.example.com' }); + + const got = await svc.get('relay_probe', 'host'); + expect(got.value).toBe('localhost'); + expect(got.source).toBe('default'); + }); +}); + +describe('[ADR-0131 D7 / ADR-0128] an encrypted global value', () => { + it('keeps its handle in sys_platform_setting.value_enc, opens, and a rotation reaps the retired handle', async () => { + const { svc, rows } = await boot(); + + await svc.set('relay_probe', 'api_key', 'first-secret'); + const [first] = rows('sys_platform_setting'); + expect(String(first.value_enc)).toMatch(/^sec_/); + expect(first.value ?? null).toBeNull(); + expect(rows('sys_secret').map((r) => r.id)).toEqual([first.value_enc]); + expect((await svc.get('relay_probe', 'api_key')).value).toBe('first-secret'); + + await svc.set('relay_probe', 'api_key', 'second-secret'); + const [second] = rows('sys_platform_setting'); + expect(second.value_enc).not.toBe(first.value_enc); + expect((await svc.get('relay_probe', 'api_key')).value).toBe('second-secret'); + // The reaper re-read the platform row, saw it repointed, and removed the + // retired ciphertext — exactly one secret row survives, the one in force. + expect(rows('sys_secret').map((r) => r.id)).toEqual([second.value_enc]); + expect(rows('sys_setting')).toEqual([]); + }); + + it('a handle sealed by a settings row opens from sys_platform_setting unchanged (AAD = producer scope + namespace + key)', async () => { + const { svc, cryptoProvider, seed } = await boot(); + // Sealed the way every settings write seals it, with an organization in the + // context — the context a tenant admin's write, or a pre-v18 global write, + // carried. Nothing about the holder object or the organization is bound in. + const handle = await cryptoProvider.encrypt('moved-secret', { + scope: 'settings', namespace: 'relay_probe', key: 'api_key', tenantId: 'org_1', + }); + seed('sys_secret', { + id: handle.id, namespace: 'relay_probe', key: 'api_key', + kms_key_id: handle.kmsKeyId, alg: handle.alg, version: handle.version, ciphertext: handle.ciphertext, + }); + // The row the v18 ceremony writes: the same handle, in the new store. + seed('sys_platform_setting', { id: 'ps_1', namespace: 'relay_probe', key: 'api_key', encrypted: true, value_enc: handle.id }); + + const got = await svc.get('relay_probe', 'api_key'); + expect(got.value).toBe('moved-secret'); + expect(got.source).toBe('global'); + }); +}); diff --git a/packages/services/service-settings/src/settings-loadrows-scope.test.ts b/packages/services/service-settings/src/settings-loadrows-scope.test.ts index 1358c931c59..97207ee143c 100644 --- a/packages/services/service-settings/src/settings-loadrows-scope.test.ts +++ b/packages/services/service-settings/src/settings-loadrows-scope.test.ts @@ -43,9 +43,21 @@ function matches(row: Record, where: Record): }); } +// [ADR-0131 D7] A row is stored by its rung: a `global` row in +// `sys_platform_setting` (which has no `scope` and no `user_id` column), every +// other row in `sys_setting`. The double answers each object from its own rows. +function storeOf(obj: string, rows: Array>) { + if (obj === 'sys_platform_setting') { + return rows + .filter((r) => r.scope === 'global') + .map(({ scope: _scope, user_id: _userId, ...stored }) => stored); + } + return rows.filter((r) => r.scope !== 'global'); +} + function makeEngine(rows: Array>) { - const find = vi.fn(async (_obj: string, opts: any) => - rows.filter((r) => matches(r, opts?.where ?? {})), + const find = vi.fn(async (obj: string, opts: any) => + storeOf(obj, rows).filter((r) => matches(r, opts?.where ?? {})), ); return { find, diff --git a/packages/services/service-settings/src/settings-prebind-read-warning.test.ts b/packages/services/service-settings/src/settings-prebind-read-warning.test.ts index efa856101f4..4f9b0563c9a 100644 --- a/packages/services/service-settings/src/settings-prebind-read-warning.test.ts +++ b/packages/services/service-settings/src/settings-prebind-read-warning.test.ts @@ -50,7 +50,7 @@ import { describe, expect, it, vi } from 'vitest'; import { LiteKernel } from '@objectstack/core'; import type { Plugin, PluginContext } from '@objectstack/core'; import { ObjectQL } from '@objectstack/objectql'; -import { SysSecret, SysSetting } from '@objectstack/platform-objects/system'; +import { SysPlatformSetting, SysSecret, SysSetting } from '@objectstack/platform-objects/system'; import type { SettingsManifest } from '@objectstack/spec/system'; import { SettingsService } from './settings-service.js'; import { SettingsServicePlugin, wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; @@ -99,6 +99,9 @@ function makeMemoryDriver() { const matches = (row: Record, where: any): boolean => { if (!where || typeof where !== 'object') return true; return Object.entries(where).every(([k, v]) => { + // `$or` is the one combinator the settings reads emit (ADR-0131 D7: every + // `sys_setting` read names its rungs); anything else still refuses. + if (k === '$or') return (v as any[]).some((b) => matches(row, b)); if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return (row[k] ?? null) === (v ?? null); }); @@ -224,7 +227,11 @@ async function bootKernel(opts: { readerFirst: boolean; declareSettingsOrder?: boolean; withEngine?: boolean; - /** Row seeded into `sys_setting` before boot — the value an in-window read misses. */ + /** + * Row seeded before boot — the value an in-window read misses. The probe + * manifest is `scope: 'global'`, so the row is seeded where that rung is + * stored: `sys_platform_setting` (ADR-0131 D7). + */ seedPersisted?: string; }) { const withEngine = opts.withEngine !== false; @@ -232,15 +239,13 @@ async function bootKernel(opts: { const engine = new ObjectQL(); engine.registerDriver(driver, true); await engine.init(); - for (const o of [SysSetting, SysSecret]) engine.registry.registerObject(o as any, OWNER_PACKAGE); + for (const o of [SysSetting, SysPlatformSetting, SysSecret]) engine.registry.registerObject(o as any, OWNER_PACKAGE); if (opts.seedPersisted !== undefined) { - rowsOf('sys_setting').set('seed_1', { + rowsOf('sys_platform_setting').set('seed_1', { id: 'seed_1', namespace: 'read_probe', key: 'provider', - scope: 'global', - user_id: null, value: opts.seedPersisted, value_enc: null, encrypted: false, @@ -424,7 +429,7 @@ describe('the report is scoped to the window, not to engine-less-ness', () => { const engine = new ObjectQL(); engine.registerDriver(driver, true); await engine.init(); - for (const o of [SysSetting, SysSecret]) engine.registry.registerObject(o as any, OWNER_PACKAGE); + for (const o of [SysSetting, SysPlatformSetting, SysSecret]) engine.registry.registerObject(o as any, OWNER_PACKAGE); const warn = vi.fn<(message: string) => void>(); const svc = new SettingsService({ env: {}, engineBindPending: true, logger: { warn } }); 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 b296081231e..1e8086f0d5c 100644 --- a/packages/services/service-settings/src/settings-secret-rotation.test.ts +++ b/packages/services/service-settings/src/settings-secret-rotation.test.ts @@ -77,7 +77,7 @@ import { describe, expect, it, vi } from 'vitest'; // the #8262 doubles below stay pinned to the real dispatch contract without // adding a dependency or an alias. import { assertEngineUpdateDispatch, ObjectQL } from '@objectstack/objectql'; -import { SysSecret, SysSetting } from '@objectstack/platform-objects/system'; +import { SysPlatformSetting, SysSecret, SysSetting } from '@objectstack/platform-objects/system'; import type { SettingsManifest } from '@objectstack/spec/system'; import { SettingsService } from './settings-service.js'; import { wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; @@ -124,6 +124,9 @@ function makeMemoryDriver() { const matches = (row: Record, where: any): boolean => { if (!where || typeof where !== 'object') return true; return Object.entries(where).every(([k, v]) => { + // `$or` is the one combinator the settings reads emit (ADR-0131 D7: every + // `sys_setting` read names its rungs); anything else still refuses. + if (k === '$or') return (v as any[]).some((b) => matches(row, b)); if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return (row[k] ?? null) === (v ?? null); }); @@ -274,7 +277,7 @@ async function boot(opts: { const { driver, rowsOf } = makeMemoryDriver(); engine.registerDriver(driver, true); await engine.init(); - for (const o of [SysSetting, SysSecret]) { + for (const o of [SysSetting, SysPlatformSetting, SysSecret]) { engine.registry.registerObject(o as any, OWNER_PACKAGE); } diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index 0a13d9abbf9..d3b0dd4d73f 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -55,14 +55,42 @@ import { const DEFAULT_OBJECT = 'sys_setting'; /** - * The execution context `SettingsService`'s own `sys_setting` reads and writes - * run under (#8030, #21913). + * The tenant-less object that stores the cascade's GLOBAL rung (ADR-0131 D7). * - * `sys_setting` is a platform-owned table with platform-owned columns - * (`value_enc`, `updated_by` are declared `readonly: true`), and this service - * is the only writer of them — after its own capability, lock and validation - * gates. See {@link SettingsService.upsertRow} for the full argument and for - * why the field stays `readonly` for everybody else. + * The global rung left the tenant-scoped `sys_setting`: a deployment-level + * value has no organization, and a tenant-scoped table could only hold it as a + * NULL-organization row that a walled posture hides from every reader. So the + * rung has its own store, read and written ONLY here: + * + * - a write at a key whose declared scope is `global` lands in this object, + * keyed `(namespace, key)` — never in `sys_setting`; + * - the resolver's global rung is read from this object and nowhere else. + * `sys_setting` reads exclude `scope = 'global'` in the query itself, so a + * row a pre-v18 database still holds there is not a second source for the + * rung. Moving those rows is the v18 upgrade ceremony's job (ADR-0131 D14: + * a manual operator step behind a boot refusal), ⛔ never a fallback read + * here and never an automatic move at boot. + * + * The rank table ({@link SettingsService.scopeRank}), the `source: 'global'` + * resolution value and `SpecifierScope` are unchanged: they name the cascade + * rung, not its storage. + */ +const PLATFORM_SETTING_OBJECT = 'sys_platform_setting'; + +/** + * The execution context `SettingsService`'s own reads and writes of its two + * stores — `sys_setting` and {@link PLATFORM_SETTING_OBJECT} — run under + * (#8030, #21913). + * + * Both are platform-owned tables with platform-owned columns (`value_enc`, + * `updated_by` are declared `readonly: true`), and this service is the only + * writer of them — after its own capability, lock and validation gates. See + * {@link SettingsService.upsertRow} for the full argument and for why the field + * stays `readonly` for everybody else. On `sys_platform_setting` the opt-in also + * carries the read past the object's own `requiredPermissions` gate: the + * settings door in front of the service has already applied the manifest's + * capability, and the platform-only capability on the object governs the + * generic data API, not this service's plumbing (ADR-0131 D7). * * The reads ({@link SettingsService.loadRows}, `upsertRow`'s existence probe, * and [#21908] `readStoredHandle`'s re-read of the stored handle) and @@ -614,7 +642,8 @@ export class SettingsService { * Late-bind a data engine and (optionally) an audit sink. Plugins * call this from `kernel:ready` once `objectql` is wired so the * SettingsService swaps from its in-memory fallback to the real - * `sys_setting` table without re-registering the service. + * `sys_setting` / `sys_platform_setting` tables without re-registering the + * service. */ bindEngine( engine: SettingsEngine, @@ -1271,7 +1300,10 @@ export class SettingsService { * exactly as {@link get} does), then group the remaining keys by which * `loadRows` argument their scope requires — `user`-scoped keys read * `(namespace, userId)`, everything else `(namespace, null)` — one load per - * group, and walk each key's cascade over its group's rows. + * group, and walk each key's cascade over its group's rows. [ADR-0131 D7] + * The global rung lives in its own object and no grouping argument changes + * it, so it is read once per call, whatever the grouping: at most two + * `sys_setting` reads plus one `sys_platform_setting` read. * * Row-for-row equivalent to calling {@link get} per key BY CONSTRUCTION: * the env-override branch, the scope→userId mapping, and the cascade are @@ -1344,14 +1376,16 @@ export class SettingsService { if (pending.length > 0) { const userKeys = pending.filter((p) => p.scope === 'user'); const otherKeys = pending.filter((p) => p.scope !== 'user'); - const [userRows, otherRows] = await Promise.all([ - userKeys.length > 0 - ? this.loadRows(namespace, ctx.userId ?? null) - : Promise.resolve([] as SettingsRow[]), - otherKeys.length > 0 - ? this.loadRows(namespace, null) - : Promise.resolve([] as SettingsRow[]), - ]); + // [ADR-0131 D7] One load per required `loadRows` argument, as before — + // and the global rung, which no argument changes, is read ONCE for both + // (see `loadRowSets`). + const groups: Array = [ + ...(userKeys.length > 0 ? [ctx.userId ?? null] : []), + ...(otherKeys.length > 0 ? [null] : []), + ]; + const sets = await this.loadRowSets(namespace, groups); + const userRows = userKeys.length > 0 ? sets[0] : []; + const otherRows = otherKeys.length > 0 ? sets[sets.length - 1] : []; for (const { key, scope } of userKeys) { out[key] = await this.resolveKeyFromRows(reg, key, scope, userRows); } @@ -1725,9 +1759,10 @@ export class SettingsService { for (const [key, rawValue] of Object.entries(patch)) { const scope = reg.scopes.get(key)!; - // global rows are platform-wide (tenant_id=null, user_id=null); - // user rows pin to ctx.userId; tenant rows leave user_id null and - // let the engine's tenant scoping fill in tenant_id from ctx. + // global rows are deployment-wide and land in `sys_platform_setting`, + // which has no organization and no user column (ADR-0131 D7, see + // `rowIdentity`); user rows pin to ctx.userId; tenant rows leave user_id + // null and let the engine's tenant scoping fill in tenant_id from ctx. const userId = scope === 'user' ? ctx.userId ?? null : null; const isEncrypted = reg.encryptedKeys.has(key); const isNull = rawValue === null || typeof rawValue === 'undefined'; @@ -2316,41 +2351,32 @@ export class SettingsService { // Persistence helpers (engine or in-memory) // --------------------------------------------------------------------- + /** The namespace's rows every rung of one key's cascade can draw on. */ private async loadRows(namespace: string, userId: string | null): Promise { + return (await this.loadRowSets(namespace, [userId]))[0]; + } + + /** + * One row set per requested `userId`, each holding every rung's rows for the + * namespace — the global rung from `sys_platform_setting`, the tenant and + * user rungs from `sys_setting` ([ADR-0131 D7]). + * + * The global rung does not depend on `userId`, so on the engine path it is + * read ONCE and shared by every set; each set then costs one `sys_setting` + * read. That is what keeps {@link getMany}'s grouped load at one + * `sys_setting` read per grouping argument (#10826) with the rung's own read + * added once, rather than once per group. + */ + private async loadRowSets( + namespace: string, + userIds: ReadonlyArray, + ): Promise { if (this.engine) { - const where: Record = { namespace }; - // A user-keyed load must still see tenant/global rows (user_id NULL): - // resolveKey's user→tenant→global cascade and the Phase-2 upper-scope - // lock check both search THIS one result set, so a bare user_id - // equality starves them of every upper-scope row on engine-bound - // deployments while the in-memory branch below includes them (#11228). - if (userId !== null) { - where.$or = [{ user_id: userId }, { scope: 'tenant' }, { scope: 'global' }]; - } - // Settings rows include platform-wide (`global` scope, tenant_id=null) - // entries; bypass the tenant-scoping audit warning so loads work - // uniformly across global/tenant/user without log noise. Per-tenant - // isolation for `tenant`-scope rows is still enforced by the engine - // once an ExecutionContext.tenantId is plumbed through (Phase 2+). - // The explicit system opt-in: see SETTINGS_SYSTEM_CONTEXT. - const rows = await this.engine.find(this.objectName, { - where, - bypassTenantAudit: true, - context: SETTINGS_SYSTEM_CONTEXT, - } as any); - return rows.map((r) => ({ - namespace: r.namespace, - key: r.key, - scope: r.scope as SpecifierScope, - user_id: r.user_id ?? null, - value: r.value ?? null, - value_enc: r.value_enc ?? null, - encrypted: Boolean(r.encrypted), - locked: Boolean(r.locked), - locked_reason: r.locked_reason ?? null, - updated_at: r.updated_at, - updated_by: r.updated_by ?? null, - })); + const [globalRows, ...scopedSets] = await Promise.all([ + this.loadGlobalRows(namespace), + ...userIds.map((userId) => this.loadScopedRows(namespace, userId)), + ]); + return scopedSets.map((scoped) => [...globalRows, ...scoped]); } // The engine branch above was not taken. When a bind is DECLARED-but-pending // that is the pre-bind window, and this read is about to be answered from a @@ -2361,13 +2387,83 @@ export class SettingsService { // `loadRows`, and that answer IS correct (env outranks every persisted // scope), so it must not be reported. this.reportPreBindRead(namespace); - return this.memory.filter( - (r) => - r.namespace === namespace && - (userId === null || r.user_id === userId || r.scope === 'tenant' || r.scope === 'global'), + // The in-memory fallback is ONE store for every rung: rows keep their + // `scope` tag, and there is no table split to mirror. + return userIds.map((userId) => + this.memory.filter( + (r) => + r.namespace === namespace && + (userId === null || r.user_id === userId || r.scope === 'tenant' || r.scope === 'global'), + ), ); } + /** + * [ADR-0131 D7] The global rung: the namespace's `sys_platform_setting` rows, + * tagged `scope: 'global'` for the cascade walk. The object has no `scope`, + * no `user_id` and no organization column — one row per `(namespace, key)` + * for the deployment — so the read is `namespace` alone and needs no + * tenant-audit bypass. The explicit system opt-in: see SETTINGS_SYSTEM_CONTEXT. + */ + private async loadGlobalRows(namespace: string): Promise { + const rows = await this.engine!.find(PLATFORM_SETTING_OBJECT, { + where: { namespace }, + context: SETTINGS_SYSTEM_CONTEXT, + }); + return rows.map((r) => this.toSettingsRow(r, 'global')); + } + + /** + * The tenant and user rungs: the namespace's `sys_setting` rows. + * + * ⛔ `scope = 'global'` is excluded IN THE QUERY. The global rung's one source + * is {@link loadGlobalRows}; a global row a pre-v18 database still holds in + * `sys_setting` must not answer as a second one — the v18 upgrade ceremony + * moves it (ADR-0131 D14), and until then it is simply not a rung. + */ + private async loadScopedRows(namespace: string, userId: string | null): Promise { + // A user-keyed load must still see the tenant rows (user_id NULL): + // resolveKey's user→tenant→global cascade and the Phase-2 upper-scope lock + // check both search THIS one result set, so a bare user_id equality starves + // them of every upper-scope row on engine-bound deployments while the + // in-memory branch includes them (#11228). The global rung is not in this + // table any more; `loadRowSets` adds it. + const where: Record = { + namespace, + $or: userId !== null + ? [{ user_id: userId }, { scope: 'tenant' }] + : [{ scope: 'tenant' }, { scope: 'user' }], + }; + // Bypass the tenant-scoping audit warning so loads work uniformly across + // the tenant and user rungs without log noise. Per-tenant isolation for + // `tenant`-scope rows is still enforced by the engine once an + // ExecutionContext.tenantId is plumbed through (Phase 2+). + // The explicit system opt-in: see SETTINGS_SYSTEM_CONTEXT. + const rows = await this.engine!.find(this.objectName, { + where, + bypassTenantAudit: true, + context: SETTINGS_SYSTEM_CONTEXT, + } as any); + return rows.map((r) => this.toSettingsRow(r, r.scope as SpecifierScope)); + } + + /** A stored row in the cascade's shape, on the rung its store says it is. */ + private toSettingsRow(r: any, scope: SpecifierScope): SettingsRow { + return { + namespace: r.namespace, + key: r.key, + scope, + user_id: scope === 'global' ? null : r.user_id ?? null, + value: r.value ?? null, + value_enc: r.value_enc ?? null, + encrypted: Boolean(r.encrypted), + locked: Boolean(r.locked), + locked_reason: r.locked_reason ?? null, + updated_at: r.updated_at, + updated_by: r.updated_by ?? null, + }; + } + /** * Write one settings row, INSERT-or-UPDATE, and report the `value_enc` the * row held **before** the write (`null` when there was no row, or the row @@ -2376,7 +2472,8 @@ export class SettingsService { * ### Why the update is a SYSTEM write (#8030) * * `sys_setting.value_enc` and `sys_setting.updated_by` are declared - * `readonly: true` (`packages/platform-objects/src/system/sys-setting.object.ts`), + * `readonly: true` (`packages/platform-objects/src/system/sys-setting.object.ts`; + * the global rung's `sys_platform_setting` declares the same two the same way), * and the engine STRIPS author-declared read-only columns from a * **non-system** caller's UPDATE payload (`stripReadonlyFields`, gated on * `if (!opCtx.context?.isSystem)` in `packages/objectql/src/engine.ts`). On @@ -2410,31 +2507,44 @@ export class SettingsService { */ /** * The composite key identifying ONE settings row, in the shape the engine - * path needs. + * path needs — the object that stores the row's rung, the `where` that names + * the row there, and the columns the row is written with. * * Extracted rather than repeated (#8262): `reapRotatedSecret`'s verification * read has to target **exactly** the row `upsertRow` just wrote. A re-read * aimed at a slightly different row would answer a different question while * looking correct — and the answer decides whether a ciphertext is deleted. + * + * [ADR-0131 D7] The rung picks the store. A `global` row lives in + * `sys_platform_setting`, keyed `(namespace, key)` and written WITHOUT `scope` + * or `user_id` — that object declares neither, and no organization column + * either. A `tenant` / `user` row lives in `sys_setting`, keyed + * `(namespace, key, scope, user_id)` exactly as before. Neither carries a + * tenant-audit bypass: the global rung's object has no tenant field to audit, + * and the tenant/user rungs keep the warning for a write missing its tenant. */ private rowIdentity(row: SettingsRow): { + object: string; where: Record; - // Narrow on purpose: spread into a `SettingsEngine.find` options object it - // has to stay assignable to the DECLARED option type, so the verification - // read below needs no `as any` erasure of the contract it depends on. - bypass: { bypassTenantAudit?: true }; + data: Record; } { + if (row.scope === 'global') { + const { scope: _scope, user_id: _userId, ...data } = row; + return { + object: PLATFORM_SETTING_OBJECT, + where: { namespace: row.namespace, key: row.key }, + data, + }; + } return { + object: this.objectName, where: { namespace: row.namespace, key: row.key, scope: row.scope, user_id: row.user_id ?? null, }, - // global rows are platform-wide — bypass the tenant audit warning - // (we intentionally write tenant_id=null). tenant/user rows still - // benefit from the warning when ctx.tenantId is missing. - bypass: row.scope === 'global' ? { bypassTenantAudit: true } : {}, + data: { ...row }, }; } @@ -2461,42 +2571,43 @@ export class SettingsService { private async upsertRow(row: SettingsRow): Promise { if (this.engine) { - const { where, bypass } = this.rowIdentity(row); + const { object, where, data } = this.rowIdentity(row); // All three engine calls carry the explicit system opt-in // (SETTINGS_SYSTEM_CONTEXT): the probe and the insert for the same // reason as the update below, and none of them relies on a missing // principal to pass the security middleware. - const existing = await this.engine.find(this.objectName, { + const existing = await this.engine.find(object, { where, limit: 1, - ...bypass, context: SETTINGS_SYSTEM_CONTEXT, - } as any); + }); if (existing[0]) { const previousEnc = (existing[0] as { value_enc?: unknown }).value_enc; - await this.engine.update(this.objectName, { + await this.engine.update(object, { where, - data: { ...row }, + data, context: SETTINGS_SYSTEM_CONTEXT, - ...bypass, - } as any); + }); return SettingsService.handleOf(previousEnc); } // Under the opt-in the engine no longer checks that a user-scope row's // `user_id` names a user, so the service keeps that refusal before the // insert (see assertUserReferenceResolves). The update branch above was - // already a system write and is unchanged. + // already a system write and is unchanged. A global row names no user + // (its object has no `user_id`), so there is nothing to resolve. const engine = this.engine; - await assertUserReferenceResolves( - async (id) => (await engine.find(USER_OBJECT, { where: { id }, limit: 1, context: SETTINGS_SYSTEM_CONTEXT }))[0], - { - object: this.objectName, - field: 'user_id', - label: registeredLabel(engine, { object: this.objectName, field: 'user_id' }, 'User'), - }, - row.user_id, - ); - await this.engine.insert(this.objectName, { ...row }, { ...bypass, context: SETTINGS_SYSTEM_CONTEXT } as any); + if (object === this.objectName) { + await assertUserReferenceResolves( + async (id) => (await engine.find(USER_OBJECT, { where: { id }, limit: 1, context: SETTINGS_SYSTEM_CONTEXT }))[0], + { + object: this.objectName, + field: 'user_id', + label: registeredLabel(engine, { object: this.objectName, field: 'user_id' }, 'User'), + }, + row.user_id, + ); + } + await this.engine.insert(object, data, { context: SETTINGS_SYSTEM_CONTEXT }); return null; } const idx = this.memoryIndexOf(row); @@ -2522,7 +2633,8 @@ export class SettingsService { * hygiene we ask operators to practise. * * Nothing else can reference the handle: ids are minted per `encrypt()` call, - * `sys_setting.value_enc` is the only column that holds one, and the audit + * `value_enc` on the row's own store (`sys_setting`, or `sys_platform_setting` + * for the global rung) is the only column that holds one, and the audit * trail records digests (`hmac-sha256:…`) rather than handles — so it stays * readable after the ciphertext is gone. * @@ -2637,13 +2749,12 @@ export class SettingsService { row: SettingsRow, ): Promise<{ found: boolean; handle: string | null }> { if (this.engine) { - const { where, bypass } = this.rowIdentity(row); + const { object, where } = this.rowIdentity(row); // The explicit system opt-in, as the write it verifies carries: see - // SETTINGS_SYSTEM_CONTEXT. - const rows = await this.engine.find(this.objectName, { + // SETTINGS_SYSTEM_CONTEXT. Same object and same `where` as the write. + const rows = await this.engine.find(object, { where, limit: 1, - ...bypass, context: SETTINGS_SYSTEM_CONTEXT, }); const current = Array.isArray(rows) ? rows[0] : undefined; 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 707adb67901..f8622b497f4 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 @@ -58,7 +58,9 @@ function recordingEngine() { }, async insert(object: string, data: Record, options?: any) { calls.push({ verb: 'insert', object, context: options?.context }); - if (object === 'sys_setting' || object === 'sys_secret') rows.push({ __object: object, ...data }); + if (object === 'sys_setting' || object === 'sys_platform_setting' || object === 'sys_secret') { + rows.push({ __object: object, ...data }); + } return { ...data }; }, async update(object: string, data: Record, options?: any) { @@ -104,7 +106,38 @@ describe('[#21913] SettingsService engine calls carry the explicit system opt-in expect(onSettings.filter((c) => c.verb === 'insert')).toHaveLength(1); // …and the insert's user-reference probe ran too, under the same opt-in. expect(calls.filter((c) => c.object === 'sys_user')).toHaveLength(1); - expect(onSettings.length + 1).toBe(calls.length); + // [ADR-0131 D7] Every resolution also reads the global rung's own store. + const onPlatform = calls.filter((c) => c.object === 'sys_platform_setting'); + expect(onPlatform.filter((c) => c.verb === 'find').length).toBeGreaterThanOrEqual(3); + expect(onSettings.length + onPlatform.length + 1).toBe(calls.length); + for (const call of calls) { + expect(call.context, `${call.verb} on ${call.object}`).toEqual({ isSystem: true }); + } + }); + + it('[ADR-0131 D7] a GLOBAL write: the probe, insert, update and re-reads on sys_platform_setting pass isSystem', async () => { + const { engine, calls, rows } = recordingEngine(); + const svc = new SettingsService(); + svc.registerManifest({ + namespace: 'mail_probe', + label: 'Mail probe', + scope: 'global', + specifiers: [{ key: 'from_email', type: 'text', default: 'noreply@example.com' }], + } as any); + svc.bindEngine(wrapEngineAsSettingsEngine(engine as any)); + + await svc.set('mail_probe', 'from_email', 'ops@example.com'); + await svc.set('mail_probe', 'from_email', 'it@example.com'); + expect((await svc.get('mail_probe', 'from_email')).value).toBe('it@example.com'); + + // The population: one row, in the global rung's store, written once and + // updated once — and no write reached `sys_setting` at all. + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ __object: 'sys_platform_setting', namespace: 'mail_probe', key: 'from_email' }); + const verbs = calls.map((c) => `${c.verb}:${c.object}`); + expect(verbs.filter((v) => v === 'insert:sys_platform_setting')).toHaveLength(1); + expect(verbs.filter((v) => v === 'update:sys_platform_setting')).toHaveLength(1); + expect(verbs.filter((v) => v.startsWith('insert:sys_setting') || v.startsWith('update:sys_setting'))).toEqual([]); for (const call of calls) { expect(call.context, `${call.verb} on ${call.object}`).toEqual({ isSystem: true }); } 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 155f93552e7..22e338735fb 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 @@ -17,7 +17,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SysUser } from '@objectstack/platform-objects/identity'; -import { SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; +import { SysPlatformSetting, SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; import { SettingsService } from './settings-service.js'; import { buildSettingAuditWriter, wrapEngineAsSettingsEngine } from './settings-service-plugin.js'; @@ -83,7 +83,7 @@ beforeEach(async () => { rowsOf = memory.rowsOf; engine.registerDriver(memory.driver, true); await engine.init(); - for (const o of [SysUser, SysSetting, SysSettingAudit]) engine.registry.registerObject(o as any, '@objectstack/platform-objects'); + for (const o of [SysUser, SysSetting, SysPlatformSetting, SysSettingAudit]) engine.registry.registerObject(o as any, '@objectstack/platform-objects'); const user = await engine.insert('sys_user', { name: 'Ada', email: 'ada@example.test' }, SYS); userId = String((user as any).id); }); 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 3866ff0155f..8b38b0676fb 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 @@ -43,7 +43,7 @@ import { describe, expect, it } from 'vitest'; // devDependency here and `vitest.config.ts` already aliases it to SOURCE, so // this adds no dependency and no new alias. import { assertEngineUpdateDispatch, ObjectQL } from '@objectstack/objectql'; -import { SysSecret, SysSetting } from '@objectstack/platform-objects/system'; +import { SysPlatformSetting, SysSecret, SysSetting } from '@objectstack/platform-objects/system'; import type { SettingsManifest } from '@objectstack/spec/system'; import type { CryptoHandle, ICryptoProvider } from '@objectstack/spec/contracts'; import { SettingsService } from './settings-service.js'; @@ -241,7 +241,7 @@ async function boot(opts: { forwardContext?: boolean; withDelete?: boolean } = { const { driver, rowsOf } = makeMemoryDriver(); engine.registerDriver(driver, true); await engine.init(); - for (const o of [SysSetting, SysSecret]) { + for (const o of [SysSetting, SysPlatformSetting, SysSecret]) { engine.registry.registerObject(o as any, OWNER_PACKAGE); } @@ -366,7 +366,7 @@ describe('#8103 reachability fact 2 — `sys_setting.value_enc` is NOT the only const { driver, rowsOf } = makeMemoryDriver(); engine.registerDriver(driver, true); await engine.init(); - for (const o of [SysSetting, SysSecret]) { + for (const o of [SysSetting, SysPlatformSetting, SysSecret]) { engine.registry.registerObject(o as any, OWNER_PACKAGE); } // A perfectly ordinary business object carrying a `secret` field — the kind diff --git a/packages/spec/src/migrations/entries/semantic/18.sys-setting-global-rung-moved.ts b/packages/spec/src/migrations/entries/semantic/18.sys-setting-global-rung-moved.ts new file mode 100644 index 00000000000..439e150a846 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.sys-setting-global-rung-moved.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #15207 (ADR-0131 D7, C6 item 3) — the settings cascade's global rung leaves +// the tenant-scoped sys_setting for the tenant-less sys_platform_setting, and +// the `global` option of sys_setting.scope retires with it. A platform-object +// storage move, not a spec-key retirement: SpecifierScopeSchema and the +// `source: 'global'` resolution value name the cascade rung and stay, so +// nothing lands in RETIRED_KEYS_BY_MAJOR and no D2 conversion exists to pair +// with. Existing rows move in the v18 operator ceremony (ADR-0131 D14), which +// this entry does not perform. +export const entry: SemanticMigration = { + id: 'sys-setting-global-rung-moved', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'sys_setting.scope global — the settings cascade global rung left the tenant-scoped settings ' + + 'table: a value for a key declared at global scope is stored in the new tenant-less object ' + + 'sys_platform_setting (packages/platform-objects/src/system/sys-platform-setting.object.ts), ' + + 'and the global option of sys_setting.scope is retired', + replacement: + '`sys_platform_setting`, one row per `(namespace, key)` for the deployment, with the same ' + + '`value`, `value_enc`, `encrypted`, `locked`, `locked_reason` and `updated_by` columns and no ' + + '`scope`, `user_id` or `organization_id`. Write it only through the settings door ' + + '(`/api/settings/:namespace`), which routes a global-scope key there. Reading it through the ' + + 'generic data API requires the `manage_platform_settings` capability. Delete any authored ' + + 'filter, list-view column or seed that names `scope = global` on `sys_setting`', + reason: + 'ADR-0131 D7: deployment-level runtime settings leave the tenant-scoped table, and a tenant-less ' + + 'object holds the values an operator must change without a restart. A census of every ' + + 'manifest at commit 51290bca2c of this repository\'s main branch found seven namespaces whose ' + + 'keys sit at the global rung (ai, auth, knowledge, mail, sms, storage and the ObjectQL lifecycle ' + + 'defaults), every one edited live in Setup, so none of them moves to boot configuration. The ' + + 'settings service is the only writer of a global row, it writes under a system context, and the ' + + 'row names no organization, so on sys_setting the injected organization column only ever held ' + + 'NULL there and a walled posture hid the row from every reader. The resolver reads the rung ' + + 'from the new object alone and excludes scope global from its sys_setting reads, so a row a ' + + 'pre-v18 database still holds there is not a second source; no write path produces one any ' + + 'more, which is why the select option retires rather than staying a declared value no write can ' + + 'reach. The cascade order, the lock semantics, SpecifierScopeSchema and the global resolution ' + + 'source are unchanged. An encrypted value moves without re-encryption: the ADR-0128 AAD binds ' + + 'the settings scope, namespace and key, never the holder object or an organization, so a ' + + 'sys_secret handle copied into the new row opens as it did. Existing databases: nothing moves ' + + 'automatically (ADR-0131 D14). The v18 upgrade ceremony moves each sys_setting row at scope ' + + 'global into sys_platform_setting by namespace and key, value_enc handle included; until it ' + + 'runs, those values read as their next rung or the manifest default.', + acceptanceCriteria: + 'A write of a global-scope settings key creates or updates exactly one `sys_platform_setting` ' + + 'row for its `(namespace, key)` and no `sys_setting` row, and a read of the key answers that ' + + 'value with source `global`. A `sys_setting` row still at `scope = global` is not answered by ' + + 'any read. An encrypted global value keeps its `sys_secret` handle in ' + + '`sys_platform_setting.value_enc` and opens. On every tenancy posture a principal without ' + + '`manage_platform_settings` is refused 403 PERMISSION_DENIED on a generic data read of ' + + '`sys_platform_setting`, while the settings door keeps answering it for a holder of the ' + + 'manifest capability. After the v18 ceremony no `sys_setting` row is at `scope = global`.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index cd840b3e8cb..c63b382036e 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6227,6 +6227,20 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'M apps is a judgment), so the entry prescribes the hand move instead of deleting ' + 'authored content silently.', }, + { + id: 'sys-setting-global-rung-moved', + order: 87, + text: + 'It also moves the settings cascade\'s global rung out of the tenant-scoped `sys_setting` into ' + + 'the new tenant-less `sys_platform_setting` (ADR-0131 D7): one row per namespace and key for the ' + + 'deployment, no organization column, reads governed by the `manage_platform_settings` ' + + 'capability. The settings service writes a global-scope key there and reads the rung from there ' + + 'alone, and the `global` option of `sys_setting.scope` retires because no write reaches it. The ' + + 'cascade order and the `global` resolution source are unchanged. Nothing moves automatically: the ' + + 'v18 upgrade ceremony moves existing global rows, `sys_secret` handles included, and they open ' + + 'unchanged because the ADR-0128 AAD binds no holder object and no organization. The D3 record is ' + + 'the `sys-setting-global-rung-moved` semantic entry.', + }, { id: 'time-default-zone-refused', order: 50, @@ -19294,6 +19308,59 @@ const step18: MigrationStep = { + '`sys_presence`, and a platform administrator lists every row with no organization filter. ' + 'After os migrate apply --allow-destructive the boot no longer reports the orphaned column.', }, + // #15207 (ADR-0131 D7, C6 item 3) — the settings cascade's global rung leaves + // the tenant-scoped sys_setting for the tenant-less sys_platform_setting, and + // the `global` option of sys_setting.scope retires with it. A platform-object + // storage move, not a spec-key retirement: SpecifierScopeSchema and the + // `source: 'global'` resolution value name the cascade rung and stay, so + // nothing lands in RETIRED_KEYS_BY_MAJOR and no D2 conversion exists to pair + // with. Existing rows move in the v18 operator ceremony (ADR-0131 D14), which + // this entry does not perform. + { + id: 'sys-setting-global-rung-moved', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'sys_setting.scope global — the settings cascade global rung left the tenant-scoped settings ' + + 'table: a value for a key declared at global scope is stored in the new tenant-less object ' + + 'sys_platform_setting (packages/platform-objects/src/system/sys-platform-setting.object.ts), ' + + 'and the global option of sys_setting.scope is retired', + replacement: + '`sys_platform_setting`, one row per `(namespace, key)` for the deployment, with the same ' + + '`value`, `value_enc`, `encrypted`, `locked`, `locked_reason` and `updated_by` columns and no ' + + '`scope`, `user_id` or `organization_id`. Write it only through the settings door ' + + '(`/api/settings/:namespace`), which routes a global-scope key there. Reading it through the ' + + 'generic data API requires the `manage_platform_settings` capability. Delete any authored ' + + 'filter, list-view column or seed that names `scope = global` on `sys_setting`', + reason: + 'ADR-0131 D7: deployment-level runtime settings leave the tenant-scoped table, and a tenant-less ' + + 'object holds the values an operator must change without a restart. A census of every ' + + 'manifest at commit 51290bca2c of this repository\'s main branch found seven namespaces whose ' + + 'keys sit at the global rung (ai, auth, knowledge, mail, sms, storage and the ObjectQL lifecycle ' + + 'defaults), every one edited live in Setup, so none of them moves to boot configuration. The ' + + 'settings service is the only writer of a global row, it writes under a system context, and the ' + + 'row names no organization, so on sys_setting the injected organization column only ever held ' + + 'NULL there and a walled posture hid the row from every reader. The resolver reads the rung ' + + 'from the new object alone and excludes scope global from its sys_setting reads, so a row a ' + + 'pre-v18 database still holds there is not a second source; no write path produces one any ' + + 'more, which is why the select option retires rather than staying a declared value no write can ' + + 'reach. The cascade order, the lock semantics, SpecifierScopeSchema and the global resolution ' + + 'source are unchanged. An encrypted value moves without re-encryption: the ADR-0128 AAD binds ' + + 'the settings scope, namespace and key, never the holder object or an organization, so a ' + + 'sys_secret handle copied into the new row opens as it did. Existing databases: nothing moves ' + + 'automatically (ADR-0131 D14). The v18 upgrade ceremony moves each sys_setting row at scope ' + + 'global into sys_platform_setting by namespace and key, value_enc handle included; until it ' + + 'runs, those values read as their next rung or the manifest default.', + acceptanceCriteria: + 'A write of a global-scope settings key creates or updates exactly one `sys_platform_setting` ' + + 'row for its `(namespace, key)` and no `sys_setting` row, and a read of the key answers that ' + + 'value with source `global`. A `sys_setting` row still at `scope = global` is not answered by ' + + 'any read. An encrypted global value keeps its `sys_secret` handle in ' + + '`sys_platform_setting.value_enc` and opens. On every tenancy posture a principal without ' + + '`manage_platform_settings` is refused 403 PERMISSION_DENIED on a generic data read of ' + + '`sys_platform_setting`, while the settings door keeps answering it for a holder of the ' + + 'manifest capability. After the v18 ceremony no `sys_setting` row is at `scope = global`.', + }, { id: 'system-cache-durations-unit-in-key', // No backticks in `surface` — build-upgrade-guide.ts renders it inside a diff --git a/packages/spec/src/system/constants/platform-object-names.ts b/packages/spec/src/system/constants/platform-object-names.ts index 32422747860..33d6474caf2 100644 --- a/packages/spec/src/system/constants/platform-object-names.ts +++ b/packages/spec/src/system/constants/platform-object-names.ts @@ -72,6 +72,7 @@ export const PLATFORM_OBJECTS_BY_PACKAGE: Readonly