diff --git a/.changeset/field-value-domain-slot.md b/.changeset/field-value-domain-slot.md
new file mode 100644
index 0000000000..49e1ceb476
--- /dev/null
+++ b/.changeset/field-value-domain-slot.md
@@ -0,0 +1,46 @@
+---
+'@objectstack/spec': minor
+---
+
+feat(spec): `Field.valueDomain` — one closed standard-domain vocabulary and one membership predicate shared by settings specifiers and object fields (maintainer ruling 2026-09-02 on #14168, spec half)
+
+
+
+An object field can now declare that its written value must be a member of a
+published standard, with the SAME closed vocabulary a settings specifier's
+`valueDomain` already uses — `iana_time_zone` · `iso_4217_currency` ·
+`iso_3166_alpha2` — and the same definition of membership. The vocabulary does
+not widen.
+
+- `Field.valueDomain` (`@objectstack/spec/data`): authorable on `text` only —
+ the one type whose stored value is a single plain string naming the member.
+ On any other type the declaration is refused at parse with a located issue
+ naming the type (the same applicability door `maxLength` / `minLength` use).
+ A domain outside the vocabulary (`iso_8601_date`) is refused by name.
+- `ValueDomainSchema` / `ValueDomain` / `isValueDomainMember(domain, value)` /
+ `ISO_3166_ALPHA2_CODES` (`@objectstack/spec/shared`): the vocabulary and its
+ ONE membership predicate, declared once. `iana_time_zone` is the
+ `Intl.DateTimeFormat` probe (`UTC`, `Asia/Kolkata`, `Europe/Kyiv` are
+ members; `Europe/Munich` is not — never the `Intl.supportedValuesOf`
+ enumeration, which omits `UTC`); `iso_4217_currency` is the key set of the
+ package's checked-in CLDR snapshot (162 codes, exact uppercase);
+ `iso_3166_alpha2` is the explicit list of the 249 officially assigned
+ codes (exact uppercase).
+- `SpecifierValueDomainSchema` / `SpecifierValueDomain`
+ (`@objectstack/spec/system`): unchanged name, unchanged members, now an
+ alias of `ValueDomainSchema` — nothing that imports them moves.
+- `FieldErrorCode` gains `value_domain` (ADR-0114 D1: the code is the
+ property's own name, like `max_length`), with message templates in the
+ four platform locales (`value_domain`, plus one finer variant per domain).
+
+What this release does NOT yet do: refuse a non-member on the record write
+path. The record validator does not read `Field.valueDomain` yet; that
+enforcement and the settings door's re-point onto the shared predicate are the
+engine and services halves of the same ruling and ship in their own releases.
+Until the engine half lands, a domain declared on a `text` field is accepted
+at parse and describes the contract the write path will enforce.
diff --git a/content/docs/api/error-catalog.mdx b/content/docs/api/error-catalog.mdx
index aead001c2a..dc208c30e3 100644
--- a/content/docs/api/error-catalog.mdx
+++ b/content/docs/api/error-catalog.mdx
@@ -691,7 +691,7 @@ snake_case, so the code and the schema property are the same word.
| Presence and shape | `required`, `invalid_type`, `invalid_shape`, `unknown_field` |
| Per-type parse | `invalid_boolean`, `invalid_number`, `invalid_date`, `invalid_time`, `invalid_email`, `invalid_url`, `invalid_phone`, `invalid_json`, `invalid_format` |
| Bounded ranges | `min_length`, `max_length`, `min_value`, `max_value`, `max_scale`, `min_items`, `max_items` |
-| Closed sets and references | `invalid_option`, `invalid_value`, `reference_not_found`, `reference_ambiguous` |
+| Closed sets and references | `invalid_option`, `value_domain` (the written value is not a member of the field's declared `valueDomain` standard), `invalid_value`, `reference_not_found`, `reference_ambiguous` |
| Declarative rules | `rule_violation`, `json_schema_violation`, `invalid_initial_state`, `invalid_transition` |
Branch on `code` to decide *how* to mark an input; show `message` to the user.
diff --git a/content/docs/data-modeling/field-types.mdx b/content/docs/data-modeling/field-types.mdx
index 826b8d69ba..3abcdf0bab 100644
--- a/content/docs/data-modeling/field-types.mdx
+++ b/content/docs/data-modeling/field-types.mdx
@@ -22,6 +22,7 @@ Single-line plain text input.
| `maxLength` | `number` | — | Maximum character length |
| `minLength` | `number` | — | Minimum character length |
| `format` | `string` | — | Validation format pattern |
+| `valueDomain` | `'iana_time_zone' \| 'iso_4217_currency' \| 'iso_3166_alpha2'` | — | Standard the written value must be a member of (IANA time zone, ISO 4217 currency code, ISO 3166-1 alpha-2 country code); `text` only |
```typescript
{ name: 'first_name', label: 'First Name', type: 'text', maxLength: 100 }
diff --git a/content/docs/data-modeling/fields.mdx b/content/docs/data-modeling/fields.mdx
index 8238716e55..cf37881c0c 100644
--- a/content/docs/data-modeling/fields.mdx
+++ b/content/docs/data-modeling/fields.mdx
@@ -304,6 +304,7 @@ These properties are available on all field types:
| `defaultValue` | `any` | — | Default value for new records |
| `maxLength` | `number` | — | Maximum character length |
| `minLength` | `number` | — | Minimum character length |
+| `valueDomain` | `'iana_time_zone' \| 'iso_4217_currency' \| 'iso_3166_alpha2'` | — | Standard the written value must be a member of (`text` only): an IANA time zone, an ISO 4217 currency code or an ISO 3166-1 alpha-2 country code. The same closed vocabulary a settings specifier's `valueDomain` uses; membership, not a pattern, is what a write is judged against |
| `min` | `number` | — | Minimum numeric value |
| `max` | `number` | — | Maximum numeric value |
diff --git a/content/docs/data-modeling/validation-rules.mdx b/content/docs/data-modeling/validation-rules.mdx
index 6cf27395dd..bc103c7ef3 100644
--- a/content/docs/data-modeling/validation-rules.mdx
+++ b/content/docs/data-modeling/validation-rules.mdx
@@ -44,6 +44,7 @@ These properties apply to **all** field types and are validated by the base `Fie
| `maxLength` | `number` | — | Rejects values exceeding character count |
| `minLength` | `number` | — | Rejects values below character count |
| `format` | `string` | — | Validates against format pattern (e.g., regex) |
+| `valueDomain` | `'iana_time_zone' \| 'iso_4217_currency' \| 'iso_3166_alpha2'` | — | Constrains the written value to a published standard — an IANA time zone (judged by the `Intl.DateTimeFormat` probe, so `UTC` and `Asia/Kolkata` are members and `Europe/Munich` is not), an ISO 4217 currency code or an ISO 3166-1 alpha-2 country code (both exact uppercase). Membership, not shape: a pattern such as `^[A-Z]{2}$` admits `ZZ`; the domain does not. The same closed vocabulary and the same membership test as a settings specifier's `valueDomain`; a non-member is refused on the write path with the field error code `value_domain` (the engine half of the same ruling — until it lands, the declaration is accepted at parse and not yet enforced on writes). `text` only — declaring it on any other type is refused at parse. |
**Default constraints:** None. Unbounded text unless `maxLength` is set.
@@ -514,7 +515,7 @@ section above). See the
| Field Type | Required Props | Key Constraints |
|:---|:---|:---|
-| `text` | — | `maxLength`, `minLength`, `format` |
+| `text` | — | `maxLength`, `minLength`, `format`, `valueDomain` |
| `textarea` | — | `maxLength`, `minLength` |
| `email` | — | Basic `local@domain` shape (not full RFC 5322) |
| `url` | — | Valid URL with protocol |
diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx
index f93099406f..2c6c34f213 100644
--- a/content/docs/getting-started/quick-reference.mdx
+++ b/content/docs/getting-started/quick-reference.mdx
@@ -206,7 +206,7 @@ from the provider itself, not from hand-written spec files.
|:---------|:-----------|:------------|:--------|
| **[Connector](/docs/references/integration/connector)** | `connector.zod.ts` | Connector | The connector protocol — auth, sync, webhooks, rate limiting |
-## Shared Protocol (5 of 7 schemas)
+## Shared Protocol (5 of 8 schemas)
Common utilities used across all protocols.
diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx
index 848b910cab..4ff66b14b9 100644
--- a/content/docs/permissions/system-context.mdx
+++ b/content/docs/permissions/system-context.mdx
@@ -196,7 +196,7 @@ assuming `isSystem` covers it is a documented source of bugs.
| "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1971` (rationale at `:1881`–`1883`, #3760), `flow.zod.ts:702` |
| "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) |
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10008`–`10025` |
-| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1540` (#3493 / #6640) |
+| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1580` (#3493 / #6640) |
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` |
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1520`, `:1549`; `domains/actions.ts:404` |
diff --git a/content/docs/protocol/objectql/schema.mdx b/content/docs/protocol/objectql/schema.mdx
index e2ad7f958d..2020d432c9 100644
--- a/content/docs/protocol/objectql/schema.mdx
+++ b/content/docs/protocol/objectql/schema.mdx
@@ -288,6 +288,7 @@ fields:
| `description` | `string` | All | Tooltip/Help text. |
| `maxLength` | `number` | `text`, `textarea` | Maximum character length. |
| `minLength` | `number` | `text`, `textarea` | Minimum character length. |
+| `valueDomain` | `string` | `text` | Standard the written value must belong to: `iana_time_zone`, `iso_4217_currency` or `iso_3166_alpha2` (the closed vocabulary shared with settings specifiers). |
| `min` | `number` | `number`, `currency` | Minimum numeric value. |
| `max` | `number` | `number`, `currency` | Maximum numeric value. |
| `scale` | `number` | `number`, `currency` | Decimal places (e.g., `2` for cents). |
diff --git a/content/docs/references/api/errors.mdx b/content/docs/references/api/errors.mdx
index 2c53c3e445..914c21b94e 100644
--- a/content/docs/references/api/errors.mdx
+++ b/content/docs/references/api/errors.mdx
@@ -182,7 +182,7 @@ const result = EnhancedApiErrorSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **field** | `string` | ✅ | Field path (supports dot notation) |
-| **code** | `Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| 'invalid_boolean' \| 'invalid_number' \| 'invalid_date' \| 'invalid_time' \| 'invalid_email' \| … +19 more>` | ✅ | Which constraint the value violated (field-level catalog, ADR-0114) |
+| **code** | `Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| 'invalid_boolean' \| 'invalid_number' \| 'invalid_date' \| 'invalid_time' \| 'invalid_email' \| … +20 more>` | ✅ | Which constraint the value violated (field-level catalog, ADR-0114) |
| **message** | `string` | ✅ | Human-readable error message, rendered in the caller’s locale |
| **label** | `string` | optional | Field display label in the caller’s locale |
| **value** | `any` | optional | The invalid value that was provided |
@@ -211,6 +211,7 @@ const result = EnhancedApiErrorSchema.parse(data);
* `min_items`
* `max_items`
* `invalid_option`
+* `value_domain`
* `invalid_value`
* `reference_not_found`
* `reference_ambiguous`
@@ -247,6 +248,7 @@ const result = EnhancedApiErrorSchema.parse(data);
* `min_items`
* `max_items`
* `invalid_option`
+* `value_domain`
* `invalid_value`
* `reference_not_found`
* `reference_ambiguous`
diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx
index 7e5a17d791..15424f1d37 100644
--- a/content/docs/references/data/field.mdx
+++ b/content/docs/references/data/field.mdx
@@ -65,6 +65,7 @@ const result = CurrencyConfigSchema.parse(data);
| **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. |
| **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused. |
| **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value shorter than a bound declared later is never re-read and survives unrelated edits — only a write carrying a too-short value is refused. |
+| **valueDomain** | `Enum<'iana_time_zone' \| 'iso_4217_currency' \| 'iso_3166_alpha2'>` | optional | Standard value domain the WRITTEN value must be a member of: `iana_time_zone` (an IANA/tzdb zone identifier such as `UTC`, `Asia/Kolkata`, `Europe/Kyiv` — membership is the `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf` enumeration, which omits `UTC`), `iso_4217_currency` (an ISO 4217 alphabetic currency code, uppercase, e.g. `CHF`) or `iso_3166_alpha2` (an ISO 3166-1 alpha-2 country code, uppercase, e.g. `CH`). The same closed vocabulary and the same membership predicate as a settings specifier's `valueDomain`. Only authorable on `text` — the one type whose stored value is a single plain string naming the member. Checked on the WRITTEN value only (the `min`/`max`/`maxLength` transition-gate class): a stored value outside a domain declared later is never re-read and survives unrelated edits — only a write carrying a non-member is refused, with the field error code `value_domain`. Reach for it precisely where a pattern cannot help: `^[A-Z]{2}$` admits `ZZ`, and `Mars/Olympus` is a shape-valid zone that does not exist. |
| **rows** | `integer` | optional | Height of the INLINE multiline editor, in text rows (positive integer — the HTML textarea `rows` attribute; fullscreen/dialog editor surfaces size themselves and ignore it). Only authorable on multiline editor types: textarea, markdown, html, richtext. Omit it for the widget default height. |
| **precision** | `integer` | optional | Total digits (non-negative integer) |
| **scale** | `integer` | optional | Decimal places (non-negative integer) |
diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx
index cb6011730b..5cba52d166 100644
--- a/content/docs/references/data/object.mdx
+++ b/content/docs/references/data/object.mdx
@@ -227,6 +227,7 @@ const result = ApiMethod.parse(data);
| **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. |
| **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused. |
| **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value shorter than a bound declared later is never re-read and survives unrelated edits — only a write carrying a too-short value is refused. |
+| **valueDomain** | `Enum<'iana_time_zone' \| 'iso_4217_currency' \| 'iso_3166_alpha2'>` | optional | Standard value domain the WRITTEN value must be a member of: `iana_time_zone` (an IANA/tzdb zone identifier such as `UTC`, `Asia/Kolkata`, `Europe/Kyiv` — membership is the `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf` enumeration, which omits `UTC`), `iso_4217_currency` (an ISO 4217 alphabetic currency code, uppercase, e.g. `CHF`) or `iso_3166_alpha2` (an ISO 3166-1 alpha-2 country code, uppercase, e.g. `CH`). The same closed vocabulary and the same membership predicate as a settings specifier's `valueDomain`. Only authorable on `text` — the one type whose stored value is a single plain string naming the member. Checked on the WRITTEN value only (the `min`/`max`/`maxLength` transition-gate class): a stored value outside a domain declared later is never re-read and survives unrelated edits — only a write carrying a non-member is refused, with the field error code `value_domain`. Reach for it precisely where a pattern cannot help: `^[A-Z]{2}$` admits `ZZ`, and `Mars/Olympus` is a shape-valid zone that does not exist. |
| **rows** | `integer` | optional | Height of the INLINE multiline editor, in text rows (positive integer — the HTML textarea `rows` attribute; fullscreen/dialog editor surfaces size themselves and ignore it). Only authorable on multiline editor types: textarea, markdown, html, richtext. Omit it for the widget default height. |
| **precision** | `integer` | optional | Total digits (non-negative integer) |
| **scale** | `integer` | optional | Decimal places (non-negative integer) |
@@ -558,6 +559,7 @@ const result = ApiMethod.parse(data);
| **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. |
| **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused. |
| **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value shorter than a bound declared later is never re-read and survives unrelated edits — only a write carrying a too-short value is refused. |
+| **valueDomain** | `Enum<'iana_time_zone' \| 'iso_4217_currency' \| 'iso_3166_alpha2'>` | optional | Standard value domain the WRITTEN value must be a member of: `iana_time_zone` (an IANA/tzdb zone identifier such as `UTC`, `Asia/Kolkata`, `Europe/Kyiv` — membership is the `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf` enumeration, which omits `UTC`), `iso_4217_currency` (an ISO 4217 alphabetic currency code, uppercase, e.g. `CHF`) or `iso_3166_alpha2` (an ISO 3166-1 alpha-2 country code, uppercase, e.g. `CH`). The same closed vocabulary and the same membership predicate as a settings specifier's `valueDomain`. Only authorable on `text` — the one type whose stored value is a single plain string naming the member. Checked on the WRITTEN value only (the `min`/`max`/`maxLength` transition-gate class): a stored value outside a domain declared later is never re-read and survives unrelated edits — only a write carrying a non-member is refused, with the field error code `value_domain`. Reach for it precisely where a pattern cannot help: `^[A-Z]{2}$` admits `ZZ`, and `Mars/Olympus` is a shape-valid zone that does not exist. |
| **rows** | `integer` | optional | Height of the INLINE multiline editor, in text rows (positive integer — the HTML textarea `rows` attribute; fullscreen/dialog editor surfaces size themselves and ignore it). Only authorable on multiline editor types: textarea, markdown, html, richtext. Omit it for the widget default height. |
| **precision** | `integer` | optional | Total digits (non-negative integer) |
| **scale** | `integer` | optional | Decimal places (non-negative integer) |
diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx
index 7368846594..8a02890e89 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 — 1592 schemas across 14 protocol modules
+description: Every schema published by @objectstack/spec — 1593 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/. */}
@@ -29,11 +29,11 @@ counts are sums of the rows they head. Regenerate with
| [Kernel Protocol](/docs/references/kernel) | 30 | 163 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. |
| [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. |
| [Security Protocol](/docs/references/security) | 5 | 29 | Permission sets, row-level security, sharing rules, tenancy posture. |
-| [Shared Protocol](/docs/references/shared) | 7 | 25 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. |
+| [Shared Protocol](/docs/references/shared) | 8 | 26 | 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) | 36 | 291 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
| [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
-| **Total** | **199** | **1592** | 14 protocol modules |
+| **Total** | **200** | **1593** | 14 protocol modules |
---
@@ -286,7 +286,7 @@ Permission sets, row-level security, sharing rules, tenancy posture.
## Shared Protocol
-**Source:** `packages/spec/src/shared/` · **Import:** `@objectstack/spec/shared` · **7 pages, 25 schemas**
+**Source:** `packages/spec/src/shared/` · **Import:** `@objectstack/spec/shared` · **8 pages, 26 schemas**
Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums.
@@ -299,6 +299,7 @@ Primitives used across every protocol — identifiers, HTTP, expressions, error
| [`mapping.zod.ts`](/docs/references/shared/mapping) | `FieldMapping` |
| [`metadata-types.zod.ts`](/docs/references/shared/metadata-types) | `BaseMetadataRecord`, `MetadataFormat` |
| [`protection.zod.ts`](/docs/references/shared/protection) | `Protection` |
+| [`value-domain.zod.ts`](/docs/references/shared/value-domain) | `ValueDomain` |
---
diff --git a/content/docs/references/shared/index.mdx b/content/docs/references/shared/index.mdx
index ad6e4c54b6..02268ead5a 100644
--- a/content/docs/references/shared/index.mdx
+++ b/content/docs/references/shared/index.mdx
@@ -15,4 +15,5 @@ This section contains all protocol schemas for the shared layer of ObjectStack.
+
diff --git a/content/docs/references/shared/meta.json b/content/docs/references/shared/meta.json
index 32f137891d..c06c4191b8 100644
--- a/content/docs/references/shared/meta.json
+++ b/content/docs/references/shared/meta.json
@@ -7,6 +7,7 @@
"identifiers",
"mapping",
"metadata-types",
- "protection"
+ "protection",
+ "value-domain"
]
}
\ No newline at end of file
diff --git a/content/docs/references/shared/value-domain.mdx b/content/docs/references/shared/value-domain.mdx
new file mode 100644
index 0000000000..7ba96e5841
--- /dev/null
+++ b/content/docs/references/shared/value-domain.mdx
@@ -0,0 +1,117 @@
+---
+title: Value Domain
+description: Value Domain protocol schemas
+---
+
+{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
+
+Standard value domains: one closed vocabulary and one membership predicate for settings and fields.
+
+`Specifier.valueDomain` (settings) and `Field.valueDomain` (objects) both
+reference the schema below and both are judged by the predicate below.
+
+Maintainer ruling 2026-09-02 (A on the field-level card, verbatim 「同意」):
+`FieldSchema` gains a `valueDomain` slot whose vocabulary is exactly the
+settings specifier's three members — one closed vocabulary and one
+membership predicate shared by settings specifiers and object fields; the
+vocabulary does not widen. Before that ruling the vocabulary lived in
+`system/settings-manifest.zod.ts` and the predicate lived in
+`service-settings` only, so a second declaring surface would have meant a
+second copy of both. This module is the one home; `SpecifierValueDomainSchema`
+is an alias of `ValueDomainSchema` (same name, same three members, same
+shape — nothing consuming it moved).
+
+## Why the PREDICATE lives in `packages/spec` at all
+
+Prime Directive #2 keeps business logic out of the spec, and the earlier
+TSDoc of this vocabulary read that as "the list does not live here". The
+ruling above settles it the other way for this one predicate, on the same
+footing as the package's existing shared verdicts: `currencyPrecisionContradiction`
+(a checked-in CLDR table and the rule read over it), `filterVerdict`, the
+comparand-shape door. Each is a pure, dependency-free function two or more
+doors must answer IDENTICALLY — and "the same answer on both doors" is
+exactly what a shared contract is for. The predicate takes no I/O, holds no
+state, and reads no runtime service; the write path (engine) and the
+settings door call it, they do not re-derive it.
+
+## The definitions of membership, per domain
+
+"The IANA time zone database" and "what this Node happens to enumerate" are
+measurably different sets, and picking the wrong one rejects legal values.
+Every definition below was measured on the repo's Node 22 baseline
+(v22.22.2, full-icu); the shared test re-measures each trap so drift goes
+red instead of rotting.
+
+- `iana_time_zone` — an IANA/tzdb zone identifier (`UTC`, `Asia/Kolkata`,
+ `Europe/Kyiv`). **Membership is the `Intl.DateTimeFormat` probe**
+ (construct with `{ timeZone: value }`, catch the `RangeError`) — the
+ definition `isValidTimeZone` in
+ `packages/core/src/security/resolve-authz-context.ts` and
+ `localization.manifest.test.ts` already use.
+ NOT `Intl.supportedValuesOf('timeZone')`: measured, 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` — ICU keeps the
+ old spellings (`Asia/Calcutta`, `Europe/Kiev`) as its canonical names, so
+ testing membership against that list rejects values every runtime accepts.
+ 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. `Europe/Munich` and `Mars/Olympus` are
+ shape-valid zones that do not exist, and the probe refuses both — the
+ thing a `pattern` cannot do.
+- `iso_4217_currency` — an ISO 4217 alphabetic currency code (`USD`, `CHF`),
+ exact uppercase as the standard spells it. Membership is the key set of the
+ checked-in CLDR snapshot `CURRENCY_FRACTION_DIGITS`
+ (`data/currency-fraction-digits.ts`, 162 codes): that table was generated
+ FROM `Intl.supportedValuesOf('currency')` on the baseline, and the shared
+ test pins the two equal, so this is the same set the settings door has
+ enforced since it shipped — read from a snapshot rather than probed at
+ run time for the reason that table's own header gives (the verdict cannot
+ vary with the host's ICU build, and the check takes no `Intl` dependency).
+ Known, deliberate gaps of that definition: the recently assigned `VED` and
+ the metal/fund codes (`XAU`, `XAG`, …). Widen by regenerating the snapshot,
+ never by falling back to a regex.
+- `iso_3166_alpha2` — an ISO 3166-1 alpha-2 country code (`US`, `GB`, `CN`),
+ exact uppercase. 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.
+ Membership is the explicit list of the 249 officially assigned codes
+ (`ISO_3166_ALPHA2_CODES`); user-assigned and reserved elements
+ (`ZZ`, `XX`, `UK`, `AA`, `QM`–`QZ`, …) are deliberately absent. One strict
+ spelling is the shape AI-authored metadata cannot get subtly wrong.
+
+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.
+`bcp47_locale` was proposed and dropped (no membership registry to enforce
+against — `Intl.getCanonicalLocales('xx-YY')` succeeds, so a "domain" would
+only re-check syntax, the weakness `pattern` already has).
+
+
+**Source:** `packages/spec/src/shared/value-domain.zod.ts`
+
+
+## TypeScript Usage
+
+```typescript
+import { ValueDomainSchema } from '@objectstack/spec/shared';
+import type { ValueDomain } from '@objectstack/spec/shared';
+
+// Validate data
+const result = ValueDomainSchema.parse(data);
+```
+
+---
+
+## ValueDomain
+
+### Allowed Values
+
+* `iana_time_zone`
+* `iso_4217_currency`
+* `iso_3166_alpha2`
+
+
+---
+
diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx
index 220505090b..a3ecaff540 100644
--- a/content/docs/references/system/migration.mdx
+++ b/content/docs/references/system/migration.mdx
@@ -65,6 +65,7 @@ Add a new field to an existing object
| **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. |
| **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused. |
| **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value shorter than a bound declared later is never re-read and survives unrelated edits — only a write carrying a too-short value is refused. |
+| **valueDomain** | `Enum<'iana_time_zone' \| 'iso_4217_currency' \| 'iso_3166_alpha2'>` | optional | Standard value domain the WRITTEN value must be a member of: `iana_time_zone` (an IANA/tzdb zone identifier such as `UTC`, `Asia/Kolkata`, `Europe/Kyiv` — membership is the `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf` enumeration, which omits `UTC`), `iso_4217_currency` (an ISO 4217 alphabetic currency code, uppercase, e.g. `CHF`) or `iso_3166_alpha2` (an ISO 3166-1 alpha-2 country code, uppercase, e.g. `CH`). The same closed vocabulary and the same membership predicate as a settings specifier's `valueDomain`. Only authorable on `text` — the one type whose stored value is a single plain string naming the member. Checked on the WRITTEN value only (the `min`/`max`/`maxLength` transition-gate class): a stored value outside a domain declared later is never re-read and survives unrelated edits — only a write carrying a non-member is refused, with the field error code `value_domain`. Reach for it precisely where a pattern cannot help: `^[A-Z]{2}$` admits `ZZ`, and `Mars/Olympus` is a shape-valid zone that does not exist. |
| **rows** | `integer` | optional | Height of the INLINE multiline editor, in text rows (positive integer — the HTML textarea `rows` attribute; fullscreen/dialog editor surfaces size themselves and ignore it). Only authorable on multiline editor types: textarea, markdown, html, richtext. Omit it for the widget default height. |
| **precision** | `integer` | optional | Total digits (non-negative integer) |
| **scale** | `integer` | optional | Decimal places (non-negative integer) |
@@ -483,6 +484,7 @@ Add a new field to an existing object
| **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. |
| **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused. |
| **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value shorter than a bound declared later is never re-read and survives unrelated edits — only a write carrying a too-short value is refused. |
+| **valueDomain** | `Enum<'iana_time_zone' \| 'iso_4217_currency' \| 'iso_3166_alpha2'>` | optional | Standard value domain the WRITTEN value must be a member of: `iana_time_zone` (an IANA/tzdb zone identifier such as `UTC`, `Asia/Kolkata`, `Europe/Kyiv` — membership is the `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf` enumeration, which omits `UTC`), `iso_4217_currency` (an ISO 4217 alphabetic currency code, uppercase, e.g. `CHF`) or `iso_3166_alpha2` (an ISO 3166-1 alpha-2 country code, uppercase, e.g. `CH`). The same closed vocabulary and the same membership predicate as a settings specifier's `valueDomain`. Only authorable on `text` — the one type whose stored value is a single plain string naming the member. Checked on the WRITTEN value only (the `min`/`max`/`maxLength` transition-gate class): a stored value outside a domain declared later is never re-read and survives unrelated edits — only a write carrying a non-member is refused, with the field error code `value_domain`. Reach for it precisely where a pattern cannot help: `^[A-Z]{2}$` admits `ZZ`, and `Mars/Olympus` is a shape-valid zone that does not exist. |
| **rows** | `integer` | optional | Height of the INLINE multiline editor, in text rows (positive integer — the HTML textarea `rows` attribute; fullscreen/dialog editor surfaces size themselves and ignore it). Only authorable on multiline editor types: textarea, markdown, html, richtext. Omit it for the widget default height. |
| **precision** | `integer` | optional | Total digits (non-negative integer) |
| **scale** | `integer` | optional | Decimal places (non-negative integer) |
diff --git a/packages/drivers/driver-sql/src/builtin-column-collision.ts b/packages/drivers/driver-sql/src/builtin-column-collision.ts
index 02995a58aa..83822b1f16 100644
--- a/packages/drivers/driver-sql/src/builtin-column-collision.ts
+++ b/packages/drivers/driver-sql/src/builtin-column-collision.ts
@@ -97,6 +97,7 @@ export const FIELD_KEY_STORAGE_CLASS: Readonly> =
format: 'presentation', // display/validation hint
required: 'presentation', // ADR-0113: the WRITE contract, enforced by the engine, not the column
minLength: 'presentation', // write-time validation
+ valueDomain: 'presentation', // write-time membership validation of the WRITTEN string against a standard (#14168, the settings specifier's closed vocabulary shared with fields) — never read by `createColumn`: the column stays the string `maxLength` sizes, so the DDL has nothing to discard
min: 'presentation', // write-time validation
max: 'presentation', // write-time validation
step: 'presentation', // input granularity
diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json
index 37a12f0bde..225cf220d0 100644
--- a/packages/spec/api-surface/data.json
+++ b/packages/spec/api-surface/data.json
@@ -636,6 +636,7 @@
"UnknownAuthoringKeyFinding (interface)",
"UnorderedPaginationConformanceCase (interface)",
"VALID_AST_OPERATORS (const)",
+ "VALUE_DOMAIN_FIELD_TYPES (const)",
"VALUE_ROUNDTRIP_CASES (const)",
"VALUE_ROUNDTRIP_COLLISION_PAIRS (const)",
"VALUE_ROUNDTRIP_FIELDS (const)",
diff --git a/packages/spec/api-surface/shared.json b/packages/spec/api-surface/shared.json
index b1272f7333..d3d56924f4 100644
--- a/packages/spec/api-surface/shared.json
+++ b/packages/spec/api-surface/shared.json
@@ -32,6 +32,7 @@
"HttpRequest (type)",
"HttpRequestParsed (type)",
"HttpRequestSchema (const)",
+ "ISO_3166_ALPHA2_CODES (const)",
"IsolationLevel (type)",
"IsolationLevelEnum (const)",
"KeySetGuidance (interface)",
@@ -80,6 +81,8 @@
"TemplateExpressionInputSchema (const)",
"VISIBILITY_ALIAS_KEYS (const)",
"VISIBILITY_STRICT_OPTIONS (const)",
+ "ValueDomain (type)",
+ "ValueDomainSchema (const)",
"applyProtection (function)",
"canonicalMetaUrlType (function)",
"cel (function)",
@@ -89,6 +92,7 @@
"formatSuggestion (function)",
"formatZodError (function)",
"formatZodIssue (function)",
+ "isValueDomainMember (function)",
"keySetMatches (function)",
"lazySchema (function)",
"levenshteinDistance (function)",
diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json
index 288be6b94a..7718349fee 100644
--- a/packages/spec/authorable-surface/data.json
+++ b/packages/spec/authorable-surface/data.json
@@ -408,6 +408,7 @@
"data/Field:type",
"data/Field:unique",
"data/Field:useGrouping",
+ "data/Field:valueDomain",
"data/Field:visibleWhen",
"data/Field:widget",
"data/FieldMaskingKeep:keepHead",
diff --git a/packages/spec/declaration-map/shared.json b/packages/spec/declaration-map/shared.json
index 1f58d35aa8..6b2c3b7f71 100644
--- a/packages/spec/declaration-map/shared.json
+++ b/packages/spec/declaration-map/shared.json
@@ -46,7 +46,9 @@
"SystemIdentifier": "shared/SystemIdentifier",
"SystemIdentifierSchema": "shared/SystemIdentifier",
"TemplateExpressionInput": "shared/TemplateExpressionInput",
- "TemplateExpressionInputSchema": "shared/TemplateExpressionInput"
+ "TemplateExpressionInputSchema": "shared/TemplateExpressionInput",
+ "ValueDomain": "shared/ValueDomain",
+ "ValueDomainSchema": "shared/ValueDomain"
},
"collisions": []
}
diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json
index ccf45dd1a2..9f0e192f56 100644
--- a/packages/spec/export-origins/data.json
+++ b/packages/spec/export-origins/data.json
@@ -636,6 +636,7 @@
"UnknownAuthoringKeyFinding": "src/data/authoring-key-lint.ts#UnknownAuthoringKeyFinding (interface)",
"UnorderedPaginationConformanceCase": "src/data/pagination-conformance.ts#UnorderedPaginationConformanceCase (interface)",
"VALID_AST_OPERATORS": "src/data/filter.zod.ts#VALID_AST_OPERATORS (const)",
+ "VALUE_DOMAIN_FIELD_TYPES": "src/data/field.zod.ts#VALUE_DOMAIN_FIELD_TYPES (const)",
"VALUE_ROUNDTRIP_CASES": "src/data/value-roundtrip-conformance.ts#VALUE_ROUNDTRIP_CASES (const)",
"VALUE_ROUNDTRIP_COLLISION_PAIRS": "src/data/value-roundtrip-conformance.ts#VALUE_ROUNDTRIP_COLLISION_PAIRS (const)",
"VALUE_ROUNDTRIP_FIELDS": "src/data/value-roundtrip-conformance.ts#VALUE_ROUNDTRIP_FIELDS (const)",
diff --git a/packages/spec/export-origins/shared.json b/packages/spec/export-origins/shared.json
index 322504cfb7..1991e1234d 100644
--- a/packages/spec/export-origins/shared.json
+++ b/packages/spec/export-origins/shared.json
@@ -32,6 +32,7 @@
"HttpRequest": "src/shared/http.zod.ts#HttpRequest (type)",
"HttpRequestParsed": "src/shared/http.zod.ts#HttpRequestParsed (type)",
"HttpRequestSchema": "src/shared/http.zod.ts#HttpRequestSchema (const)",
+ "ISO_3166_ALPHA2_CODES": "src/shared/value-domain.zod.ts#ISO_3166_ALPHA2_CODES (const)",
"IsolationLevel": "src/shared/enums.zod.ts#IsolationLevel (type)",
"IsolationLevelEnum": "src/shared/enums.zod.ts#IsolationLevelEnum (const)",
"KeySetGuidance": "src/shared/suggestions.zod.ts#KeySetGuidance (interface)",
@@ -80,6 +81,8 @@
"TemplateExpressionInputSchema": "src/shared/expression.zod.ts#TemplateExpressionInputSchema (const)",
"VISIBILITY_ALIAS_KEYS": "src/shared/visibility.ts#VISIBILITY_ALIAS_KEYS (const)",
"VISIBILITY_STRICT_OPTIONS": "src/shared/visibility.ts#VISIBILITY_STRICT_OPTIONS (const)",
+ "ValueDomain": "src/shared/value-domain.zod.ts#ValueDomain (type)",
+ "ValueDomainSchema": "src/shared/value-domain.zod.ts#ValueDomainSchema (const)",
"applyProtection": "src/shared/protection.zod.ts#applyProtection (function)",
"canonicalMetaUrlType": "src/meta-spelling/metadata-url-spelling.ts#canonicalMetaUrlType (function)",
"cel": "src/shared/expression.zod.ts#cel (function)",
@@ -89,6 +92,7 @@
"formatSuggestion": "src/shared/suggestions.zod.ts#formatSuggestion (function)",
"formatZodError": "src/shared/error-map.zod.ts#formatZodError (function)",
"formatZodIssue": "src/shared/error-map.zod.ts#formatZodIssue (function)",
+ "isValueDomainMember": "src/shared/value-domain.zod.ts#isValueDomainMember (function)",
"keySetMatches": "src/shared/suggestions.zod.ts#keySetMatches (function)",
"lazySchema": "src/shared/lazy-schema.ts#lazySchema (function)",
"levenshteinDistance": "src/shared/suggestions.zod.ts#levenshteinDistance (function)",
diff --git a/packages/spec/json-schema.manifest/shared.json b/packages/spec/json-schema.manifest/shared.json
index 15eb80fe54..a5350b4de9 100644
--- a/packages/spec/json-schema.manifest/shared.json
+++ b/packages/spec/json-schema.manifest/shared.json
@@ -26,6 +26,7 @@
"shared/SortItem",
"shared/StaticMount",
"shared/SystemIdentifier",
- "shared/TemplateExpressionInput"
+ "shared/TemplateExpressionInput",
+ "shared/ValueDomain"
]
}
diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json
index 0fdd5dbf3c..9d48a657ac 100644
--- a/packages/spec/liveness/field.json
+++ b/packages/spec/liveness/field.json
@@ -221,6 +221,12 @@
"evidence": "packages/objectql/src/validation/record-validator.ts#validateOne (`if (def.minLength !== undefined && s.length < def.minLength) return fail('min_length', { minLength: def.minLength, actual: s.length })`)",
"note": "CAVEAT — server camel; client form reads min_length. 2026-08-28: RE-ANCHORED (#13003) and REPOINTED — `:130` had rotted onto `export class ValidationError extends Error`, the error class rather than any check; its sibling `maxLength` cited `:127` three lines above, so the pair had drifted together. Re-closed by hand against 8cb96ec41."
},
+ "valueDomain": {
+ "status": "planned",
+ "verifiedAt": "2026-09-04",
+ "evidence": "packages/spec/src/data/field.zod.ts#VALUE_DOMAIN_FIELD_TYPES (the parse-time applicability door: the key is accepted on `text` only and refused with a located `custom` issue at [valueDomain] on every other type — the same superRefine mechanism `maxLength` / `minLength` use); packages/spec/src/shared/value-domain.zod.ts#isValueDomainMember (the ONE membership predicate the write path will call — shared with the settings door)",
+ "note": "PLANNED, deliberately not `dead`, and the difference is the point. Declared spec-first under the maintainer's 2026-09-02 ruling (option A on the field-level `valueDomain` card: one closed vocabulary — `iana_time_zone` / `iso_4217_currency` / `iso_3166_alpha2` — and one membership predicate shared by settings specifiers and object fields; the vocabulary does not widen). What ships here: the slot, its applicability refusal (a domain on a `number` field is refused at parse, not ignored), the shared predicate, the ADR-0114 catalog member `value_domain` and its four-locale message templates. What does NOT ship here and is the reason for `planned`: the record validator's call into the predicate — `record-validator.ts` does not yet read `def.valueDomain`, so a non-member WRITTEN to a `text` field declaring a domain is accepted today. That write-path refusal is the engine follow-up card the PM files at ACCEPT of the spec half (domain:engine, `Blocked-by:` the spec card); when it lands this row flips `live` with the record-validator seam cited beside `maxLength`'s. The settings door (`service-settings/value-domains.ts`) re-points onto the shared predicate in its own follow-up card and is unchanged until then."
+ },
"rows": {
"status": "live",
"verifiedAt": "2026-08-31",
diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md
index c7a41c8b13..1c05900b29 100644
--- a/packages/spec/liveness/state-counts.md
+++ b/packages/spec/liveness/state-counts.md
@@ -28,7 +28,7 @@ for both corollaries.
| Type | live | exp | elsewhere | dead | planned | classified |
|---|---|---|---|---|---|---|
| `object` | 51 | 0 | 0 | 0 | 1 | 52 |
-| `field` | 89 | 0 | 0 | 1 | 2 | 92 |
+| `field` | 89 | 0 | 0 | 1 | 3 | 93 |
| `flow` | 34 | 0 | 0 | 6 | 0 | 40 |
| `action` | 41 | 0 | 0 | 3 | 4 | 48 |
| `hook` | 19 | 0 | 0 | 2 | 0 | 21 |
@@ -62,4 +62,4 @@ for both corollaries.
| `metadata_endpoints` | 6 | 0 | 0 | 2 | 0 | 8 |
| `batch_endpoints` | 5 | 0 | 0 | 2 | 0 | 7 |
| `route_generation` | 0 | 0 | 0 | 4 | 0 | 4 |
-| **total** | **844** | **5** | **1** | **84** | **12** | **946** |
+| **total** | **844** | **5** | **1** | **84** | **13** | **947** |
diff --git a/packages/spec/llms.txt b/packages/spec/llms.txt
index b0b5b3aa74..74bba77ccc 100644
--- a/packages/spec/llms.txt
+++ b/packages/spec/llms.txt
@@ -77,7 +77,7 @@ const query = {
---
-## 3. Schema Inventory by Domain (206 schemas)
+## 3. Schema Inventory by Domain (207 schemas)
Counted as `*.zod.ts` modules under `packages/spec/src//` — the sources
that ship in this tarball (`files` includes `src/**/*.zod.ts`), so every number
@@ -91,7 +91,7 @@ here is verifiable from the installed package.
| api | 30 | Endpoint, REST Server, Discovery, OData, Batch, WebSocket, Response Envelope, Package Lifecycle |
| ui | 18 | View, App, Action, Dashboard, Page, Chart, Component, Animation |
| automation | 13 | Flow, Approval, BPMN Interop, Control Flow, State Machine, Webhook |
-| shared | 12 | Enums, HTTP, Identifiers, Mapping, Metadata Types, Connector Auth, Retry Policy |
+| shared | 13 | Enums, HTTP, Identifiers, Mapping, Metadata Types, Connector Auth, Retry Policy, Value Domain |
| ai | 11 | Agent, Conversation, Knowledge Source/Document, Model Registry, MCP, Skill, Tool |
| cloud | 11 | Marketplace, Developer Portal, App Store, Environment, Package, Tenant |
| identity | 5 | Identity, Organization, Position, SCIM, Eval User |
diff --git a/packages/spec/src/api/errors.test.ts b/packages/spec/src/api/errors.test.ts
index ff07875149..0595c58c8c 100644
--- a/packages/spec/src/api/errors.test.ts
+++ b/packages/spec/src/api/errors.test.ts
@@ -320,7 +320,9 @@ describe('FieldErrorCode', () => {
// The whole argument for D1's casing: the code IS the schema property name.
// If these ever diverge, the field vocabulary has lost its reason to be
// lowercase and the decision should be revisited rather than patched.
- for (const constraint of ['required', 'max_length', 'min_length', 'max_value', 'min_value'] as const) {
+ // `value_domain` joined 2026-09-04 (#14168): the field-level `valueDomain`
+ // slot's write-path code, named for its property like the four before it.
+ for (const constraint of ['required', 'max_length', 'min_length', 'max_value', 'min_value', 'value_domain'] as const) {
expect(members).toContain(constraint);
}
});
diff --git a/packages/spec/src/api/errors.zod.ts b/packages/spec/src/api/errors.zod.ts
index 785e78d944..dd9432190d 100644
--- a/packages/spec/src/api/errors.zod.ts
+++ b/packages/spec/src/api/errors.zod.ts
@@ -259,6 +259,13 @@ export const FieldErrorCode = z.enum([
'max_items',
// closed sets and references
'invalid_option', // not a member of the field's declared options
+ // the WRITTEN value is not a member of the field's declared `valueDomain`
+ // (the closed standard-domain vocabulary shared with settings specifiers —
+ // `shared/value-domain.zod.ts`). Named for the property it mirrors, per D1,
+ // exactly as `max_length` is; a new constraint KIND adds a member here
+ // rather than borrowing `invalid_value` (maintainer ruling 2026-09-02, the
+ // field-level `valueDomain` card's spec half).
+ 'value_domain',
'invalid_value', // rejected for a reason no other member names
'reference_not_found', // a lookup target that does not exist
'reference_ambiguous', // a lookup that matched more than one record
diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts
index 67681ef6d6..ddc0bef4ca 100644
--- a/packages/spec/src/data/field.test.ts
+++ b/packages/spec/src/data/field.test.ts
@@ -7,6 +7,8 @@ import {
CurrencyConfigSchema,
CurrencyValueSchema,
Field,
+ BOUNDED_STRING_FIELD_TYPES,
+ VALUE_DOMAIN_FIELD_TYPES,
type SelectOption,
type CurrencyConfig,
type CurrencyValue,
@@ -549,6 +551,109 @@ describe('FieldSchema', () => {
}
});
});
+
+ /**
+ * #14168 (maintainer ruling 2026-09-02, option A) — a field-level
+ * `valueDomain` drawn from the settings specifier's closed vocabulary
+ * (`shared/value-domain.zod.ts`: `iana_time_zone` / `iso_4217_currency` /
+ * `iso_3166_alpha2`; the vocabulary does not widen). Applicability is the
+ * #11566 template: authorable on VALUE_DOMAIN_FIELD_TYPES (`text` only —
+ * measured NARROWER than the twelve-type `maxLength` family, see the
+ * set's docblock) and refused with a located issue elsewhere. Value: a
+ * stranger domain is refused by name at the slot's own path.
+ */
+ describe('valueDomain — the closed standard-domain vocabulary on a text field (#14168)', () => {
+ it('accepts each of the three ruled domains on a text field, and echoes it', () => {
+ for (const domain of ['iana_time_zone', 'iso_4217_currency', 'iso_3166_alpha2'] as const) {
+ const result = FieldSchema.safeParse({
+ name: 'timezone', label: 'Time zone', type: 'text', valueDomain: domain,
+ });
+ expect(result.success, domain).toBe(true);
+ if (result.success) expect(result.data.valueDomain).toBe(domain);
+ }
+ });
+
+ it('refuses a stranger domain (iso_8601_date) by name, at [valueDomain], with the members listed', () => {
+ const result = FieldSchema.safeParse({
+ name: 'due', label: 'Due', type: 'text', valueDomain: 'iso_8601_date',
+ });
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const issue = result.error.issues.find((i) => i.path[0] === 'valueDomain');
+ expect(issue?.code).toBe('invalid_value');
+ expect(issue?.path).toEqual(['valueDomain']);
+ // First sentence: zod's own enum refusal names the legal members and
+ // the stranger, so an AI author can fix it without leaving the message.
+ expect(issue?.message.split(/[.\n]/)[0]).toMatch(/Invalid option/);
+ for (const member of ['iana_time_zone', 'iso_4217_currency', 'iso_3166_alpha2']) {
+ expect(issue?.message).toContain(member);
+ }
+ }
+ });
+
+ // One representative per family the base-schema placement would wrongly
+ // admit — including the eleven OTHER bounded-string types, which the
+ // `maxLength` family accepts and this key deliberately does not (a
+ // currency code is never an email; a body is not one identifier).
+ const wrongTypes = [
+ 'number', 'boolean', 'date', 'select', 'lookup', 'autonumber', 'formula', 'json',
+ 'textarea', 'email', 'url', 'phone', 'password', 'markdown', 'html', 'richtext',
+ 'code', 'signature', 'qrcode',
+ ] as const;
+ for (const type of wrongTypes) {
+ it(`refuses valueDomain on type: '${type}' with a custom issue at [valueDomain]`, () => {
+ const fixture = type === 'lookup'
+ ? { name: 'f', label: 'F', type, reference: 'company', valueDomain: 'iana_time_zone' }
+ : { name: 'f', label: 'F', type, valueDomain: 'iana_time_zone' };
+ const result = FieldSchema.safeParse(fixture);
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ const issue = result.error.issues.find((i) => i.path[0] === 'valueDomain');
+ expect(issue?.code).toBe('custom');
+ // The refusal names the legal set and the offending type.
+ expect(issue?.message).toMatch(/single plain string/);
+ expect(issue?.message).toContain("'text'");
+ expect(issue?.message).toContain(`\`${type}\``);
+ }
+ });
+ }
+
+ it('composes with the bounded-string family — a text field may declare a domain AND a length', () => {
+ const result = FieldSchema.safeParse({
+ name: 'country', label: 'Country', type: 'text', valueDomain: 'iso_3166_alpha2', maxLength: 2, minLength: 2,
+ });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.valueDomain).toBe('iso_3166_alpha2');
+ expect(result.data.maxLength).toBe(2);
+ }
+ });
+
+ it('absent valueDomain stays absent — no default materializes, on any type (positive control)', () => {
+ for (const type of ['text', 'number', 'textarea', 'lookup'] as const) {
+ const fixture = type === 'lookup'
+ ? { name: 'f', label: 'F', type, reference: 'company' }
+ : { name: 'f', label: 'F', type };
+ const result = FieldSchema.safeParse(fixture);
+ expect(result.success, type).toBe(true);
+ if (result.success) expect('valueDomain' in result.data).toBe(false);
+ }
+ });
+
+ it('the exported applicability set is exactly { text } and is a subset of the bounded-string family', () => {
+ expect([...VALUE_DOMAIN_FIELD_TYPES]).toEqual(['text']);
+ for (const t of VALUE_DOMAIN_FIELD_TYPES) expect(BOUNDED_STRING_FIELD_TYPES.has(t)).toBe(true);
+ });
+
+ it('surfaces in the derived JSON schema as an enum of the three members (Studio / OpenAPI / form-layer path)', () => {
+ const json = z.toJSONSchema(FieldSchema, { io: 'input', unrepresentable: 'any' }) as {
+ properties?: Record;
+ };
+ expect(json.properties?.valueDomain?.enum).toEqual([
+ 'iana_time_zone', 'iso_4217_currency', 'iso_3166_alpha2',
+ ]);
+ });
+ });
});
describe('useGrouping — number-field digit-grouping presentation hint (#7768)', () => {
diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts
index f093a0a4f6..5b6f87db72 100644
--- a/packages/spec/src/data/field.zod.ts
+++ b/packages/spec/src/data/field.zod.ts
@@ -31,6 +31,7 @@ import { AddressSchema } from './field-value.zod';
// ruling 2026-08-12, Option A). One shared verdict for both anchors: the
// field-level `precision` key and `CurrencyConfigSchema.precision`.
import { currencyPrecisionContradiction } from './currency-fraction-digits';
+import { ValueDomainSchema } from '../shared/value-domain.zod';
/**
* Field Type Enum
@@ -138,6 +139,35 @@ export const BOUNDED_STRING_FIELD_TYPES: ReadonlySet = new Set([
'signature', 'qrcode',
] as const satisfies readonly FieldType[]);
+/**
+ * Field types on which `valueDomain` is authorable — the set whose stored
+ * value is ONE plain string that names a member of a published standard
+ * (maintainer ruling 2026-09-02, option A on #14168: a field-level
+ * `valueDomain` drawn from the settings specifier's closed vocabulary; see
+ * `shared/value-domain.zod.ts`).
+ *
+ * Measured against BOUNDED_STRING_FIELD_TYPES (the `maxLength` / `minLength`
+ * family, twelve types) and deliberately NARROWER. A domain member is a short
+ * identifier (`UTC`, `CHF`, `CH`) and the whole stored value is that
+ * identifier, so only the type that stores a single plain string qualifies.
+ * The other eleven store something else: `textarea` / `markdown` / `html` /
+ * `richtext` / `code` store a multi-line body; `email` / `url` / `phone`
+ * already carry their own shape family and a currency code is never an
+ * email; `password` stores a masked credential (ADR-0100); `signature` /
+ * `qrcode` store a data URI. Declaring a domain on any of those would parse
+ * and describe nothing that is stored — the declared-but-inert shape
+ * ADR-0078 keeps out — so `FieldSchema` refuses it there (the superRefine
+ * below, the #11566 template).
+ *
+ * `select` is NOT here, on purpose: a select's membership boundary is its
+ * `options` table, exhaustive on the write path. The settings specifier lets
+ * a domain DEMOTE `options` to a suggestion list; importing that semantics
+ * onto fields is a second ruling, not a widening of this set.
+ */
+export const VALUE_DOMAIN_FIELD_TYPES: ReadonlySet = new Set([
+ 'text',
+] as const satisfies readonly FieldType[]);
+
/**
* Field types whose value is edited in a MULTILINE text editor whose inline
* (non-fullscreen) surface is sized by the HTML `rows` attribute — the set on
@@ -980,6 +1010,16 @@ export const FieldSchema = lazySchema(() => {
// metadata author mass-produces — and is refused loudly at authoring instead
// of parsing cleanly and asserting nothing.
minLength: z.number().int().min(1).optional().describe('Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value shorter than a bound declared later is never re-read and survives unrelated edits — only a write carrying a too-short value is refused.'),
+ // #14168 (maintainer ruling 2026-09-02, option A): a field-level value
+ // domain drawn from the SAME closed vocabulary and judged by the SAME
+ // membership predicate as `Specifier.valueDomain` (shared/value-domain.zod.ts
+ // — `isValueDomainMember`), so a time zone accepted in Settings is the time
+ // zone accepted in a field. The vocabulary does not widen here. Which TYPES
+ // may author the key is the superRefine below (VALUE_DOMAIN_FIELD_TYPES —
+ // `text` only; the docblock on that set records the measurement against the
+ // `maxLength` family). The write-path refusal (`value_domain`, ADR-0114
+ // catalog member) is the engine's half of the same ruling.
+ valueDomain: ValueDomainSchema.optional().describe('Standard value domain the WRITTEN value must be a member of: `iana_time_zone` (an IANA/tzdb zone identifier such as `UTC`, `Asia/Kolkata`, `Europe/Kyiv` — membership is the `Intl.DateTimeFormat` probe, never the `Intl.supportedValuesOf` enumeration, which omits `UTC`), `iso_4217_currency` (an ISO 4217 alphabetic currency code, uppercase, e.g. `CHF`) or `iso_3166_alpha2` (an ISO 3166-1 alpha-2 country code, uppercase, e.g. `CH`). The same closed vocabulary and the same membership predicate as a settings specifier\'s `valueDomain`. Only authorable on `text` — the one type whose stored value is a single plain string naming the member. Checked on the WRITTEN value only (the `min`/`max`/`maxLength` transition-gate class): a stored value outside a domain declared later is never re-read and survives unrelated edits — only a write carrying a non-member is refused, with the field error code `value_domain`. Reach for it precisely where a pattern cannot help: `^[A-Z]{2}$` admits `ZZ`, and `Mars/Olympus` is a shape-valid zone that does not exist.'),
// objectui#6140 (maintainer ruling 2026-08-25, Option A — verbatim:
// 「就全部接受,然后继续下一批」): `rows` was consumed-but-undeclared.
@@ -1887,6 +1927,31 @@ export const FieldSchema = lazySchema(() => {
});
}
+ // [#14168] (maintainer ruling 2026-09-02, option A — the #11566 template
+ // applies): `valueDomain` is only authorable on the types whose stored value
+ // is one plain string naming a member of the standard
+ // (VALUE_DOMAIN_FIELD_TYPES — its docblock carries the measurement against
+ // the `maxLength` family). On any other type the declaration would parse
+ // and constrain nothing that is stored — the declared-but-inert shape
+ // ADR-0078 keeps out — so it is refused at the authoring seam, where the fix
+ // is one keystroke away. `valueDomain` has no schema default, so `undefined`
+ // here always means "not authored" — a field without the key can never fire
+ // this. The message enumerates the set ITSELF rather than a prose copy of
+ // it (#12017 two-copies failure shape).
+ if (field.valueDomain !== undefined && !VALUE_DOMAIN_FIELD_TYPES.has(field.type)) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['valueDomain'],
+ message:
+ `\`valueDomain\` is only valid on field types that store a single plain string — ` +
+ `${[...VALUE_DOMAIN_FIELD_TYPES].map((t) => `'${t}'`).join(', ')} — ` +
+ `and this field is \`${field.type}\`: its stored value is not one identifier ` +
+ 'for a standard-domain membership test to judge, so the declaration would parse and ' +
+ 'constrain nothing (the write-time validator applies `valueDomain` to exactly those ' +
+ 'types). Drop the key, or use a `text` field.',
+ });
+ }
+
// objectui#6140 (maintainer ruling 2026-08-25, Option A): `rows` is only
// authorable on the multiline editor types whose widget actually reads it
// (MULTILINE_EDITOR_FIELD_TYPES — see its docblock for the measured
diff --git a/packages/spec/src/shared/index.ts b/packages/spec/src/shared/index.ts
index 68dfd7517a..24bd628950 100644
--- a/packages/spec/src/shared/index.ts
+++ b/packages/spec/src/shared/index.ts
@@ -28,3 +28,8 @@ export * from './expression.zod';
export * from './visibility';
export * from './protection.zod';
export * from './resilient-fetch';
+// The closed standard-domain vocabulary (`iana_time_zone` / `iso_4217_currency`
+// / `iso_3166_alpha2`) and its ONE membership predicate, shared by settings
+// specifiers and object fields (maintainer ruling 2026-09-02). Declared here so
+// `system/` and `data/` both reference one schema instead of carrying a copy.
+export * from './value-domain.zod';
diff --git a/packages/spec/src/shared/value-domain.test.ts b/packages/spec/src/shared/value-domain.test.ts
new file mode 100644
index 0000000000..7bb400bfba
--- /dev/null
+++ b/packages/spec/src/shared/value-domain.test.ts
@@ -0,0 +1,176 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * `shared/value-domain.zod.ts` — the ONE standard-domain vocabulary and the ONE
+ * membership predicate shared by settings specifiers and object fields
+ * (maintainer ruling 2026-09-02 on #14168, option A).
+ *
+ * Two families of pins. The STRUCTURAL ones hold the vocabulary closed at
+ * exactly its three members and hold `SpecifierValueDomainSchema` to be the
+ * same schema (an alias, not a copy). The DEFINITION ones re-measure every
+ * trap the module header records — the obvious oracle is the wrong one for two
+ * of the three domains, and a doc nobody re-measures rots; these go red when
+ * ICU changes under the definition instead of letting the spelling drift.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { z } from 'zod';
+import {
+ ValueDomainSchema,
+ ISO_3166_ALPHA2_CODES,
+ isValueDomainMember,
+} from './value-domain.zod';
+import { SpecifierValueDomainSchema } from '../system/settings-manifest.zod';
+import { CURRENCY_FRACTION_DIGITS } from '../data/currency-fraction-digits';
+
+describe('ValueDomainSchema — the closed vocabulary', () => {
+ it('has exactly the three ruled members, in declaration order', () => {
+ expect(ValueDomainSchema.options).toEqual([
+ 'iana_time_zone',
+ 'iso_4217_currency',
+ 'iso_3166_alpha2',
+ ]);
+ });
+
+ it('is the SAME schema the settings specifier declares under its historical name', () => {
+ // An alias, not a second declaration: the ruling is one vocabulary, and the
+ // pin is identity, which a copy with equal members would not satisfy.
+ expect(SpecifierValueDomainSchema).toBe(ValueDomainSchema);
+ expect(SpecifierValueDomainSchema.options).toEqual(ValueDomainSchema.options);
+ });
+
+ it('refuses a stranger by name — the vocabulary does not widen', () => {
+ for (const stranger of ['iso_8601_date', 'bcp47_locale', 'iana_timezone', '']) {
+ const result = ValueDomainSchema.safeParse(stranger);
+ expect(result.success, stranger).toBe(false);
+ if (!result.success) expect(result.error.issues[0]?.code).toBe('invalid_value');
+ }
+ });
+
+ it('derives a JSON-schema enum carrying the three members (the Studio / OpenAPI path)', () => {
+ const json = z.toJSONSchema(ValueDomainSchema, { io: 'input', unrepresentable: 'any' });
+ expect(json.enum).toEqual(['iana_time_zone', 'iso_4217_currency', 'iso_3166_alpha2']);
+ });
+});
+
+describe('isValueDomainMember — iana_time_zone is the Intl.DateTimeFormat probe', () => {
+ const member = (v: string) => isValueDomainMember('iana_time_zone', v);
+
+ it('admits the zones the enumeration omits — UTC, Asia/Kolkata, Europe/Kyiv', () => {
+ // The #14168 card's own measurement, re-run: `Intl.supportedValuesOf` is
+ // NOT the definition, because it rejects the platform's own default.
+ for (const tz of ['UTC', 'Asia/Kolkata', 'Europe/Kyiv']) {
+ expect(member(tz), tz).toBe(true);
+ }
+ const enumerated = Intl.supportedValuesOf('timeZone');
+ for (const tz of ['UTC', 'Asia/Kolkata', 'Europe/Kyiv']) {
+ expect(enumerated, `${tz} must stay outside the enumeration or the header's claim is stale`).not.toContain(tz);
+ }
+ // Not merely a subset — a rename: the legacy spellings are its canonical names.
+ expect(enumerated).toContain('Asia/Calcutta');
+ });
+
+ it('admits the other curated and legacy spellings every runtime accepts', () => {
+ for (const tz of ['Asia/Ho_Chi_Minh', 'US/Eastern', 'GMT', 'Asia/Shanghai', 'Europe/Zurich']) {
+ expect(member(tz), tz).toBe(true);
+ }
+ });
+
+ it('refuses Europe/Munich — a shape-valid zone that does not exist', () => {
+ expect(member('Europe/Munich')).toBe(false);
+ expect(member('Mars/Olympus')).toBe(false);
+ expect(member('')).toBe(false);
+ expect(member('not a zone')).toBe(false);
+ });
+
+ it('is case-insensitive, because the probe is — that IS the pinned definition', () => {
+ expect(member('europe/zurich')).toBe(true);
+ });
+});
+
+describe('isValueDomainMember — iso_4217_currency is the checked-in CLDR snapshot', () => {
+ const member = (v: string) => isValueDomainMember('iso_4217_currency', v);
+
+ it('admits the nine curated localization options plus CHF, and refuses XYZ', () => {
+ for (const c of ['USD', 'EUR', 'GBP', 'JPY', 'CNY', 'INR', 'AUD', 'CAD', 'BRL', 'CHF']) {
+ expect(member(c), c).toBe(true);
+ }
+ expect(member('XYZ')).toBe(false);
+ });
+
+ it('is exact uppercase — the settings door has always enforced it so', () => {
+ expect(member('usd')).toBe(false);
+ expect(member('Usd')).toBe(false);
+ expect(member('')).toBe(false);
+ });
+
+ it('carries the definition\'s KNOWN gaps rather than papering over them', () => {
+ // `VED` (recently assigned) and the metal/fund codes are outside CLDR's
+ // `currencyData`; the module header records them as deliberate. Widening
+ // is a snapshot regeneration, never a regex fallback.
+ expect(member('VED')).toBe(false);
+ expect(member('XAU')).toBe(false);
+ });
+
+ it('does not read Object.prototype as a code', () => {
+ for (const notACode of ['toString', 'constructor', 'hasOwnProperty', '__proto__']) {
+ expect(member(notACode), notACode).toBe(false);
+ }
+ });
+
+ it('equals Intl.supportedValuesOf(\'currency\') on the repo\'s Node baseline — the drift detector', () => {
+ // The snapshot was generated FROM this enumeration; if ICU moves the set,
+ // this goes red and the snapshot is regenerated (its header carries the
+ // snippet). Both directions, so a code the host gained or lost is named.
+ const live = new Set(Intl.supportedValuesOf('currency'));
+ const snapshot = new Set(Object.keys(CURRENCY_FRACTION_DIGITS));
+ expect([...snapshot].filter((c) => !live.has(c))).toEqual([]);
+ expect([...live].filter((c) => !snapshot.has(c))).toEqual([]);
+ expect(snapshot.size).toBe(162);
+ });
+});
+
+describe('isValueDomainMember — iso_3166_alpha2 is the explicit 249-code list', () => {
+ const member = (v: string) => isValueDomainMember('iso_3166_alpha2', v);
+
+ it('carries exactly the 249 officially assigned codes, each two uppercase letters, none twice', () => {
+ expect(ISO_3166_ALPHA2_CODES.size).toBe(249);
+ for (const code of ISO_3166_ALPHA2_CODES) {
+ expect(code).toMatch(/^[A-Z]{2}$/);
+ }
+ });
+
+ it('admits real codes and refuses the reserved / user-assigned / alias elements', () => {
+ for (const c of ['US', 'GB', 'CN', 'CH', 'DE', 'JP', 'IN', 'BR']) {
+ expect(member(c), c).toBe(true);
+ }
+ // `ZZ` is the value the domain exists to reject (it passes ^[A-Z]{2}$);
+ // `UK` is a CLDR alias, not an ISO 3166-1 code; `XX`/`AA`/`QM` are
+ // user-assigned or reserved.
+ for (const c of ['ZZ', 'UK', 'XX', 'AA', 'QM', 'QZ', 'EU']) {
+ expect(member(c), c).toBe(false);
+ }
+ });
+
+ it('is exact uppercase', () => {
+ expect(member('us')).toBe(false);
+ expect(member('Us')).toBe(false);
+ expect(member('')).toBe(false);
+ });
+
+ it('Intl.DisplayNames is NOT a membership oracle (why the list is explicit)', () => {
+ const regionNames = new Intl.DisplayNames(['en'], { type: 'region' });
+ // "the display name differs from the input" admits both of these.
+ expect(regionNames.of('ZZ')).not.toBe('ZZ');
+ expect(regionNames.of('UK')).not.toBe('UK');
+ });
+});
+
+describe('isValueDomainMember — every vocabulary member has a definition', () => {
+ it('answers a boolean for each member, never throws, never returns undefined', () => {
+ for (const domain of ValueDomainSchema.options) {
+ expect(typeof isValueDomainMember(domain, 'definitely-not-a-member')).toBe('boolean');
+ expect(isValueDomainMember(domain, 'definitely-not-a-member')).toBe(false);
+ }
+ });
+});
diff --git a/packages/spec/src/shared/value-domain.zod.ts b/packages/spec/src/shared/value-domain.zod.ts
new file mode 100644
index 0000000000..04e7a369fe
--- /dev/null
+++ b/packages/spec/src/shared/value-domain.zod.ts
@@ -0,0 +1,177 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Standard value domains: one closed vocabulary and one membership predicate for settings and fields.
+ *
+ * `Specifier.valueDomain` (settings) and `Field.valueDomain` (objects) both
+ * reference the schema below and both are judged by the predicate below.
+ *
+ * Maintainer ruling 2026-09-02 (A on the field-level card, verbatim 「同意」):
+ * `FieldSchema` gains a `valueDomain` slot whose vocabulary is exactly the
+ * settings specifier's three members — one closed vocabulary and one
+ * membership predicate shared by settings specifiers and object fields; the
+ * vocabulary does not widen. Before that ruling the vocabulary lived in
+ * `system/settings-manifest.zod.ts` and the predicate lived in
+ * `service-settings` only, so a second declaring surface would have meant a
+ * second copy of both. This module is the one home; `SpecifierValueDomainSchema`
+ * is an alias of {@link ValueDomainSchema} (same name, same three members, same
+ * shape — nothing consuming it moved).
+ *
+ * ## Why the PREDICATE lives in `packages/spec` at all
+ *
+ * Prime Directive #2 keeps business logic out of the spec, and the earlier
+ * TSDoc of this vocabulary read that as "the list does not live here". The
+ * ruling above settles it the other way for this one predicate, on the same
+ * footing as the package's existing shared verdicts: `currencyPrecisionContradiction`
+ * (a checked-in CLDR table and the rule read over it), `filterVerdict`, the
+ * comparand-shape door. Each is a pure, dependency-free function two or more
+ * doors must answer IDENTICALLY — and "the same answer on both doors" is
+ * exactly what a shared contract is for. The predicate takes no I/O, holds no
+ * state, and reads no runtime service; the write path (engine) and the
+ * settings door call it, they do not re-derive it.
+ *
+ * ## The definitions of membership, per domain
+ *
+ * "The IANA time zone database" and "what this Node happens to enumerate" are
+ * measurably different sets, and picking the wrong one rejects legal values.
+ * Every definition below was measured on the repo's Node 22 baseline
+ * (v22.22.2, full-icu); the shared test re-measures each trap so drift goes
+ * red instead of rotting.
+ *
+ * - `iana_time_zone` — an IANA/tzdb zone identifier (`UTC`, `Asia/Kolkata`,
+ * `Europe/Kyiv`). **Membership is the `Intl.DateTimeFormat` probe**
+ * (construct with `{ timeZone: value }`, catch the `RangeError`) — the
+ * definition `isValidTimeZone` in
+ * `packages/core/src/security/resolve-authz-context.ts` and
+ * `localization.manifest.test.ts` already use.
+ * NOT `Intl.supportedValuesOf('timeZone')`: measured, 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` — ICU keeps the
+ * old spellings (`Asia/Calcutta`, `Europe/Kiev`) as its canonical names, so
+ * testing membership against that list rejects values every runtime accepts.
+ * 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. `Europe/Munich` and `Mars/Olympus` are
+ * shape-valid zones that do not exist, and the probe refuses both — the
+ * thing a `pattern` cannot do.
+ * - `iso_4217_currency` — an ISO 4217 alphabetic currency code (`USD`, `CHF`),
+ * exact uppercase as the standard spells it. Membership is the key set of the
+ * checked-in CLDR snapshot `CURRENCY_FRACTION_DIGITS`
+ * (`data/currency-fraction-digits.ts`, 162 codes): that table was generated
+ * FROM `Intl.supportedValuesOf('currency')` on the baseline, and the shared
+ * test pins the two equal, so this is the same set the settings door has
+ * enforced since it shipped — read from a snapshot rather than probed at
+ * run time for the reason that table's own header gives (the verdict cannot
+ * vary with the host's ICU build, and the check takes no `Intl` dependency).
+ * Known, deliberate gaps of that definition: the recently assigned `VED` and
+ * the metal/fund codes (`XAU`, `XAG`, …). Widen by regenerating the snapshot,
+ * never by falling back to a regex.
+ * - `iso_3166_alpha2` — an ISO 3166-1 alpha-2 country code (`US`, `GB`, `CN`),
+ * exact uppercase. 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.
+ * Membership is the explicit list of the 249 officially assigned codes
+ * ({@link ISO_3166_ALPHA2_CODES}); user-assigned and reserved elements
+ * (`ZZ`, `XX`, `UK`, `AA`, `QM`–`QZ`, …) are deliberately absent. One strict
+ * spelling is the shape AI-authored metadata cannot get subtly wrong.
+ *
+ * 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.
+ * `bcp47_locale` was proposed and dropped (no membership registry to enforce
+ * against — `Intl.getCanonicalLocales('xx-YY')` succeeds, so a "domain" would
+ * only re-check syntax, the weakness `pattern` already has).
+ */
+
+import { z } from 'zod';
+import { CURRENCY_FRACTION_DIGITS } from '../data/currency-fraction-digits';
+
+/**
+ * The closed standard-domain vocabulary. Declared once here; referenced by
+ * `Specifier.valueDomain` (`system/settings-manifest.zod.ts`, under its
+ * historical export name `SpecifierValueDomainSchema`) and by
+ * `Field.valueDomain` (`data/field.zod.ts`).
+ */
+export const ValueDomainSchema = z.enum([
+ 'iana_time_zone',
+ 'iso_4217_currency',
+ 'iso_3166_alpha2',
+]);
+export type ValueDomain = z.input;
+
+/**
+ * `iana_time_zone` membership — the `Intl.DateTimeFormat` probe (see the
+ * module header for why the enumeration is NOT the definition).
+ */
+function isIanaTimeZone(value: string): boolean {
+ try {
+ new Intl.DateTimeFormat('en-US', { timeZone: value });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * The 249 officially assigned ISO 3166-1 alpha-2 codes — the explicit list the
+ * module header argues no standard-library oracle can replace. Exported for
+ * the structural pins (count, spelling) and for the settings door's own pins;
+ * membership questions go through {@link isValueDomainMember}.
+ */
+export const ISO_3166_ALPHA2_CODES: ReadonlySet = 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(' '),
+);
+
+/**
+ * One membership test per domain — a `Record` over the vocabulary, so a
+ * member added to {@link ValueDomainSchema} without a definition here fails
+ * to compile rather than becoming a declared-but-unenforceable domain.
+ */
+const DOMAIN_MEMBERSHIP: Readonly boolean>> = {
+ iana_time_zone: isIanaTimeZone,
+ iso_4217_currency: (value) => Object.prototype.hasOwnProperty.call(CURRENCY_FRACTION_DIGITS, value),
+ iso_3166_alpha2: (value) => ISO_3166_ALPHA2_CODES.has(value),
+};
+
+/**
+ * Is `value` a member of `domain`? THE shared membership predicate — the one
+ * answer the settings door and the record write path both give, so a value
+ * accepted in Settings is the same value accepted in a field and vice versa.
+ *
+ * Judges a single string exactly as written (no trimming, no case folding of
+ * its own — the time-zone probe's case-insensitivity is the probe's, and the
+ * two code domains are exact uppercase). Element-wise iteration over a
+ * multi-value carrier, and the prose a refusal message needs, are the
+ * caller's: this function answers membership and nothing else.
+ */
+export function isValueDomainMember(domain: ValueDomain, value: string): boolean {
+ return DOMAIN_MEMBERSHIP[domain](value);
+}
diff --git a/packages/spec/src/system/settings-manifest.test.ts b/packages/spec/src/system/settings-manifest.test.ts
index edaeb09d67..1145f514bc 100644
--- a/packages/spec/src/system/settings-manifest.test.ts
+++ b/packages/spec/src/system/settings-manifest.test.ts
@@ -12,6 +12,7 @@ import {
type SettingsManifest,
type Specifier,
} from './settings-manifest.zod';
+import { ValueDomainSchema, isValueDomainMember } from '../shared/value-domain.zod';
describe('SpecifierType', () => {
it('accepts the closed set of specifier kinds', () => {
@@ -392,11 +393,31 @@ describe('`visible` — the settings visibility grammar (#7327)', () => {
});
});
-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.
+describe('valueDomain membership definitions — the measurements the shared predicate implements', () => {
+ // These pin the definitions recorded on the shared vocabulary
+ // (`shared/value-domain.zod.ts`). Since #14168 (maintainer ruling
+ // 2026-09-02) the predicate itself ships in `packages/spec` as
+ // `isValueDomainMember`, ONE answer for the settings door and the record
+ // write path — but the definition still has to be re-measured, because the
+ // obvious oracle is the wrong one for two of the three domains. A doc nobody
+ // re-measures rots; these go red when it does. The predicate's own pins are
+ // in `shared/value-domain.test.ts`; this block keeps the settings-side
+ // reading and adds the identity: the specifier's schema IS the shared one.
+
+ it('SpecifierValueDomainSchema is the shared ValueDomainSchema — an alias, not a copy', () => {
+ expect(SpecifierValueDomainSchema).toBe(ValueDomainSchema);
+ });
+
+ it('the shared predicate agrees with each measured definition below', () => {
+ for (const tz of ['UTC', 'Asia/Kolkata', 'Europe/Kyiv']) {
+ expect(isValueDomainMember('iana_time_zone', tz), tz).toBe(true);
+ }
+ expect(isValueDomainMember('iana_time_zone', 'Europe/Munich')).toBe(false);
+ expect(isValueDomainMember('iso_4217_currency', 'CHF')).toBe(true);
+ expect(isValueDomainMember('iso_4217_currency', 'XYZ')).toBe(false);
+ expect(isValueDomainMember('iso_3166_alpha2', 'CH')).toBe(true);
+ expect(isValueDomainMember('iso_3166_alpha2', 'ZZ')).toBe(false);
+ });
const probeTimeZone = (tz: string): boolean => {
try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; }
@@ -441,7 +462,8 @@ describe('valueDomain membership definitions — the measurements service-settin
// 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.
+ // membership test needs an explicit code list — since #14168 that list
+ // is `ISO_3166_ALPHA2_CODES` in `shared/value-domain.zod.ts`.
const regionNames = new Intl.DisplayNames(['en'], { type: 'region' });
expect(regionNames.of('ZZ')).not.toBe('ZZ');
expect(regionNames.of('UK')).not.toBe('UK');
diff --git a/packages/spec/src/system/settings-manifest.zod.ts b/packages/spec/src/system/settings-manifest.zod.ts
index c6815a188b..87cc1eed3c 100644
--- a/packages/spec/src/system/settings-manifest.zod.ts
+++ b/packages/spec/src/system/settings-manifest.zod.ts
@@ -5,6 +5,7 @@ import { lazySchema } from '../shared/lazy-schema';
import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
import { ExpressionInputSchema } from '../shared/expression.zod';
import { I18nLabelSchema } from '../ui/i18n.zod';
+import { ValueDomainSchema } from '../shared/value-domain.zod';
/**
* Settings Manifest Protocol
@@ -135,54 +136,24 @@ 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).
+ * The closed vocabulary of **standard value domains** a specifier's value may
+ * be drawn from — the settings-specifier NAME of the ONE vocabulary declared
+ * in `shared/value-domain.zod.ts` ({@link ValueDomainSchema}), which
+ * `Field.valueDomain` references too (maintainer ruling 2026-09-02: one closed
+ * vocabulary and one membership predicate shared by settings specifiers and
+ * object fields). Same three members, same shape; this is an alias, not a
+ * second declaration, so nothing that imports it under this name moved.
*
* 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).
+ * authoring semantics. Each member's **definition of membership** (and the
+ * measured traps — `Intl.supportedValuesOf('timeZone')` is NOT the time-zone
+ * definition; `Intl.DisplayNames` is NOT a country-code oracle) is documented
+ * on the shared schema, and the shared predicate `isValueDomainMember` is what
+ * the settings door enforces with on the write path (`service-settings`).
*/
-export const SpecifierValueDomainSchema = z.enum([
- 'iana_time_zone',
- 'iso_4217_currency',
- 'iso_3166_alpha2',
-]);
+export const SpecifierValueDomainSchema = ValueDomainSchema;
export type SpecifierValueDomain = z.input;
// ---------------------------------------------------------------------------
diff --git a/packages/spec/src/system/validation-message.test.ts b/packages/spec/src/system/validation-message.test.ts
index f140ee4d5b..d60979a045 100644
--- a/packages/spec/src/system/validation-message.test.ts
+++ b/packages/spec/src/system/validation-message.test.ts
@@ -61,6 +61,13 @@ describe('validation message catalog — completeness', () => {
invalid_option: ['{{allowed}}'],
invalid_option_value: ['{{value}}', '{{allowed}}'],
invalid_transition: ['{{from}}', '{{to}}'],
+ // `value_domain` (#14168): the code-named default names the domain by
+ // its machine word and echoes the offending value; each finer variant
+ // spells the standard out in prose and must still echo the value.
+ value_domain: ['{{valueDomain}}', '{{value}}'],
+ value_domain_iana_time_zone: ['{{value}}'],
+ value_domain_iso_4217_currency: ['{{value}}'],
+ value_domain_iso_3166_alpha2: ['{{value}}'],
};
for (const [locale, catalog] of Object.entries(BUILTIN_VALIDATION_MESSAGES)) {
for (const [key, placeholders] of Object.entries(required)) {
diff --git a/packages/spec/src/system/validation-message.ts b/packages/spec/src/system/validation-message.ts
index 091cbc68b8..e448901d7e 100644
--- a/packages/spec/src/system/validation-message.ts
+++ b/packages/spec/src/system/validation-message.ts
@@ -101,6 +101,14 @@ export const BUILTIN_VALIDATION_MESSAGES: Record>
invalid_option: '{{label}} must be one of: {{allowed}}',
reference_not_found: '{{label}}: no {{target}} record has id "{{value}}"',
invalid_option_value: '{{label}}: "{{value}}" is not one of: {{allowed}}',
+ // `value_domain` (ADR-0114 member; the field-level `valueDomain` card's
+ // spec half) — the code-named default names the domain by its machine
+ // word; the three finer variants (one per vocabulary member, rendering
+ // detail that never reaches the wire) spell the standard out for a human.
+ value_domain: '{{label}} must be a member of the {{valueDomain}} value domain (got "{{value}}")',
+ value_domain_iana_time_zone: '{{label}} must be a valid IANA time zone identifier, e.g. Europe/Zurich (got "{{value}}")',
+ value_domain_iso_4217_currency: '{{label}} must be a valid ISO 4217 currency code, e.g. CHF (got "{{value}}")',
+ value_domain_iso_3166_alpha2: '{{label}} must be a valid ISO 3166-1 alpha-2 country code, e.g. CH (got "{{value}}")',
option_unavailable: "{{label}}: option '{{value}}' is not available",
invalid_type_array: '{{label}} must be an array of values',
invalid_value_shape: '{{label}} has an invalid {{type}} value: {{detail}}',
@@ -137,6 +145,10 @@ export const BUILTIN_VALIDATION_MESSAGES: Record>
invalid_option: '{{label}}必须是以下值之一:{{allowed}}',
reference_not_found: '{{label}}:不存在 id 为“{{value}}”的{{target}}记录',
invalid_option_value: '{{label}}:“{{value}}”不在允许的取值范围内:{{allowed}}',
+ value_domain: '{{label}}必须是 {{valueDomain}} 值域的成员(当前 “{{value}}”)',
+ value_domain_iana_time_zone: '{{label}}必须是有效的 IANA 时区标识符,例如 Europe/Zurich(当前 “{{value}}”)',
+ value_domain_iso_4217_currency: '{{label}}必须是有效的 ISO 4217 货币代码,例如 CHF(当前 “{{value}}”)',
+ value_domain_iso_3166_alpha2: '{{label}}必须是有效的 ISO 3166-1 alpha-2 国家代码,例如 CH(当前 “{{value}}”)',
option_unavailable: '{{label}}:选项“{{value}}”当前不可用',
invalid_type_array: '{{label}}必须是数组',
invalid_value_shape: '{{label}}的 {{type}} 值格式无效:{{detail}}',
@@ -170,6 +182,10 @@ export const BUILTIN_VALIDATION_MESSAGES: Record>
invalid_option: '{{label}}は次のいずれかを指定してください:{{allowed}}',
reference_not_found: '{{label}}:id が「{{value}}」の{{target}}レコードは存在しません',
invalid_option_value: '{{label}}:「{{value}}」は指定できません(指定可能:{{allowed}})',
+ value_domain: '{{label}}は {{valueDomain}} 値ドメインのメンバーでなければなりません(現在「{{value}}」)',
+ value_domain_iana_time_zone: '{{label}}は有効な IANA タイムゾーン識別子でなければなりません(例: Europe/Zurich、現在「{{value}}」)',
+ value_domain_iso_4217_currency: '{{label}}は有効な ISO 4217 通貨コードでなければなりません(例: CHF、現在「{{value}}」)',
+ value_domain_iso_3166_alpha2: '{{label}}は有効な ISO 3166-1 alpha-2 国コードでなければなりません(例: CH、現在「{{value}}」)',
option_unavailable: '{{label}}:選択肢「{{value}}」は現在利用できません',
invalid_type_array: '{{label}}は配列で指定してください',
invalid_value_shape: '{{label}}の {{type}} 値が不正です:{{detail}}',
@@ -203,6 +219,10 @@ export const BUILTIN_VALIDATION_MESSAGES: Record>
invalid_option: '{{label}} debe ser uno de: {{allowed}}',
reference_not_found: '{{label}}: ningún registro de {{target}} tiene el id «{{value}}»',
invalid_option_value: '{{label}}: «{{value}}» no es uno de: {{allowed}}',
+ value_domain: '{{label}} debe pertenecer al dominio de valores {{valueDomain}} (actual: «{{value}}»)',
+ value_domain_iana_time_zone: '{{label}} debe ser un identificador de zona horaria IANA válido, p. ej. Europe/Zurich (actual: «{{value}}»)',
+ value_domain_iso_4217_currency: '{{label}} debe ser un código de moneda ISO 4217 válido, p. ej. CHF (actual: «{{value}}»)',
+ value_domain_iso_3166_alpha2: '{{label}} debe ser un código de país ISO 3166-1 alfa-2 válido, p. ej. CH (actual: «{{value}}»)',
option_unavailable: '{{label}}: la opción «{{value}}» no está disponible',
invalid_type_array: '{{label}} debe ser una lista de valores',
invalid_value_shape: '{{label}} tiene un valor {{type}} no válido: {{detail}}',
diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts
index a49264887e..a485934846 100644
--- a/packages/spec/src/type-alias-convention.pin.test.ts
+++ b/packages/spec/src/type-alias-convention.pin.test.ts
@@ -267,9 +267,10 @@ import type * as M167 from './ui/view.zod.js';
import type * as M170 from './ui/component.zod.js';
// [#10235] The served sortability projection — new module, next free index.
import type * as M183 from './api/sortability.zod.js';
+import type * as M184 from './shared/value-domain.zod.js';
// ---------------------------------------------------------------------------
-// 829 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared.
+// 830 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared.
//
// That number is machine-checked, not hand-kept. The runtime companion at the
// bottom of this file recomputes the pin count from the source and asserts that
@@ -1047,6 +1048,11 @@ export type Iso501 = Assert,
// shared/protection.zod.ts
export type Iso502 = Assert, z.infer< typeof M115.ProtectionSchema > >>;
+// shared/value-domain.zod.ts — the ONE standard-domain vocabulary (#14168);
+// `SpecifierValueDomainSchema` (Iso758) is an alias of it, so both pins hold
+// or fall together. A `z.enum` has no default or transform, the (RISE) case.
+export type Iso867 = Assert, z.infer< typeof M184.ValueDomainSchema > >>;
+
// stack.zod.ts
export type Iso503 = Assert, z.infer< typeof M116.DatasourceMappingRuleSchema > >>;
export type Iso504 = Assert, z.infer< typeof M116.ConflictStrategySchema > >>;
@@ -1680,7 +1686,7 @@ describe('ADR-0122 type-alias convention', () => {
// this title and the section header above the pin list — are now asserted
// against the recomputed count below, so neither can go stale without a red
// test naming it.
- it('still declares all 829 isomorphic pins', () => {
+ it('still declares all 830 isomorphic pins', () => {
// The truth of each pin is proved by tsc, not here — an `Assert>`
// that stops holding is a compile error with the alias named. What tsc
// cannot notice is a pin that was DELETED: removing the assertion removes
@@ -2103,9 +2109,16 @@ describe('ADR-0122 type-alias convention', () => {
// `KnowledgeSourceParsed`) in the same commit — the ADR-0122 D6 order:
// declare the parsed name, THEN delete the pin. -2 removed; the Iso
// numbers stay vacant.
+ //
+ // 829 -> 830 is #14168's `ValueDomainSchema` (shared/value-domain.zod.ts)
+ // — the ONE standard-domain vocabulary the settings specifier and the
+ // field slot now share (maintainer ruling 2026-09-02). A `z.enum` with no
+ // default or transform: the (RISE) case, one new pin (`Iso867`).
+ // `SpecifierValueDomainSchema` became an alias of it, so its own pin
+ // (`Iso758`) stays and the two hold or fall together. +1 added.
const self = readFileSync(fileURLToPath(import.meta.url), 'utf8');
const pins = self.match(/^export type Iso\d+ = Assert