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 + // `
::::` 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); @@ -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 `::::`, 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; let restored = 0; const debare = (node: unknown): void => { @@ -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`'); }); diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index c0ab420eef..1d33afc116 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -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) @@ -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, @@ -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>; } @@ -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'); } diff --git a/packages/objectql/src/validation/record-validator.value-domain.test.ts b/packages/objectql/src/validation/record-validator.value-domain.test.ts new file mode 100644 index 0000000000..fcf228805e --- /dev/null +++ b/packages/objectql/src/validation/record-validator.value-domain.test.ts @@ -0,0 +1,178 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { BOUNDED_STRING_FIELD_TYPES, VALUE_DOMAIN_FIELD_TYPES } from '@objectstack/spec/data'; +import { validateRecord, ValidationError } from './record-validator.js'; + +/** + * #14168 (maintainer ruling 2026-09-02, option A) — `Field.valueDomain` binds + * at the write seam. The spec half declared the slot, the closed vocabulary + * (`iana_time_zone` / `iso_4217_currency` / `iso_3166_alpha2`), the ONE shared + * membership predicate `isValueDomainMember`, the ADR-0114 catalog member + * `value_domain` and its four-locale templates; this is the enforcement half — + * without it a declared domain parsed and constrained nothing, the + * declared-but-inert shape ADR-0078 keeps out. + * + * The predicate is the settings door's own, deliberately: a time zone accepted + * in Settings is the time zone accepted in a field. These pins therefore assert + * the ENVELOPE (`code` + `constraint.valueDomain`) rather than re-testing + * membership — the vocabulary's own membership pins live beside the predicate + * in `packages/spec`. + */ + +const fieldsOf = ( + schema: unknown, + data: Record, + mode: 'insert' | 'update' = 'insert', +) => { + try { + validateRecord(schema as never, data, mode); + } catch (e) { + return (e as ValidationError).fields; + } + return null; +}; + +/** + * The reachability invariant this enforcement rides on. The check lives inside + * the bounded-string branch (beside `maxLength`'s seam, where the card puts + * it), so a type that may AUTHOR a domain must also reach that branch. Today + * `VALUE_DOMAIN_FIELD_TYPES` is `{text}` and `BOUNDED_STRING_FIELD_TYPES` is + * the twelve-member `maxLength` family — a strict subset. If the spec ever + * widens the domain set to a type outside the bounded-string family, this pin + * goes red instead of the enforcement silently ceasing to fire for it: the + * failure mode a subset relation would otherwise hide. + */ +describe('Field.valueDomain — the enforcement is reachable for every type that may author it', () => { + it('VALUE_DOMAIN_FIELD_TYPES is a subset of BOUNDED_STRING_FIELD_TYPES', () => { + const outside = [...VALUE_DOMAIN_FIELD_TYPES].filter((t) => !BOUNDED_STRING_FIELD_TYPES.has(t)); + expect(outside).toEqual([]); + }); +}); + +/** + * The card's exact matrix: one pin per vocabulary member, member admitted and + * non-member refused. Each non-member is chosen to be a case a `pattern` could + * not catch — `ZZ` is shape-valid and unassigned, `Mars/Olympus` is a + * shape-valid zone that does not exist, and `chf` is the right code in the + * wrong case (ISO 4217 is exact uppercase). + */ +describe('validateRecord — Field.valueDomain refuses a non-member on the write path (#14168)', () => { + const CASES = [ + { domain: 'iso_3166_alpha2', member: 'CH', nonMember: 'ZZ' }, + { domain: 'iana_time_zone', member: 'UTC', nonMember: 'Mars/Olympus' }, + { domain: 'iso_4217_currency', member: 'CHF', nonMember: 'chf' }, + ] as const; + + for (const { domain, member, nonMember } of CASES) { + const schema = { fields: { code: { type: 'text', valueDomain: domain } } }; + + it(`${domain}: admits the member '${member}'`, () => { + expect(fieldsOf(schema, { code: member })).toBeNull(); + }); + + it(`${domain}: refuses the non-member '${nonMember}' with value_domain + constraint.valueDomain`, () => { + const errs = fieldsOf(schema, { code: nonMember }); + expect(errs).not.toBeNull(); + expect(errs?.[0]).toMatchObject({ + field: 'code', + code: 'value_domain', + constraint: { valueDomain: domain }, + }); + }); + + it(`${domain}: refuses on update as well as insert`, () => { + const errs = fieldsOf(schema, { code: nonMember }, 'update'); + expect(errs?.[0]).toMatchObject({ field: 'code', code: 'value_domain' }); + }); + } + + it('the refusal message names the standard and interpolates the offending value', () => { + const errs = fieldsOf( + { fields: { code: { type: 'text', label: 'Country', valueDomain: 'iso_3166_alpha2' } } }, + { code: 'ZZ' }, + ); + // The finer per-domain template (`value_domain_iso_3166_alpha2`) spells the + // standard out for a human; the WIRE code stays the catalog member. A + // template whose `{{value}}` went uninterpolated would ship the literal + // placeholder to the user, so the assertion is on the rendered sentence. + expect(errs?.[0]?.message).toBe( + 'Country must be a valid ISO 3166-1 alpha-2 country code, e.g. CH (got "ZZ")', + ); + expect(errs?.[0]?.message).not.toContain('{{'); + }); + + it('a text field with NO declared domain accepts anything (the key is opt-in)', () => { + const schema = { fields: { code: { type: 'text' } } }; + expect(fieldsOf(schema, { code: 'ZZ' })).toBeNull(); + }); + + /** + * WRITTEN VALUE ONLY — the `min`/`max`/`maxLength` transition-gate class. A + * value stored before the domain was declared is never re-read, so an edit + * of ANOTHER field on that record must not 400. This is the spec's stated + * semantics and the reason the check hangs off the supplied value rather + * than off the record. + */ + it('unchanged-on-read: a stored non-member survives an edit of another field', () => { + const schema = { + fields: { + code: { type: 'text', valueDomain: 'iso_3166_alpha2' }, + note: { type: 'text' }, + }, + }; + // The PATCH touches `note` only; `code` still holds the legacy 'ZZ'. + expect(fieldsOf(schema, { note: 'edited' }, 'update')).toBeNull(); + }); + + /** + * Absent / empty follows the field's `required` handling, NOT this check — + * `''` on a required field is a `required` error, and on an optional field it + * is simply nothing to judge. A membership test that fired on emptiness would + * make every optional domain field required by the back door. + */ + it('an empty value on an OPTIONAL domain field is admitted, not judged for membership', () => { + const schema = { fields: { code: { type: 'text', valueDomain: 'iso_3166_alpha2' } } }; + expect(fieldsOf(schema, { code: '' })).toBeNull(); + expect(fieldsOf(schema, { code: null })).toBeNull(); + }); + + it('an empty value on a REQUIRED domain field fails as `required`, never as `value_domain`', () => { + const schema = { + fields: { code: { type: 'text', required: true, valueDomain: 'iso_3166_alpha2' } }, + }; + const errs = fieldsOf(schema, { code: '' }); + expect(errs?.[0]).toMatchObject({ field: 'code', code: 'required' }); + }); + + /** + * The declared bounds still win. `maxLength` / `minLength` are checked first + * in the same branch, so a value that is both over-long and a non-member + * reports the bound — one error per field, first rule wins, as everywhere + * else in this validator. + */ + it('a declared maxLength is still reported first for a value that violates both', () => { + const schema = { + fields: { code: { type: 'text', maxLength: 2, valueDomain: 'iso_3166_alpha2' } }, + }; + const errs = fieldsOf(schema, { code: 'ZZZZ' }); + expect(errs?.[0]).toMatchObject({ field: 'code', code: 'max_length' }); + }); + + /** + * CONTROL — the applicability door is the spec's `VALUE_DOMAIN_FIELD_TYPES`, + * and this seam reads that same constant rather than firing on every + * bounded-string type that happens to carry the key. `FieldSchema` refuses + * `valueDomain` outside the set at parse (a located `custom` issue at + * `[valueDomain]`), so authored metadata can never reach here in this shape; + * a hand-built runtime schema can, and the spec's own refusal message states + * what happens then — "the write-time validator applies `valueDomain` to + * exactly those types". Enforcing on the other eleven would make that + * sentence false and would fork the two seams into two opinions, the drift + * #11875 closed for `maxLength`. + */ + it("a bounded-string type OUTSIDE the domain set is not judged — the two seams read one constant", () => { + const schema = { fields: { body: { type: 'textarea', valueDomain: 'iso_3166_alpha2' } } }; + expect(fieldsOf(schema, { body: 'ZZ' })).toBeNull(); + }); +}); diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index ffb5cb1f08..008aab082b 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -120,6 +120,10 @@ export const enMetadataForms: NonNullable = { label: "Min Length", helpText: "Min characters" }, + "fields.valueDomain": { + label: "Value Domain", + helpText: "Standard the written value must belong to; a write carrying a non-member is refused" + }, "fields.rows": { label: "Rows", helpText: "Inline editor height (text rows)" @@ -384,6 +388,10 @@ export const enMetadataForms: NonNullable = { label: "Max Length", helpText: "Maximum character length" }, + valueDomain: { + label: "Value Domain", + helpText: "Standard the written value must belong to: iana_time_zone, iso_4217_currency or iso_3166_alpha2. A write carrying a non-member is refused" + }, rows: { label: "Rows", helpText: "Inline editor height in text rows" diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 4c37198c4d..e926abea2a 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -120,6 +120,10 @@ export const esESMetadataForms: NonNullable = label: "Longitud mínima", helpText: "Mínimo de caracteres" }, + "fields.valueDomain": { + label: "Value Domain", + helpText: "Standard the written value must belong to; a write carrying a non-member is refused" + }, "fields.rows": { label: "Rows", helpText: "Inline editor height (text rows)" @@ -384,6 +388,10 @@ export const esESMetadataForms: NonNullable = label: "Longitud máxima", helpText: "Longitud máxima de caracteres" }, + valueDomain: { + label: "Value Domain", + helpText: "Standard the written value must belong to: iana_time_zone, iso_4217_currency or iso_3166_alpha2. A write carrying a non-member is refused" + }, rows: { label: "Rows", helpText: "Inline editor height in text rows" diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts index cd88187980..d4ed7bf8e7 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts @@ -77,6 +77,8 @@ export const esESGeneratedSourceHashes: Readonly> = { "metadataForms.field.fields.summaryOperations.object.label": "d0c49032d95ebb36", "metadataForms.field.fields.summaryOperations.relationshipField.helpText": "5d8a9925c9a1b45a", "metadataForms.field.fields.summaryOperations.relationshipField.label": "d8e119c564a5453a", + "metadataForms.field.fields.valueDomain.helpText": "a3556fad7893a205", + "metadataForms.field.fields.valueDomain.label": "18ebeb7e56e792a1", "metadataForms.hook.fields.body.memoryMb.helpText": "f53c826ce3c94ad9", "metadataForms.hook.fields.body.memoryMb.label": "a1e1612eff7f82ea", "metadataForms.hook.fields.retryPolicy.backoffMs.helpText": "42137a83459ed6b7", @@ -122,6 +124,8 @@ export const esESGeneratedSourceHashes: Readonly> = { "metadataForms.object.fields.fields.summaryOperations.label": "bcf65520573738ee", "metadataForms.object.fields.fields.summaryOperations.object.helpText": "7e6fc21663360844", "metadataForms.object.fields.fields.summaryOperations.object.label": "d0c49032d95ebb36", + "metadataForms.object.fields.fields.valueDomain.helpText": "649e2e89dfeb2e8a", + "metadataForms.object.fields.fields.valueDomain.label": "18ebeb7e56e792a1", "metadataForms.object.fields.fields.visibleWhen.helpText": "46de2d3ff57b6667", "metadataForms.object.fields.fields.visibleWhen.label": "c852d4249db93285", "metadataForms.object.fields.lifecycle.archive.after.helpText": "e0e76ef140528e1c", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 630f3918f9..0e63df7c1b 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -120,6 +120,10 @@ export const jaJPMetadataForms: NonNullable = label: "最小長", helpText: "最小文字数" }, + "fields.valueDomain": { + label: "Value Domain", + helpText: "Standard the written value must belong to; a write carrying a non-member is refused" + }, "fields.rows": { label: "Rows", helpText: "Inline editor height (text rows)" @@ -384,6 +388,10 @@ export const jaJPMetadataForms: NonNullable = label: "最大長", helpText: "最大文字数" }, + valueDomain: { + label: "Value Domain", + helpText: "Standard the written value must belong to: iana_time_zone, iso_4217_currency or iso_3166_alpha2. A write carrying a non-member is refused" + }, rows: { label: "Rows", helpText: "Inline editor height in text rows" diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts index e6e9a32814..083cd9700a 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts @@ -77,6 +77,8 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "metadataForms.field.fields.summaryOperations.object.label": "d0c49032d95ebb36", "metadataForms.field.fields.summaryOperations.relationshipField.helpText": "5d8a9925c9a1b45a", "metadataForms.field.fields.summaryOperations.relationshipField.label": "d8e119c564a5453a", + "metadataForms.field.fields.valueDomain.helpText": "a3556fad7893a205", + "metadataForms.field.fields.valueDomain.label": "18ebeb7e56e792a1", "metadataForms.hook.fields.body.memoryMb.helpText": "f53c826ce3c94ad9", "metadataForms.hook.fields.body.memoryMb.label": "a1e1612eff7f82ea", "metadataForms.hook.fields.retryPolicy.backoffMs.helpText": "42137a83459ed6b7", @@ -122,6 +124,8 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "metadataForms.object.fields.fields.summaryOperations.label": "bcf65520573738ee", "metadataForms.object.fields.fields.summaryOperations.object.helpText": "7e6fc21663360844", "metadataForms.object.fields.fields.summaryOperations.object.label": "d0c49032d95ebb36", + "metadataForms.object.fields.fields.valueDomain.helpText": "649e2e89dfeb2e8a", + "metadataForms.object.fields.fields.valueDomain.label": "18ebeb7e56e792a1", "metadataForms.object.fields.fields.visibleWhen.helpText": "46de2d3ff57b6667", "metadataForms.object.fields.fields.visibleWhen.label": "c852d4249db93285", "metadataForms.object.fields.lifecycle.archive.after.helpText": "e0e76ef140528e1c", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 47f5089fe9..a39c5c8863 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -120,6 +120,10 @@ export const zhCNMetadataForms: NonNullable = label: "最小长度", helpText: "最小字符数" }, + "fields.valueDomain": { + label: "Value Domain", + helpText: "Standard the written value must belong to; a write carrying a non-member is refused" + }, "fields.rows": { label: "Rows", helpText: "Inline editor height (text rows)" @@ -384,6 +388,10 @@ export const zhCNMetadataForms: NonNullable = label: "最大长度", helpText: "最多字符数" }, + valueDomain: { + label: "Value Domain", + helpText: "Standard the written value must belong to: iana_time_zone, iso_4217_currency or iso_3166_alpha2. A write carrying a non-member is refused" + }, rows: { label: "Rows", helpText: "Inline editor height in text rows" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts index d3542beb59..469f43a8c6 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts @@ -77,6 +77,8 @@ export const zhCNGeneratedSourceHashes: Readonly> = { "metadataForms.field.fields.summaryOperations.object.label": "d0c49032d95ebb36", "metadataForms.field.fields.summaryOperations.relationshipField.helpText": "5d8a9925c9a1b45a", "metadataForms.field.fields.summaryOperations.relationshipField.label": "d8e119c564a5453a", + "metadataForms.field.fields.valueDomain.helpText": "a3556fad7893a205", + "metadataForms.field.fields.valueDomain.label": "18ebeb7e56e792a1", "metadataForms.hook.fields.body.memoryMb.helpText": "f53c826ce3c94ad9", "metadataForms.hook.fields.body.memoryMb.label": "a1e1612eff7f82ea", "metadataForms.hook.fields.retryPolicy.backoffMs.helpText": "42137a83459ed6b7", @@ -122,6 +124,8 @@ export const zhCNGeneratedSourceHashes: Readonly> = { "metadataForms.object.fields.fields.summaryOperations.label": "bcf65520573738ee", "metadataForms.object.fields.fields.summaryOperations.object.helpText": "7e6fc21663360844", "metadataForms.object.fields.fields.summaryOperations.object.label": "d0c49032d95ebb36", + "metadataForms.object.fields.fields.valueDomain.helpText": "649e2e89dfeb2e8a", + "metadataForms.object.fields.fields.valueDomain.label": "18ebeb7e56e792a1", "metadataForms.object.fields.fields.visibleWhen.helpText": "46de2d3ff57b6667", "metadataForms.object.fields.fields.visibleWhen.label": "c852d4249db93285", "metadataForms.page.fields.interfaceConfig.addRecord.helpText": "3eb7b86c3a630db9", diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index bd7407cc02..f90063b4ac 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -1098,6 +1098,20 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ '\'max_scale\' is one of them. It reaches `ApiError.details.fields[].code`, never `error.code`, so ' + 'no ledger row can be owed for it (ADR-0112 D6).', }, + { + code: 'value_domain', + file: 'packages/objectql/src/validation/record-validator.ts', + shape: 'objlithelper', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'record-validator\'s `fail(code: FieldErrorCode, …)` builds one `{ field, code, def, constraint, ' + + 'messageKey, options, value }` per violated constraint. Its `code` parameter is typed `code: ' + + 'FieldErrorCode`, so the value is a member of the closed ADR-0114 D2 catalog by construction; ' + + '\'value_domain\' is one of them — the field-level `valueDomain` write-path refusal. It ' + + 'reaches `ApiError.details.fields[].code`, never `error.code`, so no ledger row can be owed for it ' + + '(ADR-0112 D6).', + }, // fallback() — rule-validator.ts { code: 'invalid_initial_state', diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index 9d48a657ac..52ec904ca8 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -222,10 +222,10 @@ "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", + "status": "live", "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." + "evidence": "packages/objectql/src/validation/record-validator.ts#validateOne (the write-path seam, beside `maxLength`'s: `if (def.valueDomain !== undefined && VALUE_DOMAIN_FIELD_TYPES.has(t) && !isValueDomainMember(def.valueDomain, s)) return fail('value_domain', { valueDomain: def.valueDomain }, ...)` \u2014 a non-member WRITTEN to a `text` field declaring a domain is refused with the ADR-0114 code `value_domain` and `constraint.valueDomain`); 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": "The write path enforces it since 2026-09-04 (#15161, the engine half of the maintainer ruling 2026-09-02 option A on #14168; the spec half declared the slot, the closed vocabulary, the shared predicate, the ADR-0114 catalog member and its four-locale templates). 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, and an absent/empty value is the field's `required` handling, not this check \u2014 both pinned in packages/objectql/src/validation/record-validator.value-domain.test.ts, together with the per-domain matrix (iso_3166_alpha2 admits CH and refuses ZZ; iana_time_zone admits UTC and refuses Mars/Olympus; iso_4217_currency admits CHF and refuses chf). The applicability door is one constant read by both seams \u2014 the schema refuses the key outside VALUE_DOMAIN_FIELD_TYPES at parse and the validator judges exactly that set, so the two cannot drift into two opinions (the #11875 discipline; the subset relation to BOUNDED_STRING_FIELD_TYPES, which the enforcement branch rides on, is pinned in the same file). 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", diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 1c86358a7d..f444009204 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 | 3 | 93 | +| `field` | 90 | 0 | 0 | 1 | 2 | 93 | | `flow` | 34 | 0 | 0 | 6 | 0 | 40 | | `action` | 41 | 0 | 0 | 3 | 4 | 48 | | `hook` | 19 | 0 | 0 | 2 | 0 | 21 | @@ -63,4 +63,4 @@ for both corollaries. | `batch_endpoints` | 5 | 0 | 0 | 2 | 0 | 7 | | `route_generation` | 0 | 0 | 0 | 4 | 0 | 4 | | `realtime_subscription` | 0 | 0 | 0 | 6 | 0 | 6 | -| **total** | **844** | **5** | **1** | **90** | **13** | **953** | +| **total** | **845** | **5** | **1** | **90** | **12** | **953** | diff --git a/packages/spec/src/data/field.form.ts b/packages/spec/src/data/field.form.ts index eecedfca9c..29d30033c5 100644 --- a/packages/spec/src/data/field.form.ts +++ b/packages/spec/src/data/field.form.ts @@ -49,6 +49,19 @@ export const fieldForm = defineForm({ // #11875 added `signature`/`qrcode` to the set (the write seam now // enforces their declared bound); this visibleWhen moves with it. { field: 'maxLength', visibleWhen: "data.type in ['text','textarea','email','url','phone','password','markdown','html','richtext','code','signature','qrcode']", helpText: 'Maximum character length' }, + // #14168 (maintainer ruling 2026-09-02, option A) — `valueDomain` is + // shown for exactly the types the schema accepts it on + // (VALUE_DOMAIN_FIELD_TYPES in field.zod.ts — `text` alone, the one + // type whose stored value is a single plain string naming the member); + // this visibleWhen moves with the set, like the two rows above. The + // `in [...]` form rather than `== 'text'` for that reason: the row + // mirrors a SET, and a widening edits the list in place. + // + // Shown in the same stroke that the record validator starts refusing a + // non-member (`value_domain`) and the liveness row flips `live` — + // declared = enforced = shown. A key offered in Studio before any write + // path enforces it is the ADR-0078 shape. + { field: 'valueDomain', visibleWhen: "data.type in ['text']", helpText: 'Standard the written value must belong to: iana_time_zone, iso_4217_currency or iso_3166_alpha2. A write carrying a non-member is refused' }, // objectui#6140 (maintainer ruling 2026-08-25, Option A) — `rows` is // shown for exactly the multiline editor types the schema accepts it // on (MULTILINE_EDITOR_FIELD_TYPES in field.zod.ts); this visibleWhen diff --git a/packages/spec/src/data/object.form.ts b/packages/spec/src/data/object.form.ts index 87c23fe4c5..aeb00065ca 100644 --- a/packages/spec/src/data/object.form.ts +++ b/packages/spec/src/data/object.form.ts @@ -1,6 +1,25 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { defineForm } from '../ui/view.zod'; +import { ValueDomainSchema, type ValueDomain } from '../shared/value-domain.zod'; + +/** + * The `valueDomain` control's choices, DERIVED from the closed vocabulary + * (#14168). The labels are a `Record` over the union, so a member added to + * `ValueDomainSchema` without a label here fails to compile rather than + * silently reaching Studio as a machine word — the same exhaustiveness the + * membership table beside the schema uses. + */ +const VALUE_DOMAIN_LABELS: Readonly> = { + iana_time_zone: 'IANA time zone', + iso_4217_currency: 'ISO 4217 currency code', + iso_3166_alpha2: 'ISO 3166-1 alpha-2 country code', +}; + +const VALUE_DOMAIN_OPTIONS = ValueDomainSchema.options.map((value) => ({ + label: VALUE_DOMAIN_LABELS[value], + value, +})); /** * Form Layout for Object Metadata Type @@ -134,6 +153,22 @@ export const objectForm = defineForm({ // to stop at 9 types (`code` was the one it was missing); it moves // with the set, exactly like the row above. { field: 'minLength', type: 'number', helpText: 'Min characters', visibleWhen: "data.type in ['text','textarea','email','url','phone','password','markdown','html','richtext','code','signature','qrcode']" }, + // #14168 (maintainer ruling 2026-09-02, option A) — `valueDomain` + // is shown for exactly the types the schema accepts it on + // (VALUE_DOMAIN_FIELD_TYPES in field.zod.ts — `text` alone); this + // visibleWhen moves with the set, and the offered choices are + // DERIVED from the vocabulary rather than re-typed here, so this + // control cannot become a second opinion on what the closed + // vocabulary is (the #12017 two-copies shape). Shown in the same + // stroke that the write path starts refusing a non-member and the + // liveness row flips `live` — declared = enforced = shown. + { + field: 'valueDomain', + type: 'select', + helpText: 'Standard the written value must belong to; a write carrying a non-member is refused', + visibleWhen: "data.type in ['text']", + options: VALUE_DOMAIN_OPTIONS, + }, // objectui#6140 (maintainer ruling 2026-08-25, Option A) — `rows` // is shown for exactly the multiline editor types the schema // accepts it on (MULTILINE_EDITOR_FIELD_TYPES in field.zod.ts);