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