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
66 changes: 66 additions & 0 deletions .changeset/settings-door-value-domain-shared-predicate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
"@objectstack/service-settings": minor
---

fix(service-settings): the settings door answers from the ONE shared value-domain predicate, and refuses a non-member with `value_domain` (#15162)

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable moves: no spec key, export, config field or stored metadata changes spelling or shape, no schema in `packages/spec` changes, and `objectstack migrate meta` has nothing to rewrite. What moves is the `code` value one `FieldError` carries for one condition — the settings save door's refusal of a value outside a declared `valueDomain` — which now spells the standard-catalog member `value_domain` that the field-level half of the same ruling added. The consumer note below is guidance for a client branching on that code; it prescribes no rewrite of any authored artifact. -->

**BREAKING** for a client that branches on the refusal code. Landing inside
the launch window, so it ships as `minor` (the lockstep convention forbids
`major`); the banner is the carrier, not the bump.

The services half of the maintainer's ruling of 2026-09-02: **one closed
vocabulary and one membership predicate shared by settings specifiers and
object fields**. The spec half declared them in `@objectstack/spec/shared`;
this package had been carrying a second copy of all three definitions since
`Specifier.valueDomain` shipped. The copies are deleted and the door now asks
`isValueDomainMember` — the call the record write path will make when the
engine half of the same ruling lands (PR #15316, still open).

**The wire change**, measured on `PUT /api/settings/localization` with
`{"timezone": "Mars/Olympus"}`, base `a56baa2bd` vs this branch:

| | before | after |
|:--|:--|:--|
| `fields[0].code` | `invalid_value` | `value_domain` |
| `fields[0].message` | `Default timezone must be a valid IANA time zone identifier (e.g. 'Europe/Zurich'). Received 'Mars/Olympus'.` | `Default timezone must be a valid IANA time zone identifier, e.g. Europe/Zurich (got "Mars/Olympus")` |

Everything else is byte-identical: HTTP 400, the envelope code
`SETTINGS_VALIDATION`, `field`, `label`, `constraint: { valueDomain: … }` and
the echoed `value`. A client that reads `constraint.valueDomain` — the
machine-readable half ADR-0114 asks it to read — is unaffected. A client that
branches on `code === 'invalid_value'` for a domain breach must move to
`value_domain`.

Why the code moved: ADR-0114's rule is that the code is the **constraint's own
name**, the way `max_length` names the bound it breached. This branch took
`invalid_value` — the catalog's slot for "rejected for a reason no other
member names" — only while no member named a standard-domain breach. The
field-level card's spec half added one, so the slot no longer applies. The
message now renders the published catalog template
`value_domain_<domain>` in `en` — the catalog the record write path will render
from once PR #15316 lands, so the two doors under one ruling will describe one
domain in one set of words instead of each composing its own sentence. For an `encrypted` specifier the offending value is still never
echoed: the template's value placeholder takes the same mask the REST boundary
uses (`fields[0].value` stays absent, as before).

**No value changes verdict.** The accept sets were measured, not assumed, on
the repo's Node 22 baseline (v22.22.2):

- `iso_3166_alpha2` — the two 249-code lists diffed mechanically before either
was deleted: identical, including order; symmetric difference 0.
- `iso_4217_currency` — this one changes DEFINITION: a run-time
`Intl.supportedValuesOf('currency')` probe becomes the key set of the
checked-in CLDR snapshot `CURRENCY_FRACTION_DIGITS`. 162 codes vs 162,
symmetric difference 0 in both directions (`CHF` in both, `XYZ` in neither).
The behaviour that changes is that the verdict no longer varies with the
host's ICU build — the direction the shared module argues for. A door-level
test now re-measures it: every code the run-time probe admits must still be
admitted.
- `iana_time_zone` — the identical `Intl.DateTimeFormat` probe on both sides,
unmoved.

A ratchet pin (`value-domains.shared-predicate.pin.test.ts`) reddens if any
non-test source in this package re-acquires a membership table, an `Intl`
enumeration probe, or a second caller of the predicate.
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ describe('family ordering agrees between doors: options → pattern → valueDom
svc.registerManifest(orderingManifest);
await expect(svc.setMany('pattern_order', { country_like: 'ZZ' })).rejects.toMatchObject({
code: 'SETTINGS_VALIDATION',
fields: [{ field: 'country_like', code: 'invalid_value', constraint: { valueDomain: 'iso_3166_alpha2' } }],
fields: [{ field: 'country_like', code: 'value_domain', constraint: { valueDomain: 'iso_3166_alpha2' } }],
});

const { errors, logger: envLogger } = spyLogger();
Expand Down
54 changes: 52 additions & 2 deletions packages/services/service-settings/src/settings-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ describe('settings-routes', () => {
expect(state.body.data.values.currency.value).toBe('CHF');
});

it('PUT /api/settings/localization rejects garbage with 400 + SETTINGS_VALIDATION + invalid_value', async () => {
it('PUT /api/settings/localization rejects garbage with 400 + SETTINGS_VALIDATION + value_domain', async () => {
const http = new MockHttp();
const svc = new SettingsService({ env: {} });
svc.registerManifest(localizationSettingsManifest);
Expand All @@ -260,13 +260,63 @@ describe('settings-routes', () => {
expect(state.body.error.details.fields).toEqual([
expect.objectContaining({
field: 'timezone',
code: 'invalid_value',
// The wire-visible half of the re-point onto the shared predicate:
// this refusal answered `invalid_value` until the catalog gained a
// member named for the constraint itself (ADR-0114, maintainer ruling
// 2026-09-02). A client branching on the code sees `value_domain`.
code: 'value_domain',
constraint: { valueDomain: 'iana_time_zone' },
value: 'Mars/Olympus',
}),
]);
});

/**
* The wire-visible change, stated AS a change.
*
* Measured on this endpoint with `timezone: 'Mars/Olympus'`, base
* `a56baa2bd` vs this branch:
*
* - before — `code: 'invalid_value'`, message `Default timezone must be a
* valid IANA time zone identifier (e.g. 'Europe/Zurich'). Received
* 'Mars/Olympus'.`
* - after — `code: 'value_domain'`, message `Default timezone must be a
* valid IANA time zone identifier, e.g. Europe/Zurich (got
* "Mars/Olympus")` — the published catalog template
* `value_domain_iana_time_zone`, rendered in `en`.
*
* UNCHANGED across it, and asserted here so the blast radius is stated and
* not merely believed: HTTP 400, the envelope code `SETTINGS_VALIDATION`,
* `field`, `label`, `constraint.valueDomain` and the echoed `value`. A
* client reading `constraint.valueDomain` is unaffected; one branching on
* `code === 'invalid_value'` is, and that is the whole of the break.
*/
it('the domain refusal is `value_domain` with the catalog sentence — not `invalid_value`', async () => {
const http = new MockHttp();
const svc = new SettingsService({ env: {} });
svc.registerManifest(localizationSettingsManifest);
registerSettingsRoutes(http, svc, { contextFromRequest: adminProvider });

const h = http.routes.get('PUT /api/settings/:namespace')!;
const { req, res, state } = makeReqRes({
params: { namespace: 'localization' },
body: { timezone: 'Mars/Olympus' },
});
await h(req, res);

expect(state.status).toBe(400);
expect(state.body.error.code).toBe('SETTINGS_VALIDATION');
const field = state.body.error.details.fields[0];
expect(field.code).toBe('value_domain');
expect(field.code).not.toBe('invalid_value');
expect(field.message).toBe(
'Default timezone must be a valid IANA time zone identifier, e.g. Europe/Zurich (got "Mars/Olympus")',
);
expect(field.label).toBe('Default timezone');
expect(field.constraint).toEqual({ valueDomain: 'iana_time_zone' });
expect(field.value).toBe('Mars/Olympus');
});

/**
* #7169 — the STATUS half of the fail-closed refusal.
*
Expand Down
35 changes: 22 additions & 13 deletions packages/services/service-settings/src/settings-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2096,12 +2096,15 @@ describe('SettingsService — a declared valueDomain is the save-time boundary (
fields: [
{
field: 'timezone',
// No `FieldErrorCode` member names a standard-domain breach, so it
// takes `invalid_value` — the catalog's slot for "rejected for a
// reason no other member names" (ADR-0114), the #6199 precedent.
// NOT `invalid_option`: the declared options are exactly the list
// a domain-bearing value may legitimately be outside of.
code: 'invalid_value',
// `value_domain` — ADR-0114's rule is that the code is the
// constraint's own name, as `max_length` names the bound it
// breached. This branch answered `invalid_value` (the catalog's
// slot for "rejected for a reason no other member names") only
// while no member named a standard-domain breach; the field-level
// card's spec half added one, and the settings door adopts it.
// Still NOT `invalid_option`: the declared options are exactly the
// list a domain-bearing value may legitimately be outside of.
code: 'value_domain',
label: 'Default timezone',
constraint: { valueDomain: 'iana_time_zone' },
value: tz,
Expand All @@ -2112,7 +2115,7 @@ describe('SettingsService — a declared valueDomain is the save-time boundary (
await expect(svc.setMany('localization', { currency: 'XYZ' })).rejects.toMatchObject({
code: 'SETTINGS_VALIDATION',
fields: [
{ field: 'currency', code: 'invalid_value', constraint: { valueDomain: 'iso_4217_currency' } },
{ field: 'currency', code: 'value_domain', constraint: { valueDomain: 'iso_4217_currency' } },
],
});
// Atomic: nothing landed.
Expand All @@ -2128,7 +2131,7 @@ describe('SettingsService — a declared valueDomain is the save-time boundary (
for (const cc of ['ZZ', 'UK']) {
await expect(svc.setMany('localization', { default_country: cc })).rejects.toMatchObject({
fields: [
{ field: 'default_country', code: 'invalid_value', constraint: { valueDomain: 'iso_3166_alpha2' } },
{ field: 'default_country', code: 'value_domain', constraint: { valueDomain: 'iso_3166_alpha2' } },
],
});
}
Expand Down Expand Up @@ -2194,7 +2197,7 @@ describe('SettingsService — a declared valueDomain is the save-time boundary (
} as any);
await expect(svc.setMany('multi', { currencies: ['USD', 'CHF'] })).resolves.toBeDefined();
await expect(svc.setMany('multi', { currencies: ['USD', 'XYZ'] })).rejects.toMatchObject({
fields: [{ field: 'currencies', code: 'invalid_value', value: 'XYZ' }],
fields: [{ field: 'currencies', code: 'value_domain', value: 'XYZ' }],
});
});

Expand All @@ -2211,9 +2214,15 @@ describe('SettingsService — a declared valueDomain is the save-time boundary (
} as any);
const err = await svc.setMany('vaultdom', { region_code: 'ZZ' }).catch((e) => e);
expect(err.code).toBe('SETTINGS_VALIDATION');
expect(err.fields[0]).toMatchObject({ field: 'region_code', code: 'invalid_value' });
expect(err.fields[0]).toMatchObject({ field: 'region_code', code: 'value_domain' });
expect(err.fields[0].value).toBeUndefined();
expect(err.message).not.toContain('ZZ');
// The catalog template always interpolates the offending value, so the
// redaction is the REST boundary's own mask rather than a truncated
// sentence — the rejected value still never appears.
expect(err.fields[0].message).toBe(
'Region code must be a valid ISO 3166-1 alpha-2 country code, e.g. CH (got "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022")',
);
// The domain still travels, so the caller learns what to do.
expect(err.fields[0].constraint).toMatchObject({ valueDomain: 'iso_3166_alpha2' });
});
Expand All @@ -2240,7 +2249,7 @@ describe('SettingsService — a declared valueDomain is the save-time boundary (
await expect(svc.setMany('localization', { currency: 'CHF' })).resolves.toBeDefined();
// Only re-writing the key itself is refused.
await expect(svc.setMany('localization', { timezone: 'Mars/Olympus' })).rejects.toMatchObject({
fields: [{ field: 'timezone', code: 'invalid_value' }],
fields: [{ field: 'timezone', code: 'value_domain' }],
});
});
});
Expand Down Expand Up @@ -2381,7 +2390,7 @@ describe('SettingsService — company.country adopts iso_3166_alpha2 (#6579)', (
fields: [
{
field: 'country',
code: 'invalid_value',
code: 'value_domain',
label: 'Country',
constraint: { valueDomain: 'iso_3166_alpha2' },
value: cc,
Expand Down Expand Up @@ -2409,7 +2418,7 @@ describe('SettingsService — company.country adopts iso_3166_alpha2 (#6579)', (
const svc = companyService();
await expect(svc.setMany('company', { country: 'us' })).rejects.toMatchObject({
fields: [
{ field: 'country', code: 'invalid_value', constraint: { valueDomain: 'iso_3166_alpha2' } },
{ field: 'country', code: 'value_domain', constraint: { valueDomain: 'iso_3166_alpha2' } },
],
});
});
Expand Down
53 changes: 41 additions & 12 deletions packages/services/service-settings/src/settings-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ import {
UnknownKeyError,
UnknownNamespaceError,
} from './settings-service.types.js';
// The published field-message catalog (ADR-0114). Rendered for the
// `value_domain` refusal so the settings door and the record write path say
// one thing about one domain; every other refusal here still carries its own
// hand-written sentence, unchanged.
import { renderValidationMessage } from '@objectstack/spec/system';
import { SETTINGS_SECRET_MASK } from './settings-secret-redaction.js';
import {
firstRejectedDomainMember,
knownValueDomain,
Expand Down Expand Up @@ -1810,7 +1816,7 @@ export class SettingsService {
* the declared table → rejected (`invalid_option`) — unless the specifier
* declares a `valueDomain`, which moves the boundary (next bullet).
* - `valueDomain` (#5712) + non-empty value that is not a member of the
* declared standard → rejected (`invalid_value`). The domain REPLACES the
* declared standard → rejected (`value_domain`). The domain REPLACES the
* option table as the membership boundary: `options` degrades to a UI
* convenience list, so a value outside `options` but inside the domain is
* accepted. Judged AFTER `pattern` — shape and membership narrow
Expand Down Expand Up @@ -2050,27 +2056,50 @@ export class SettingsService {
// shape-valid, and the question is purely whether the standard's
// membership admits it (`Mars/Olympus` is a shape-valid time zone that
// does not exist; `ZZ` matches `^[A-Za-z]{2}$` and is assigned to
// nobody). No `FieldErrorCode` member names a standard-domain breach, so
// it takes `invalid_value` — the catalog's declared slot for "rejected
// for a reason no other member names" (ADR-0114), the same verdict the
// step grid reached in #6199. `invalid_option` would be a lie about
// which set was consulted: the declared options are exactly the list a
// domain-bearing value may legitimately be outside of.
// nobody).
//
// The refusal code is `value_domain` — ADR-0114's rule is that the code
// is the constraint's OWN name, the way `max_length` names the bound it
// breached. Until the field-level card's spec half landed (maintainer
// ruling 2026-09-02) no `FieldErrorCode` member named a standard-domain
// breach, so this branch took `invalid_value`, the catalog's declared
// slot for "rejected for a reason no other member names"; that slot was
// right only while no member named this one, and now one does. The
// change is wire-visible on `PUT /api/settings/:namespace` and is pinned
// as such (`settings-routes.test.ts`, `settings-service.test.ts`).
// `invalid_option` would still be a lie about which set was consulted:
// the declared options are exactly the list a domain-bearing value may
// legitimately be outside of.
if (!empty && domain) {
const rejected = firstRejectedDomainMember(domain, value);
if (rejected) {
const offending = rejected.value;
const { member, example } = valueDomainPhrasing(domain);
// Same redaction rule as `invalid_option`, same reason: a domain
// member is not a secret, but `encrypted` is authorable on any
// specifier and this message travels back through the API and into
// logs.
// logs. The catalog templates always interpolate the offending
// value, so a secret key is rendered with the mask the REST boundary
// already uses for a withheld value — a redacted sentence rather
// than a truncated one.
const secret = reg.encryptedKeys.has(key);
const got = secret ? '' : ` Received '${String(offending)}'.`;
errors.push({
field: key,
code: 'invalid_value',
message: `${label} must be a valid ${member} (e.g. '${example}').${got}`,
code: 'value_domain',
// The published catalog template for this code, rendered in `en`
// (ADR-0114) — the same catalog the record write path renders, so
// the two doors under one ruling describe one domain in one set of
// words. The per-domain variant spells the standard out for a
// human; the machine-readable half is `code` + `constraint`, which
// is what a localized client re-renders from.
message: renderValidationMessage({
messageKey: `value_domain_${domain}`,
label,
field: key,
params: {
valueDomain: domain,
value: secret ? SETTINGS_SECRET_MASK : String(offending),
},
}),
label,
// The declared domain, spelled by the property it comes from
// (`FieldError.constraint`, ADR-0114), so a client can branch on
Expand Down
Loading
Loading