Skip to content
46 changes: 46 additions & 0 deletions .changeset/field-value-domain-slot.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: not-required (accept-set expansion) One new CLOSED optional key
on an existing shape (`FieldSchema.valueDomain`) and one new member of the
closed field-level error catalog (`value_domain`); nothing authorable is
renamed, retired or tombstoned, so there is no conversion to register.
`SpecifierValueDomainSchema` keeps its export name and its exact three
members — it is now an alias of the shared schema, not a second declaration. -->

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.
2 changes: 1 addition & 1 deletion content/docs/api/error-catalog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions content/docs/data-modeling/field-types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
1 change: 1 addition & 0 deletions content/docs/data-modeling/fields.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
3 changes: 2 additions & 1 deletion content/docs/data-modeling/validation-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/getting-started/quick-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
1 change: 1 addition & 0 deletions content/docs/protocol/objectql/schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down
4 changes: 3 additions & 1 deletion content/docs/references/api/errors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -211,6 +211,7 @@ const result = EnhancedApiErrorSchema.parse(data);
* `min_items`
* `max_items`
* `invalid_option`
* `value_domain`
* `invalid_value`
* `reference_not_found`
* `reference_ambiguous`
Expand Down Expand Up @@ -247,6 +248,7 @@ const result = EnhancedApiErrorSchema.parse(data);
* `min_items`
* `max_items`
* `invalid_option`
* `value_domain`
* `invalid_value`
* `reference_not_found`
* `reference_ambiguous`
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/data/field.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
Loading
Loading