diff --git a/.changeset/27587-objectui-skill-teaches-cel-predicates.md b/.changeset/27587-objectui-skill-teaches-cel-predicates.md new file mode 100644 index 00000000000..1da68cc36cd --- /dev/null +++ b/.changeset/27587-objectui-skill-teaches-cel-predicates.md @@ -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. diff --git a/skills/objectui/evals/schema-expressions.json b/skills/objectui/evals/schema-expressions.json index a249e16c61e..d998fab476b 100644 --- a/skills/objectui/evals/schema-expressions.json +++ b/skills/objectui/evals/schema-expressions.json @@ -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'", "&&", "${" ], diff --git a/skills/objectui/guides/schema-expressions.md b/skills/objectui/guides/schema-expressions.md index 74190d7c1d2..03088767824 100644 --- a/skills/objectui/guides/schema-expressions.md +++ b/skills/objectui/guides/schema-expressions.md @@ -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 @@ -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 @@ -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: `&&`, `||`, `!` @@ -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: ```jsonc @@ -251,9 +247,19 @@ The node-level condition keys are `visibleWhen` (show), `hidden` and ```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. @@ -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 @@ -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 @@ -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`. diff --git a/skills/objectui/rules/protocol.md b/skills/objectui/rules/protocol.md index 45c9f45cd36..1f4a601edb1 100644 --- a/skills/objectui/rules/protocol.md +++ b/skills/objectui/rules/protocol.md @@ -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. |