Skip to content

Commit 0169d49

Browse files
claude[bot]claudeos-sam
authored
docs(spec): state the transition-gate vs invariant boundary at each declaration (#14889)
`Field.requiredWhen` and the field bounds (`min`/`max`/`minLength`/`maxLength`) are transition gates; a `validations[]` `script` rule is a true invariant. Both semantics are deliberate and neither moves here — what was missing is that no platform surface said so, while "required when X" reads as an invariant. Measured against the engine before writing a word (12 cases, all green): `evaluateValidationRules` refuses a `requiredWhen` write only when the merged record violates AND the pre-write record complied (the ADR-0113 non-regression branch), `validateRecord` iterates the UPDATE payload so a bound is checked on the written value only, and `checkPredicate` re-evaluates a `script` rule against the merged record with no pre-state exemption. Adds a schema-reachable pin over the contract text, and regenerates the reference pages the `.describe()` strings feed. Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Sam Altman <sam@objectstack.ai>
1 parent 7b19b9e commit 0169d49

8 files changed

Lines changed: 286 additions & 39 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): state the transition-gate vs invariant boundary where each tool is declared
6+
7+
`Field.requiredWhen` and the field bounds (`min` / `max` / `minLength` /
8+
`maxLength`) are **transition gates**; a `validations[]` `script` rule is a
9+
**true invariant**. Both semantics are deliberate, and neither moves here — what
10+
was missing is that no surface said so, while "required when X" reads to a human
11+
and to an AI metadata author as an invariant. Measured downstream: three rules
12+
written in prose as invariants were all implemented with the gate tool, with
13+
nothing to signal the difference.
14+
15+
The contract text now says it at each declaration, in the copy that ships as the
16+
JSON Schema `description` and as the generated reference page:
17+
18+
- `Field.requiredWhen` — the write is refused only when the merged record
19+
violates AND the pre-write record complied. So the write that flips the
20+
predicate TRUE, an INSERT born inside the gate, and a write that clears the
21+
cell are refused, while a row that was already missing the value keeps passing
22+
unrelated edits and state moves that stay inside the gate (ADR-0113
23+
non-regression: adding the rule to a deployed object never bricks existing
24+
rows).
25+
- `min` / `max` / `minLength` / `maxLength` — checked on the WRITTEN value only,
26+
because an UPDATE validates just the fields the payload carries; a stored
27+
out-of-bound value is never re-read.
28+
- `validations[]` `script` `condition` — re-evaluated against the merged record
29+
on every write with no exemption for a violation that was already stored, so a
30+
violating row is refused on any edit until a repairing write lands: frozen,
31+
not bricked.
32+
- The inline-grid column `requiredWhen` — presentation only. Nothing on the
33+
write path reads it; the enforced contract is the child field's own
34+
`requiredWhen`.
35+
36+
Each half names the other tool, so a reader who picked the wrong one is
37+
redirected rather than merely described to. No schema, accepted key set,
38+
validator or runtime behaviour changes.

content/docs/references/data/field.mdx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,13 @@ const result = CurrencyConfigSchema.parse(data);
6363
| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). |
6464
| **unique** | `boolean \| 'global' \| 'organization'` | optional (default: `false`) | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization' |
6565
| **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. |
66-
| **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. |
67-
| **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. |
66+
| **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused. |
67+
| **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value shorter than a bound declared later is never re-read and survives unrelated edits — only a write carrying a too-short value is refused. |
6868
| **rows** | `integer` | optional | Height of the INLINE multiline editor, in text rows (positive integer — the HTML textarea `rows` attribute; fullscreen/dialog editor surfaces size themselves and ignore it). Only authorable on multiline editor types: textarea, markdown, html, richtext. Omit it for the widget default height. |
6969
| **precision** | `integer` | optional | Total digits (non-negative integer) |
7070
| **scale** | `integer` | optional | Decimal places (non-negative integer) |
71-
| **min** | `number` | optional | Minimum value |
72-
| **max** | `number` | optional | Maximum value |
71+
| **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. |
72+
| **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. |
7373
| **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. |
7474
| **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. |
7575
| **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. |
@@ -103,7 +103,7 @@ const result = CurrencyConfigSchema.parse(data);
103103
| **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") |
104104
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is shown only when TRUE (else hidden). e.g. P`record.type == 'invoice'` |
105105
| **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'` |
106-
| **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. The only slot; the `conditionalRequired` alias was removed in protocol 17. |
106+
| **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a `validations[]` `script` rule, which re-checks the merged record with no exemption. Enforced by `evaluateValidationRules`. The only slot; the `conditionalRequired` alias was removed in protocol 17. |
107107
| **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
108108
| **widget** | `string` | optional | Form widget override — names a registered field component (resolved as `field:<widget>`) to render this field instead of the `type` default. Degrades to the `type` renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". |
109109
| **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI |
@@ -218,7 +218,7 @@ const result = CurrencyConfigSchema.parse(data);
218218
| **scale** | `integer` | optional | Decimal places to round a computed numeric/currency result to. |
219219
| **autofill** | `boolean` | optional | For `lookup` columns: picking a record copies its same-named fields into sibling columns (a product's unit_price/description). On by default; set false to disable. |
220220
| **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is read-only when TRUE, evaluated per row against the row as `record` plus the header as `parent` (e.g. P`parent.status == 'paid'`). |
221-
| **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is required when TRUE. Same `record` + `parent` scope as `readonlyWhen`. |
221+
| **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is required when TRUE. Same `record` + `parent` scope as `readonlyWhen`. PRESENTATION ONLY: this flags the cell inline-invalid in the grid; nothing on the write path reads it. The server-enforced contract is the child FIELD's own `requiredWhen` — a transition gate, see `Field.requiredWhen` — which hydration copies onto an identity-only column, so declaring the requirement here alone enforces nothing. |
222222

223223
### Nested Shape: `Field.summaryOperations`
224224

@@ -368,7 +368,7 @@ Allowed Values: `phone`, `id_card`, `bank_account`, `email`, `name`
368368
| **scale** | `integer` | optional | Decimal places to round a computed numeric/currency result to. |
369369
| **autofill** | `boolean` | optional | For `lookup` columns: picking a record copies its same-named fields into sibling columns (a product's unit_price/description). On by default; set false to disable. |
370370
| **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is read-only when TRUE, evaluated per row against the row as `record` plus the header as `parent` (e.g. P`parent.status == 'paid'`). |
371-
| **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is required when TRUE. Same `record` + `parent` scope as `readonlyWhen`. |
371+
| **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is required when TRUE. Same `record` + `parent` scope as `readonlyWhen`. PRESENTATION ONLY: this flags the cell inline-invalid in the grid; nothing on the write path reads it. The server-enforced contract is the child FIELD's own `requiredWhen` — a transition gate, see `Field.requiredWhen` — which hydration copies onto an identity-only column, so declaring the requirement here alone enforces nothing. |
372372

373373
### Nested Shape: `InlineGridColumn.options[number]`
374374

0 commit comments

Comments
 (0)