Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/27587-objectui-skill-teaches-cel-predicates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/skills': patch
---

docs(skills): objectui teaches CEL for a bare-string predicate, with a FROM → TO list for the JavaScript spellings

Clause-②: no
Metadata-protocol: none

- **Before.** `skills/objectui` taught bare predicates in the legacy JavaScript grammar: `"hidden": "data.status === 'draft'"` in `rules/protocol.md`, `data.status === 'active'` and `data.status !== 'active'` in `guides/schema-expressions.md`, and an operator list (`===`, `!==`, `.includes()`, `.length`) that read as condition syntax. Since #27584 the console reads a bare string in a predicate key as CEL, as the spec defines it and the server reads it, so an agent following the skill wrote a gate that faulted and hid the node.
- **Now.** Every bare-predicate example is CEL. The guide says a bare condition string is CEL on `@objectstack/formula` and that the operator list is the `${…}` template grammar only, and it gives a FROM → TO list measured on the console's evaluator: `===` / `!==` → `==` / `!=`; `list.includes(x)` → `x in list`, `s.includes('x')` → `s.contains('x')`; `list.length` → `size(list)`; a `data.*` key the scope may lack → `has(data.x) && …`, because CEL faults on a missing key. The row-predicate bullet says only a `${…}` condition still reads on the legacy engine. Eval 2 in `evals/schema-expressions.json` hands over a JS-syntax predicate and expects the CEL rewrite.
- No schema, key or behaviour changes.
7 changes: 4 additions & 3 deletions skills/objectui/evals/schema-expressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@
},
{
"id": 2,
"prompt": "I need to make a button visible only to admin users AND only when the record status is 'active'. Also, the button should show the record owner's name. Show me the correct schema.",
"expected_output": "Produces a schema with combined visible/hidden expression using && operator, and does not put the owner name in props.label — that envelope is evaluated and then discarded. The name is resolved in the host, or carried on a text node's content.",
"prompt": "My button should show only to admins AND only while the status is 'active', with the owner's name on it. I wrote \"visibleWhen\": \"data.role === 'admin' && data.status === 'active'\" and it never shows. Fix the schema.",
"expected_output": "Says a bare predicate is CEL, so === faults and hides the node; rewrites it with == and &&. The owner name goes on a text node's content, not props.label.",
"files": [],
"assertions": {
"must_contain": [
"visible",
"CEL",
"status == 'active'",
"&&",
"${"
],
Expand Down
41 changes: 22 additions & 19 deletions skills/objectui/guides/schema-expressions.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,8 @@ Writing and debugging dynamic expressions in ObjectUI schemas. The expression sy

ObjectUI uses a two-tier expression evaluator:

1. **Template expressions** — strings containing `${...}` placeholders: `"Hello ${user.name}"`
2. **Condition expressions** — raw boolean expressions without wrappers: `data.status === 'active'`

Both are parsed by a **recursive-descent parser** (no `eval()`, no `new Function()`). This means expressions are CSP-safe and work under strict Content Security Policy headers.
1. **Template expressions** — strings containing `${...}` placeholders: `"Hello ${user.name}"`, parsed by a **recursive-descent parser** (no `eval()`, no `new Function()`), so CSP-safe.
2. **Condition expressions** — a bare string in a predicate key: `data.status == 'active'`. It is **CEL**, evaluated by `@objectstack/formula`, as on the server.

Key files (for reference, not for editing):
- `packages/core/src/evaluator/ExpressionEvaluator.ts` — main entry point
Expand Down Expand Up @@ -55,11 +53,6 @@ this rule. The four cases that decide most schemas:
{ "type": "text", "content": "${data.customer.name}" }
```

`title` / `label` / `value` / `description` are template-evaluated on the types
that declare them -- `statistic` (`label` / `value` / `description`), `card`
(`title` / `description`), `button` (`label`) -- and read raw everywhere else.
The set is closed and declared in `@objectstack/spec`.

## Template expression syntax (`${}`)

### Basic property access
Expand All @@ -79,6 +72,9 @@ ${!isLocked} → boolean negation
```

### Supported operators (full list)

The `${…}` grammar only. A bare condition string is CEL — see "Condition fields".

- Arithmetic: `+`, `-`, `*`, `/`, `%`
- Comparison: `>`, `<`, `>=`, `<=`, `==`, `===`, `!=`, `!==`
- Logical: `&&`, `||`, `!`
Expand Down Expand Up @@ -237,7 +233,7 @@ name, so calls are case-insensitive.
### Syntax options

The node-level condition keys are `visibleWhen` (show), `hidden` and
`disabled`. Each takes a boolean, a `${}` template, or a raw expression:
`disabled`. Each takes a boolean, a `${}` template, or a bare CEL string:

<!-- os:check -->
```jsonc
Expand All @@ -251,9 +247,19 @@ The node-level condition keys are `visibleWhen` (show), `hidden` and

<!-- os:check -->
```jsonc
{ "hidden": "data.status !== 'active'" } // raw expression (no ${} needed)
{ "hidden": "data.status != 'active'" } // bare string = CEL (no ${} needed)
```

A JS-only spelling in a bare string faults: a visibility gate hides the node,
`disabled` disables. FROM → TO:

- `a === b` / `a !== b` → `a == b` / `a != b`
- `list.includes(x)` → `x in list`; `s.includes('x')` → `s.contains('x')`
- `list.length` → `size(list)`
- a `data.*` key the scope may lack (`data.x == 1`, `!data.x`, `?.`, `??`) →
`has(data.x) && data.x == 1`, `!has(data.x) || !data.x`: CEL faults on a
missing key

`visibleOn` / `hiddenOn` / `disabledOn` are retired and refused by name —
rename to `visibleWhen` / `hidden` / `disabled`; the value is unchanged.

Expand Down Expand Up @@ -292,10 +298,8 @@ rename to `visibleWhen` / `hidden` / `disabled`; the value is unchanged.
## Field-level conditional rules (`visibleWhen` / `readonlyWhen` / `requiredWhen`)

These live on the **object's field metadata**, are written in CEL, and are
evaluated by `@objectstack/formula` -- a different tier from the node-level
conditions above, which are schema/widget-level and run on the recursive-descent
`SafeExpressionParser`. **How to author them is the `objectstack-data` skill's
job**, under "Conditional Field Rules": the predicate scope, the client-only vs
evaluated by `@objectstack/formula`. **How to author them is the
`objectstack-data` skill's job**, under "Conditional Field Rules": the predicate scope, the client-only vs
client-and-server enforcement split, the invariant-vs-transition-gate choice,
and the protocol-17 removal of the `conditionalRequired` alias are all there.
Author them in the canonical tagged-template form that skill teaches
Expand Down Expand Up @@ -332,9 +336,8 @@ Three renderer-side facts that live nowhere else:
operator, value }` form and the `{ expression: "${…}" }` template form are
retired, refused at the schema door by name with the respelling. A STORED
legacy rule still renders through the shared evaluator (a compatibility read,
not an authoring form); a `condition` in legacy-only syntax (`${…}`, `===`,
`.includes()`) still routes to the old engine with a one-time deprecation
warning -- write CEL.
not an authoring form); only a `${…}` `condition` still routes to the old
engine. `===` or `.includes()` faults on CEL. Both warn once -- write CEL.
- **`data.*` is the trap in a row predicate.** A row predicate binds ONE root,
`record.*`; a bare `status` or `data.status` is retired there and faults on
the runtime engine (`Unknown variable`), under each surface's existing error
Expand Down Expand Up @@ -637,6 +640,6 @@ When an expression isn't working:
1. **Which key is it on, and does that type declare the key?** `content` and the predicate keys are evaluated and read on every type. `title` / `label` / `value` / `description` are evaluated **only on the types that declare them** — `statistic` (`label` / `value` / `description`), `card` (`title` / `description`), `button` (`label`) — and read raw everywhere else, including on a namespaced spelling such as `ui:statistic`. A `${...}` inside a `props` envelope is evaluated and then discarded. (A `properties` envelope is the one that is evaluated *and* hoisted onto the node — see [`rules/protocol.md`](../rules/protocol.md) for why that is recorded, not recommended.)
2. Is the `${}` syntax correct? Check for unmatched braces.
3. Is the data actually available in scope? Check what the host published on `PredicateScopeProvider` — ⛔ not `SchemaRendererProvider`'s `dataSource`, which publishes no expression root.
4. For conditions: `visibleWhen` / `hidden` / `disabled` take a raw expression or a `${}` template; the `On` spellings are refused.
4. For conditions: `visibleWhen` / `hidden` / `disabled` take a bare CEL string or a `${}` template.
5. Does the expression use a blocked pattern? Check for constructors, `eval`, `window`, etc.
6. Is type coercion causing issues? `${0 && "yes"}` returns `0`, not `false`.
4 changes: 2 additions & 2 deletions skills/objectui/rules/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ value must clear before it reaches the screen.
| Field | Evaluation Type | Return Type | Example |
|---|---|---|---|
| `content` | Template (`${}`) | string | `"content": "Total: ${data.total}"` |
| `visibleWhen` | Condition | boolean | `"visibleWhen": "data.permissions.canView"` (raw expression, no `${}`) |
| `hidden` | Condition | boolean | `"hidden": "data.status === 'draft'"` |
| `visibleWhen` | Condition | boolean | `"visibleWhen": "data.permissions.canView"` (bare string = CEL) |
| `hidden` | Condition | boolean | `"hidden": "data.status == 'draft'"` |
| `visible` | Condition | boolean | `"visible": "${data.isActive}"` |
| `disabled` | Condition | boolean | `"disabled": "!data.hasPermission"` |
| `title` / `label` / `value` / `description` | Template (`${}`) | Preserves original type | **Only on the component types the spec declares** — see "Rule: Bindable Text Keys" below. `"value": "${data.total}"` on a `statistic` renders the number. |
Expand Down
Loading