Skip to content

Commit 4f9d0cf

Browse files
committed
chore(spec): changeset + regenerate the reference docs for the narrowed predicate
`gen:docs` was the one artifact `check:generated` proved stale; the published reference now states `source` as required on `Predicate` / `PredicateInput`. Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6120437 commit 4f9d0cf

5 files changed

Lines changed: 41 additions & 17 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
**BREAKING (accept-set narrows)** — a predicate slot now accepts only what the expression engine can actually run. `PredicateSchema` / `PredicateInputSchema` compose the EVALUATED rule instead of the persistence contract, and the `FieldSchema` field-rule triad — `visibleWhen`, `readonlyWhen`, `requiredWhen` — binds them (#17778, ADR-0136 D1).
6+
7+
Clause-②: yes (narrowing)
8+
9+
**FROM → TO.** Two spellings stop parsing on those three slots, and on any slot composing the `Predicate*` aliases:
10+
11+
- `{ dialect: 'cel', ast: … }` with no `source` → **TO** `{ dialect: 'cel', source: "record.status == 'paid'" }`
12+
- `{ dialect: 'cel', source: ' ' }`, or the bare-string shorthand `' '` → **TO** the same non-blank envelope, or the `P` tagged template the examples use
13+
14+
**The one-line fix:** author the predicate's `source`, or REMOVE the key if the field was meant to carry no rule. Removal is behaviour-preserving here — all three slots resolved both refused spellings to a no-op already — so it is a safe default, but it records that the rule never ran. Authoring the `source` is the repair.
15+
16+
**Why this is a break worth taking.** The CEL engine evaluates `source` alone, so both spellings parsed, registered, passed `objectstack validate`, and then produced a rule that silently did nothing — by three *different* mechanisms, each measured at its own end: a field-level `visibleWhen` is never evaluated server-side at all; `isReadonlyWhenLocked` returns LOCKED only for an unbound-ROOT fault and logs `change allowed through` for every other one, which is the arm an unevaluable envelope takes, silently waiving the declared lock; `requiredWhen` logs and SKIPS the check. One unauthored predicate therefore made a form show more, lock less and demand less at once, with no state saying the rule had not run.
17+
18+
**`ExpressionSchema` / `ExpressionInputSchema` are NOT narrowed** — they remain the persistence contract (`source` OR `ast`), and an `ast` BESIDE a string `source` stays admitted everywhere. Absence is untouched: "no rule" was never a malformed rule.
19+
20+
**Declared beside the narrowing, delivered by consumers.** ADR-0136 also records the fault semantics the renderer and submit path are held to: a faulting field-rule predicate refuses the SUBMIT loudly, naming the field and the rule (D2), while visibility stays fail-OPEN at RENDER so a rule that could not run never hides a control and lets the form write `null` over a column the user never saw (D3) — a pair, neither safe alone. `packages/spec` carries no business logic, so those land in the ObjectUI half. The evaluation helper's fallback stays freely specifiable (D5): fault-to-flag and fault-to-throw both exist only because it is a parameter.
21+
22+
**Repo census.** Zero field-rule predicates of either refused spelling across `examples/`, `packages/`, `content/` and `skills/` at `03b7b8187`, against two live lit controls (15 files with non-blank tagged-template predicates, 14 with envelope-form ones). Nothing in this repository needed rewriting.
23+
24+
<!-- adr-0087: registered field-rule-predicate-evaluated-slot-source-required -->

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,9 @@ const result = CurrencyConfigSchema.parse(data);
102102
| **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) |
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") |
105-
| **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. |
105+
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is shown only when TRUE (else hidden). Needs a non-blank `source`: an evaluated slot is held to what the engine can run (ADR-0136 D1). A fault at RENDER is fail-open (the field shows) and refuses the SUBMIT, naming the field and this rule (D2/D3). 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. Needs a non-blank `source` (ADR-0136 D1); a fault refuses the submit and names the field and this rule (D2). 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. Needs a non-blank `source` (ADR-0136 D1); a fault refuses the submit and names the field and this rule (D2). 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. |
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 |

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -264,9 +264,9 @@ const result = ApiMethod.parse(data);
264264
| **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) |
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") |
267-
| **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. |
267+
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is shown only when TRUE (else hidden). Needs a non-blank `source`: an evaluated slot is held to what the engine can run (ADR-0136 D1). A fault at RENDER is fail-open (the field shows) and refuses the SUBMIT, naming the field and this rule (D2/D3). 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. Needs a non-blank `source` (ADR-0136 D1); a fault refuses the submit and names the field and this rule (D2). 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. Needs a non-blank `source` (ADR-0136 D1); a fault refuses the submit and names the field and this rule (D2). 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. |
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,9 +597,9 @@ const result = ApiMethod.parse(data);
597597
| **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) |
598598
| **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. |
599599
| **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") |
600-
| **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'` |
601-
| **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'` |
602-
| **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+
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is shown only when TRUE (else hidden). Needs a non-blank `source`: an evaluated slot is held to what the engine can run (ADR-0136 D1). A fault at RENDER is fail-open (the field shows) and refuses the SUBMIT, naming the field and this rule (D2/D3). e.g. P`record.type == 'invoice'` |
601+
| **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is read-only when TRUE. Needs a non-blank `source` (ADR-0136 D1); a fault refuses the submit and names the field and this rule (D2). e.g. P`record.status == 'paid'` |
602+
| **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. Needs a non-blank `source` (ADR-0136 D1); a fault refuses the submit and names the field and this rule (D2). 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. |
603603
| **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. |
604604
| **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". |
605605
| **hidden** | `boolean` | optional (default: `false`) | Hidden from default UI |

‎content/docs/references/shared/expression.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ Type: `string`
208208
| Property | Type | Required | Description |
209209
| :--- | :--- | :--- | :--- |
210210
| **dialect** | `Enum<'cel' \| 'cron' \| 'template'>` | ✅ | |
211-
| **source** | `string` | optional | |
211+
| **source** | `string` | ✅ | |
212212
| **ast** | `any` | optional | |
213213
| **meta** | `{ rationale?: string; generatedBy?: string }` | optional | |
214214

@@ -234,7 +234,7 @@ Type: `string`
234234
| Property | Type | Required | Description |
235235
| :--- | :--- | :--- | :--- |
236236
| **dialect** | `Enum<'cel' \| 'cron' \| 'template'>` | ✅ | |
237-
| **source** | `string` | optional | |
237+
| **source** | `string` | ✅ | |
238238
| **ast** | `any` | optional | |
239239
| **meta** | `{ rationale?: string; generatedBy?: string }` | optional | |
240240

0 commit comments

Comments
 (0)