Skip to content

Commit e4471e6

Browse files
fix(lint)!: refuse a field-level predicate that reads through a reference field at objectstack validate (#20185)
Fixes #20078 Clause-②: no ## What changes `objectstack validate` (and `build` / `lint`, which run the same authoring rule) now refuse a field-level `requiredWhen` / `readonlyWhen`, or a select option's `visibleWhen`, that reads THROUGH a reference field (`lookup` / `master_detail` / `user` / `tree`) — `record.account.tier`. The refusal is an `expression-invalid` error located at the slot (`object 'O' · field 'F' requiredWhen`, `… readonlyWhen`, `… option 'V' visibleWhen`), naming the reference path and the related column(s), with the repair that is true for the root the predicate reads: - `record.REF.COL` — "Express the check as a `validations[]` `script` rule, or read a column this object declares." These are the same words the runtime's own refusal of this fault carries (`unevaluableFieldRuleError` in `rule-validator.ts`), so the author meets one repair at both ends. - `previous.REF.COL` — no seam hydrates `previous`, a validation rule included, so the repair is to compare the reference itself or read an own column. - `parent.REF.COL` on a master-detail line item (judged against the master's declared field types) — no validation rule binds `parent`, so the repair is a column the master declares. This is remedy A as triage routed it (comment 5825661201). Remedy B, hydrating the field level, is not attempted. The runtime is untouched. | File | Change | |---|---| | `packages/lint/src/validate-expressions.ts` | `refuseFieldTraversal` in the field walk (requiredWhen, readonlyWhen, option visibleWhen); `fieldTraversalMessage`; `singleMasterOf` | | `packages/lint/src/validate-expressions.test.ts` | 18 cases: 10 refusals (each slot, each root, the null-safe spellings, one error per reference) and 8 controls | | `packages/cli/test/validate-field-predicate-traversal.test.ts` | the validate door, spawned (integration project, queue tier): a traversing config exits 1 with exactly three `expression-invalid` errors carrying the prescription; a control config exits 0 | | `packages/spec/src/data/field.zod.ts` | `.describe()` and TSDoc of the three slots state the rule | | `packages/spec/src/migrations/entries/semantic/18.field-predicate-reference-traversal-refused.ts` + regenerated `registry.ts` | ADR-0087 semantic entry | | `content/docs/references/**` | regenerated by `gen:docs`, not hand-edited | | `.changeset/20078-field-predicate-reference-traversal-refused.md` | `@objectstack/lint` + `@objectstack/spec` minor, declared breaking (`!` and banner), disposition `registered` | ## The dispatch's mechanism hypotheses, measured - **H1 — confirmed at the door.** On the unfixed tree (lint dist built from `49144fc` sources; `grep -c` of the new code in `packages/lint/dist/index.js` = 0), `objectstack validate` over a config carrying all three traversing predicates printed `Validation passed`, exit 0. With the fix the same config exits 1 (the CLI test above). - **H2 — the shared judgment exists; nothing on the runtime side moved.** The runtime names a field-rule traversal fault with `@objectstack/formula`'s `analyzeRelationshipTraversals` plus the reference arbiter (`referenceTargetOf` over `REFERENCE_VALUE_TYPES`). The lint pass composes the same two: `analyzeRelationshipTraversals` imported from `@objectstack/formula`, `REFERENCE_VALUE_TYPES` from `@objectstack/spec/data` (the set `validateExpression`'s own traversal-conflict arm asks). The runtime has no pre-evaluation field-level check to import; it detects the fault after evaluation. No `domain:engine` file is touched. - **H3 — the `current_user` option shapes stay accepted.** Only `record` / `previous` / `parent` member chains are analysed, so `current_user.can('trav_order', 'edit') && 'admin' in current_user.positions` on an option is a pinned control (lint unit test and the CLI control config), green before and after. - **H4 — the route is `registered`.** The rule text: a narrowing of a published accept set is a breaking change (`scripts/pm/clause2-line.mjs`, AGENTS.md Post-Task step 3), and a declared-breaking changeset must carry an ADR-0087 disposition (`check-adr-0087-registration.mjs`). The candidates: `no-migration-prescription` is refused by the gate when the changeset carries a rewrite recipe, and this one must (the fix is a rewrite into a `validations[]` rule), so claiming it would rest on a detector miss; `registered` gives deployed metadata a structured TODO in the step-18 semantic list, which is what triage asked for (「按 ADR-0087 的规则登记这条收窄,写更新说明」). No D2 conversion: turning a field predicate into a validation rule inverts it into a failure condition and changes form behaviour, which is the author's judgement. Gate reading: `✓ check-adr-0087-registration: 1 declared-breaking changeset(s) … [BREAKING+bang] registered field-predicate-reference-traversal-refused (new here: field-predicate-reference-traversal-refused)`. - **Census (in-repo half).** Through the lint rule itself: platform objects — 50 objects, 0 field-predicate slots present, 0 refusals, positive control (one injected traversal) fired 1; `examples/app-showcase` objects — 24 objects, 12 field-predicate slots present, 0 refusals. Through the validate door: `app-todo`, `app-crm`, `app-multi-package` valid, 0 errors. `app-showcase` could not load at the door locally (`connector-mcp` dist not built), hence the object-level reading above. Deployed metadata: NOT MEASURED. ## The runtime behaviour the messages state (engine probe, scratch file, not committed) In-memory driver, objectql at base source: | Shape | Outcome | |---|---| | `requiredWhen: record.account.tier == 'enterprise'` — insert, and an unrelated update | both REFUSED, `rule_violation` / `unevaluable`, "reads 'tier' through 'account'" | | `readonlyWhen: record.account.tier == 'gold'` — update writing the field / update not writing it / insert | REFUSED / accepted / accepted | | option `visibleWhen: record.account.tier == 'enterprise'` picked with a `gold` and an `enterprise` account | both ACCEPTED (fail-open, gate never enforced) | | `requiredWhen: previous.account.tier == 'gold'` — update | REFUSED | | `requiredWhen: parent.account.tier == 'gold'` on a detail line — insert | REFUSED | | the prescribed rewrites: script `record.account.tier == 'enterprise' && (record.po_number == null or empty)`; script with `events: ['update']` and `record.account.tier == 'gold' && record.discount != previous.discount` | refused exactly on the failure rows, accepted otherwise | ## Verification — HEAD `55f3d0a800` - `pnpm --filter @objectstack/lint test` — 4162 passed; `pnpm --filter @objectstack/lint typecheck` — exit 0. - `pnpm --filter @objectstack/cli exec vitest run --project integration test/validate-field-predicate-traversal.test.ts` — 2 passed. `--project unit test/vitest-tiers-partition.test.ts` — 22 passed. `pnpm --filter @objectstack/cli typecheck` — exit 0. The rest of the CLI unit and integration tiers: not run locally, declared to CI. - `pnpm --filter @objectstack/spec exec vitest run --project local src/migrations src/data` — 3307 passed, 2 todo (a declared narrowing of the spec suite); `pnpm --filter @objectstack/spec typecheck` — exit 0; `pnpm --filter @objectstack/spec check:generated` — all 15 artifacts up to date. - `packages/objectql/src/engine-field-predicate-fault.test.ts` (read-only control) — 13 passed; the runtime refusal is unchanged. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran …` — `112 derived famil(ies) accounted for — 110 run, 2 NOT-MEASURED`. NOT MEASURED: `check:dual-build-cjs-loads` (exit 3, PREREQUISITE NOT MET: unrelated packages have no local dist); `check:type-check-debt` (its re-measure leg rebuilds package closures; the one local run hit my 400s wrapper and was killed; repo-wide ratchet, CI-owned). Every other family exit 0. - ESLint, narrowed and proven: the 6 changed TypeScript files, `eslint --no-inline-config --format json`: 6 files, 0 errors, 0 warnings. The config never enables type-aware linting (no `parserOptions.project`, per `eslint.config.mjs`), so this diff cannot move a verdict on an untouched file. Repo-wide `pnpm lint` is CI's. ## Reverse verification `node scripts/ablation-replace.mjs` on `validate-expressions.ts`, anchor `for (const { root, types, owner } of holders) {` → `… of [] as FieldTraversalHolder[]) {` (anchor 1 → 0, blob `42952246fad7` → `15165f37c26b`), then the lint test file: **10 failed, 327 passed** — exactly the 10 refusal cases; all 8 controls stayed green. Restored: blob == HEAD (`42952246fad7`), `git diff HEAD` empty; re-run 337 passed. The lint tests import the rule from `src`, so no build sits between the mutation and the reading. The door's before/after is H1 above: the unfixed dist passed the config, the fixed dist refuses it. ## Acceptance notes - **`Clause-②` arm.** The claim's line is `Clause-②: no` with no arm, copied verbatim above. The change narrows `objectstack validate`'s accept set, which `clause2-line.mjs` spells `no (narrowing)`; breaking-ness is carried in the changeset by the `!` summary and the `**BREAKING**` banner instead, and the ADR-0087 gate reads it (`[BREAKING+bang]`). - **Runtime wording for `previous` / `parent` traversals** (same family, not filed, carrier: none). The probe shows the runtime refusing them with the generic sentence "reads 'tier', which this object does not declare — fix the rule's condition, or declare the field", the repair the runtime's own docblock calls wrong for a traversal; its specific sentence recognises the `record` root only. With this PR authoring refuses both with the right repair, so the runtime sentence is reached only by metadata that never passes the authoring rule. `rule-validator.ts` is `domain:engine` and outside this claim. - **Stored objects are not re-judged.** This rule's runtime publish-gate surface covers flow / action / hook writes, not object writes, so an object saved through Studio, REST or `sys_metadata` keeps being refused at write time exactly as before. - **Coverage boundary.** Injected system columns (`owner_id`, `created_by`, …) carry no type in the lint index, so a read through one is not refused (a missed finding, never a false one). Field-level `visibleWhen`, form-view option `visibleWhen` and action-param option `visibleWhen` are outside the three slots triage named and are not judged. - **Shared describe.** `FormSelectOptionSchema` reuses `SelectOptionSchema.visibleWhen` by reference, so the option's `.describe()` also renders on the form-view face; the added sentence is scoped to "an OBJECT field's option" so it stays true there. The pre-existing sentence ("The one VISIBILITY predicate the SERVER also enforces") renders on the form-view face too; not measured here. - **Not merged with `main`.** Base `49144fc`; `origin/main` has moved (including new step-18 registry entries). A driver-less `git merge-tree` probe (bare shared clone, no `merge.os-regen` driver) merged clean. `dispatch-gates.mjs` flagged the tree as behind `origin/main` with its own family files changed there, so the family list above is this tree's. --- _Generated by [Claude Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 98f722a commit e4471e6

11 files changed

Lines changed: 759 additions & 25 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
'@objectstack/lint': minor
3+
'@objectstack/spec': minor
4+
---
5+
6+
fix(lint)!: a field-level predicate that reads through a reference field is refused at `objectstack validate` (#20078)
7+
8+
<!-- adr-0087: registered field-predicate-reference-traversal-refused -->
9+
10+
**BREAKING** in the accept-set sense — a stack that validates today can fail tomorrow. Landing in
11+
the launch window as `minor` (`major` is refused by `check-changeset-no-major`); breaking-ness is
12+
carried by this banner, the `!` above and the ADR-0087 registration.
13+
14+
Clause-②: no
15+
16+
A field `requiredWhen` / `readonlyWhen`, or a select option's `visibleWhen`, that reads THROUGH a
17+
`lookup` / `master_detail` / `user` / `tree` field — `record.account.tier` — passed `objectstack
18+
validate`, `build` and `lint`. It cannot work: the field level is never hydrated, so the reference
19+
holds the related record's bare id and every read through it faults. At run time a traversing
20+
`requiredWhen` refuses every write that reaches it, a traversing `readonlyWhen` refuses every
21+
update that writes its field (ADR-0137 D2), and a traversing option predicate is never enforced
22+
(option visibility is fail-open). The authoring pass now refuses all three as
23+
`expression-invalid`, naming the slot, the reference path and the related column, before deploy.
24+
25+
What to write instead — the refusal says the same:
26+
27+
- **A `record.<reference>.<column>` read** — express the check as a `validations[]` rule of
28+
`type: 'script'`. Its `condition` is the one predicate the server reads one hop through a
29+
reference, and it states the FAILURE: for `requiredWhen: P` on `po_number`,
30+
`P && (record.po_number == null || record.po_number == '')`; for `readonlyWhen: P` on
31+
`discount`, `P && record.discount != previous.discount` with `events: ['update']`; for an
32+
option gated by `P`, that option picked while `P` does not hold (the option is then offered to
33+
everyone and refused on save). Or read a column the object itself declares.
34+
- **A `previous.<reference>.<column>` or `parent.<reference>.<column>` read** — no seam hydrates
35+
either root, a validation rule included, so read a column the bound record declares.
36+
37+
Unchanged: the same traversal inside a `validations[]` `script` rule is accepted, as is reading
38+
the reference itself (`record.account == 'acc_1'`, `record.account != null`), an object-valued
39+
field that is not a reference (`record.ship_to.city`), and an option gated on `current_user`
40+
(including `current_user.can(…)`). The runtime is untouched, and an object already stored in
41+
`sys_metadata` is not re-validated by this. `@objectstack/spec` states the rule on the three
42+
slots' `.describe()` text and registers the ADR-0087 semantic entry
43+
`field-predicate-reference-traversal-refused`.

‎content/docs/references/data/field.mdx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,8 @@ const result = CurrencyConfigSchema.parse(data);
103103
| **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. |
104104
| **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") |
105105
| **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'` |
106-
| **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'` |
107-
| **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. |
106+
| **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'`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every update that writes the field — `objectstack validate` refuses it. Put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. |
107+
| **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`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every write that reaches it — `objectstack validate` refuses it; put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. The only slot; the `conditionalRequired` alias was removed in protocol 17. |
108108
| **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. |
109109
| **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". |
110110
| **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI |
@@ -194,7 +194,7 @@ const result = CurrencyConfigSchema.parse(data);
194194
| **description** | `string` | optional | Optional secondary/help text for this option. Lookup option search matches it in addition to the label; renderers may show it as supporting text. |
195195
| **color** | `string` | optional | Color code for badges/charts |
196196
| **default** | `boolean` | optional | Is default option |
197-
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions` |
197+
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`. On an OBJECT field's option it reads the record's OWN columns: the server never reads a related record there, so a read THROUGH a reference field (`record.account.tier`) would fault and be admitted unchecked, and `objectstack validate` refuses it — enforce such a restriction with a `validations[]` `script` rule, whose `condition` is read one hop through a reference. |
198198

199199
### Nested Shape: `Field.inlineColumns[number]`
200200

@@ -406,7 +406,7 @@ Allowed Values: `phone`, `id_card`, `bank_account`, `email`, `name`
406406
| **description** | `string` | optional | Optional secondary/help text for this option. Lookup option search matches it in addition to the label; renderers may show it as supporting text. |
407407
| **color** | `string` | optional | Color code for badges/charts |
408408
| **default** | `boolean` | optional | Is default option |
409-
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions` |
409+
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions`. On an OBJECT field's option it reads the record's OWN columns: the server never reads a related record there, so a read THROUGH a reference field (`record.account.tier`) would fault and be admitted unchecked, and `objectstack validate` refuses it — enforce such a restriction with a `validations[]` `script` rule, whose `condition` is read one hop through a reference. |
410410

411411

412412
---

‎content/docs/references/data/object.mdx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -265,8 +265,8 @@ const result = ApiMethod.parse(data);
265265
| **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. |
266266
| **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") |
267267
| **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'` |
268-
| **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'` |
269-
| **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. |
268+
| **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'`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every update that writes the field — `objectstack validate` refuses it. Put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. |
269+
| **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`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every write that reaches it — `objectstack validate` refuses it; put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. The only slot; the `conditionalRequired` alias was removed in protocol 17. |
270270
| **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. |
271271
| **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". |
272272
| **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI |
@@ -597,8 +597,8 @@ const result = ApiMethod.parse(data);
597597
| **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. |
598598
| **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") |
599599
| **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'` |
600-
| **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'` |
601-
| **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. |
600+
| **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'`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every update that writes the field — `objectstack validate` refuses it. Put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. |
601+
| **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`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every write that reaches it — `objectstack validate` refuses it; put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. The only slot; the `conditionalRequired` alias was removed in protocol 17. |
602602
| **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. |
603603
| **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". |
604604
| **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI |

0 commit comments

Comments
 (0)