From eb692a200fba3cb97921df3b6f0152f751ff741f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 02:51:09 +0000 Subject: [PATCH] feat(spec): SpecifierSchema gains a closed `valueDomain` enum (#5933) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #5933. The spec half of #5712 (maintainer ruling 2026-08-06, reading 1); the ruling comment pre-authorised this contract-first split, and the enforcement half stays in `service-settings` (#5712, Blocked-by this). ## Premise, re-verified against origin/main (1818998) All three of the issue's claims hold, measured here rather than taken on trust: - `SpecifierSchema`'s value-constraint slots are exactly `options` / `pattern` / `min` / `max` / `minLength` / `maxLength` (settings-manifest.zod.ts). There is no slot that can say "a published standard is the boundary", and an undeclared key is stripped by Zod rather than refused. - The pull is real and larger than the issue states: `localization.manifest.ts` needs it on `timezone` (:34), `currency` (:114) and `default_country` (:67), and `company.manifest.ts:55` carries the *same* `^[A-Za-z]{2}$` country pattern with the same hole. Four keys, two manifests. - The Intl traps reproduce exactly on this container (Node v22.22.2): `Intl.supportedValuesOf('timeZone')` returns 418 entries and omits `UTC`, `Asia/Kolkata`, `Europe/Kyiv`, `Asia/Ho_Chi_Minh`, `US/Eastern` and `GMT`; the `Intl.DateTimeFormat` probe accepts all six and rejects `Mars/Olympus`; `Intl.supportedValuesOf('currency')` returns 162, holds `CHF` and all nine curated codes, and does not hold `XYZ`. The named precedents are where the issue says: `isValidTimeZone` at `packages/core/src/security/resolve-authz-context.ts:449`, and the IANA-probe assertion in `localization.manifest.test.ts`. ## What lands `SpecifierValueDomainSchema` — a closed enum — plus an optional `specifier.valueDomain` key that references it. Declaring it moves the enforcement boundary onto the standard's membership and degrades `options` to a UI convenience list; omitting it changes nothing, so #5131's exhaustive-options semantics stay intact for registry-backed tables (`mail.provider`, `sms.provider`). `pattern` / `minLength` / `maxLength` still apply alongside a domain: shape and membership narrow independently and a value must satisfy both. One parse rule is added: a layout-only specifier carries no value, so it may not declare a `valueDomain`. Without it the key would be a silent no-op on a `group` — declared and never enforced, the shape Prime Directive #10 refuses. No enforcement ships here (Prime Directive #2): spec declares, `service-settings` executes. ## Vocabulary: three members, not the proposal's four The dispatch left the final vocabulary to this seat. `iana_time_zone`, `iso_4217_currency` and `iso_3166_alpha2` are in — each has a metadata key that needs it today (above). `bcp47_locale` is deliberately OUT, on two measured grounds: - Its only candidate key is `localization.locale`, a `select` whose four options ARE the shipped message catalogs — a registry-backed table. Declaring a domain there would LOOSEN it, admitting locales the platform has no catalog for. That is a regression, not the fix the issue asks for. - BCP-47 has no membership registry to enforce against: `Intl.getCanonicalLocales('xx-YY')` succeeds (measured). The "domain" would only re-check syntax — precisely the weakness `pattern` already has and this key exists to fix, so it would buy zero rejection power. Startup-focus reading: a fourth member with no pull and no enforceable definition is vocabulary expansion for its own sake. The enum is closed, so it is refused by name rather than silently accepted, and it can return the day a key needs it. ## The membership definitions are pinned, not just documented Both halves have to agree on WHAT each domain is, and for two of the three the obvious oracle is the wrong one. The TSDoc states each definition and a test block measures it, so the doc goes red rather than rotting: - `iana_time_zone` = the `Intl.DateTimeFormat` probe. NOT `supportedValuesOf` — which not only omits values this platform ships but *renames* them (`Asia/Calcutta`, `Europe/Kiev`), so even a normalising membership test is not free. - `iso_4217_currency` = `Intl.supportedValuesOf('currency')`. Known gaps (`VED`, the metal/fund codes) are named in the TSDoc so widening is a decision rather than a surprise. - `iso_3166_alpha2` has NO standard-library oracle, and this is the finding most worth carrying to #5712: the tempting test — "the `Intl.DisplayNames` region name differs from the input" — admits `ZZ` (it maps to "Unknown Region", and `ZZ` is the exact value #5933 cites as slipping past `^[A-Za-z]{2}$`) and admits `UK` (a CLDR alias that is not an ISO 3166-1 code). The enforcing side must carry an explicit alpha-2 list. That list does not belong in spec. ## Reverse verification (direction predicted first) Predicted RED, and red it is: deleting the `valueDomain` limb from the schema turns 4 of the 6 `Specifier.valueDomain` cases red, headed by "SURVIVES the parse" — which is #5712's own `format` measurement inverted (Zod strips the undeclared key, `parsed.valueDomain` is `undefined`). "rejects an unknown domain" also goes red, i.e. without the limb a misspelt domain is silently swallowed rather than refused. The 2 that stay green stay green honestly: "is optional" and "a select still requires options" do not depend on the limb. The schema file was restored byte-identical afterwards (verified with diff). ## Verification (all foreground, real readings) - `pnpm --filter @objectstack/spec test` -> 340 files, **8723 passed**, 0 failed - targeted verbose run of `settings-manifest.test.ts` + `type-alias-convention.pin.test.ts` -> **46 passed**, including all 13 new cases - `pnpm --filter @objectstack/spec exec tsc --noEmit` -> clean - `pnpm --filter @objectstack/spec typecheck` -> clean (test layer compiles; debt ledger unchanged at 58 files / 267 errors) - `turbo run typecheck --filter=./packages/* --filter=./packages/*/*` -> **119 successful, 119 total** - `pnpm --filter @objectstack/spec check:generated` -> **10/10 up to date** (api-surface and content/docs/references were stale and regenerated with `--fix`, exactly the 2 it proved stale; authorable-surface auto-recorded `system/Specifier:valueDomain`) - `pnpm --filter @objectstack/spec gen:openapi` -> ran (ungated generator); writes only the gitignored `json-schema/openapi.json`, no tracked diff - `pnpm lint` -> clean - `pnpm check:spec-parsed-alias` -> "1465 bare z.input aliases, **755 pinned isomorphic**, 710 paired with an XParsed. OK" - `check:nul-bytes` / `check:empty-changeset` / `check:adr-0087-registration` / `check:quick-reference-counts` / `check:doc-authoring` / `check:role-word` / `check:adr-anchors` / `check:release-notes` / `check:published-files` / `check:exported-any` / `check:dual-source-exports` / `check:liveness` / `check:generated --reconcile-only` -> all green - `pnpm --filter @objectstack/service-settings test` -> 15 files, **269 passed** (nearest consumer, untouched by this change; the package declares no `typecheck` script) `check:adr-0087-registration` confirms this is not a declared-breaking changeset — the change is purely additive, so no disposition marker is owed. ## ADR-0122 `SpecifierValueDomainSchema` is a `z.enum`: no default, no transform, so `z.input` and `z.infer` coincide. Per the ADR it gets a pin (`Iso758`) rather than a permanent-synonym `SpecifierValueDomainParsed`, and the registry's hardcoded count moves 754 -> 755 with its receipt comment recorded at the count case, in the file's own idiom. ## Ruling mapping | Dispatch clause | Where it lands | |:---|:---| | optional closed `valueDomain` enum | `settings-manifest.zod.ts` — `SpecifierValueDomainSchema` + the `valueDomain` key | | semantics in TSDoc, `options` degrades to UI list | the key's TSDoc; pinned by the "still requires options" and "is optional" cases | | undeclared = unchanged (#5131) | pinned by "is optional — keeps #5131 exhaustive-options semantics" | | enforcement stays in service-settings | nothing executes here; stated in the TSDoc and the changeset | | timezone = `Intl.DateTimeFormat` probe, not `supportedValuesOf` | TSDoc + two pin cases | | currency = `supportedValuesOf('currency')` | TSDoc + one pin case | | final vocabulary decided by this seat, with reasons | three members; `bcp47_locale` dropped, reasons above and in the changeset | | schema + authorable-surface baseline + pins + docs + minor changeset | all present | | do not ride #5932 | not touched | ## Out-of-scope findings (recorded, not fixed) 1. `type-alias-convention.pin.test.ts`'s count case is titled "still declares all 751 isomorphic pins" while asserting 754 (now 755). The title has been stale since #6037 moved 751 -> 754 without updating it. Left alone deliberately — it predates this change and a title edit is not this issue's scope — but it now reads two moves behind, so it is worth a one-line docs fix. 2. `pattern` is documented "(text only)" at `settings-manifest.zod.ts` yet no parse rule scopes it to `text`; the same is true of `rows`, `min`/`max` and the length bounds. `valueDomain` follows that same house style (documented applicable types, only the layout-only case refused) rather than inventing a stricter convention for one key. If the family should be type-scoped, that is one deliberate change across all of them, not a rider here. Distinct from #5932, which is about those bounds being inert on the *write path*. ## 交付摘要(PM 开 PR 素材) - **前提**:对 origin/main 1818998 逐条复核成立,且拉动比 issue 所述更强 —— 四个键、两个 manifest(`localization` 的 timezone/currency/default_country + `company.country` 同一个 `^[A-Za-z]{2}$` 洞)。Node v22.22.2 上三条 Intl 实测读数与 issue 完全一致。 - **落地**:spec 只声明不执行 —— 新增闭合枚举 `SpecifierValueDomainSchema` 与可选键 `specifier.valueDomain`,外加一条 parse 规则(布局型 specifier 不得声明)。 - **词表定形**:三员,砍掉提案里的 `bcp47_locale`。两条实测理由:它唯一的候选键 `localization.locale` 的 options 就是随包发的语言包(注册表背书表),声明域反而会放松它;且 BCP-47 没有成员登记表可校验(`Intl.getCanonicalLocales('xx-YY')` 通过),那个「域」只会退化成又一次语法检查 —— 正是本键要修的 `pattern` 短板。 - **给 #5712 的关键交接**:`iso_3166_alpha2` 没有标准库 oracle。`Intl.DisplayNames` 的「显示名与输入不同」判据会放行 `ZZ`(映射为 "Unknown Region",正是本单点名要拒的值)和 `UK`(CLDR 别名,并非 ISO 3166-1 码)。执行半边必须自带 alpha-2 码表,码表不进 spec。 - **反向验证**:先预测后跑,方向为红 —— 摘掉 `valueDomain` 肢体后 6 个用例红 4,领头的正是 #5712 那次 `format` 被静默剥掉的实测反过来写。另 2 个诚实地保持绿(不依赖该肢体)。schema 文件已 diff 验证还原为逐字节一致。 - **验证读数**:spec 8723 测试全绿、`turbo typecheck` 119/119、生成物 10/10、`check:spec-parsed-alias` 报 755 pinned、lint 与门禁族全绿、service-settings 269 测试全绿。 - **界外发现**两条见上(pin 计数用例标题过时;`pattern` 族的类型作用域「声明而不强制」),均未夹带。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011btrhv6sHn6JkN93YRtGQp --- .changeset/specifier-value-domain.md | 59 +++++++ content/docs/references/index.mdx | 10 +- .../references/system/settings-manifest.mdx | 16 +- packages/spec/api-surface/system.json | 2 + packages/spec/authorable-surface/system.json | 1 + .../spec/json-schema.manifest/system.json | 1 + .../spec/src/system/settings-manifest.test.ts | 165 ++++++++++++++++++ .../spec/src/system/settings-manifest.zod.ts | 97 ++++++++++ .../src/type-alias-convention.pin.test.ts | 8 +- 9 files changed, 351 insertions(+), 8 deletions(-) create mode 100644 .changeset/specifier-value-domain.md diff --git a/.changeset/specifier-value-domain.md b/.changeset/specifier-value-domain.md new file mode 100644 index 0000000000..aa5665e4a1 --- /dev/null +++ b/.changeset/specifier-value-domain.md @@ -0,0 +1,59 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): SettingsManifest specifiers can declare a standard `valueDomain` (#5933) + +`SpecifierSchema`'s value constraints were `options` / `pattern` / `min` / `max` / +`minLength` / `maxLength`, and none of them can express "the legal values here are +whatever the published standard says". `pattern` constrains the *shape* of a string, +so `^[A-Za-z]{2}$` admits `ZZ` and `Mars/Olympus` is a shape-valid time zone that does +not exist; `options` is exhaustive (#5131), so completing it would mean checking a +600-entry tzdb table into a manifest and re-checking it every tzdb release. The +`localization` manifest hits this on three keys at once — `timezone`, `currency`, +`default_country` — and `company.country` carries the same two-letter pattern with the +same hole. + +**New optional key: `specifier.valueDomain`**, a closed enum with three members: + +- `iana_time_zone` +- `iso_4217_currency` +- `iso_3166_alpha2` + +Declaring it moves the enforcement boundary: the standard's membership becomes what a +write is judged against, and `options` degrades to a **UI convenience list** — a curated +dropdown of values worth suggesting, no longer an exhaustive statement of what is legal. +A value outside `options` but inside the domain is accepted. + +**Nothing changes when it is absent.** `options` stays exhaustive and the save path keeps +rejecting anything the table does not list, which is the right shape for tables the +platform itself backs (`mail.provider`, `sms.provider`) where "legal" means "this +deployment ships an adapter for it". `pattern` / `minLength` / `maxLength` still apply +alongside a domain and still narrow — shape and membership are independent, and a value +must satisfy both. + +The **enforcement** is not in this release. `packages/spec` declares the domain and +nothing more (Prime Directive #2); the write-path check lands in `service-settings` +(#5712, blocked on this). What ships here so both halves agree is the *definition of +membership* for each domain, pinned by tests rather than left to prose, because for two +of the three the obvious oracle is the wrong one: + +- `iana_time_zone` is the `Intl.DateTimeFormat` probe, not + `Intl.supportedValuesOf('timeZone')` — measured on the Node 22 baseline, that list + holds 418 CLDR canonical names and omits `UTC` (this platform's own declared default) + and `Asia/Kolkata` (a value the shipped localization manifest curates), carrying the + latter only under the legacy spelling `Asia/Calcutta`. +- `iso_4217_currency` **is** `Intl.supportedValuesOf('currency')` — 162 entries, + admitting `CHF` and all nine curated options while rejecting `XYZ`. +- `iso_3166_alpha2` has no standard-library oracle at all: + `Intl.DisplayNames(…, { type: 'region' }).of()` returns a distinct 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 enforcing side must carry an explicit + code list. + +`bcp47_locale`, the fourth member the proposal listed, is deliberately **not** in the +vocabulary. Its only candidate key is `localization.locale`, whose options are exactly +the shipped message catalogs — a registry-backed table, so a domain there would loosen +it and admit locales with no catalog. And BCP-47 has no membership registry to enforce +against (`Intl.getCanonicalLocales('xx-YY')` succeeds), so the "domain" would only +re-check syntax — the weakness `pattern` already has and this key exists to fix. diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index a2faf3eb17..f7f074d784 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1604 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1605 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -31,9 +31,9 @@ counts are sums of the rows they head. Regenerate with | [Security Protocol](/docs/references/security) | 5 | 27 | Permission sets, row-level security, sharing rules, tenancy posture. | | [Shared Protocol](/docs/references/shared) | 8 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | -| [System Protocol](/docs/references/system) | 37 | 295 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | +| [System Protocol](/docs/references/system) | 37 | 296 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 146 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **200** | **1604** | 14 protocol modules | +| **Total** | **200** | **1605** | 14 protocol modules | --- @@ -318,7 +318,7 @@ Studio designer metadata — the authoring surfaces for the protocols above. ## System Protocol -**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **37 pages, 295 schemas** +**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **37 pages, 296 schemas** The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. @@ -353,7 +353,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a | [`search-engine.zod.ts`](/docs/references/system/search-engine) | `AnalyzerConfig`, `FacetConfig`, `SearchConfig`, `SearchIndexConfig`, `SearchProvider` | | [`security-context.zod.ts`](/docs/references/system/security-context) | `ComplianceAuditRequirement`, `ComplianceEncryptionRequirement`, `ComplianceFramework`, `DataClassification`, `DataClassificationPolicy`, `MaskingVisibilityRule`, `SecurityContextConfig`, `SecurityEventCorrelation` | | [`settings-client.zod.ts`](/docs/references/system/settings-client) | `SettingsChangeEvent` | -| [`settings-manifest.zod.ts`](/docs/references/system/settings-manifest) | `ResolvedSettingValue`, `SettingsActionResult`, `SettingsManifest`, `SettingsNamespacePayload`, `Specifier`, `SpecifierHandler`, `SpecifierOption`, `SpecifierScope`, `SpecifierType` | +| [`settings-manifest.zod.ts`](/docs/references/system/settings-manifest) | `ResolvedSettingValue`, `SettingsActionResult`, `SettingsManifest`, `SettingsNamespacePayload`, `Specifier`, `SpecifierHandler`, `SpecifierOption`, `SpecifierScope`, `SpecifierType`, `SpecifierValueDomain` | | [`stack-server.zod.ts`](/docs/references/system/stack-server) | `ServerRateLimitConfig`, `StackServerConfig`, `StackServerSecurity` | | [`supplier-security.zod.ts`](/docs/references/system/supplier-security) | `SupplierAssessmentStatus`, `SupplierRiskLevel`, `SupplierSecurityAssessment`, `SupplierSecurityPolicy`, `SupplierSecurityRequirement` | | [`tenant.zod.ts`](/docs/references/system/tenant) | `DatabaseLevelIsolationStrategy`, `DatabaseProvider`, `QuotaEnforcementResult`, `RowLevelIsolationStrategy`, `SchemaLevelIsolationStrategy`, `Tenant`, `TenantConnectionConfig`, `TenantIsolationConfig`, `TenantIsolationLevel`, `TenantQuota`, `TenantSecurityPolicy`, `TenantUsage` | diff --git a/content/docs/references/system/settings-manifest.mdx b/content/docs/references/system/settings-manifest.mdx index ee42c1f1f7..3a8518d95c 100644 --- a/content/docs/references/system/settings-manifest.mdx +++ b/content/docs/references/system/settings-manifest.mdx @@ -31,8 +31,8 @@ Resolution order (handled by `SettingsService.get`): ## TypeScript Usage ```typescript -import { ResolvedSettingValueSchema, SettingsActionResultSchema, SettingsManifestSchema, SettingsNamespacePayloadSchema, SpecifierSchema, SpecifierHandlerSchema, SpecifierOptionSchema, SpecifierScopeSchema, SpecifierType } from '@objectstack/spec/system'; -import type { ResolvedSettingValue, SettingsActionResult, SettingsManifest, SettingsNamespacePayload, Specifier, SpecifierHandler, SpecifierOption, SpecifierScope, SpecifierType } from '@objectstack/spec/system'; +import { ResolvedSettingValueSchema, SettingsActionResultSchema, SettingsManifestSchema, SettingsNamespacePayloadSchema, SpecifierSchema, SpecifierHandlerSchema, SpecifierOptionSchema, SpecifierScopeSchema, SpecifierType, SpecifierValueDomainSchema } from '@objectstack/spec/system'; +import type { ResolvedSettingValue, SettingsActionResult, SettingsManifest, SettingsNamespacePayload, Specifier, SpecifierHandler, SpecifierOption, SpecifierScope, SpecifierType, SpecifierValueDomain } from '@objectstack/spec/system'; // Validate data const result = ResolvedSettingValueSchema.parse(data); @@ -130,6 +130,7 @@ const result = ResolvedSettingValueSchema.parse(data); | **deprecated** | `boolean` | optional | Mark deprecated | | **replacedBy** | `string` | optional | Replacement key (used when deprecated=true) | | **options** | `{ value: string \| number \| boolean; label: string; description?: string; icon?: string }[]` | optional | Options for select/radio/multiselect | +| **valueDomain** | `Enum<'iana_time_zone' \| 'iso_4217_currency' \| 'iso_3166_alpha2'>` | optional | Standard value domain enforced on write (options degrade to a UI suggestion list) | | **min** | `number` | optional | | | **max** | `number` | optional | | | **step** | `number` | optional | | @@ -267,3 +268,14 @@ This schema accepts one of the following structures: --- +## SpecifierValueDomain + +### Allowed Values + +* `iana_time_zone` +* `iso_4217_currency` +* `iso_3166_alpha2` + + +--- + diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 9f9a215fdd..3594ed81bc 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -619,6 +619,8 @@ "SpecifierScope (type)", "SpecifierScopeSchema (const)", "SpecifierType (type)", + "SpecifierValueDomain (type)", + "SpecifierValueDomainSchema (const)", "StackServerConfig (type)", "StackServerConfigParsed (type)", "StackServerConfigSchema (const)", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index c9da8ba25d..27ad36886f 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -1142,6 +1142,7 @@ "system/Specifier:scope", "system/Specifier:step", "system/Specifier:type", + "system/Specifier:valueDomain", "system/Specifier:visible", "system/Specifier:writePermission", "system/SpecifierOption:description", diff --git a/packages/spec/json-schema.manifest/system.json b/packages/spec/json-schema.manifest/system.json index 25b6090c5b..e2834118fc 100644 --- a/packages/spec/json-schema.manifest/system.json +++ b/packages/spec/json-schema.manifest/system.json @@ -245,6 +245,7 @@ "system/SpecifierOption", "system/SpecifierScope", "system/SpecifierType", + "system/SpecifierValueDomain", "system/StackServerConfig", "system/StackServerSecurity", "system/StorageAcl", diff --git a/packages/spec/src/system/settings-manifest.test.ts b/packages/spec/src/system/settings-manifest.test.ts index 6269b9666c..da2713b8cd 100644 --- a/packages/spec/src/system/settings-manifest.test.ts +++ b/packages/spec/src/system/settings-manifest.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect } from 'vitest'; import { SpecifierType, + SpecifierValueDomainSchema, SpecifierSchema, SettingsManifestSchema, ResolvedSettingValueSchema, @@ -193,6 +194,170 @@ describe('SpecifierSchema — layout-only specifiers', () => { }); }); +describe('SpecifierValueDomainSchema — the closed standard-domain vocabulary (#5933)', () => { + it('accepts exactly the three domains with measured authoring pull', () => { + for (const d of ['iana_time_zone', 'iso_4217_currency', 'iso_3166_alpha2']) { + expect(() => SpecifierValueDomainSchema.parse(d)).not.toThrow(); + } + expect(SpecifierValueDomainSchema.options).toEqual([ + 'iana_time_zone', + 'iso_4217_currency', + 'iso_3166_alpha2', + ]); + }); + + it('rejects `bcp47_locale` — the proposal member that was deliberately dropped', () => { + // #5933's proposal listed a fourth member. Its only candidate key is + // `localization.locale`, a `select` whose options ARE the shipped message + // catalogs — a registry-backed table, so declaring a domain there would + // LOOSEN it (options degrade to a suggestion list) and admit locales that + // have no catalog. And BCP-47 has no membership registry to enforce + // against: `Intl.getCanonicalLocales('xx-YY')` succeeds, so the "domain" + // would only re-check syntax — exactly the weakness `pattern` already has + // and this key exists to fix. It is not in the vocabulary; a manifest that + // spells it is refused by name rather than silently stripped. + expect(() => SpecifierValueDomainSchema.parse('bcp47_locale')).toThrow(); + expect(() => Intl.getCanonicalLocales('xx-YY')).not.toThrow(); + }); + + it('rejects unknown domains', () => { + expect(() => SpecifierValueDomainSchema.parse('iso_9999_unicorn')).toThrow(); + expect(() => SpecifierValueDomainSchema.parse('')).toThrow(); + }); +}); + +describe('Specifier.valueDomain (#5933)', () => { + it('SURVIVES the parse — a dropped key is the defect this closes', () => { + // #5712's dev measured the current shape by smuggling a `format` key in: + // Zod stripped it and `parse()` returned `undefined`, so the manifest had + // no way to say "the boundary is a standard". This assertion is that + // measurement inverted, and it is the one that must never regress. + const parsed = SpecifierSchema.parse({ + type: 'select', + key: 'timezone', + label: 'Default timezone', + valueDomain: 'iana_time_zone', + options: [{ value: 'UTC', label: 'UTC' }], + }); + expect(parsed.valueDomain).toBe('iana_time_zone'); + }); + + it('is optional — an undeclared specifier keeps #5131 exhaustive-options semantics', () => { + const parsed = SpecifierSchema.parse({ + type: 'select', + key: 'provider', + label: 'Provider', + options: [{ value: 'smtp', label: 'SMTP' }], + }); + expect(parsed.valueDomain).toBeUndefined(); + }); + + it('accepts a `text` specifier alongside pattern/length constraints', () => { + // Shape and membership are independent narrowings and a value must satisfy + // both — `^[A-Za-z]{2}$` is what admits `ZZ` today, which is why the domain + // is added rather than the pattern replaced. + const parsed = SpecifierSchema.parse({ + type: 'text', + key: 'default_country', + label: 'Default country', + valueDomain: 'iso_3166_alpha2', + pattern: '^[A-Za-z]{2}$', + minLength: 2, + maxLength: 2, + }); + expect(parsed.valueDomain).toBe('iso_3166_alpha2'); + expect(parsed.pattern).toBe('^[A-Za-z]{2}$'); + }); + + it('still requires `options` on a select — the list degrades, it does not vanish', () => { + expect(() => + SpecifierSchema.parse({ + type: 'select', + key: 'currency', + label: 'Default currency', + valueDomain: 'iso_4217_currency', + }) + ).toThrow(/requires non-empty 'options'/); + }); + + it('rejects a valueDomain on a layout-only specifier', () => { + expect(() => + SpecifierSchema.parse({ + type: 'group', + label: 'Region', + valueDomain: 'iana_time_zone', + }) + ).toThrow(/carries no value, so it must not declare a 'valueDomain'/); + }); + + it('rejects an unknown domain on an otherwise valid specifier', () => { + expect(() => + SpecifierSchema.parse({ + type: 'text', + key: 'default_country', + label: 'Default country', + valueDomain: 'iso_3166_alpha3', + }) + ).toThrow(); + }); +}); + +describe('valueDomain membership definitions — the measurements service-settings must implement', () => { + // These pin the TSDoc on `SpecifierValueDomainSchema`. `packages/spec` does + // not enforce a domain (Prime Directive #2) — but the two halves have to agree + // on WHAT the domain is, and the obvious oracle is the wrong one for two of + // the three. A doc nobody re-measures rots; these go red when it does. + + const probeTimeZone = (tz: string): boolean => { + try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; } + }; + + it('iana_time_zone: the Intl.DateTimeFormat probe is the definition', () => { + // Same shape as `isValidTimeZone` in + // packages/core/src/security/resolve-authz-context.ts. + for (const tz of ['UTC', 'Asia/Kolkata', 'Europe/Kyiv', 'Asia/Ho_Chi_Minh', 'US/Eastern', 'GMT', 'Asia/Shanghai']) { + expect(probeTimeZone(tz)).toBe(true); + } + // And it does what `pattern` cannot: a shape-valid zone that does not exist. + expect(probeTimeZone('Mars/Olympus')).toBe(false); + }); + + it('iana_time_zone: Intl.supportedValuesOf is NOT the definition', () => { + // Measured on the repo's Node 22 baseline: a CLDR canonical-name subset + // that omits values this platform itself ships. If this ever goes green in + // the other direction, ICU has changed — re-measure before relaxing the + // TSDoc, do not just delete the assertion. + const enumerated = Intl.supportedValuesOf('timeZone'); + for (const shipped of ['UTC', 'Asia/Kolkata']) { + expect(probeTimeZone(shipped)).toBe(true); + expect(enumerated).not.toContain(shipped); + } + // It is not merely a subset — it renames: the zones above are present under + // legacy spellings, so even a normalising membership test is not free. + expect(enumerated).toContain('Asia/Calcutta'); + }); + + it('iso_4217_currency: Intl.supportedValuesOf IS the definition', () => { + const currencies = Intl.supportedValuesOf('currency'); + // The nine curated localization options plus CHF, the canonical "valid but + // not curated" value the degraded-options semantics must admit. + for (const c of ['USD', 'EUR', 'GBP', 'JPY', 'CNY', 'INR', 'AUD', 'CAD', 'BRL', 'CHF']) { + expect(currencies).toContain(c); + } + expect(currencies).not.toContain('XYZ'); + }); + + it('iso_3166_alpha2: Intl.DisplayNames is NOT a membership oracle', () => { + // The tempting test is "the display name differs from the input". It admits + // `ZZ` — the exact value #5933 cites as slipping past `^[A-Za-z]{2}$` — and + // `UK`, which is a CLDR alias and not an ISO 3166-1 code at all. The + // enforcing side needs an explicit code list; that list is not spec's. + const regionNames = new Intl.DisplayNames(['en'], { type: 'region' }); + expect(regionNames.of('ZZ')).not.toBe('ZZ'); + expect(regionNames.of('UK')).not.toBe('UK'); + }); +}); + describe('SettingsManifestSchema', () => { const minimalManifest: SettingsManifest = { namespace: 'mail', diff --git a/packages/spec/src/system/settings-manifest.zod.ts b/packages/spec/src/system/settings-manifest.zod.ts index e012e6aa30..2dfc25c422 100644 --- a/packages/spec/src/system/settings-manifest.zod.ts +++ b/packages/spec/src/system/settings-manifest.zod.ts @@ -134,6 +134,57 @@ export type SpecifierHandlerParsed = z.infer; export const SpecifierScopeSchema = z.enum(['global', 'tenant', 'user']); export type SpecifierScope = z.input; +/** + * Closed vocabulary of **standard value domains** a specifier's value may be + * drawn from (#5933, the spec half of #5712). + * + * A specifier that declares `valueDomain` says: *the legal values for this key + * are the members of this published standard*, and that membership — not the + * `options` table — is the enforcement boundary. See {@link Specifier} for the + * authoring semantics; enforcement itself lives in `service-settings` + * (Prime Directive #2 — the spec declares, it does not execute). + * + * The vocabulary is closed and deliberately small: a member earns its place by + * a metadata key that actually needs it, not by being a standard that exists. + * Each member below carries the **definition of membership** the enforcing side + * must implement, because "the IANA time zone database" and "what this Node + * happens to enumerate" are measurably different sets, and picking the wrong + * one rejects legal values. + * + * - `iana_time_zone` — an IANA/tzdb zone identifier (`UTC`, `Asia/Kolkata`, + * `Europe/Kyiv`). **Membership is the `Intl.DateTimeFormat` probe** + * (construct with `{ timeZone: value }`, catch `RangeError`) — the definition + * already used by `isValidTimeZone` in + * `packages/core/src/security/resolve-authz-context.ts` and by + * `localization.manifest.test.ts`. + * NOT `Intl.supportedValuesOf('timeZone')`: measured on the repo's Node 22 + * baseline it returns 418 CLDR *canonical* names and omits `UTC` (this + * platform's own declared default), `Asia/Kolkata` (a curated option in the + * shipped localization manifest), `Europe/Kyiv`, `Asia/Ho_Chi_Minh`, + * `US/Eastern` and `GMT`. Testing membership against that list rejects values + * every runtime accepts. + * - `iso_4217_currency` — an ISO 4217 alphabetic currency code (`USD`, `CHF`). + * Here `Intl.supportedValuesOf('currency')` IS usable: measured 162 entries + * on the same baseline, admitting `CHF` and all nine curated localization + * options while rejecting `XYZ`. Known gaps are the recently assigned `VED` + * and the metal/fund codes (`XAU`, `XDR`, …) — widen the definition + * deliberately if a deployment needs one, but never fall back to a regex. + * - `iso_3166_alpha2` — an ISO 3166-1 alpha-2 country code (`US`, `GB`, `CN`). + * There is no standard-library oracle for this one: measured, + * `Intl.DisplayNames(…, { type: 'region' }).of()` returns a distinct 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 enforcing side must + * carry an explicit alpha-2 code list; that list does not live here, because + * `packages/spec` holds no data tables (Prime Directive #2). + */ +export const SpecifierValueDomainSchema = z.enum([ + 'iana_time_zone', + 'iso_4217_currency', + 'iso_3166_alpha2', +]); +export type SpecifierValueDomain = z.input; + // --------------------------------------------------------------------------- // Specifier schema (the unit of UI in a manifest) // --------------------------------------------------------------------------- @@ -231,6 +282,41 @@ export const SpecifierSchema = lazySchema(() => z.object({ /** Options for `select` / `radio` / `multiselect`. */ options: z.array(SpecifierOptionSchema).optional().describe('Options for select/radio/multiselect'), + /** + * Declares that this key's legal values are the members of a published + * standard — see {@link SpecifierValueDomainSchema} for the vocabulary and + * for each domain's definition of membership. + * + * **Declaring it moves the enforcement boundary.** When `valueDomain` is + * present, the standard's membership is what a write is judged against, and + * `options` degrades to a **UI convenience list**: a curated dropdown of the + * values worth suggesting, no longer an exhaustive statement of what is + * legal. A value outside `options` but inside the domain is accepted. + * + * When `valueDomain` is ABSENT nothing changes: `options` remains exhaustive + * and the save path rejects anything the table does not list (#5131). That is + * the right shape for tables the platform itself backs — `mail.provider`, + * `sms.provider` — where "legal" means "this deployment ships an adapter for + * it", not "some registrar published it". Do not add `valueDomain` to those. + * + * `pattern` / `minLength` / `maxLength` still apply and still narrow: they + * constrain the *shape* of the string, the domain constrains its + * *membership*. A value must satisfy both. Reach for `valueDomain` precisely + * where `pattern` cannot help — `^[A-Za-z]{2}$` admits `ZZ`, and + * `Mars/Olympus` is a shape-valid time zone that does not exist. + * + * Only meaningful on value-bearing, string-valued specifiers (`text`, + * `select`, `radio`, `multiselect`); the parse refuses it on layout-only + * specifiers, which carry no value to judge. For a `select`, `options` is + * still required — a dropdown needs something to draw. + * + * The check itself runs in `service-settings` on the write path (#5712); + * `packages/spec` only declares the domain. + */ + valueDomain: SpecifierValueDomainSchema + .optional() + .describe('Standard value domain enforced on write (options degrade to a UI suggestion list)'), + /** `number` / `slider`: numeric bounds and step. */ min: z.number().optional(), max: z.number().optional(), @@ -270,6 +356,17 @@ export const SpecifierSchema = lazySchema(() => z.object({ message: `Specifier of type '${spec.type}' must not declare a 'key'.`, }); } + // A value domain judges a VALUE, so a specifier that carries none cannot + // declare one. Caught here rather than left to the renderer because a + // `valueDomain` on a `group` is a silent no-op otherwise — declared and never + // enforced, which is the shape Prime Directive #10 exists to refuse. + if (spec.valueDomain && !SPECIFIERS_REQUIRING_KEY.has(spec.type)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['valueDomain'], + message: `Specifier of type '${spec.type}' carries no value, so it must not declare a 'valueDomain'.`, + }); + } // select/radio/multiselect require options. if (['select', 'radio', 'multiselect'].includes(spec.type)) { if (!spec.options || spec.options.length === 0) { diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 8082b72c28..2d46ec5d03 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -1194,6 +1194,7 @@ export type Iso624 = Assert, z.infer< t export type Iso625 = Assert, z.infer< typeof M147.SpecifierOptionSchema > >>; export type Iso626 = Assert, z.infer< typeof M147.SpecifierScopeSchema > >>; export type Iso627 = Assert, z.infer< typeof M147.SettingsActionResultSchema > >>; +export type Iso758 = Assert, z.infer< typeof M147.SpecifierValueDomainSchema > >>; // system/supplier-security.zod.ts export type Iso628 = Assert, z.infer< typeof M148.SupplierRiskLevelSchema > >>; @@ -1490,9 +1491,14 @@ describe('ADR-0122 type-alias convention', () => { // 751 -> 754 is #6037's `ValidateDataIssue` / `ValidateDataRequest` / // `ValidateDataResponse` — three new protocol shapes with no defaults or // transforms anywhere in their trees, i.e. the second (RISE) case above. + // + // 754 -> 755 is #5933's `SpecifierValueDomain` — one new closed enum on + // `SettingsManifest`'s SpecifierSchema, the same (RISE) case: a `z.enum` + // has no default or transform, so its two shapes coincide and it gets a pin + // rather than a `SpecifierValueDomainParsed` synonym. const self = readFileSync(fileURLToPath(import.meta.url), 'utf8'); const pins = self.match(/^export type Iso\d+ = Assert {