Skip to content

Commit fa125f3

Browse files
claude[bot]claude
andauthored
feat(objectql,spec): enforce Field.valueDomain on the write path — refuse a non-member with value_domain, show the key in both authoring forms, flip the liveness row (#15316)
* feat(objectql): enforce Field.valueDomain on the write path 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * feat(spec): show `Field.valueDomain` in both authoring forms, flip its liveness row live 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * chore(spec,runtime): the two ledger rows the new refusal code owes 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * chore(runtime): spell the new row's dash like its six siblings Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * test(lint): the shipped-predicate census learns the two valueDomain rows `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 `<form>::<field>::<source>` 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * chore(platform-objects): regenerate the metadata-form i18n bundles for the two new rows `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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * docs(data-modeling): the valueDomain row states the enforced behaviour 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * chore(changeset): banner the accept-set narrowing — 17.3.0 shipped `valueDomain` unenforced 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
1 parent 791a0cb commit fa125f3

17 files changed

Lines changed: 437 additions & 14 deletions
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/spec': minor
4+
---
5+
6+
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)
7+
8+
**BREAKING** accept-set narrowing on the ObjectQL record write path, shipped as
9+
`minor` under the repo's launch-window convention for breaking changes.
10+
11+
The key is **already published, and published unenforced**. The version-packages
12+
cut `8a1bad8b8` (2026-09-04 10:20Z) consumed the spec half's changeset
13+
`field-value-domain-slot.md` and released `@objectstack/spec@17.3.0`, which
14+
declares `Field.valueDomain`, parses it, and refuses it on any type other than
15+
`text` — and never reads it when a record is written. The 17.3.0 liveness ledger
16+
states the gap in its own words: "a non-member WRITTEN to a `text` field
17+
declaring a domain is accepted today". That write is accepted on 17.3.0 and is
18+
refused from this release on.
19+
20+
**Refused shape**, precisely: a record write that supplies a value for a `text`
21+
field whose definition declares `valueDomain`, where the WRITTEN value is not a
22+
member of the named standard. It fails with the field error code `value_domain`,
23+
carrying `constraint: { valueDomain }` and a message that names the standard in
24+
all four platform locales. Nothing else narrows — a field that declares no
25+
`valueDomain` is untouched, and so is every other field type, because the schema
26+
accepts the key on `text` alone and the validator judges exactly that set.
27+
28+
**Remedy: write a member of the declared standard.** `iana_time_zone` admits
29+
`UTC` and refuses `Mars/Olympus`; `iso_4217_currency` admits `CHF` and refuses
30+
`chf`; `iso_3166_alpha2` admits `CH` and refuses `ZZ`. Dropping the
31+
`valueDomain` declaration from the field lifts the refusal entirely, for an
32+
author who declared a domain they did not mean.
33+
34+
**No stored row is touched, and none becomes invalid.** This is the `min` /
35+
`max` / `maxLength` transition-gate class: a value stored before the domain was
36+
declared — or before this release — is never re-read, and it survives an edit of
37+
another field on the same record. An absent or empty value follows the field's
38+
`required` handling, not this check.
39+
40+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is
41+
renamed, retired or tombstoned. `Field.valueDomain` keeps its name, its type and
42+
its position; this release only makes the declaration the key already carries
43+
bind at the write seam, so `objectstack migrate meta` has no metadata to
44+
rewrite — a document that declares a domain is already in its final spelling,
45+
and one that declares none is untouched. ⚠️ This disposition does NOT rest on
46+
the key being unpublished, and must not be read that way: 17.3.0 shipped
47+
`Field.valueDomain` declared, parsed and UNENFORCED, which is exactly why this
48+
changeset carries the BREAKING banner above. It rests on the stored side
49+
instead. A stored value outside a declared domain is never re-read, so no stored
50+
row is invalidated here and none is reachable by a ledger entry at all. And
51+
which member a stored non-member SHOULD have been is authoring intent no ledger
52+
entry can decide: the stored string carries no evidence of whether the author
53+
meant a different member of that standard, a different standard, or no
54+
declaration at all. The channel that reaches the author is the refusal itself,
55+
raised at the write, naming the standard — the same ground the sibling
56+
accept-set narrowing #15319 stands its own `no-migration-prescription`
57+
disposition on. -->
58+
59+
- The membership test is the spec's shared `isValueDomainMember` — the same
60+
predicate, over the same closed vocabulary, that a settings specifier's
61+
`valueDomain` uses. A time zone accepted in Settings is the time zone
62+
accepted in a field.
63+
- The two authoring forms (`fieldForm`, `objectForm`) gain a `valueDomain`
64+
control, shown on exactly the types the schema accepts the key on. The
65+
object-form control's choices are derived from the vocabulary, not re-typed.

content/docs/data-modeling/validation-rules.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ These properties apply to **all** field types and are validated by the base `Fie
4444
| `maxLength` | `number` || Rejects values exceeding character count |
4545
| `minLength` | `number` || Rejects values below character count |
4646
| `format` | `string` || Validates against format pattern (e.g., regex) |
47-
| `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. |
47+
| `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. |
4848

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

packages/lint/src/validate-predicate-path-refs.test.ts

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

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

677692
const findings = validatePredicatePathRefs({ views: [objectForm] });
678-
expect(findings).toHaveLength(18);
693+
expect(findings).toHaveLength(19);
679694
expect(new Set(findings.map((f) => f.rule))).toEqual(new Set([PREDICATE_PATH_UNROOTED]));
680695
expect(findings[0].message).toContain('`type`');
681696
});

packages/objectql/src/validation/record-validator.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
* multi-value field `[]` is an empty value (#9476 — the
2323
* #9447 ruling: required means non-empty array).
2424
* - `maxLength` / `minLength` (text/textarea/email/url/phone/password)
25+
* - `valueDomain` a declared standard domain's membership, judged by the
26+
* spec's shared `isValueDomainMember` — the WRITTEN value
27+
* only (#14168, maintainer ruling 2026-09-02 option A)
2528
* - `min` / `max` (number/currency/percent/rating/slider)
2629
* - `scale` more decimal places than declared → `max_scale` (#7501;
2730
* rejection, NEVER rounding — maintainer ruling 2026-08-11)
@@ -48,11 +51,13 @@ import {
4851
ALL_OPERATORS,
4952
RETIRED_FILTER_OPERATORS,
5053
BOUNDED_STRING_FIELD_TYPES,
54+
VALUE_DOMAIN_FIELD_TYPES,
5155
REFERENCE_VALUE_TYPES,
5256
FILE_REFERENCE_TYPES,
5357
STRUCTURED_JSON_TYPES,
5458
} from '@objectstack/spec/data';
5559
import type { FieldErrorCode } from '@objectstack/spec/api';
60+
import { isValueDomainMember, type ValueDomain } from '@objectstack/spec/shared';
5661
import {
5762
renderValidationMessage,
5863
objectFieldLabelKey,
@@ -168,6 +173,15 @@ interface FieldDef {
168173
max?: number;
169174
/** Max decimal places for number types — enforced by rejection (#7501). */
170175
scale?: number;
176+
/**
177+
* Standard value domain the WRITTEN value must be a member of (#14168) —
178+
* the same closed vocabulary and the same membership predicate a settings
179+
* specifier's `valueDomain` uses, so a time zone accepted in Settings is the
180+
* time zone accepted in a field. Typed as the spec's `ValueDomain` rather
181+
* than `string`: an unknown domain word has no membership test to run, and
182+
* `isValueDomainMember` is a total function over exactly this union.
183+
*/
184+
valueDomain?: ValueDomain;
171185
options?: Array<{ value: string | number; label?: string } | string | number>;
172186
}
173187

@@ -573,6 +587,51 @@ function validateOne(
573587
if (def.minLength !== undefined && s.length < def.minLength) {
574588
return fail('min_length', { minLength: def.minLength, actual: s.length });
575589
}
590+
// ── `valueDomain` — membership in a published standard (#14168) ──
591+
// Maintainer ruling 2026-09-02 (option A): ONE closed vocabulary and ONE
592+
// membership predicate, shared by settings specifiers and object fields —
593+
// so a currency code accepted in Settings is the code accepted in a field.
594+
// The predicate is imported, never re-implemented: the repo already carries
595+
// hand-rolled copies of the IANA probe, and a second opinion on membership
596+
// is how "accepted in Settings, refused in a field" happens.
597+
//
598+
// The applicability door is the SPEC'S `VALUE_DOMAIN_FIELD_TYPES`, read as
599+
// a constant for the same reason this branch reads `BOUNDED_STRING_FIELD_TYPES`
600+
// (#11875): two seams reading one constant cannot drift into two opinions.
601+
// The set is a strict subset of the bounded-string family (`text` alone
602+
// today, against twelve) — which is why the check sits inside this branch
603+
// and why the subset relation is pinned as a test rather than assumed.
604+
// `FieldSchema` refuses the key outside the set at parse with a located
605+
// issue at [valueDomain], and its refusal message states this seam's half
606+
// of the contract verbatim: "the write-time validator applies `valueDomain`
607+
// to exactly those types". Judging a hand-built runtime schema's key on the
608+
// other eleven would make that sentence false.
609+
//
610+
// WRITTEN VALUE ONLY — the `min`/`max`/`maxLength` transition-gate class: a
611+
// stored value outside a domain declared later is never re-read and survives
612+
// unrelated edits (an omitted field never reaches here on update), and an
613+
// absent/empty value is the `required` check's business above, not this one.
614+
if (
615+
def.valueDomain !== undefined &&
616+
VALUE_DOMAIN_FIELD_TYPES.has(t) &&
617+
!isValueDomainMember(def.valueDomain, s)
618+
) {
619+
// One wire code — the ADR-0114 catalog member `value_domain`, with the
620+
// domain shipped in `constraint` so a client can name it. The finer
621+
// per-domain message key spells the standard out for a human ("a valid
622+
// ISO 4217 currency code, e.g. CHF") in all four locales; it is a
623+
// RENDERING choice and never reaches the wire, the same code/messageKey
624+
// split `invalid_value_shape` and `required_cleared` use. The value is
625+
// echoed because every one of those templates interpolates `{{value}}` —
626+
// an uninterpolated placeholder ships `{{value}}` to the user verbatim.
627+
return fail(
628+
'value_domain',
629+
{ valueDomain: def.valueDomain },
630+
`value_domain_${def.valueDomain}`,
631+
undefined,
632+
s,
633+
);
634+
}
576635
if (t === 'email' && !EMAIL_RE.test(s)) {
577636
return fail('invalid_email');
578637
}

0 commit comments

Comments
 (0)