Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

Renderer/action visible/disabled predicates bypass the canonical CEL engine — home-grown JS evaluator diverges from server enforcement #2661

Closed

Description

@os-zhuang

Found while auditing the client-side blast radius of framework#3183 (the dateField == today() silent-miss, fixed in framework#3205).

The split

ObjectUI evaluates runtime predicates through two different engines, and they systematically diverge:

Path Sites Engine CEL-correct?
A — field rules visibleWhen / readonlyWhen / requiredWhen packages/core/src/evaluator/fieldRules.ts → delegates to @objectstack/formula's ExpressionEngine.evaluate ✅ canonical — same verdict as server enforcement (its header explicitly cites the framework's "No private expression DSL" rule)
B — component/action predicates visible / hidden / disabled / visibleOn / hiddenOn / disabledOn (SchemaRenderer.tsx:277-316), action buttons via useExpression.ts / useCondition, ExpressionProvider.tsx home-grown packages/core/src/evaluator/ExpressionEvaluator.ts — compiles the predicate as plain JavaScript (ExpressionCache.compile → fn(...)) ❌ not CEL at all

Path B unwraps a { dialect: 'cel', source } envelope but then ignores the dialect and executes the source as JS (ExpressionEvaluator.evaluate, comment says "the underlying syntax is identical" — it isn't):

  • FormulaFunctions has no today / daysFromNow / daysAgo / now — any date predicate an author writes per the platform's CEL catalog throws today is not defined → fail-open.
  • CEL-isms diverge under JS semantics: has(record.x), in, "a" in list, single-quoted strings inside ${} templating, == coercion (JS == is loose where CEL is typed), size(), matches(), isBlank() — none behave per the authoring contract that validateExpression / the server enforce.
  • Consequence class: an action visible predicate that the server-side ActionEngine (fail-closed, CEL) and build-time lint both accept can evaluate differently — or not at all — in the browser. The framework's whole guardrail program (ADR-0032, fix(console): resolve ctx.user-gated record-header actions for platform admins #1928 family, framework#3183) stops at this boundary.

Why now

framework#3205 just fixed record.due_date == today() at the single celEngine.evaluate chokepoint. Path A inherits that fix with a dependency bump (objectui#<bump-PR>). Path B inherits nothing — it isn't downstream of the engine.

Proposed direction

Converge Path B onto the canonical engine, exactly as fieldRules.ts already does:

  1. In useCondition / SchemaRenderer / ExpressionProvider, when the predicate is a { dialect: 'cel' } envelope (or once spec normalizes these sites to CEL), route it to @objectstack/formula's ExpressionEngine.evaluate with { record, extra: scope } — same fail-open fallback semantics as today.
  2. Keep the legacy ${...} JS-template path for plain strings during a deprecation window (it's a documented ObjectUI DX shorthand), but stop treating CEL envelopes as JS.
  3. Reuse evalFieldPredicate's pattern (or extract a shared evalCelPredicate) so there is one client-side delegation point.
  4. Bundle-size note: app-shell's celAuthoring.ts already lazy-loads @objectstack/formula via dynamic import() — the same trick applies if engine weight in the base renderer is a concern.

Acceptance

  • An action visible: { dialect: 'cel', source: 'record.status == "open" && record.due_date == today()' } evaluates identically in the browser and in the server ActionEngine (incl. the framework#3205 date-equality fix).
  • has(...), in, string equality, isBlank, today()-family all behave per the CEL catalog in component/action predicates.
  • Legacy ${...} string predicates unchanged.
  • fieldRules Path A untouched (already canonical).

Refs: framework ADR-0032, framework#1928, framework#3183, framework#3205; packages/core/src/evaluator/fieldRules.ts (the in-repo precedent).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions