You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
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.
Copy file name to clipboardExpand all lines: content/docs/data-modeling/validation-rules.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -44,7 +44,7 @@ These properties apply to **all** field types and are validated by the base `Fie
44
44
|`maxLength`|`number`| — | Rejects values exceeding character count |
45
45
|`minLength`|`number`| — | Rejects values below character count |
46
46
|`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. |
48
48
49
49
**Default constraints:** None. Unbounded text unless `maxLength` is set.
0 commit comments