Context
B2 (ADR-0036) standardized field-level conditional rules on CEL — @objectstack/formula's ExpressionEngine, evaluated identically client + server via evalFieldPredicate / resolveFieldRuleState (packages/core/src/evaluator/fieldRules.ts). ADR-0089 then unified the key name (visibleWhen) across layers.
The list-view conditional tier never moved: it still evaluates on the legacy ExpressionEvaluator (@object-ui/core's recursive-descent JS-dialect parser), even where code comments already claim "CEL predicate".
This is a spec-compliance gap, not just a cleanup. Per @objectstack/spec, these surfaces are already CEL by contract:
view.zod.ts conditionalFormatting: [{ condition: ExpressionInputSchema /* "Predicate (CEL)" */, style }]
action.zod.ts visible: ExpressionInputSchema /* "Visibility predicate (CEL)" */
ExpressionInputSchema normalizes a bare string to { dialect: 'cel', source } at parse — yet ObjectUI's toPredicateInput (packages/react/src/hooks/useExpression.ts) unwraps that envelope and feeds the source to the legacy engine. A predicate explicitly declared dialect: 'cel' is evaluated in the wrong dialect today.
ADR-0058 frames the platform contract: one authoring language (CEL), one interpret backend (formula's cel-engine). The list tier is the notable client surface still off it.
Affected sites (all on the legacy engine today)
| Surface |
Site |
Notes |
| Conditional formatting (list) |
packages/plugin-list/src/ListView.tsx evaluateConditionalFormatting |
3 rule shapes: {field, operator, value}, spec {condition, style}, {expression: '${…}'} |
| Conditional formatting (grid) |
packages/plugin-grid/src/ObjectGrid.tsx getRowStyle |
inline copy of the ListView logic, not shared |
| Conditional formatting (kanban) |
plugin-kanban KanbanEnhanced.tsx / KanbanImpl.tsx |
field/operator/value only; plugin-view/ObjectView.tsx's kanban branch drops top-level conditionalFormatting entirely (bug) |
| Row-action visibility |
plugin-grid/src/components/RowActionMenu.tsx, components/src/renderers/complex/data-table.tsx (RowActionItem) |
visible/disabled from objectDef.actions (locations: ['list_item']) — server-authored CEL, client-parsed as JS via useCondition |
| Action engine |
core/src/actions/ActionEngine.ts getActionsForLocation |
legacy evaluator, fail-closed + warn |
| Legacy form condition |
components/src/renderers/form/form.tsx condition: {field, equals/notEquals/in} |
hand-rolled, bypasses any engine; only live authors are 2 sites in plugin-designer/FieldDesigner.tsx |
The dialect fork is already observable
- Legacy parser has no
in operator → the canonical CEL idiom 'admin' in user.roles / record.status in ['sent','paid'] fails to parse on a row action today and fails open (item stays visible).
- Legacy-only syntax (
===, ?., .includes(), ${} interpolation) is not CEL — so the same visibleWhen-style string means different things depending on which tier evaluates it.
- Missing map key: CEL faults (needs null-seeding, cf. ADR-0036's gotcha); legacy returns
undefined.
- Failure postures are inconsistent:
useCondition fail-open, ActionEngine fail-closed+warn, evaluatePlainCondition fail-closed.
Back-compat blast radius: near zero (surveyed)
Full-tree survey (apps/, examples/, e2e/, content/, docs/, skills/): zero real authored usages of list conditionalFormatting, row-action string predicates, or the legacy form condition. Only 3 package-test fixtures use {condition: '${data.x === "y"}', style}, and most action-predicate fixtures are already CEL-compatible (record.id == ctx.user.id, !record.two_factor_enabled). Transparent translation + dialect routing is cheap.
Plan
- Core helpers (
@object-ui/core, zero-React, next to fieldRules.ts):
evalRowPredicate(pred, row, opts) — wraps evalFieldPredicate; binds record + bare row fields (via scope) + the global predicate scope (features/user/current_user from PredicateScopeContext — required for scope parity with existing predicates); null-seeds row fields.
resolveConditionalFormatting(record, rules) — the single implementation for all three rule shapes: {field, operator, value} mechanically translated to CEL (equals→==, in→in, contains→.contains()); string/envelope routed by dialect.
- Dialect routing:
{dialect:'cel'} envelope → CEL engine, always (fixes the active protocol violation). Bare string → CEL per spec contract, unless it carries legacy markers (${, ===, ?., method calls) → legacy engine + one-time deprecation warning.
- Converge consumers:
ListView.evaluateConditionalFormatting becomes a thin wrapper (export kept); delete ObjectGrid.getRowStyle's inline copy; kanban consumes the same helper and plugin-view forwards top-level conditionalFormatting to the kanban branch; RowActionMenu / data-table RowActionItem switch to the CEL-first path.
- Failure posture (per ADR-0058's fail-policy matrix — fail-soft-but-logged for non-security): formatting failure = no style + console warn; row-action
visible failure = hidden + warn (aligns with ActionEngine); nothing silent.
- Retire the legacy form
condition: translate {field, equals/notEquals/in} → CEL (record.f == v / record.f in […]) into evalFieldPredicate at the form.tsx entry; migrate the 2 FieldDesigner authoring sites to visibleWhen.
- Docs/skill/tests:
skills/objectui/guides/schema-expressions.md gains a "list conditional tier = CEL (data-model tier)" section; unit tests for the 3 shapes + dialect routing + missing-key; live e2e on a showcase list (formatting + row-action visible).
Ship order: ① core helpers → ② formatting convergence (incl. kanban pass-through fix) → ③ row-action predicates → ④ form condition retirement → ⑤ docs/e2e. Each step is independently releasable with a changeset.
Naming (settled by ADR-0089 — do NOT rename)
- Actions keep
visible — ADR-0089 renamed only the field/view/page visibility keys; action.zod.ts keeps visible and it is already CEL by contract.
- Formatting keeps spec's
{condition, style} as canonical; ObjectUI-local shapes become back-compat translations (normalize once at the spec-bridge boundary, mirroring ADR-0089 D2).
useCondition/useExpression remain the schema/widget-tier hooks (${} templates, dashboards, pages) — do not swap their engine globally; only row/record predicate call-sites move.
Risks / open questions
- Perf:
formula's celEngine.evaluate re-parses the source per call (no program cache; cel-js can't re-execute a parsed AST). Lists are rows × rules × re-renders. Likely acceptable at page-size row counts, but measure; consider upstream caching in @objectstack/formula if hot.
- Rows are server-serialized: benefits from cel-engine's built-in numeric/date string overload-retry — a correctness gain over the legacy engine, but worth covering in tests.
- Behavior change (intended): previously-unparseable row-action predicates that silently stayed visible will now hide + warn.
Acceptance
- A list view expresses conditional formatting + row-action visibility as CEL predicates over the row record, evaluated by
@objectstack/formula — same verdict as the server for the same record.
- A
{dialect:'cel'} envelope is never evaluated by the legacy engine.
- Legacy JSON
{field, operator, value} and legacy-dialect strings keep working (translated / routed), with deprecation warnings.
- Top-level
conditionalFormatting reaches kanban.
- Unit tests + a live e2e on a showcase list; docs/skill note updated.
Priority & references
Context
B2 (ADR-0036) standardized field-level conditional rules on CEL —
@objectstack/formula'sExpressionEngine, evaluated identically client + server viaevalFieldPredicate/resolveFieldRuleState(packages/core/src/evaluator/fieldRules.ts). ADR-0089 then unified the key name (visibleWhen) across layers.The list-view conditional tier never moved: it still evaluates on the legacy
ExpressionEvaluator(@object-ui/core's recursive-descent JS-dialect parser), even where code comments already claim "CEL predicate".This is a spec-compliance gap, not just a cleanup. Per
@objectstack/spec, these surfaces are already CEL by contract:view.zod.tsconditionalFormatting: [{ condition: ExpressionInputSchema /* "Predicate (CEL)" */, style }]action.zod.tsvisible: ExpressionInputSchema /* "Visibility predicate (CEL)" */ExpressionInputSchemanormalizes a bare string to{ dialect: 'cel', source }at parse — yet ObjectUI'stoPredicateInput(packages/react/src/hooks/useExpression.ts) unwraps that envelope and feeds the source to the legacy engine. A predicate explicitly declareddialect: 'cel'is evaluated in the wrong dialect today.ADR-0058 frames the platform contract: one authoring language (CEL), one interpret backend (
formula's cel-engine). The list tier is the notable client surface still off it.Affected sites (all on the legacy engine today)
packages/plugin-list/src/ListView.tsxevaluateConditionalFormatting{field, operator, value}, spec{condition, style},{expression: '${…}'}packages/plugin-grid/src/ObjectGrid.tsxgetRowStyleplugin-kanbanKanbanEnhanced.tsx/KanbanImpl.tsxplugin-view/ObjectView.tsx's kanban branch drops top-levelconditionalFormattingentirely (bug)plugin-grid/src/components/RowActionMenu.tsx,components/src/renderers/complex/data-table.tsx(RowActionItem)visible/disabledfromobjectDef.actions(locations: ['list_item']) — server-authored CEL, client-parsed as JS viauseConditioncore/src/actions/ActionEngine.tsgetActionsForLocationcomponents/src/renderers/form/form.tsxcondition: {field, equals/notEquals/in}plugin-designer/FieldDesigner.tsxThe dialect fork is already observable
inoperator → the canonical CEL idiom'admin' in user.roles/record.status in ['sent','paid']fails to parse on a row action today and fails open (item stays visible).===,?.,.includes(),${}interpolation) is not CEL — so the samevisibleWhen-style string means different things depending on which tier evaluates it.undefined.useConditionfail-open,ActionEnginefail-closed+warn,evaluatePlainConditionfail-closed.Back-compat blast radius: near zero (surveyed)
Full-tree survey (apps/, examples/, e2e/, content/, docs/, skills/): zero real authored usages of list
conditionalFormatting, row-action string predicates, or the legacy formcondition. Only 3 package-test fixtures use{condition: '${data.x === "y"}', style}, and most action-predicate fixtures are already CEL-compatible (record.id == ctx.user.id,!record.two_factor_enabled). Transparent translation + dialect routing is cheap.Plan
@object-ui/core, zero-React, next tofieldRules.ts):evalRowPredicate(pred, row, opts)— wrapsevalFieldPredicate; bindsrecord+ bare row fields (viascope) + the global predicate scope (features/user/current_userfromPredicateScopeContext— required for scope parity with existing predicates); null-seeds row fields.resolveConditionalFormatting(record, rules)— the single implementation for all three rule shapes:{field, operator, value}mechanically translated to CEL (equals→==,in→in,contains→.contains()); string/envelope routed by dialect.{dialect:'cel'}envelope → CEL engine, always (fixes the active protocol violation). Bare string → CEL per spec contract, unless it carries legacy markers (${,===,?., method calls) → legacy engine + one-time deprecation warning.ListView.evaluateConditionalFormattingbecomes a thin wrapper (export kept); deleteObjectGrid.getRowStyle's inline copy; kanban consumes the same helper andplugin-viewforwards top-levelconditionalFormattingto the kanban branch;RowActionMenu/ data-tableRowActionItemswitch to the CEL-first path.visiblefailure = hidden + warn (aligns withActionEngine); nothing silent.condition: translate{field, equals/notEquals/in}→ CEL (record.f == v/record.f in […]) intoevalFieldPredicateat the form.tsx entry; migrate the 2FieldDesignerauthoring sites tovisibleWhen.skills/objectui/guides/schema-expressions.mdgains a "list conditional tier = CEL (data-model tier)" section; unit tests for the 3 shapes + dialect routing + missing-key; live e2e on a showcase list (formatting + row-actionvisible).Ship order: ① core helpers → ② formatting convergence (incl. kanban pass-through fix) → ③ row-action predicates → ④ form
conditionretirement → ⑤ docs/e2e. Each step is independently releasable with a changeset.Naming (settled by ADR-0089 — do NOT rename)
visible— ADR-0089 renamed only the field/view/page visibility keys;action.zod.tskeepsvisibleand it is already CEL by contract.{condition, style}as canonical; ObjectUI-local shapes become back-compat translations (normalize once at the spec-bridge boundary, mirroring ADR-0089 D2).useCondition/useExpressionremain the schema/widget-tier hooks (${}templates, dashboards, pages) — do not swap their engine globally; only row/record predicate call-sites move.Risks / open questions
formula'scelEngine.evaluatere-parses the source per call (no program cache; cel-js can't re-execute a parsed AST). Lists are rows × rules × re-renders. Likely acceptable at page-size row counts, but measure; consider upstream caching in@objectstack/formulaif hot.Acceptance
@objectstack/formula— same verdict as the server for the same record.{dialect:'cel'}envelope is never evaluated by the legacy engine.{field, operator, value}and legacy-dialect strings keep working (translated / routed), with deprecation warnings.conditionalFormattingreaches kanban.Priority & references
CelPredicateField/celAuthoring.tsfrom feat(app-shell): CEL authoring safety for RLS policies — lint, field autocomplete, test-run #2533 for formatting/action predicates later.visibleWhenunification), objectui#2490 / feat(components): page:tabs honors item-level visibleWhen — conditional tabs (framework#2606) #2516 (ADR-0089 renderer reads).