Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .changeset/field-value-domain-write-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
---
'@objectstack/objectql': minor
'@objectstack/spec': minor
---

feat(objectql,spec): `Field.valueDomain` binds at the write seam — a non-member is refused with `value_domain` (maintainer ruling 2026-09-02 on #14168, engine half)

**BREAKING** accept-set narrowing on the ObjectQL record write path, shipped as
`minor` under the repo's launch-window convention for breaking changes.

The key is **already published, and published unenforced**. The version-packages
cut `8a1bad8b8` (2026-09-04 10:20Z) consumed the spec half's changeset
`field-value-domain-slot.md` and released `@objectstack/spec@17.3.0`, which
declares `Field.valueDomain`, parses it, and refuses it on any type other than
`text` — and never reads it when a record is written. The 17.3.0 liveness ledger
states the gap in its own words: "a non-member WRITTEN to a `text` field
declaring a domain is accepted today". That write is accepted on 17.3.0 and is
refused from this release on.

**Refused shape**, precisely: a record write that supplies a value for a `text`
field whose definition declares `valueDomain`, where the WRITTEN value is not a
member of the named standard. It fails with the field error code `value_domain`,
carrying `constraint: { valueDomain }` and a message that names the standard in
all four platform locales. Nothing else narrows — a field that declares no
`valueDomain` is untouched, and so is every other field type, because the schema
accepts the key on `text` alone and the validator judges exactly that set.

**Remedy: write a member of the declared standard.** `iana_time_zone` admits
`UTC` and refuses `Mars/Olympus`; `iso_4217_currency` admits `CHF` and refuses
`chf`; `iso_3166_alpha2` admits `CH` and refuses `ZZ`. Dropping the
`valueDomain` declaration from the field lifts the refusal entirely, for an
author who declared a domain they did not mean.

**No stored row is touched, and none becomes invalid.** This is the `min` /
`max` / `maxLength` transition-gate class: a value stored before the domain was
declared — or before this release — is never re-read, and it survives an edit of
another field on the same record. An absent or empty value follows the field's
`required` handling, not this check.

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is
renamed, retired or tombstoned. `Field.valueDomain` keeps its name, its type and
its position; this release only makes the declaration the key already carries
bind at the write seam, so `objectstack migrate meta` has no metadata to
rewrite — a document that declares a domain is already in its final spelling,
and one that declares none is untouched. ⚠️ This disposition does NOT rest on
the key being unpublished, and must not be read that way: 17.3.0 shipped
`Field.valueDomain` declared, parsed and UNENFORCED, which is exactly why this
changeset carries the BREAKING banner above. It rests on the stored side
instead. A stored value outside a declared domain is never re-read, so no stored
row is invalidated here and none is reachable by a ledger entry at all. And
which member a stored non-member SHOULD have been is authoring intent no ledger
entry can decide: the stored string carries no evidence of whether the author
meant a different member of that standard, a different standard, or no
declaration at all. The channel that reaches the author is the refusal itself,
raised at the write, naming the standard — the same ground the sibling
accept-set narrowing #15319 stands its own `no-migration-prescription`
disposition on. -->

- The membership test is the spec's shared `isValueDomainMember` — the same
predicate, over the same closed vocabulary, that a settings specifier's
`valueDomain` uses. A time zone accepted in Settings is the time zone
accepted in a field.
- The two authoring forms (`fieldForm`, `objectForm`) gain a `valueDomain`
control, shown on exactly the types the schema accepts the key on. The
object-form control's choices are derived from the vocabulary, not re-typed.
2 changes: 1 addition & 1 deletion content/docs/data-modeling/validation-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +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. |
| `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`. `text` only — declaring it on any other type is refused at parse. |

**Default constraints:** None. Unbounded text unless `maxLength` is set.

Expand Down
31 changes: 23 additions & 8 deletions packages/lint/src/validate-predicate-path-refs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,12 +535,22 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => {
// a `page` section to `view.form.ts` — the surface block for the new `page`
// view type, gated by `visibleWhen: "data.type == 'page'"` exactly as every
// other surface block is — so the walk has one more predicate to reach.
// It is 51 today: objectui#6140 (maintainer ruling 2026-08-25, Option A)
// It was 51 after objectui#6140 (maintainer ruling 2026-08-25, Option A)
// declared `rows` on the multiline editor types, adding one
// `data.type in […]`-gated row to the field form AND one to the object
// form's fields repeater — two more predicates for the walk to reach.
// Earlier measurements stay what they were: history, not the census.
expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(51);
// It is 53 today, and for the same shape: the maintainer ruling 2026-09-02
// (option A on the field-level `valueDomain`) put a `valueDomain` row in
// both authoring forms, each gated `data.type in ['text']` — the applicable
// type set, mirrored from the schema's own `VALUE_DOMAIN_FIELD_TYPES`.
// Measured rather than inferred from the delta: the corpus was enumerated
// on this tree and on the merge base, and differenced by
// `<form>::<field>::<source>` rather than by array index (inserting a row
// shifts every later sibling's index, which is churn, not corpus change).
// Exactly two entries were added — `field :: valueDomain` and
// `object :: valueDomain`, both `data.type in ['text']` — and NONE was
// removed. Earlier measurements stay what they were: history, not the census.
expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(53);

const findings = validatePredicatePathRefs(corrupted);
expect(findings).toHaveLength(predicates);
Expand Down Expand Up @@ -640,9 +650,14 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => {
// no longer offered a `set_null` the schema refuses. It is 18 today:
// objectui#6140 added a `rows` row to the object form's fields repeater,
// gated by `data.type in ['textarea','markdown','html','richtext']` — one
// more `data.type`-rooted predicate for the debare walk to restore. #6254's
// own measurement was 16 and stays 16 — that number is history, this one
// is a census.
// more `data.type`-rooted predicate for the debare walk to restore. It is 19
// today: the maintainer ruling 2026-09-02 (option A on the field-level
// `valueDomain`) added a `valueDomain` row to the same repeater, gated
// `data.type in ['text']`. That row is the ONLY object-form predicate this
// tree adds over its merge base — the two corpora were enumerated and
// differenced by `<form>::<field>::<source>`, and the object-form half of
// the two-entry delta is exactly it. #6254's own measurement was 16 and
// stays 16 — that number is history, this one is a census.
const objectForm = structuredClone(METADATA_FORM_REGISTRY.object) as Record<string, unknown>;
let restored = 0;
const debare = (node: unknown): void => {
Expand Down Expand Up @@ -672,10 +687,10 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => {
expect(
restored,
"the object form's `data.type`-rooted predicates are no longer where this test looks",
).toBe(18);
).toBe(19);

const findings = validatePredicatePathRefs({ views: [objectForm] });
expect(findings).toHaveLength(18);
expect(findings).toHaveLength(19);
expect(new Set(findings.map((f) => f.rule))).toEqual(new Set([PREDICATE_PATH_UNROOTED]));
expect(findings[0].message).toContain('`type`');
});
Expand Down
59 changes: 59 additions & 0 deletions packages/objectql/src/validation/record-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
* multi-value field `[]` is an empty value (#9476 — the
* #9447 ruling: required means non-empty array).
* - `maxLength` / `minLength` (text/textarea/email/url/phone/password)
* - `valueDomain` a declared standard domain's membership, judged by the
* spec's shared `isValueDomainMember` — the WRITTEN value
* only (#14168, maintainer ruling 2026-09-02 option A)
* - `min` / `max` (number/currency/percent/rating/slider)
* - `scale` more decimal places than declared → `max_scale` (#7501;
* rejection, NEVER rounding — maintainer ruling 2026-08-11)
Expand All @@ -48,11 +51,13 @@ import {
ALL_OPERATORS,
RETIRED_FILTER_OPERATORS,
BOUNDED_STRING_FIELD_TYPES,
VALUE_DOMAIN_FIELD_TYPES,
REFERENCE_VALUE_TYPES,
FILE_REFERENCE_TYPES,
STRUCTURED_JSON_TYPES,
} from '@objectstack/spec/data';
import type { FieldErrorCode } from '@objectstack/spec/api';
import { isValueDomainMember, type ValueDomain } from '@objectstack/spec/shared';
import {
renderValidationMessage,
objectFieldLabelKey,
Expand Down Expand Up @@ -168,6 +173,15 @@ interface FieldDef {
max?: number;
/** Max decimal places for number types — enforced by rejection (#7501). */
scale?: number;
/**
* Standard value domain the WRITTEN value must be a member of (#14168) —
* the same closed vocabulary and the same membership predicate a settings
* specifier's `valueDomain` uses, so a time zone accepted in Settings is the
* time zone accepted in a field. Typed as the spec's `ValueDomain` rather
* than `string`: an unknown domain word has no membership test to run, and
* `isValueDomainMember` is a total function over exactly this union.
*/
valueDomain?: ValueDomain;
options?: Array<{ value: string | number; label?: string } | string | number>;
}

Expand Down Expand Up @@ -573,6 +587,51 @@ function validateOne(
if (def.minLength !== undefined && s.length < def.minLength) {
return fail('min_length', { minLength: def.minLength, actual: s.length });
}
// ── `valueDomain` — membership in a published standard (#14168) ──
// Maintainer ruling 2026-09-02 (option A): ONE closed vocabulary and ONE
// membership predicate, shared by settings specifiers and object fields —
// so a currency code accepted in Settings is the code accepted in a field.
// The predicate is imported, never re-implemented: the repo already carries
// hand-rolled copies of the IANA probe, and a second opinion on membership
// is how "accepted in Settings, refused in a field" happens.
//
// The applicability door is the SPEC'S `VALUE_DOMAIN_FIELD_TYPES`, read as
// a constant for the same reason this branch reads `BOUNDED_STRING_FIELD_TYPES`
// (#11875): two seams reading one constant cannot drift into two opinions.
// The set is a strict subset of the bounded-string family (`text` alone
// today, against twelve) — which is why the check sits inside this branch
// and why the subset relation is pinned as a test rather than assumed.
// `FieldSchema` refuses the key outside the set at parse with a located
// issue at [valueDomain], and its refusal message states this seam's half
// of the contract verbatim: "the write-time validator applies `valueDomain`
// to exactly those types". Judging a hand-built runtime schema's key on the
// other eleven would make that sentence false.
//
// 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 (an omitted field never reaches here on update), and an
// absent/empty value is the `required` check's business above, not this one.
if (
def.valueDomain !== undefined &&
VALUE_DOMAIN_FIELD_TYPES.has(t) &&
!isValueDomainMember(def.valueDomain, s)
) {
// One wire code — the ADR-0114 catalog member `value_domain`, with the
// domain shipped in `constraint` so a client can name it. The finer
// per-domain message key spells the standard out for a human ("a valid
// ISO 4217 currency code, e.g. CHF") in all four locales; it is a
// RENDERING choice and never reaches the wire, the same code/messageKey
// split `invalid_value_shape` and `required_cleared` use. The value is
// echoed because every one of those templates interpolates `{{value}}` —
// an uninterpolated placeholder ships `{{value}}` to the user verbatim.
return fail(
'value_domain',
{ valueDomain: def.valueDomain },
`value_domain_${def.valueDomain}`,
undefined,
s,
);
}
if (t === 'email' && !EMAIL_RE.test(s)) {
return fail('invalid_email');
}
Expand Down
Loading
Loading