diff --git a/.changeset/validation-transition-gate-vs-invariant-boundary.md b/.changeset/validation-transition-gate-vs-invariant-boundary.md new file mode 100644 index 0000000000..5b258c69e9 --- /dev/null +++ b/.changeset/validation-transition-gate-vs-invariant-boundary.md @@ -0,0 +1,38 @@ +--- +"@objectstack/spec": patch +--- + +docs(spec): state the transition-gate vs invariant boundary where each tool is declared + +`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 surface said so, while "required when X" reads to a human +and to an AI metadata author as an invariant. Measured downstream: three rules +written in prose as invariants were all implemented with the gate tool, with +nothing to signal the difference. + +The contract text now says it at each declaration, in the copy that ships as the +JSON Schema `description` and as the generated reference page: + +- `Field.requiredWhen` — the write is refused only when the merged record + violates 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 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). +- `min` / `max` / `minLength` / `maxLength` — checked on the WRITTEN value only, + because an UPDATE validates just the fields the payload carries; a stored + out-of-bound value is never re-read. +- `validations[]` `script` `condition` — re-evaluated against the merged record + on every write with no exemption for a violation that was already stored, so a + violating row is refused on any edit until a repairing write lands: frozen, + not bricked. +- The inline-grid column `requiredWhen` — presentation only. Nothing on the + write path reads it; the enforced contract is the child field's own + `requiredWhen`. + +Each half names the other tool, so a reader who picked the wrong one is +redirected rather than merely described to. No schema, accepted key set, +validator or runtime behaviour changes. diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index b594ed32a1..ba2be93ca3 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -63,13 +63,13 @@ const result = CurrencyConfigSchema.parse(data); | **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). | | **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' | | **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. | -| **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. | -| **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. | +| **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. | +| **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. | | **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. | | **precision** | `integer` | optional | Total digits (non-negative integer) | | **scale** | `integer` | optional | Decimal places (non-negative integer) | -| **min** | `number` | optional | Minimum value | -| **max** | `number` | optional | Maximum value | +| **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. | +| **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. | | **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. | | **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. | | **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); | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | | **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'` | | **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'` | -| **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. | +| **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. | | **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. | | **widget** | `string` | optional | Form widget override — names a registered field component (resolved as `field:`) to render this field instead of the `type` default. Degrades to the `type` renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". | | **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI | @@ -218,7 +218,7 @@ const result = CurrencyConfigSchema.parse(data); | **scale** | `integer` | optional | Decimal places to round a computed numeric/currency result to. | | **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. | | **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'`). | -| **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`. | +| **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. | ### Nested Shape: `Field.summaryOperations` @@ -368,7 +368,7 @@ Allowed Values: `phone`, `id_card`, `bank_account`, `email`, `name` | **scale** | `integer` | optional | Decimal places to round a computed numeric/currency result to. | | **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. | | **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'`). | -| **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`. | +| **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. | ### Nested Shape: `InlineGridColumn.options[number]` diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index de59cf7060..6dd342c6f7 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -225,13 +225,13 @@ const result = ApiMethod.parse(data); | **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). | | **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' | | **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. | -| **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. | -| **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. | +| **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. | +| **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. | | **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. | | **precision** | `integer` | optional | Total digits (non-negative integer) | | **scale** | `integer` | optional | Decimal places (non-negative integer) | -| **min** | `number` | optional | Minimum value | -| **max** | `number` | optional | Maximum value | +| **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. | +| **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. | | **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. | | **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. | | **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. | @@ -265,7 +265,7 @@ const result = ApiMethod.parse(data); | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | | **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'` | | **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'` | -| **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. | +| **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. | | **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. | | **widget** | `string` | optional | Form widget override — names a registered field component (resolved as `field:`) to render this field instead of the `type` default. Degrades to the `type` renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". | | **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI | @@ -554,13 +554,13 @@ const result = ApiMethod.parse(data); | **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). | | **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' | | **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. | -| **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. | -| **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. | +| **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. | +| **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. | | **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. | | **precision** | `integer` | optional | Total digits (non-negative integer) | | **scale** | `integer` | optional | Decimal places (non-negative integer) | -| **min** | `number` | optional | Minimum value | -| **max** | `number` | optional | Maximum value | +| **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. | +| **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. | | **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. | | **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. | | **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. | @@ -594,7 +594,7 @@ const result = ApiMethod.parse(data); | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | | **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'` | | **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'` | -| **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. | +| **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. | | **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. | | **widget** | `string` | optional | Form widget override — names a registered field component (resolved as `field:`) to render this field instead of the `type` default. Degrades to the `type` renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". | | **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI | diff --git a/content/docs/references/data/validation.mdx b/content/docs/references/data/validation.mdx index 6ec5925568..1d7648c068 100644 --- a/content/docs/references/data/validation.mdx +++ b/content/docs/references/data/validation.mdx @@ -51,6 +51,33 @@ no-ops. Use the layer that already does each one correctly: a `delete` event was a proven silent no-op — the enum value was removed rather than left advertised-but-unenforced (#3184; see docs/audits/2026-06-validationschema-property-liveness.md). +### Transition gate vs invariant — which tool enforces what + +A rule declared here is an INVARIANT. `script` (and `cross_field`, which shares its +evaluation path) is re-evaluated against the MERGED record — the prior row overlaid +with the incoming patch — on every write, with no exemption for a violation that was +already stored. A row that already violates is therefore refused on ANY edit until a +repairing write lands: **frozen, not bricked**. + +The FIELD-level checks are the other half of the boundary and are deliberately not +invariants. `Field.requiredWhen` is a **transition gate**: the write is refused only +when the merged record violates AND the pre-write record complied, so a row that +predates the rule keeps passing unrelated edits and state moves that stay inside the +gate. The field bounds (`min` / `max` / `minLength` / `maxLength`) are checked on the +WRITTEN value only, so a stored out-of-bound value is never re-read at all. Both are +what lets a new rule land on a deployed object without bricking existing rows +(ADR-0113 non-regression). + +Choosing between them: need an INVARIANT ("X may never exceed Y") ⇒ a `validations[]` +`script` rule, at the price of freezing existing violators until they are repaired. +Need a TRANSITION CONDITION ("by the time it reaches state S, X must be present") ⇒ +`Field.requiredWhen` or a field bound, at the price of letting rows that predate the +rule through. "Required when X" reads like an invariant and is not one — that +mis-read is what this section exists to prevent. The rules here and the field +predicates are both evaluated by `evaluateValidationRules` +(`objectql/src/validation/rule-validator.ts`); the field bounds by `validateRecord` +(`objectql/src/validation/record-validator.ts`). + ### Salesforce Comparison ObjectStack validation rules are inspired by Salesforce validation rules but enhanced: @@ -139,7 +166,7 @@ const result = ConditionalValidationSchema.parse(data); | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | | **type** | `'script'` | ✅ | | -| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Predicate (CEL). If TRUE, validation fails. e.g. P`record.amount < 0` | +| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Predicate (CEL). If TRUE, validation fails. e.g. P`record.amount < 0`. A TRUE INVARIANT, not a transition gate: re-evaluated against the merged record (the prior row overlaid with this write) on every write, with no exemption for a violation that was already stored — a row that already violates is refused on ANY edit until a repairing write lands (frozen, not bricked). Need a transition condition instead — one that only has to hold from a given state onward — use `Field.requiredWhen` or a field bound (`min` / `max` / `maxLength`), which judge the write rather than the stored row. | ### Nested Shape: `ConditionalValidation.then[type='state_machine']` @@ -260,7 +287,7 @@ const result = ConditionalValidationSchema.parse(data); | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | | **type** | `'script'` | ✅ | | -| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Predicate (CEL). If TRUE, validation fails. e.g. P`record.amount < 0` | +| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Predicate (CEL). If TRUE, validation fails. e.g. P`record.amount < 0`. A TRUE INVARIANT, not a transition gate: re-evaluated against the merged record (the prior row overlaid with this write) on every write, with no exemption for a violation that was already stored — a row that already violates is refused on ANY edit until a repairing write lands (frozen, not bricked). Need a transition condition instead — one that only has to hold from a given state onward — use `Field.requiredWhen` or a field bound (`min` / `max` / `maxLength`), which judge the write rather than the stored row. | ### Nested Shape: `ConditionalValidation.otherwise[type='state_machine']` @@ -474,7 +501,7 @@ const result = ConditionalValidationSchema.parse(data); | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | | **type** | `'script'` | ✅ | | -| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Predicate (CEL). If TRUE, validation fails. e.g. P`record.amount < 0` | +| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Predicate (CEL). If TRUE, validation fails. e.g. P`record.amount < 0`. A TRUE INVARIANT, not a transition gate: re-evaluated against the merged record (the prior row overlaid with this write) on every write, with no exemption for a violation that was already stored — a row that already violates is refused on ANY edit until a repairing write lands (frozen, not bricked). Need a transition condition instead — one that only has to hold from a given state onward — use `Field.requiredWhen` or a field bound (`min` / `max` / `maxLength`), which judge the write rather than the stored row. | --- @@ -540,7 +567,7 @@ This schema accepts one of the following structures: | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | | **type** | `'script'` | ✅ | | -| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Predicate (CEL). If TRUE, validation fails. e.g. P`record.amount < 0` | +| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Predicate (CEL). If TRUE, validation fails. e.g. P`record.amount < 0`. A TRUE INVARIANT, not a transition gate: re-evaluated against the merged record (the prior row overlaid with this write) on every write, with no exemption for a violation that was already stored — a row that already violates is refused on ANY edit until a repairing write lands (frozen, not bricked). Need a transition condition instead — one that only has to hold from a given state onward — use `Field.requiredWhen` or a field bound (`min` / `max` / `maxLength`), which judge the write rather than the stored row. | --- diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index e5e79611d5..521da9ddae 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -63,13 +63,13 @@ Add a new field to an existing object | **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). | | **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' | | **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. | -| **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. | -| **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. | +| **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. | +| **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. | | **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. | | **precision** | `integer` | optional | Total digits (non-negative integer) | | **scale** | `integer` | optional | Decimal places (non-negative integer) | -| **min** | `number` | optional | Minimum value | -| **max** | `number` | optional | Maximum value | +| **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. | +| **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. | | **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. | | **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. | | **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 @@ Add a new field to an existing object | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | | **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'` | | **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'` | -| **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. | +| **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. | | **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. | | **widget** | `string` | optional | Form widget override — names a registered field component (resolved as `field:`) to render this field instead of the `type` default. Degrades to the `type` renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". | | **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI | @@ -481,13 +481,13 @@ Add a new field to an existing object | **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). | | **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' | | **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. | -| **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. | -| **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. | +| **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. | +| **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. | | **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. | | **precision** | `integer` | optional | Total digits (non-negative integer) | | **scale** | `integer` | optional | Decimal places (non-negative integer) | -| **min** | `number` | optional | Minimum value | -| **max** | `number` | optional | Maximum value | +| **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. | +| **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. | | **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. | | **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. | | **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. | @@ -521,7 +521,7 @@ Add a new field to an existing object | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | | **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'` | | **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'` | -| **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. | +| **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. | | **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. | | **widget** | `string` | optional | Form widget override — names a registered field component (resolved as `field:`) to render this field instead of the `type` default. Degrades to the `type` renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". | | **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI | diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index d4e3c6b6df..294c9e127c 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -798,7 +798,7 @@ export const InlineGridColumnSchema = lazySchema(() => strictObject({ scale: z.number().int().nonnegative().optional().describe('Decimal places to round a computed numeric/currency result to.'), autofill: z.boolean().optional().describe("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."), readonlyWhen: ExpressionInputSchema.optional().describe("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'`)."), - requiredWhen: ExpressionInputSchema.optional().describe('Predicate (CEL) — the cell is required when TRUE. Same `record` + `parent` scope as `readonlyWhen`.'), + requiredWhen: ExpressionInputSchema.optional().describe('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.'), })); export const FieldSchema = lazySchema(() => { @@ -947,7 +947,7 @@ export const FieldSchema = lazySchema(() => { // server accepts, at severity error/destructive, before #11431 taught the // consumer to defend itself). Which TYPES may author the key is the // superRefine below (BOUNDED_STRING_FIELD_TYPES). - maxLength: z.number().int().min(1).optional().describe('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.'), + maxLength: z.number().int().min(1).optional().describe('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.'), // #11949 (maintainer ruling 2026-08-25) — `minLength` converges on the // #11566 template above, `maxLength`'s twin defect pair: same shape, same // applicability set, same forms convergence. The lower bound is deliberately @@ -955,7 +955,7 @@ export const FieldSchema = lazySchema(() => { // is a permanently-true declaration — exactly the vacuous noise an AI // metadata author mass-produces — and is refused loudly at authoring instead // of parsing cleanly and asserting nothing. - minLength: z.number().int().min(1).optional().describe('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.'), + minLength: z.number().int().min(1).optional().describe('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.'), // objectui#6140 (maintainer ruling 2026-08-25, Option A — verbatim: // 「就全部接受,然后继续下一批」): `rows` was consumed-but-undeclared. @@ -987,8 +987,8 @@ export const FieldSchema = lazySchema(() => { // own alias table (`scale → precision` there) — do not conflate. precision: z.number().int().min(0).optional().describe('Total digits (non-negative integer)'), scale: z.number().int().min(0).optional().describe('Decimal places (non-negative integer)'), - min: z.number().optional().describe('Minimum value'), - max: z.number().optional().describe('Maximum value'), + min: z.number().optional().describe('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.'), + max: z.number().optional().describe('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.'), /** * Presentation hint (#7768): whether a `number` field renders with digit * grouping (`Intl.NumberFormat`'s `useGrouping`, e.g. `2,026` vs `2026`). @@ -1446,7 +1446,7 @@ export const FieldSchema = lazySchema(() => { */ visibleWhen: ExpressionInputSchema.optional().describe("Predicate (CEL) — field is shown only when TRUE (else hidden). e.g. P`record.type == 'invoice'`"), readonlyWhen: ExpressionInputSchema.optional().describe("Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'`"), - requiredWhen: ExpressionInputSchema.optional().describe("Predicate (CEL) — field is required when TRUE. The only slot; the `conditionalRequired` alias was removed in protocol 17."), + requiredWhen: ExpressionInputSchema.optional().describe("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."), /** * [REMOVED in protocol 17 — #3855] The deprecated alias of `requiredWhen`. diff --git a/packages/spec/src/data/validation-boundary-description.test.ts b/packages/spec/src/data/validation-boundary-description.test.ts new file mode 100644 index 0000000000..94d56b02ad --- /dev/null +++ b/packages/spec/src/data/validation-boundary-description.test.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#13879] The transition-gate vs invariant boundary, pinned as contract text. + * + * Two write-time mechanisms in this spec look interchangeable and are not, and + * the difference is invisible from the key names — which is the whole reason + * this pin exists: + * + * - `Field.requiredWhen` and the field bounds (`min` / `max` / `minLength` / + * `maxLength`) are **transition gates**. `requiredWhen` refuses a write only + * when the merged record violates AND the pre-write record complied + * (`evaluateValidationRules`, the ADR-0113 non-regression branch); the bounds + * are checked on the WRITTEN value only, because `validateRecord` iterates the + * UPDATE payload rather than the field map. A row that predates the rule keeps + * passing unrelated edits either way. + * - A `validations[]` `script` rule is a **true invariant**: `checkPredicate` + * evaluates it against the merged record on every write with no pre-state + * exemption, so a row that already violates is refused on any edit until a + * repairing write lands — frozen, not bricked. + * + * Both semantics are deliberate and neither is changed here. What was missing is + * that no platform surface SAID so, while "required when X" reads to a human (and + * to an AI metadata author) as an invariant — measured downstream, where three + * rules written in prose as invariants were all implemented with the gate tool + * and nothing signalled it. + * + * ⛔ These assertions are on the `.describe()` text reached THROUGH the schema, + * not on the source file: that text is what ships as the JSON Schema + * `description` and as the generated `content/docs/references/` page, so it is + * the copy an author actually reads. A source grep would pass on a sentence that + * never reaches either. + * + * Each half must also NAME THE OTHER TOOL — a statement of the boundary that + * only describes the tool you already chose does not redirect anyone. + */ + +import { describe, it, expect } from 'vitest'; + +import { FieldSchema, InlineGridColumnSchema } from './field.zod'; +import { ScriptValidationSchema } from './validation.zod'; + +const fieldDoc = (key: string): string => + ((FieldSchema.shape as Record)[key]?.description) ?? ''; + +describe('#13879 — `Field.requiredWhen` states its TRANSITION-GATE semantics', () => { + it('names the class and the exact rejection condition', () => { + const doc = fieldDoc('requiredWhen'); + expect(doc).toContain('TRANSITION GATE'); + // The mechanism, not just the label: reject iff merged violates AND the + // pre-write record complied. Without this clause "transition gate" is a + // word an author cannot act on. + expect(doc).toContain('the merged record violates'); + expect(doc).toContain('the pre-write record complied'); + }); + + it('names the three refused writes and the writes that stay legal', () => { + const doc = fieldDoc('requiredWhen'); + expect(doc).toContain('flips the predicate TRUE'); + expect(doc).toContain('INSERT born inside the gate'); + expect(doc).toContain('clears the cell'); + // The other direction — the half an author is surprised by. + expect(doc).toContain('unrelated edits'); + expect(doc).toContain('inside the gate'); + }); + + it('redirects to the invariant tool by name', () => { + const doc = fieldDoc('requiredWhen'); + expect(doc).toContain('validations[]'); + expect(doc).toContain('`script`'); + expect(doc).toContain('evaluateValidationRules'); + }); + + it('does NOT claim invariant semantics (the control)', () => { + // The failure this pin is really guarding: a later edit that "tidies" the + // two texts into one. `requiredWhen` must keep saying it is NOT an + // invariant, in those words. + const doc = fieldDoc('requiredWhen'); + expect(doc).toContain('not an invariant'); + expect(doc).not.toContain('frozen, not bricked'); + }); +}); + +describe('#13879 — the field bounds state the same transition-gate class', () => { + // Hardcoded rather than derived from a module set, so this is an independent + // measurement of which keys carry the statement. + it.each(['min', 'max', 'minLength', 'maxLength'])( + '`%s` says the check reads the WRITTEN value only', + (key) => { + const doc = fieldDoc(key); + expect(doc).toContain('WRITTEN value only'); + expect(doc).toContain('never re-read'); + expect(doc).toContain('survives unrelated edits'); + }, + ); + + it.each(['min', 'max'])('`%s` redirects to the invariant tool by name', (key) => { + const doc = fieldDoc(key); + expect(doc).toContain('validations[]'); + expect(doc).toContain('`script`'); + }); + + it.each(['minLength', 'maxLength'])( + '`%s` places itself in the `min`/`max` class rather than restating it', + (key) => { + expect(fieldDoc(key)).toContain('`min`/`max` transition-gate class'); + }, + ); +}); + +describe('#13879 — the inline-grid column `requiredWhen` says it enforces nothing', () => { + // The trap this closes: the grid column mirrors objectui's renderer and has + // no write-path reader at all (`inlineColumns` is classified `presentation` + // by driver-sql). An author who writes the requirement only here gets no + // server enforcement — not a weaker one, none. + const doc = ((InlineGridColumnSchema.shape as Record) + .requiredWhen?.description) ?? ''; + + it('says presentation only, and that nothing on the write path reads it', () => { + expect(doc).toContain('PRESENTATION ONLY'); + expect(doc).toContain('nothing on the write path reads it'); + }); + + it('points at the child FIELD predicate as the enforced contract', () => { + expect(doc).toContain('`Field.requiredWhen`'); + expect(doc).toContain('enforces nothing'); + }); +}); + +describe('#13879 — the `script` rule states its INVARIANT semantics', () => { + const doc = ((ScriptValidationSchema.shape as Record) + .condition?.description) ?? ''; + + it('names the class, the scope and the cadence', () => { + expect(doc).toContain('TRUE INVARIANT'); + expect(doc).toContain('merged record'); + expect(doc).toContain('on every write'); + expect(doc).toContain('no exemption'); + }); + + it('states the frozen-not-bricked consequence for a pre-existing violation', () => { + expect(doc).toContain('refused on ANY edit'); + expect(doc).toContain('repairing write'); + expect(doc).toContain('frozen, not bricked'); + }); + + it('redirects to the transition-gate tools by name', () => { + expect(doc).toContain('`Field.requiredWhen`'); + expect(doc).toContain('field bound'); + }); + + it('does NOT claim transition-gate semantics (the control)', () => { + expect(doc).toContain('not a transition gate'); + }); +}); diff --git a/packages/spec/src/data/validation.zod.ts b/packages/spec/src/data/validation.zod.ts index 67dec46237..7571fca74e 100644 --- a/packages/spec/src/data/validation.zod.ts +++ b/packages/spec/src/data/validation.zod.ts @@ -52,8 +52,35 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; * a `delete` event was a proven silent no-op — the enum value was removed rather than left * advertised-but-unenforced (#3184; see docs/audits/2026-06-validationschema-property-liveness.md). * + * ## Transition gate vs invariant — which tool enforces what + * + * A rule declared here is an INVARIANT. `script` (and `cross_field`, which shares its + * evaluation path) is re-evaluated against the MERGED record — the prior row overlaid + * with the incoming patch — on every write, with no exemption for a violation that was + * already stored. A row that already violates is therefore refused on ANY edit until a + * repairing write lands: **frozen, not bricked**. + * + * The FIELD-level checks are the other half of the boundary and are deliberately not + * invariants. `Field.requiredWhen` is a **transition gate**: the write is refused only + * when the merged record violates AND the pre-write record complied, so a row that + * predates the rule keeps passing unrelated edits and state moves that stay inside the + * gate. The field bounds (`min` / `max` / `minLength` / `maxLength`) are checked on the + * WRITTEN value only, so a stored out-of-bound value is never re-read at all. Both are + * what lets a new rule land on a deployed object without bricking existing rows + * (ADR-0113 non-regression). + * + * Choosing between them: need an INVARIANT ("X may never exceed Y") ⇒ a `validations[]` + * `script` rule, at the price of freezing existing violators until they are repaired. + * Need a TRANSITION CONDITION ("by the time it reaches state S, X must be present") ⇒ + * `Field.requiredWhen` or a field bound, at the price of letting rows that predate the + * rule through. "Required when X" reads like an invariant and is not one — that + * mis-read is what this section exists to prevent. The rules here and the field + * predicates are both evaluated by `evaluateValidationRules` + * (`objectql/src/validation/rule-validator.ts`); the field bounds by `validateRecord` + * (`objectql/src/validation/record-validator.ts`). + * * ## Salesforce Comparison - * + * * ObjectStack validation rules are inspired by Salesforce validation rules but enhanced: * - Salesforce: Formula-based validation with `Error Condition Formula` * - ObjectStack: Multiple validation types with composable rules @@ -150,7 +177,7 @@ export const ScriptValidationSchema = lazySchema(() => strictObject({ }, { ...BASE_VALIDATION_SHAPE, type: z.literal('script'), - condition: ExpressionInputSchema.describe('Predicate (CEL). If TRUE, validation fails. e.g. P`record.amount < 0`'), + condition: ExpressionInputSchema.describe('Predicate (CEL). If TRUE, validation fails. e.g. P`record.amount < 0`. A TRUE INVARIANT, not a transition gate: re-evaluated against the merged record (the prior row overlaid with this write) on every write, with no exemption for a violation that was already stored — a row that already violates is refused on ANY edit until a repairing write lands (frozen, not bricked). Need a transition condition instead — one that only has to hold from a given state onward — use `Field.requiredWhen` or a field bound (`min` / `max` / `maxLength`), which judge the write rather than the stored row.'), })); /**