diff --git a/.changeset/field-value-domain-write-path.md b/.changeset/field-value-domain-write-path.md new file mode 100644 index 0000000000..8cbcd8dfa1 --- /dev/null +++ b/.changeset/field-value-domain-write-path.md @@ -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. + + + +- 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. diff --git a/content/docs/data-modeling/validation-rules.mdx b/content/docs/data-modeling/validation-rules.mdx index bc103c7ef3..f536a08383 100644 --- a/content/docs/data-modeling/validation-rules.mdx +++ b/content/docs/data-modeling/validation-rules.mdx @@ -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. diff --git a/packages/lint/src/validate-predicate-path-refs.test.ts b/packages/lint/src/validate-predicate-path-refs.test.ts index db0d6a2851..cb4dac3cc3 100644 --- a/packages/lint/src/validate-predicate-path-refs.test.ts +++ b/packages/lint/src/validate-predicate-path-refs.test.ts @@ -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 + // `