diff --git a/.changeset/settings-door-value-domain-shared-predicate.md b/.changeset/settings-door-value-domain-shared-predicate.md new file mode 100644 index 0000000000..9ce175ffdb --- /dev/null +++ b/.changeset/settings-door-value-domain-shared-predicate.md @@ -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) + + + +**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_` 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. diff --git a/packages/services/service-settings/src/settings-env-pattern.test.ts b/packages/services/service-settings/src/settings-env-pattern.test.ts index 27cae4ac98..3125dd356d 100644 --- a/packages/services/service-settings/src/settings-env-pattern.test.ts +++ b/packages/services/service-settings/src/settings-env-pattern.test.ts @@ -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(); diff --git a/packages/services/service-settings/src/settings-routes.test.ts b/packages/services/service-settings/src/settings-routes.test.ts index f599c8d20a..a7da723b8b 100644 --- a/packages/services/service-settings/src/settings-routes.test.ts +++ b/packages/services/service-settings/src/settings-routes.test.ts @@ -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); @@ -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. * diff --git a/packages/services/service-settings/src/settings-service.test.ts b/packages/services/service-settings/src/settings-service.test.ts index 764dc84c71..4f1733cf40 100644 --- a/packages/services/service-settings/src/settings-service.test.ts +++ b/packages/services/service-settings/src/settings-service.test.ts @@ -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, @@ -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. @@ -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' } }, ], }); } @@ -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' }], }); }); @@ -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' }); }); @@ -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' }], }); }); }); @@ -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, @@ -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' } }, ], }); }); diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index 440d0490ff..15e807fa15 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -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, @@ -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 @@ -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 diff --git a/packages/services/service-settings/src/value-domains.shared-predicate.pin.test.ts b/packages/services/service-settings/src/value-domains.shared-predicate.pin.test.ts new file mode 100644 index 0000000000..421086077c --- /dev/null +++ b/packages/services/service-settings/src/value-domains.shared-predicate.pin.test.ts @@ -0,0 +1,251 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Ratchet: `service-settings` does not define standard-domain membership. + * + * ## Why this file exists + * + * Maintainer ruling 2026-09-02: ONE closed vocabulary and ONE membership + * predicate, shared by settings specifiers and object fields. Before it, three + * copies of the same three definitions were in the tree — `packages/spec`, + * `packages/services/service-settings` and `packages/core`'s module-private + * `isValidTimeZone`. Three copies of one definition is the shape that drifts, + * and the copy carrying no pins is the one a future editor "modernises". + * `core` was re-pointed with its own pin + * (`resolve-authz-context.time-zone-domain.pin.test.ts`); this file is that + * pin's opposite number for the settings door, and it is the ratchet the + * settings card asked for: a re-added table goes red HERE, at the seam, rather + * than silently re-opening the divergence the ruling closed. + * + * ## Why a source scan and not an import-shape assertion + * + * A behavioural test cannot see this defect. A re-added local table that + * happens to agree with the shared one today passes every membership case in + * `value-domains.test.ts` — the divergence it opens is in the FUTURE, the day + * one of the two is edited. What has to be pinned is therefore the absence of + * the second definition, which is a fact about the source text. + * + * The scan covers the package's whole non-test source **recursively**, not just + * `value-domains.ts` and not just `src/*`: "delete the table from this file", + * "move the table to a new file" and "move it into `src/manifests/`" are one + * defect, and a single-file or single-directory check survives all but the + * first. Both table SHAPES are detected — the space-separated string this file + * replaced and the array literal a re-typing would more likely produce. + * + * ## What each check does and does not cover — measured, not claimed + * + * The absence checks are a source scan, so they see shapes. Two mutations were + * run against an earlier draft of this file and passed it GREEN, which is why + * the checks below are shaped as they are: a probe table placed one directory + * down (`src/manifests/`), and a 249-code ARRAY literal in a new sibling module + * that `value-domains.ts` imported and consulted for `iso_3166_alpha2` while + * the shared predicate still served the other two. Both now red. + * + * ## Two routes, and the two checks that are NOT interchangeable + * + * A table the DOOR REACHES is closed at its root by the import-surface pin: + * `value-domains.ts` may import from `@objectstack/spec/shared` and nothing + * else, so a table anywhere in the tree is inert while nothing here can name + * it, and a relative specifier is how it would be named. + * + * A judge STANDING IN FRONT OF THE DOOR is closed by the package-wide density + * scan, and that distinction is a measured falsification, not a design + * flourish. An earlier draft ran the density scan on the door alone and + * argued the import pin covered everything else. It does not: the same + * alternation put in the door's CALLER — `settings-service.ts`, replacing the + * `firstRejectedDomainMember(…)` call at the save-path refusal — is never + * reached by the door, answers `iso_3166_alpha2` itself, and falls through + * for the other two. Door blob unchanged, no quoted codes, no new caller of + * the predicate: every instrument here was green and the whole package was + * green, while a second judge decided the `value_domain` FieldError on every + * alpha-2 save. Deleting two codes from it turned two behavioural cases red, + * so it was live code, not decoration. + * + * Every scan below is DELIMITER-AGNOSTIC. It was not, and that is the round-2 + * finding: a verbatim double-quoted copy of the array mutation passed the + * whole file green, because the import pin and both shape regexes hard-coded + * the single quote. This package has no `quotes` lint rule active, so both + * spellings are legal here and only these pins can tell them apart. The space + * after `from` is optional for the same reason, and the density scan's + * separator class admits a newline — a one-code-per-line template literal is + * the same table. + * + * ## NOT covered — stated so the claim stays the size of the evidence + * + * Each of these was constructed and measured, and each is left open + * deliberately rather than unnoticed: + * + * - **3-letter tables.** A currency list is not shape-detected in any + * spelling; every scan here is two-letter. Currency is reached + * BEHAVIOURALLY instead, by the population agreement pins in + * `value-domains.test.ts`. + * - **Separators of three characters or more** (`'AD', 'AE',` with padding, + * or a comment between entries) fall outside the density class. + * - **No separator at all** — one 498-character string sliced with + * `.match(/../g)` — has no run to find. + * - **Widening by a single literal** (`|| v === 'XK'`) is invisible to every + * scan here, and the population pins cover NARROWING exhaustively while the + * complement is infinite. The trap corpus seeds the plausible members of + * that complement rather than pretending to close it. + * - **A table reached through a BARE package specifier** rather than a + * relative one would pass the import pin. + * - Two further routes are closed by the toolchain rather than by a pin here, + * and are recorded because a toolchain is not a guarantee: the suite's + * module resolution, and the `tsup` es2020 target. + * + * Test sources are exempt from the scan: they are evidence, not enforcement. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { join, relative } from 'node:path'; +// The repo's ONE answer to "is this span a comment, or code?" — a private +// stripper here would be the drift its header records (and +// `check:comment-mask-adoption` refuses one). `stripComments` is the right +// projection: every finding below reports a bare file name, never an offset. +// The `.mjs` specifier is deliberate; `scripts/js-comment-mask.d.mts` beside +// it is a hand-written declaration, so this import needs no `allowJs`. +import { stripComments } from '../../../../scripts/js-comment-mask.mjs'; + +/** + * Seeded from `import.meta.url` in a spelling `check:cross-package-test-inputs` + * resolves statically. The READS below do not escape the package — they are + * this package's own `src/`. + */ +const SRC = fileURLToPath(new URL('.', import.meta.url)); +const DOOR = join(SRC, 'value-domains.ts'); + +/** + * Every non-test `.ts` under this package's `src/`, RECURSIVELY, as + * `[path-relative-to-src, source]`. + * + * Recursive because `readdirSync` is not: `src/manifests/` and + * `src/translations/` exist today, and a table placed in either passed an + * earlier draft of this pin green. + */ +function runtimeSources(dir: string = SRC): Array<[string, string]> { + const out: Array<[string, string]> = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...runtimeSources(full)); + } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts')) { + out.push([relative(SRC, full), readFileSync(full, 'utf8')]); + } + } + return out; +} + +describe('value-domains.ts answers from the shared predicate', () => { + it('imports the vocabulary and the predicate from @objectstack/spec/shared', () => { + const src = readFileSync(DOOR, 'utf8'); + expect(src).toContain("from '@objectstack/spec/shared'"); + expect(src).toContain('isValueDomainMember'); + expect(src).toContain('ValueDomainSchema'); + }); + + it('imports from NOTHING ELSE — the door cannot reach a table wherever one is put', () => { + // The root close. Every absence check in this file is a shape scan and so + // is defeatable by a shape it does not know; this one is not a scan for a + // table at all, it is the statement that the door has exactly one source of + // membership. A 249-code array literal in a sibling module is harmless + // while nothing here can import it, and a relative specifier is how it + // would be reached. + // Delimiter-agnostic and space-agnostic on purpose. An earlier draft read + // `/\bfrom\s+'([^']+)'/` — single quotes and a mandatory space — and a + // verbatim double-quoted copy of the mutation below walked straight past + // it: `from "./zz-alpha2-array.js"` is not seen, so the pin reported the + // one legal specifier and passed. ESLint does not close that door either; + // this package has no `quotes` rule active, so both spellings are legal + // here and only this pin can tell them apart. + const code = stripComments(readFileSync(DOOR, 'utf8')); + const specifiers = [...code.matchAll(/\bfrom\s*(['"])([^'"]+)\1/g)].map((m) => m[2]); + expect(specifiers).toEqual(['@objectstack/spec/shared']); + }); + + it('declares no membership machinery of its own', () => { + const src = readFileSync(DOOR, 'utf8'); + // The three copies that were here, by the shape each took. Comments in + // this file's own header name them, so match on code, not on prose: every + // check below is run against the source with comments stripped. + const code = stripComments(src); + expect(code, 'the currency probe belongs to the shared module').not.toContain('supportedValuesOf'); + expect(code, 'the time-zone probe belongs to the shared module').not.toContain('Intl.DateTimeFormat'); + expect(code, 'a lookup table here is a second membership definition').not.toContain('new Set('); + }); +}); + +describe('no membership table anywhere in this package', () => { + it('carries no ISO 3166-1 alpha-2 code list, in either shape', () => { + // The alpha-2 list has no standard-library oracle, so it is the one domain + // a well-meaning editor is most likely to re-type locally. Detected by + // shape rather than by file, and BOTH shapes are needed: the + // space-separated string this package used to carry, and the array literal + // a fresh re-typing produces — an earlier draft checked only the first and + // a 249-element array passed it green. + // Both shapes, and — the round-2 finding — EVERY delimiter. The first + // draft of these two regexes hard-coded the single quote, so a verbatim + // double-quoted copy of the same table passed the whole file green. + const SPACED = /(['"`])(?:[A-Z]{2} ){7}/; + const ARRAY = /(?:(['"])[A-Z]{2}\1,\s*){7}/; + for (const [name, src] of runtimeSources()) { + const code = stripComments(src); + expect(SPACED.test(code), `${name} carries a space-separated alpha-2 code list`).toBe(false); + expect(ARRAY.test(code), `${name} carries an array-literal alpha-2 code list`).toBe(false); + } + }); + + it('carries no dense run of two-letter tokens, in ANY delimiter or none — the alternation shape', () => { + // A membership table needs no quotes and no import at all: the 249 codes + // as a regex alternation (`/^(?:AD|AE|…|ZW)$/`) is a table by every + // meaning of the word, and passes a quote-shaped scan and the import pin + // alike. What is invariant across those spellings is DENSITY: seven or + // more bare two-uppercase-letter tokens separated by one or two + // non-alphanumerics — a newline among them, which is how a + // one-code-per-line template literal spells the same thing. + // + // PACKAGE-WIDE, and the reason is a measured falsification rather than + // caution. An earlier draft ran this on the door alone, arguing that the + // import pin kept a table in a sibling module out of reach. It does — but + // only a table the door REACHES. The alternation put in the door's CALLER + // (`settings-service.ts`, replacing the `firstRejectedDomainMember(…)` + // call at the save-path refusal) is never reached by the door at all: it + // stands IN FRONT of it, answers `iso_3166_alpha2` itself, and falls + // through for the other two. Door blob unchanged, no quoted codes, no new + // caller of the predicate — every other instrument here green, and the + // whole package green, while a second judge decided the `value_domain` + // FieldError on every alpha-2 save. + // + // The false-positive exposure is a census, not a hope: this pattern hits + // 1 of 1,885 runtime `.ts` files repo-wide (49 `src` roots, comments + // masked) — the shared module `value-domain.zod.ts` in the spec package, + // i.e. the table itself, which is the one place the definition belongs. + // (Named without a repo-relative path on purpose: this test does not READ + // that file, and `check:cross-package-test-inputs` reads a spelled path as + // a declared input. Its scan is source text, comments included.) Quoted + // arrays, string enums (`MO = 'MO'`), unions and `{ AD: 1, … }` maps do + // not trip it: quotes and digits push the separator past two characters. + const DENSE = /(?:\b[A-Z]{2}\b[^A-Za-z0-9]{1,2}){7}/; + for (const [name, src] of runtimeSources()) { + expect(DENSE.test(stripComments(src)), `${name} carries a dense run of two-letter tokens`).toBe(false); + } + }); + + it('probes no Intl enumeration on any enforcement path', () => { + for (const [name, src] of runtimeSources()) { + expect(stripComments(src), `${name} probes an Intl enumeration`).not.toContain('supportedValuesOf'); + } + }); + + it('states the membership question exactly once, as the shared call', () => { + // Positive half of the ratchet: the absence checks above are satisfiable + // by deleting the enforcement altogether, so pin that the call is present + // and that exactly one file makes it. Note the honest limit, the same one + // this file's header states: what reddens for a DIVERGENT second + // definition may pass for an identical duplicate, which is why the shape + // scans exist beside the behavioural pins rather than instead of them. + const callers = runtimeSources().filter(([, src]) => stripComments(src).includes('isValueDomainMember(')); + expect(callers.map(([name]) => name)).toEqual(['value-domains.ts']); + }); +}); diff --git a/packages/services/service-settings/src/value-domains.test.ts b/packages/services/service-settings/src/value-domains.test.ts index a4237973fe..4af833ced1 100644 --- a/packages/services/service-settings/src/value-domains.test.ts +++ b/packages/services/service-settings/src/value-domains.test.ts @@ -1,32 +1,68 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * Membership pins for the enforcement half of `Specifier.valueDomain` (#5712). + * The settings door's half of `Specifier.valueDomain` (#5712), after the + * re-point onto the ONE shared predicate (maintainer ruling 2026-09-02). * - * The DEFINITIONS (probe vs. enumeration, and why `Intl.supportedValuesOf - * ('timeZone')` / `Intl.DisplayNames` are the wrong oracles) are pinned where - * they are declared — `packages/spec/src/system/settings-manifest.test.ts` - * measures the traps themselves. What is pinned HERE is that this side - * implements those definitions: the values the spec's TSDoc names as legal are - * admitted, the ones it names as the reason each trap matters are refused. + * The DEFINITIONS (probe vs. enumeration, the CLDR currency snapshot, and why + * `Intl.supportedValuesOf('timeZone')` / `Intl.DisplayNames` are the wrong + * oracles) are pinned where they are declared — + * `packages/spec/src/shared/value-domain.test.ts` measures the traps + * themselves. What is pinned HERE is what the DOOR does with them: the values + * the shared module names as legal are admitted through + * `firstRejectedDomainMember`, the ones it names as the reason each trap + * matters are refused, and the door's own prose does not drift from the + * published catalog. + * + * ⚠️ ONE PIN WENT VACUOUS WITH THE RE-POINT, deliberately and on the record. + * Before the re-point this file's first case compared two independently + * written things: the spec's vocabulary against this package's own + * `DOMAIN_MEMBERSHIP` table. `knownValueDomain` now answers from + * `ValueDomainSchema` itself, so "every member of the vocabulary is + * enforceable" can no longer detect divergence — there is nothing left here to + * diverge FROM, which is the point of the ruling. The equality that still + * matters is pinned where both sides live: `value-domain.test.ts` holds + * `SpecifierValueDomainSchema` to BE `ValueDomainSchema` (identity, not equal + * members). What replaces the vacuous half below is the question the door can + * still answer alone: does every declared member actually REFUSE something + * here — i.e. is any member enforced by an accept-everything stub — and does + * every member have door prose that agrees with the catalog. */ import { describe, it, expect } from 'vitest'; -import { SpecifierValueDomainSchema } from '@objectstack/spec/system'; import { - firstRejectedDomainMember, ISO_3166_ALPHA2_CODES, + isValueDomainMember, + type ValueDomain, + ValueDomainSchema, +} from '@objectstack/spec/shared'; +import { BUILTIN_VALIDATION_MESSAGES, VALIDATION_MESSAGE_FALLBACK_LOCALE } from '@objectstack/spec/system'; +import { + firstRejectedDomainMember, knownValueDomain, valueDomainPhrasing, } from './value-domains.js'; -describe('value domains — vocabulary parity with the spec', () => { - it('enforces exactly the members SpecifierValueDomainSchema declares', () => { - // A spec-side vocabulary change must go red HERE rather than becoming a - // declared-but-unenforced member (Prime Directive #10). Every declared - // member resolves to an enforcer, and nothing beyond the vocabulary does. - for (const member of SpecifierValueDomainSchema.options) { +describe('value domains — what the door still owns after the re-point', () => { + it('accepts every declared member, and every member actually refuses something', () => { + // The enforceability half is now structural (see the file header). What is + // NOT structural: that each member's shared enforcer is a membership test + // at all. A member wired to `() => true` would type-check, would satisfy + // "the vocabulary is fully covered", and would silently open the door to + // everything — so each one is required to reject its own garbage probe. + const garbage: Record = { + iana_time_zone: 'Mars/Olympus', + iso_4217_currency: 'XYZ', + iso_3166_alpha2: 'ZZ', + }; + for (const member of ValueDomainSchema.options) { expect(knownValueDomain(member), `${member} must be enforceable`).toBe(member); + const probe = garbage[member]; + expect(probe, `${member} needs a garbage probe in this table`).toBeTruthy(); + expect( + firstRejectedDomainMember(member, probe), + `${member} must actually refuse ${probe} — an accept-everything enforcer is the failure this pin exists for`, + ).toEqual({ value: probe }); // …and each has phrasing, so neither door can hit an undefined sentence. const p = valueDomainPhrasing(member); expect(p.member.length).toBeGreaterThan(0); @@ -42,9 +78,34 @@ describe('value domains — vocabulary parity with the spec', () => { expect(knownValueDomain('')).toBeNull(); expect(knownValueDomain(undefined)).toBeNull(); expect(knownValueDomain(42)).toBeNull(); - // Prototype-chain names must not read as members (`'toString' in obj`). + // Prototype-chain names must not read as members. The old implementation + // looked the domain up in an object literal, where `'toString' in obj` is + // true, and excluded these by a hand-written `hasOwnProperty` guard; the + // re-point replaced that guard with the closed enum's own `safeParse`. + // Same two names, pinned across the swap: a `z.enum` matches literal + // members only, and this case is what says so out loud. expect(knownValueDomain('toString')).toBeNull(); expect(knownValueDomain('constructor')).toBeNull(); + expect(knownValueDomain('__proto__')).toBeNull(); + expect(knownValueDomain('hasOwnProperty')).toBeNull(); + }); + + it('the door prose and the published catalog describe one domain in one set of words', () => { + // `valueDomainPhrasing` survived the re-point because the env-override + // door writes a LOG line, which has no error code, no locale and no + // `{{label}}` — the catalog's finished sentences do not fit it. The save + // door renders the catalog. This pin is what keeps the two from drifting: + // each domain's fragments must appear in that domain's catalog template. + const en = BUILTIN_VALIDATION_MESSAGES[VALIDATION_MESSAGE_FALLBACK_LOCALE]; + for (const member of ValueDomainSchema.options) { + const template = en[`value_domain_${member}`]; + expect(template, `catalog must carry value_domain_${member}`).toBeTruthy(); + const { member: noun, example } = valueDomainPhrasing(member); + expect(template, `${member}: the log line's noun must be the catalog's`).toContain(noun); + expect(template, `${member}: the log line's example must be the catalog's`).toContain(`e.g. ${example}`); + } + // And the code-named default exists, since that is the wire code itself. + expect(en['value_domain']).toBeTruthy(); }); }); @@ -70,7 +131,7 @@ describe('iana_time_zone — the Intl.DateTimeFormat probe', () => { }); }); -describe('iso_4217_currency — Intl.supportedValuesOf("currency")', () => { +describe('iso_4217_currency — the checked-in CLDR snapshot', () => { const ok = (v: unknown) => firstRejectedDomainMember('iso_4217_currency', v); it('admits CHF and every curated localization option', () => { @@ -83,15 +144,127 @@ describe('iso_4217_currency — Intl.supportedValuesOf("currency")', () => { expect(ok('XYZ')).toEqual({ value: 'XYZ' }); expect(ok('usd')).toEqual({ value: 'usd' }); }); + +}); + +describe('the door is a walker over the shared predicate, not a second judge', () => { + /** + * The invariant this package actually owns, and the one that survives the + * re-point: `firstRejectedDomainMember` adds no membership opinion of its + * own — it walks a carrier and asks `isValueDomainMember`. Asserted as + * AGREEMENT, so a second membership definition inside this door — the exact + * divergence the 2026-09-02 ruling closed — reddens here. + * + * ## Population, not samples — the round-2 finding + * + * A hand-written corpus is not enough, and the measurement says by how much. + * With a four-code currency corpus, a door answering `iso_4217_currency` + * from a ten-entry `.includes` allow-list passed the whole package green + * (529/529): every sampled member happened to be inside the allow-list. So + * agreement is asserted over each domain's whole POPULATION, sampled only + * for the traps that no population contains. + * + * ## Why the host enumeration is safe HERE and was not before + * + * An earlier draft asserted that everything `Intl.supportedValuesOf` + * enumerates is ADMITTED by the door. That made the host's ICU build an + * ORACLE, and the door answers from a checked-in snapshot — so a runtime + * enumerating one code the snapshot lacks reddened a CORRECT implementation, + * in a package with nothing to fix. Below, the enumeration is only the + * POPULATION: each code is put to the door and to the predicate, and the two + * must answer alike. A code the snapshot lacks is false on both sides; a + * code the host lacks is never asked. So no ICU build can redden a correct + * door, and no ICU build can hide a divergent one either. + * + * Probe-versus-snapshot — the definition question — stays where the snapshot + * is, pinned in both directions and with a size by + * `packages/spec/src/shared/value-domain.test.ts`. + */ + const CORPUS = [ + // members, one or more per domain + 'UTC', 'Asia/Kolkata', 'Europe/Kyiv', 'GMT', 'US/Eastern', 'Europe/Zurich', + 'CHF', 'USD', 'EUR', 'JPY', + 'US', 'GB', 'CH', 'UA', + // non-members, including every trap the shared module names + 'Mars/Olympus', 'Europe/Munich', 'Not A Zone', + 'XYZ', 'VED', 'XAU', 'usd', 'chf', + 'ZZ', 'UK', 'XX', 'us', 'AAA', '', ' CH', 'CH ', + // Plausible WIDENINGS — the complement the population pins cannot cover. + // A population walk catches any narrowing exhaustively; the set of values + // a second judge might wrongly ADMIT is infinite, so the corpus seeds the + // ones a real editor would reach for: a user-assigned code in live use + // (XK), a supranational alias (EU), and codes withdrawn from the standards + // (AN, CS, SU, YU, BTC, CNH, XTS). + 'XK', 'EU', 'AN', 'CS', 'SU', 'YU', 'BTC', 'CNH', 'XTS', + ]; + + /** Door and predicate, one value: `true` when both admit, `false` when both refuse. */ + const agrees = (domain: ValueDomain, value: string): boolean => + (firstRejectedDomainMember(domain, value) === null) === isValueDomainMember(domain, value); + + /** Every member of `population` on which the two disagree. */ + const disagreements = (domain: ValueDomain, population: readonly string[]): string[] => + population.filter((v) => !agrees(domain, v)); + + it('agrees with isValueDomainMember on every domain, over the trap corpus', () => { + for (const domain of ValueDomainSchema.options) { + expect(disagreements(domain, CORPUS), `${domain}: door and shared predicate disagree`).toEqual([]); + } + }); + + it('agrees over every ISO 4217 code this runtime enumerates', () => { + // POPULATION, not oracle — see the block comment above. This is the case a + // hand-written corpus cannot cover: measured, a ten-entry allow-list + // inside the door disagrees on 152 of these, and a `!startsWith('X')` + // filter on 7 (XAF, XCD, XCG, XDR, XOF, XPF, XSU); neither moves any + // sampled case. + const intl = Intl as typeof Intl & { supportedValuesOf(k: 'currency'): string[] }; + const population = intl.supportedValuesOf('currency'); + // The population must be real, or every assertion over it is vacuous. + expect(population.length, 'the runtime enumerated no currencies').toBeGreaterThan(100); + expect(disagreements('iso_4217_currency', population)).toEqual([]); + }); + + it('agrees over every IANA zone this runtime enumerates', () => { + // Same form, same reason. The zone domain has no checked-in table on + // either side, so this is a wide agreement corpus rather than a + // divergence-prone one — but a door that started case-folding or + // trimming would show up here and nowhere else. + const intl = Intl as typeof Intl & { supportedValuesOf(k: 'timeZone'): string[] }; + const population = intl.supportedValuesOf('timeZone'); + expect(population.length, 'the runtime enumerated no time zones').toBeGreaterThan(100); + expect(disagreements('iana_time_zone', population)).toEqual([]); + }); + + it('agrees over the whole published ISO 3166-1 alpha-2 population', () => { + expect(disagreements('iso_3166_alpha2', [...ISO_3166_ALPHA2_CODES])).toEqual([]); + }); + + it('agrees element-wise across a multi-value carrier too', () => { + // The walk is the door's own contract, so it is pinned against the + // predicate rather than against a hard-coded expectation. + const carrier = ['USD', 'CHF', 'XYZ', 'EUR']; + const firstBad = carrier.find((c) => !isValueDomainMember('iso_4217_currency', c)); + expect(firstRejectedDomainMember('iso_4217_currency', carrier)) + .toEqual(firstBad === undefined ? null : { value: firstBad }); + }); }); -describe('iso_3166_alpha2 — the explicit code list the spec says this side must carry', () => { +describe('iso_3166_alpha2 — the explicit code list, now carried by the spec', () => { const ok = (v: unknown) => firstRejectedDomainMember('iso_3166_alpha2', v); - it('is structurally the officially assigned set: 249 unique uppercase pairs', () => { + it('admits the whole officially assigned set the shared module publishes', () => { + // Was a structural pin on this package's own table (`size === 249`). The + // table moved to `@objectstack/spec/shared`, where its structure and its + // spelling are pinned. What is left here is a PLUMBING pin, and is worth + // saying as exactly that rather than as something stronger: it loops the + // published set through a door that answers from the published set, so it + // cannot detect a wrong table — it detects the door failing to reach the + // right one, over the whole population rather than a sample. expect(ISO_3166_ALPHA2_CODES.size).toBe(249); for (const code of ISO_3166_ALPHA2_CODES) { expect(code).toMatch(/^[A-Z]{2}$/); + expect(ok(code), `${code} is officially assigned and must be admitted`).toBeNull(); } }); diff --git a/packages/services/service-settings/src/value-domains.ts b/packages/services/service-settings/src/value-domains.ts index 78cc621b35..618033da90 100644 --- a/packages/services/service-settings/src/value-domains.ts +++ b/packages/services/service-settings/src/value-domains.ts @@ -1,129 +1,50 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * Standard value-domain membership — the enforcement half of - * `Specifier.valueDomain` (#5712; the declaration half is #5933, - * `SpecifierValueDomainSchema` in `@objectstack/spec/system`). + * Standard value-domain enforcement for settings specifiers — the settings + * door's adapter over the ONE shared predicate. * * A specifier that declares `valueDomain` says: the legal values for this key * are the members of this published standard, and that membership — not the - * curated `options` table — is the enforcement boundary. The spec deliberately - * only DECLARES the domains (Prime Directive #2 — no business logic, no data - * tables in `packages/spec`); each domain's definition of membership is pinned - * in `SpecifierValueDomainSchema`'s TSDoc and implemented here, on the - * enforcing side. Keep the two in sync — the spec-side TSDoc names the traps - * each definition was measured against, and `value-domains.test.ts` re-measures - * them so drift goes red rather than rotting. - */ - -import type { SpecifierValueDomain } from '@objectstack/spec/system'; - -/** - * `iana_time_zone` membership — the `Intl.DateTimeFormat` probe. + * curated `options` table — is the enforcement boundary. * - * NOT `Intl.supportedValuesOf('timeZone')`: measured on the repo's Node 22 - * baseline it returns 418 CLDR *canonical* names and omits `UTC` (the - * localization manifest's own declared default), `Asia/Kolkata` (a curated - * option shipped today), `Europe/Kyiv`, `US/Eastern` and `GMT` — ICU keeps the - * old spellings (`Asia/Calcutta`, `Europe/Kiev`) as its canonical names, so - * testing membership against that list rejects values every runtime accepts - * (#5712's own env repro, re-measured in #5933). The probe is the definition - * the platform's consumers already use: `isValidTimeZone` in - * `packages/core/src/security/resolve-authz-context.ts` (module-private there, - * hence re-stated rather than imported) and the IANA assertion in - * `localization.manifest.test.ts`. Note the probe is case-insensitive - * (`europe/zurich` constructs fine) — that IS the pinned definition: the - * accepted domain equals what every `Intl`-based consumer downstream accepts. - */ -function isIanaTimeZone(value: string): boolean { - try { - new Intl.DateTimeFormat('en-US', { timeZone: value }); - return true; - } catch { - return false; - } -} - -/** - * `iso_4217_currency` membership — `Intl.supportedValuesOf('currency')`. + * ## Where the definitions live now (and why not here) * - * Here the enumeration IS usable (measured 162 entries on the Node 22 - * baseline: admits `CHF` and all nine curated localization options, rejects - * `XYZ`). Known gaps are the recently assigned `VED` and the metal/fund codes - * (`XAU`, `XDR`, …) — widen deliberately if a deployment needs one, never by - * falling back to a regex. Membership is exact (uppercase, as ISO 4217 spells - * codes); computed once and cached, since the set is a property of the runtime - * and `validatePatch` runs per write. - */ -let iso4217Cache: ReadonlySet | undefined; -function iso4217Codes(): ReadonlySet { - // The cast exists because the repo's root tsconfig `lib` is ES2020 while - // `Intl.supportedValuesOf` is typed in lib.es2022.intl — the RUNTIME is - // guaranteed (engines >= 22; the spec's own vocabulary test calls it bare). - // Delete the cast when the root `lib` moves to ES2022+; do not widen it. - const intl = Intl as typeof Intl & { supportedValuesOf(key: 'currency'): string[] }; - iso4217Cache ??= new Set(intl.supportedValuesOf('currency')); - return iso4217Cache; -} - -/** - * `iso_3166_alpha2` — the officially assigned ISO 3166-1 alpha-2 codes, - * carried explicitly because there is NO standard-library oracle for this - * domain (measured in #5933): `Intl.DisplayNames(…, { type: 'region' })` - * returns a distinct display name for `ZZ` ("Unknown Region" — the exact value - * this domain exists to reject) and for `UK` (a CLDR alias that is not an - * ISO 3166-1 code), so "the name differs from the input" is not a membership - * test. The spec's TSDoc says the enforcing side must carry the list; this is - * that list — the 249 officially assigned codes. User-assigned and reserved - * elements (`ZZ`, `XX`, `UK`, `AA`, `QM`–`QZ`, …) are deliberately absent. - * Membership is exact uppercase, as the standard spells the codes; nothing in - * this repo writes lowercase country values, and one strict spelling is the - * shape AI-authored metadata cannot get subtly wrong. + * Maintainer ruling 2026-09-02 on the field-level card: **one closed vocabulary + * and one membership predicate shared by settings specifiers and object + * fields**. Both now live in `@objectstack/spec/shared` + * (`shared/value-domain.zod.ts`) — {@link ValueDomainSchema} is the vocabulary + * (`SpecifierValueDomainSchema` is an alias of it, not a copy) and + * `isValueDomainMember` is the predicate, with every definition's traps argued + * and re-measured in that module's own header and test. + * + * Until that ruling this module carried its own second copy of all three + * definitions: the `Intl.DateTimeFormat` probe, a run-time + * `Intl.supportedValuesOf('currency')` set, and the 249 ISO 3166-1 alpha-2 + * codes. The copies are gone. What is left here is what is genuinely the + * DOOR's: which declarations it agrees to enforce + * ({@link knownValueDomain}), how a multi-value carrier is walked + * ({@link firstRejectedDomainMember}), and the prose fragments the env-override + * log line needs ({@link valueDomainPhrasing}). Nothing in this file decides + * membership; a membership question that is not `isValueDomainMember` is a + * third copy re-appearing, and `value-domains.shared-predicate.pin.test.ts` is + * the ratchet that reddens when one does. + * + * ⚠️ One definition MOVED with the re-point, in a direction the shared module + * argues for: `iso_4217_currency` was a run-time probe of + * `Intl.supportedValuesOf('currency')` and is now the key set of the + * checked-in CLDR snapshot `CURRENCY_FRACTION_DIGITS`. Measured on the repo's + * Node 22 baseline (v22.22.2) the two sets are identical — 162 codes each, + * symmetric difference 0 in both directions — so no value changes verdict on + * this runtime; what changes is that the verdict no longer varies with the + * host's ICU build. */ -const ISO_3166_ALPHA2 = new Set( - ( - 'AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ ' + - 'BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ ' + - 'CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ ' + - 'DE DJ DK DM DO DZ ' + - 'EC EE EG EH ER ES ET ' + - 'FI FJ FK FM FO FR ' + - 'GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY ' + - 'HK HM HN HR HT HU ' + - 'ID IE IL IM IN IO IQ IR IS IT ' + - 'JE JM JO JP ' + - 'KE KG KH KI KM KN KP KR KW KY KZ ' + - 'LA LB LC LI LK LR LS LT LU LV LY ' + - 'MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ ' + - 'NA NC NE NF NG NI NL NO NP NR NU NZ ' + - 'OM ' + - 'PA PE PF PG PH PK PL PM PN PR PS PT PW PY ' + - 'QA ' + - 'RE RO RS RU RW ' + - 'SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ ' + - 'TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ ' + - 'UA UG UM US UY UZ ' + - 'VA VC VE VG VI VN VU ' + - 'WF WS ' + - 'YE YT ' + - 'ZA ZM ZW' - ).split(' '), -); - -/** Exported for the structural pins in `value-domains.test.ts` only. */ -export const ISO_3166_ALPHA2_CODES: ReadonlySet = ISO_3166_ALPHA2; -/** - * The closed set of domains this side knows how to enforce — kept equal to - * `SpecifierValueDomainSchema`'s members (`value-domains.test.ts` pins the - * equality, so a spec-side vocabulary change goes red here instead of becoming - * a declared-but-unenforced member, the Prime Directive #10 shape). - */ -const DOMAIN_MEMBERSHIP: Record boolean> = { - iana_time_zone: isIanaTimeZone, - iso_4217_currency: (value) => iso4217Codes().has(value), - iso_3166_alpha2: (value) => ISO_3166_ALPHA2.has(value), -}; +import { + isValueDomainMember, + ValueDomainSchema, + type ValueDomain, +} from '@objectstack/spec/shared'; /** * The declared `valueDomain`, when it is one this side can enforce; else null. @@ -136,17 +57,18 @@ const DOMAIN_MEMBERSHIP: Record boolean * check stays in force — which is the same "record nothing rather than an empty * table" leniency the option-table registration takes, and strictly safer than * accepting everything on the strength of a typo. + * + * The vocabulary's own `safeParse` is the filter. It admits the three declared + * members and nothing else — in particular no inherited property name + * (`'toString'`, `'constructor'`), which the previous implementation had to + * exclude by hand because it looked the domain up in an object literal and + * `'toString' in DOMAIN_MEMBERSHIP` is true through the prototype chain. A + * closed `z.enum` matches literal members only, so the guard is now structural + * rather than remembered; `value-domains.test.ts` keeps pinning both names. */ -export function knownValueDomain(declared: unknown): SpecifierValueDomain | null { - // Own-property, not `in`: the record is an object literal, so `'toString' in - // DOMAIN_MEMBERSHIP` is true via the prototype chain and would hand a - // hand-built manifest a "domain" whose enforcer is not a membership test. - // (`hasOwnProperty.call` rather than `Object.hasOwn` only because the root - // tsconfig `lib` is ES2020 — same story as the `supportedValuesOf` cast.) - return typeof declared === 'string' && - Object.prototype.hasOwnProperty.call(DOMAIN_MEMBERSHIP, declared) - ? (declared as SpecifierValueDomain) - : null; +export function knownValueDomain(declared: unknown): ValueDomain | null { + const parsed = ValueDomainSchema.safeParse(declared); + return parsed.success ? parsed.data : null; } /** @@ -159,24 +81,36 @@ export function knownValueDomain(declared: unknown): SpecifierValueDomain | null * string form (a stored value has been through JSON and a form post), and * returning a wrapper so "nothing rejected" and "the rejected member WAS * `undefined`" stay distinguishable. + * + * The membership question itself is `isValueDomainMember` — the same call the + * record write path makes, so a value accepted in Settings is the same value + * accepted in a field and vice versa. */ export function firstRejectedDomainMember( - domain: SpecifierValueDomain, + domain: ValueDomain, value: unknown, ): { value: unknown } | null { - const member = DOMAIN_MEMBERSHIP[domain]; const picked = Array.isArray(value) ? value : [value]; - const at = picked.findIndex((v) => !member(String(v))); + const at = picked.findIndex((v) => !isValueDomainMember(domain, String(v))); return at === -1 ? null : { value: picked[at] }; } /** - * The three prose fragments a rejection message needs, per domain — one - * definition feeding BOTH doors (`validatePatch`'s `FieldError.message` and - * `reportRejectedEnvOverride`'s log line), so the two never describe the same - * domain in different words. + * The two prose fragments the ENV-OVERRIDE log line needs, per domain. + * + * Scope narrowed with the re-point: the save path's `FieldError` no longer + * builds its sentence here — it renders the published catalog template + * `value_domain_` (`@objectstack/spec/system`), the same catalog the + * record write path renders, so the two doors cannot describe one domain in + * different words. `reportRejectedEnvOverride` writes a LOG line, not a + * `FieldError`: it has no error code, no locale and no `{{label}}`, and its + * one sentence template wants fragments ("is not a valid X for", "any X (e.g. + * 'Y')") rather than a finished sentence. So the fragments stay here, and + * `value-domains.test.ts` pins each one against the catalog template for the + * same domain — drift between the log line and the wire message goes red + * instead of rotting. */ -export function valueDomainPhrasing(domain: SpecifierValueDomain): { +export function valueDomainPhrasing(domain: ValueDomain): { /** What a legal member is called, e.g. "IANA time zone identifier". */ member: string; /** A representative legal value that is NOT in the curated options today. */ diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs index 32daef3c5d..ac41dc56c7 100644 --- a/scripts/cross-package-test-inputs.mjs +++ b/scripts/cross-package-test-inputs.mjs @@ -646,6 +646,24 @@ export const CROSS_PACKAGE_TEST_INPUTS = { 'scripts/js-comment-mask.d.mts', ], }, + '@objectstack/service-settings': { + // src/value-domains.shared-predicate.pin.test.ts (#15162) imports + // `stripComments` from `js-comment-mask.mjs` to decide which text in this + // package's `src/` is prose and which is code — the ratchet that keeps the + // settings door answering from the ONE shared value-domain predicate + // instead of re-acquiring a membership table of its own. The coupling is + // real in both directions: the guard's verdicts are census over the whole + // non-test source, and its banned tokens (`supportedValuesOf`, + // `Intl.DateTimeFormat`) are named in the very TSDoc that explains why they + // are banned — so a change in what the module counts as a comment changes + // every verdict here. The `.d.mts` sibling is declared alongside it because + // it is what types the import, so this package's `tsc --noEmit` verdict is + // a function of it too. + globs: [ + 'scripts/js-comment-mask.mjs', + 'scripts/js-comment-mask.d.mts', + ], + }, '@objectstack/trigger-api': { // src/trigger-api-route-ledger.conformance.test.ts (#12398) imports // `stripComments` from `js-comment-mask.mjs` for the same reason and in the diff --git a/turbo.json b/turbo.json index fc134881be..af038fcdef 100644 --- a/turbo.json +++ b/turbo.json @@ -23,6 +23,18 @@ "outputs": ["dist/**", "json-schema/**", ".next/**", "!.next/cache/**"], "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/content/**"] }, + "@objectstack/service-settings#test": { + "dependsOn": ["^build"], + "outputs": [], + "inputs": [ + "$TURBO_DEFAULT$", + "!dist/**", + "!coverage/**", + "!.turbo/**", + "$TURBO_ROOT$/scripts/js-comment-mask.mjs", + "$TURBO_ROOT$/scripts/js-comment-mask.d.mts" + ] + }, "@objectstack/metadata#test": { "dependsOn": ["build"], "outputs": [],