diff --git a/.changeset/sys-setting-scope-runtime-option-removed.md b/.changeset/sys-setting-scope-runtime-option-removed.md new file mode 100644 index 0000000000..0bbd96fbe5 --- /dev/null +++ b/.changeset/sys-setting-scope-runtime-option-removed.md @@ -0,0 +1,30 @@ +--- +"@objectstack/platform-objects": patch +--- + +fix(platform-objects): `sys_setting.scope` drops the never-implemented `runtime` option (#6036) + +The `scope` select declared four cascade layers while the platform only ever had +three. `SpecifierScopeSchema` (`packages/spec/src/system/settings-manifest.zod.ts`) +is `z.enum(['global', 'tenant', 'user'])`, `SettingsService` never mentions the +string `'runtime'` anywhere, and its `scopeRank()` switch handles only those same +three — so no code path could write such a row and none could read one back. The +sibling audit object `sys_setting_audit.scope` already declared only three. This +was a declared-but-unenforced value domain of the ADR-0049 kind: nobody could hit +it, but the next reader of the object definition would reasonably conclude the +platform supports a fourth scope layer. + +Removed rather than implemented — there is no runtime-scope product intent, and +the spec enum stays the reference truth for what the cascade's layers are. A new +pin (`sys-setting.scope-options.test.ts`) compares the object's option list +against `SpecifierScopeSchema` directly, so a future divergence in either +direction lands as a red test instead of a second silent one. + +Removal was gated on a measurement, not on the zero-write-path prediction: a real +engine booted over the platform objects, driven through the real +`/api/settings/:namespace` write path, stored 4 rows (`tenant` 3, `global` 1) and +**0** with `scope='runtime'` — with a positive control proving the query does +surface such a row when one is injected directly. + +No stored data is affected and no consumer read the option, so this is a +definition-only correction. 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 391ee0d64b..1378347f03 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -2931,8 +2931,7 @@ export const enObjects: NonNullable = { options: { global: "Global", tenant: "Tenant", - user: "User", - runtime: "Runtime" + user: "User" } }, user_id: { 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 30b2408e11..1bc959fd12 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 @@ -2931,8 +2931,7 @@ export const esESObjects: NonNullable = { options: { global: "Global", tenant: "Inquilino", - user: "Usuario", - runtime: "Tiempo de ejecución" + user: "Usuario" } }, user_id: { 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 b3b0285a64..92a404dcf5 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 @@ -2931,8 +2931,7 @@ export const jaJPObjects: NonNullable = { options: { global: "グローバル", tenant: "テナント", - user: "ユーザー", - runtime: "ランタイム" + user: "ユーザー" } }, user_id: { 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 3f403ddb75..543ba17def 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 @@ -2931,8 +2931,7 @@ export const zhCNObjects: NonNullable = { options: { global: "全局", tenant: "租户", - user: "用户", - runtime: "运行时" + user: "用户" } }, user_id: { diff --git a/packages/platform-objects/src/system/sys-setting.object.ts b/packages/platform-objects/src/system/sys-setting.object.ts index 29fe512255..ed799ff6a3 100644 --- a/packages/platform-objects/src/system/sys-setting.object.ts +++ b/packages/platform-objects/src/system/sys-setting.object.ts @@ -119,12 +119,20 @@ 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. scope: Field.select( [ { label: 'Global', value: 'global' }, { label: 'Tenant', value: 'tenant' }, { label: 'User', value: 'user' }, - { label: 'Runtime',value: 'runtime' }, ], { label: '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 new file mode 100644 index 0000000000..ec510db9ab --- /dev/null +++ b/packages/platform-objects/src/system/sys-setting.scope-options.test.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #6036 — `sys_setting.scope` once declared a fourth option, `runtime`, that +// nothing in the platform could produce or consume: `SpecifierScopeSchema` is +// three-valued, `SettingsService` never mentions the string, and every write +// reaches the table through `set()`/`setMany()`, whose scope comes from the +// manifest registry (`reg.scopes`) — i.e. from that same three-value enum. +// A declared-but-unenforced value domain of exactly the ADR-0049 kind. +// +// These pins make the divergence loud instead of dormant. The load-bearing one +// is the PARITY assertion: the storage column's option list and the spec enum +// are two spellings of one truth, so they are compared to each other rather +// 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. +import { describe, expect, it } from 'vitest'; +import { SpecifierScopeSchema } from '@objectstack/spec/system'; +import { SysSetting } from './sys-setting.object.js'; +import { SysSettingAudit } from './sys-setting-audit.object.js'; + +/** Declared option values of a select field, in declaration order. */ +function optionValues(object: unknown, field: string): string[] { + const f = (object as any).fields?.[field]; + expect(f, `${field} field exists`).toBeDefined(); + expect(f.type).toBe('select'); + return ((f.options ?? []) as Array<{ value: unknown }>).map((o) => String(o.value)); +} + +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('does not declare a `runtime` layer', () => { + // Spelled as its own case because THIS is the regression: the option was + // inert, so re-adding it breaks nothing at runtime and would otherwise + // sail through review a second time. + expect(optionValues(SysSetting, 'scope')).not.toContain('runtime'); + }); + + it('matches SpecifierScopeSchema — 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. + expect([...optionValues(SysSetting, 'scope')].sort()).toEqual( + [...SpecifierScopeSchema.options].sort(), + ); + }); + + it('agrees with the audit trail object, which records the same layers', () => { + expect(optionValues(SysSettingAudit, 'scope')).toEqual(optionValues(SysSetting, 'scope')); + }); + + it('keeps `tenant` as the default, and the default is a declared option', () => { + const f = (SysSetting as any).fields.scope; + expect(f.defaultValue).toBe('tenant'); + expect(optionValues(SysSetting, 'scope')).toContain(f.defaultValue); + }); +});