Skip to content

Commit 2fdb36e

Browse files
qq9340100claude
andauthored
feat(spec): SpecifierSchema gains a closed valueDomain enum (#5933) (#6515)
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` 族的类型作用域「声明而不强制」),均未夹带。 Claude-Session: https://claude.ai/code/session_011btrhv6sHn6JkN93YRtGQp Co-authored-by: Claude <noreply@anthropic.com>
1 parent 46dba97 commit 2fdb36e

9 files changed

Lines changed: 351 additions & 8 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): SettingsManifest specifiers can declare a standard `valueDomain` (#5933)
6+
7+
`SpecifierSchema`'s value constraints were `options` / `pattern` / `min` / `max` /
8+
`minLength` / `maxLength`, and none of them can express "the legal values here are
9+
whatever the published standard says". `pattern` constrains the *shape* of a string,
10+
so `^[A-Za-z]{2}$` admits `ZZ` and `Mars/Olympus` is a shape-valid time zone that does
11+
not exist; `options` is exhaustive (#5131), so completing it would mean checking a
12+
600-entry tzdb table into a manifest and re-checking it every tzdb release. The
13+
`localization` manifest hits this on three keys at once — `timezone`, `currency`,
14+
`default_country` — and `company.country` carries the same two-letter pattern with the
15+
same hole.
16+
17+
**New optional key: `specifier.valueDomain`**, a closed enum with three members:
18+
19+
- `iana_time_zone`
20+
- `iso_4217_currency`
21+
- `iso_3166_alpha2`
22+
23+
Declaring it moves the enforcement boundary: the standard's membership becomes what a
24+
write is judged against, and `options` degrades to a **UI convenience list** — a curated
25+
dropdown of values worth suggesting, no longer an exhaustive statement of what is legal.
26+
A value outside `options` but inside the domain is accepted.
27+
28+
**Nothing changes when it is absent.** `options` stays exhaustive and the save path keeps
29+
rejecting anything the table does not list, which is the right shape for tables the
30+
platform itself backs (`mail.provider`, `sms.provider`) where "legal" means "this
31+
deployment ships an adapter for it". `pattern` / `minLength` / `maxLength` still apply
32+
alongside a domain and still narrow — shape and membership are independent, and a value
33+
must satisfy both.
34+
35+
The **enforcement** is not in this release. `packages/spec` declares the domain and
36+
nothing more (Prime Directive #2); the write-path check lands in `service-settings`
37+
(#5712, blocked on this). What ships here so both halves agree is the *definition of
38+
membership* for each domain, pinned by tests rather than left to prose, because for two
39+
of the three the obvious oracle is the wrong one:
40+
41+
- `iana_time_zone` is the `Intl.DateTimeFormat` probe, not
42+
`Intl.supportedValuesOf('timeZone')` — measured on the Node 22 baseline, that list
43+
holds 418 CLDR canonical names and omits `UTC` (this platform's own declared default)
44+
and `Asia/Kolkata` (a value the shipped localization manifest curates), carrying the
45+
latter only under the legacy spelling `Asia/Calcutta`.
46+
- `iso_4217_currency` **is** `Intl.supportedValuesOf('currency')` — 162 entries,
47+
admitting `CHF` and all nine curated options while rejecting `XYZ`.
48+
- `iso_3166_alpha2` has no standard-library oracle at all:
49+
`Intl.DisplayNames(…, { type: 'region' }).of()` returns a distinct name for `ZZ`
50+
("Unknown Region", the exact value this domain exists to reject) and for `UK` (a CLDR
51+
alias that is not an ISO 3166-1 code), so the enforcing side must carry an explicit
52+
code list.
53+
54+
`bcp47_locale`, the fourth member the proposal listed, is deliberately **not** in the
55+
vocabulary. Its only candidate key is `localization.locale`, whose options are exactly
56+
the shipped message catalogs — a registry-backed table, so a domain there would loosen
57+
it and admit locales with no catalog. And BCP-47 has no membership registry to enforce
58+
against (`Intl.getCanonicalLocales('xx-YY')` succeeds), so the "domain" would only
59+
re-check syntax — the weakness `pattern` already has and this key exists to fix.

content/docs/references/index.mdx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: Protocol Reference
3-
description: Every schema published by @objectstack/spec — 1604 schemas across 14 protocol modules
3+
description: Every schema published by @objectstack/spec — 1605 schemas across 14 protocol modules
44
---
55

66
{/* ⚠️ 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
3131
| [Security Protocol](/docs/references/security) | 5 | 27 | Permission sets, row-level security, sharing rules, tenancy posture. |
3232
| [Shared Protocol](/docs/references/shared) | 8 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. |
3333
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
34-
| [System Protocol](/docs/references/system) | 37 | 295 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
34+
| [System Protocol](/docs/references/system) | 37 | 296 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
3535
| [UI Protocol](/docs/references/ui) | 16 | 146 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
36-
| **Total** | **200** | **1604** | 14 protocol modules |
36+
| **Total** | **200** | **1605** | 14 protocol modules |
3737

3838
---
3939

@@ -318,7 +318,7 @@ Studio designer metadata — the authoring surfaces for the protocols above.
318318

319319
## System Protocol
320320

321-
**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **37 pages, 295 schemas**
321+
**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **37 pages, 296 schemas**
322322

323323
The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance.
324324

@@ -353,7 +353,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a
353353
| [`search-engine.zod.ts`](/docs/references/system/search-engine) | `AnalyzerConfig`, `FacetConfig`, `SearchConfig`, `SearchIndexConfig`, `SearchProvider` |
354354
| [`security-context.zod.ts`](/docs/references/system/security-context) | `ComplianceAuditRequirement`, `ComplianceEncryptionRequirement`, `ComplianceFramework`, `DataClassification`, `DataClassificationPolicy`, `MaskingVisibilityRule`, `SecurityContextConfig`, `SecurityEventCorrelation` |
355355
| [`settings-client.zod.ts`](/docs/references/system/settings-client) | `SettingsChangeEvent` |
356-
| [`settings-manifest.zod.ts`](/docs/references/system/settings-manifest) | `ResolvedSettingValue`, `SettingsActionResult`, `SettingsManifest`, `SettingsNamespacePayload`, `Specifier`, `SpecifierHandler`, `SpecifierOption`, `SpecifierScope`, `SpecifierType` |
356+
| [`settings-manifest.zod.ts`](/docs/references/system/settings-manifest) | `ResolvedSettingValue`, `SettingsActionResult`, `SettingsManifest`, `SettingsNamespacePayload`, `Specifier`, `SpecifierHandler`, `SpecifierOption`, `SpecifierScope`, `SpecifierType`, `SpecifierValueDomain` |
357357
| [`stack-server.zod.ts`](/docs/references/system/stack-server) | `ServerRateLimitConfig`, `StackServerConfig`, `StackServerSecurity` |
358358
| [`supplier-security.zod.ts`](/docs/references/system/supplier-security) | `SupplierAssessmentStatus`, `SupplierRiskLevel`, `SupplierSecurityAssessment`, `SupplierSecurityPolicy`, `SupplierSecurityRequirement` |
359359
| [`tenant.zod.ts`](/docs/references/system/tenant) | `DatabaseLevelIsolationStrategy`, `DatabaseProvider`, `QuotaEnforcementResult`, `RowLevelIsolationStrategy`, `SchemaLevelIsolationStrategy`, `Tenant`, `TenantConnectionConfig`, `TenantIsolationConfig`, `TenantIsolationLevel`, `TenantQuota`, `TenantSecurityPolicy`, `TenantUsage` |

content/docs/references/system/settings-manifest.mdx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ Resolution order (handled by `SettingsService.get`):
3131
## TypeScript Usage
3232

3333
```typescript
34-
import { ResolvedSettingValueSchema, SettingsActionResultSchema, SettingsManifestSchema, SettingsNamespacePayloadSchema, SpecifierSchema, SpecifierHandlerSchema, SpecifierOptionSchema, SpecifierScopeSchema, SpecifierType } from '@objectstack/spec/system';
35-
import type { ResolvedSettingValue, SettingsActionResult, SettingsManifest, SettingsNamespacePayload, Specifier, SpecifierHandler, SpecifierOption, SpecifierScope, SpecifierType } from '@objectstack/spec/system';
34+
import { ResolvedSettingValueSchema, SettingsActionResultSchema, SettingsManifestSchema, SettingsNamespacePayloadSchema, SpecifierSchema, SpecifierHandlerSchema, SpecifierOptionSchema, SpecifierScopeSchema, SpecifierType, SpecifierValueDomainSchema } from '@objectstack/spec/system';
35+
import type { ResolvedSettingValue, SettingsActionResult, SettingsManifest, SettingsNamespacePayload, Specifier, SpecifierHandler, SpecifierOption, SpecifierScope, SpecifierType, SpecifierValueDomain } from '@objectstack/spec/system';
3636

3737
// Validate data
3838
const result = ResolvedSettingValueSchema.parse(data);
@@ -130,6 +130,7 @@ const result = ResolvedSettingValueSchema.parse(data);
130130
| **deprecated** | `boolean` | optional | Mark deprecated |
131131
| **replacedBy** | `string` | optional | Replacement key (used when deprecated=true) |
132132
| **options** | `{ value: string \| number \| boolean; label: string; description?: string; icon?: string }[]` | optional | Options for select/radio/multiselect |
133+
| **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) |
133134
| **min** | `number` | optional | |
134135
| **max** | `number` | optional | |
135136
| **step** | `number` | optional | |
@@ -267,3 +268,14 @@ This schema accepts one of the following structures:
267268

268269
---
269270

271+
## SpecifierValueDomain
272+
273+
### Allowed Values
274+
275+
* `iana_time_zone`
276+
* `iso_4217_currency`
277+
* `iso_3166_alpha2`
278+
279+
280+
---
281+

packages/spec/api-surface/system.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,8 @@
619619
"SpecifierScope (type)",
620620
"SpecifierScopeSchema (const)",
621621
"SpecifierType (type)",
622+
"SpecifierValueDomain (type)",
623+
"SpecifierValueDomainSchema (const)",
622624
"StackServerConfig (type)",
623625
"StackServerConfigParsed (type)",
624626
"StackServerConfigSchema (const)",

packages/spec/authorable-surface/system.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1142,6 +1142,7 @@
11421142
"system/Specifier:scope",
11431143
"system/Specifier:step",
11441144
"system/Specifier:type",
1145+
"system/Specifier:valueDomain",
11451146
"system/Specifier:visible",
11461147
"system/Specifier:writePermission",
11471148
"system/SpecifierOption:description",

packages/spec/json-schema.manifest/system.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@
245245
"system/SpecifierOption",
246246
"system/SpecifierScope",
247247
"system/SpecifierType",
248+
"system/SpecifierValueDomain",
248249
"system/StackServerConfig",
249250
"system/StackServerSecurity",
250251
"system/StorageAcl",

packages/spec/src/system/settings-manifest.test.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { describe, it, expect } from 'vitest';
44
import {
55
SpecifierType,
6+
SpecifierValueDomainSchema,
67
SpecifierSchema,
78
SettingsManifestSchema,
89
ResolvedSettingValueSchema,
@@ -193,6 +194,170 @@ describe('SpecifierSchema — layout-only specifiers', () => {
193194
});
194195
});
195196

197+
describe('SpecifierValueDomainSchema — the closed standard-domain vocabulary (#5933)', () => {
198+
it('accepts exactly the three domains with measured authoring pull', () => {
199+
for (const d of ['iana_time_zone', 'iso_4217_currency', 'iso_3166_alpha2']) {
200+
expect(() => SpecifierValueDomainSchema.parse(d)).not.toThrow();
201+
}
202+
expect(SpecifierValueDomainSchema.options).toEqual([
203+
'iana_time_zone',
204+
'iso_4217_currency',
205+
'iso_3166_alpha2',
206+
]);
207+
});
208+
209+
it('rejects `bcp47_locale` — the proposal member that was deliberately dropped', () => {
210+
// #5933's proposal listed a fourth member. Its only candidate key is
211+
// `localization.locale`, a `select` whose options ARE the shipped message
212+
// catalogs — a registry-backed table, so declaring a domain there would
213+
// LOOSEN it (options degrade to a suggestion list) and admit locales that
214+
// have no catalog. And BCP-47 has no membership registry to enforce
215+
// against: `Intl.getCanonicalLocales('xx-YY')` succeeds, so the "domain"
216+
// would only re-check syntax — exactly the weakness `pattern` already has
217+
// and this key exists to fix. It is not in the vocabulary; a manifest that
218+
// spells it is refused by name rather than silently stripped.
219+
expect(() => SpecifierValueDomainSchema.parse('bcp47_locale')).toThrow();
220+
expect(() => Intl.getCanonicalLocales('xx-YY')).not.toThrow();
221+
});
222+
223+
it('rejects unknown domains', () => {
224+
expect(() => SpecifierValueDomainSchema.parse('iso_9999_unicorn')).toThrow();
225+
expect(() => SpecifierValueDomainSchema.parse('')).toThrow();
226+
});
227+
});
228+
229+
describe('Specifier.valueDomain (#5933)', () => {
230+
it('SURVIVES the parse — a dropped key is the defect this closes', () => {
231+
// #5712's dev measured the current shape by smuggling a `format` key in:
232+
// Zod stripped it and `parse()` returned `undefined`, so the manifest had
233+
// no way to say "the boundary is a standard". This assertion is that
234+
// measurement inverted, and it is the one that must never regress.
235+
const parsed = SpecifierSchema.parse({
236+
type: 'select',
237+
key: 'timezone',
238+
label: 'Default timezone',
239+
valueDomain: 'iana_time_zone',
240+
options: [{ value: 'UTC', label: 'UTC' }],
241+
});
242+
expect(parsed.valueDomain).toBe('iana_time_zone');
243+
});
244+
245+
it('is optional — an undeclared specifier keeps #5131 exhaustive-options semantics', () => {
246+
const parsed = SpecifierSchema.parse({
247+
type: 'select',
248+
key: 'provider',
249+
label: 'Provider',
250+
options: [{ value: 'smtp', label: 'SMTP' }],
251+
});
252+
expect(parsed.valueDomain).toBeUndefined();
253+
});
254+
255+
it('accepts a `text` specifier alongside pattern/length constraints', () => {
256+
// Shape and membership are independent narrowings and a value must satisfy
257+
// both — `^[A-Za-z]{2}$` is what admits `ZZ` today, which is why the domain
258+
// is added rather than the pattern replaced.
259+
const parsed = SpecifierSchema.parse({
260+
type: 'text',
261+
key: 'default_country',
262+
label: 'Default country',
263+
valueDomain: 'iso_3166_alpha2',
264+
pattern: '^[A-Za-z]{2}$',
265+
minLength: 2,
266+
maxLength: 2,
267+
});
268+
expect(parsed.valueDomain).toBe('iso_3166_alpha2');
269+
expect(parsed.pattern).toBe('^[A-Za-z]{2}$');
270+
});
271+
272+
it('still requires `options` on a select — the list degrades, it does not vanish', () => {
273+
expect(() =>
274+
SpecifierSchema.parse({
275+
type: 'select',
276+
key: 'currency',
277+
label: 'Default currency',
278+
valueDomain: 'iso_4217_currency',
279+
})
280+
).toThrow(/requires non-empty 'options'/);
281+
});
282+
283+
it('rejects a valueDomain on a layout-only specifier', () => {
284+
expect(() =>
285+
SpecifierSchema.parse({
286+
type: 'group',
287+
label: 'Region',
288+
valueDomain: 'iana_time_zone',
289+
})
290+
).toThrow(/carries no value, so it must not declare a 'valueDomain'/);
291+
});
292+
293+
it('rejects an unknown domain on an otherwise valid specifier', () => {
294+
expect(() =>
295+
SpecifierSchema.parse({
296+
type: 'text',
297+
key: 'default_country',
298+
label: 'Default country',
299+
valueDomain: 'iso_3166_alpha3',
300+
})
301+
).toThrow();
302+
});
303+
});
304+
305+
describe('valueDomain membership definitions — the measurements service-settings must implement', () => {
306+
// These pin the TSDoc on `SpecifierValueDomainSchema`. `packages/spec` does
307+
// not enforce a domain (Prime Directive #2) — but the two halves have to agree
308+
// on WHAT the domain is, and the obvious oracle is the wrong one for two of
309+
// the three. A doc nobody re-measures rots; these go red when it does.
310+
311+
const probeTimeZone = (tz: string): boolean => {
312+
try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; }
313+
};
314+
315+
it('iana_time_zone: the Intl.DateTimeFormat probe is the definition', () => {
316+
// Same shape as `isValidTimeZone` in
317+
// packages/core/src/security/resolve-authz-context.ts.
318+
for (const tz of ['UTC', 'Asia/Kolkata', 'Europe/Kyiv', 'Asia/Ho_Chi_Minh', 'US/Eastern', 'GMT', 'Asia/Shanghai']) {
319+
expect(probeTimeZone(tz)).toBe(true);
320+
}
321+
// And it does what `pattern` cannot: a shape-valid zone that does not exist.
322+
expect(probeTimeZone('Mars/Olympus')).toBe(false);
323+
});
324+
325+
it('iana_time_zone: Intl.supportedValuesOf is NOT the definition', () => {
326+
// Measured on the repo's Node 22 baseline: a CLDR canonical-name subset
327+
// that omits values this platform itself ships. If this ever goes green in
328+
// the other direction, ICU has changed — re-measure before relaxing the
329+
// TSDoc, do not just delete the assertion.
330+
const enumerated = Intl.supportedValuesOf('timeZone');
331+
for (const shipped of ['UTC', 'Asia/Kolkata']) {
332+
expect(probeTimeZone(shipped)).toBe(true);
333+
expect(enumerated).not.toContain(shipped);
334+
}
335+
// It is not merely a subset — it renames: the zones above are present under
336+
// legacy spellings, so even a normalising membership test is not free.
337+
expect(enumerated).toContain('Asia/Calcutta');
338+
});
339+
340+
it('iso_4217_currency: Intl.supportedValuesOf IS the definition', () => {
341+
const currencies = Intl.supportedValuesOf('currency');
342+
// The nine curated localization options plus CHF, the canonical "valid but
343+
// not curated" value the degraded-options semantics must admit.
344+
for (const c of ['USD', 'EUR', 'GBP', 'JPY', 'CNY', 'INR', 'AUD', 'CAD', 'BRL', 'CHF']) {
345+
expect(currencies).toContain(c);
346+
}
347+
expect(currencies).not.toContain('XYZ');
348+
});
349+
350+
it('iso_3166_alpha2: Intl.DisplayNames is NOT a membership oracle', () => {
351+
// The tempting test is "the display name differs from the input". It admits
352+
// `ZZ` — the exact value #5933 cites as slipping past `^[A-Za-z]{2}$` — and
353+
// `UK`, which is a CLDR alias and not an ISO 3166-1 code at all. The
354+
// enforcing side needs an explicit code list; that list is not spec's.
355+
const regionNames = new Intl.DisplayNames(['en'], { type: 'region' });
356+
expect(regionNames.of('ZZ')).not.toBe('ZZ');
357+
expect(regionNames.of('UK')).not.toBe('UK');
358+
});
359+
});
360+
196361
describe('SettingsManifestSchema', () => {
197362
const minimalManifest: SettingsManifest = {
198363
namespace: 'mail',

0 commit comments

Comments
 (0)