Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/sys-setting-scope-runtime-option-removed.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -2931,8 +2931,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
options: {
global: "Global",
tenant: "Tenant",
user: "User",
runtime: "Runtime"
user: "User"
}
},
user_id: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2931,8 +2931,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
options: {
global: "Global",
tenant: "Inquilino",
user: "Usuario",
runtime: "Tiempo de ejecución"
user: "Usuario"
}
},
user_id: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2931,8 +2931,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
options: {
global: "グローバル",
tenant: "テナント",
user: "ユーザー",
runtime: "ランタイム"
user: "ユーザー"
}
},
user_id: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2931,8 +2931,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
options: {
global: "全局",
tenant: "租户",
user: "用户",
runtime: "运行时"
user: "用户"
}
},
user_id: {
Expand Down
10 changes: 9 additions & 1 deletion packages/platform-objects/src/system/sys-setting.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading