From dc057ee7596e69bebd4f50932039389809d44b94 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 09:36:09 +0000 Subject: [PATCH 1/9] feat(objectql): enforce Field.valueDomain on the write path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine half of the maintainer's 2026-09-02 ruling A (option A): one closed vocabulary and one membership predicate, shared by settings specifiers and object fields. The spec half declared the slot, the vocabulary, `isValueDomainMember`, the ADR-0114 catalog member `value_domain` and its four-locale templates; without this seam a declared domain parsed and constrained nothing. The check sits in the bounded-string branch beside `maxLength`'s, gated on the spec's own `VALUE_DOMAIN_FIELD_TYPES` — two seams reading one constant, the #11875 discipline. Written value only: an omitted field never reaches it, and absent/empty stays the `required` check's business. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .../src/validation/record-validator.ts | 59 ++++++ .../record-validator.value-domain.test.ts | 178 ++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 packages/objectql/src/validation/record-validator.value-domain.test.ts 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(); + }); +}); From 049f1b31e552320a26d4a8c029be48a3a533e700 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 09:40:22 +0000 Subject: [PATCH 2/9] feat(spec): show `Field.valueDomain` in both authoring forms, flip its liveness row live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seat ruling 1 on PR #15133: the engine card adds the two form rows in the same stroke that flips the liveness row `planned` to `live` — declared = enforced = shown. Both rows' `visibleWhen` mirrors `VALUE_DOMAIN_FIELD_TYPES`; the object form's choices are derived from the vocabulary rather than re-typed, so the control cannot become a second opinion on what the closed vocabulary is. The liveness row now cites the record-validator seam beside `maxLength`'s. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .changeset/field-value-domain-write-path.md | 38 +++++++++++++++++++++ packages/spec/liveness/field.json | 6 ++-- packages/spec/src/data/field.form.ts | 13 +++++++ packages/spec/src/data/object.form.ts | 35 +++++++++++++++++++ 4 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 .changeset/field-value-domain-write-path.md diff --git a/.changeset/field-value-domain-write-path.md b/.changeset/field-value-domain-write-path.md new file mode 100644 index 0000000000..0a23e383e1 --- /dev/null +++ b/.changeset/field-value-domain-write-path.md @@ -0,0 +1,38 @@ +--- +'@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) + + + +A `text` field declaring `valueDomain` now has that declaration enforced when a +record is written: a value that is not a member of the named standard is +refused with the field error code `value_domain`, carrying +`constraint: { valueDomain }` and a message that names the standard in all four +platform locales. Until now the key parsed and constrained nothing. + +- 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: `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`. +- **Written value only** — the `min` / `max` / `maxLength` transition-gate + class. A value stored before the domain was declared is never re-read and + 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 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/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/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); From f16b45f1b8524bc301abe60331be9090b2430dae Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 09:49:39 +0000 Subject: [PATCH 3/9] chore(spec,runtime): the two ledger rows the new refusal code owes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are generated-or-mechanical followers of the change above, each demanded by name by the gate that found it: - `packages/spec/liveness/state-counts.md` — regenerated wholesale by `pnpm --filter @objectstack/spec gen:liveness-counts` (never hand-patched). `field` moves planned 3 to 2, live 89 to 90; `classified` is unchanged at 93, so no property entered or left the walked shape. The re-read that number forces: `liveness/README.md` carries no prose naming the field planned set. - `packages/runtime/src/dispatcher-error-vocabulary.ts` — one verdict row for `value_domain`, copied from its six identical siblings (`max_length`, `min_length`, `max_scale`, ...): `foreign-vocabulary`, because record-validator's `fail(code: FieldErrorCode, ...)` reaches `ApiError.details.fields[].code` and never `error.code`, so no ADR-0112 D6 ledger row is owed. The gate is convention-scoped — it fires on a property of the change, not on a path — so no path derivation could have predicted it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .../runtime/src/dispatcher-error-vocabulary.ts | 14 ++++++++++++++ packages/spec/liveness/state-counts.md | 4 ++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index bd7407cc02..a7b5372503 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 (#14168, 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/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** | From cabfa23cdfc2275758ed299728b1230920ddd4e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 09:55:17 +0000 Subject: [PATCH 4/9] chore(runtime): strip the tracker id from the new verdict row's prose A runtime string reaches authors and operators, who cannot resolve `#NNNN` (check:doc-authoring; maintainer ruling 2026-08-12). The sentence keeps what a reader can act on and the anchor stays in git history. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- packages/runtime/src/dispatcher-error-vocabulary.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index a7b5372503..3be825952a 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -1108,7 +1108,7 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ '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 (#14168, the field-level `valueDomain` write-path refusal). It ' + + '\'value_domain\' is one of them \u2014 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).', }, From 139d43ae693632c369546bd0239643970d3684df Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 09:55:44 +0000 Subject: [PATCH 5/9] chore(runtime): spell the new row's dash like its six siblings Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- packages/runtime/src/dispatcher-error-vocabulary.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index 3be825952a..f90063b4ac 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -1108,7 +1108,7 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ '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 \u2014 the field-level `valueDomain` write-path refusal. It ' + + '\'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).', }, From f2688a10a2a3f22e0dd0a20c938ce699bbb7dce9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 10:49:39 +0000 Subject: [PATCH 6/9] test(lint): the shipped-predicate census learns the two valueDomain rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `validate-predicate-path-refs.test.ts` counts the predicates the shipped metadata forms carry. Both authoring forms gained a `valueDomain` row gated `data.type in ['text']`, so the corpus moves 51 to 53 and the object form's `data.type`-rooted half moves 18 to 19. Both are exact-equality census pins with a job — one asserts the walk is not vacuously empty, the other is a reverse-verification control — so the constants move and the SHAPE does not: no `toBeGreaterThan`, no deleted case. The delta is measured, not inferred from the counts. The corpus was enumerated on this tree and on the merge base `5b09356b7` and differenced by `
::::` rather than by array index, since inserting a row shifts every later sibling's index and that churn would swamp a positional diff. Result: exactly two entries ADDED, `field :: valueDomain` and `object :: valueDomain`, both `data.type in ['text']`, and NONE removed. The object-form half of that delta is the single row the second pin counts. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .../src/validate-predicate-path-refs.test.ts | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) 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`'); }); From 39eadd7292562f32d97b7aa19d62d6d11e18a535 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 11:02:23 +0000 Subject: [PATCH 7/9] chore(platform-objects): regenerate the metadata-form i18n bundles for the two new rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:i18n` reads built output, so it refused as NOT MEASURED (exit 3) until the workspace closure was built; with the closure it is a real red — `platform-objects DRIFTED (7)`. Regenerated by the writer the gate names, `node scripts/check-i18n-bundles.mjs --write`; the generated bundles are never hand-edited. The drift is exactly the two authoring-form rows, measured rather than assumed: 44 lines added and 0 removed across the 7 files, and every added line is a `valueDomain` key — the `label` / `helpText` pair for `metadataForms.field. fields.valueDomain` and `metadataForms.object.fields.fields.valueDomain`, plus their source-hash entries in the three translated locales. ⚠️ The `zh-CN` / `ja-JP` / `es-ES` leaves carry the ENGLISH source text. That is the extractor's merge mode, not a mistake and not drift: an existing translation is never overwritten, and a new schema key arrives filled from the source pending translation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .../src/apps/translations/en.metadata-forms.generated.ts | 8 ++++++++ .../apps/translations/es-ES.metadata-forms.generated.ts | 8 ++++++++ .../apps/translations/es-ES.source-hashes.generated.ts | 4 ++++ .../apps/translations/ja-JP.metadata-forms.generated.ts | 8 ++++++++ .../apps/translations/ja-JP.source-hashes.generated.ts | 4 ++++ .../apps/translations/zh-CN.metadata-forms.generated.ts | 8 ++++++++ .../apps/translations/zh-CN.source-hashes.generated.ts | 4 ++++ 7 files changed, 44 insertions(+) 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", From aa4cb1beee49b0c636bf4163cedae55a7d3d5d82 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 11:27:16 +0000 Subject: [PATCH 8/9] docs(data-modeling): the valueDomain row states the enforced behaviour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row's parenthetical described a PENDING state — "until it lands, the declaration is accepted at parse and not yet enforced on writes" — and this change is that landing, so the sentence becomes false at merge. It is also the one place in the docs an author looks to decide whether the key can be relied on, which is why it should not be allowed to go stale. A deletion, nothing more: the membership-not-shape explanation, the `Intl.DateTimeFormat` probe note, the `^[A-Z]{2}$` versus `ZZ` contrast and the `text`-only clause are all still true and are left exactly as they were. The docs-drift bot named five pages; the other four describe the key by MEANING ("Standard the written value must be a member of") or describe `value_domain` as the code for a non-member, and landing makes those more true rather than false. No release-owned page is named by the bot, and none is touched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- content/docs/data-modeling/validation-rules.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From cb5f6d8099795fe0234f659f7cc09a3cb947b51a Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:11:53 +0000 Subject: [PATCH 9/9] =?UTF-8?q?chore(changeset):=20banner=20the=20accept-s?= =?UTF-8?q?et=20narrowing=20=E2=80=94=2017.3.0=20shipped=20`valueDomain`?= =?UTF-8?q?=20unenforced?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changeset declared no BREAKING banner on one premise: that `Field.valueDomain` had never appeared in a published release, its declaring changeset still pending in `.changeset/`. That premise was true when this branch was cut and became false while the PR sat. `8a1bad8b8` (`chore: version packages`, 2026-09-04 10:20Z) consumed `field-value-domain-slot.md` — one of 872 changesets that cut took — and released `@objectstack/spec@17.3.0`, which declares and parses the key and never reads it on a write (0 `valueDomain` hits in `record-validator.ts` at that commit, against 6 `maxLength` hits in the same blob as the control). `8a1bad8b8` is not an ancestor of this branch, which is why the pending-changeset reading was true when it was taken. So this PR narrows a PUBLISHED accept set, and during the launch window the banner plus the ADR-0087 disposition are the only signal there is. - Add the **BREAKING** banner, naming the refused shape (a record write supplying a value for a `text` field that declares `valueDomain`, where the WRITTEN value is not a member of the named standard) and the remedy (write a member, or drop the declaration). - Rewrite the `adr-0087:` reasoning off the false never-shipped fact and onto the stored side, which survives the release: nothing is renamed, retired or tombstoned; a stored non-member is never re-read; and which member it should have been is authoring intent no ledger entry can decide. Disposition value unchanged at `not-required (no-migration-prescription)`. Bump level stays `minor` — the launch-window level for a bannered breaking change. No `src/` file and no test is touched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .changeset/field-value-domain-write-path.md | 73 ++++++++++++++------- 1 file changed, 50 insertions(+), 23 deletions(-) diff --git a/.changeset/field-value-domain-write-path.md b/.changeset/field-value-domain-write-path.md index 0a23e383e1..8cbcd8dfa1 100644 --- a/.changeset/field-value-domain-write-path.md +++ b/.changeset/field-value-domain-write-path.md @@ -5,34 +5,61 @@ 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. + - -A `text` field declaring `valueDomain` now has that declaration enforced when a -record is written: a value that is not a member of the named standard is -refused with the field error code `value_domain`, carrying -`constraint: { valueDomain }` and a message that names the standard in all four -platform locales. Until now the key parsed and constrained nothing. +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: `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`. -- **Written value only** — the `min` / `max` / `maxLength` transition-gate - class. A value stored before the domain was declared is never re-read and - survives an edit of another field on the same record; an absent or empty - value follows the field's `required` handling, not this check. + 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.