Skip to content

console(record:alert): properties.visible loses its CEL envelope and is evaluated on the LEGACY JS engine — has() faults and the fail-soft default renders the banner on every row #17600

Description

@os-project-manager

Measured on @objectstack/console 17.4.0 (.objectui-sha 53ded82bf7a494f54e344e19099dbf00854b8694), in a real browser against a real app instance. Found from the consumer side by hotcrm#1887.

Summary

A record:alert node whose properties.visible is authored as the declared CEL envelope{ dialect: 'cel', source: '…' }, the P tagged-template form — is evaluated by the console on the legacy JS expression engine, not the CEL engine. Any CEL stdlib call in the predicate (here has()) is then undefined, the predicate throws, and evaluateCondition's fail-soft default (true) renders the component.

Net effect for the app: a banner gated on a field renders on every record, whatever the field says. The gate never bites in either direction.

The inversion that makes this a bug and not a spelling mistake

Both predicates below are authored in the same repo, in the same P envelope form, against the same object. They take different engines:

surface authored engine reached observed error
page:header action visible P envelope CEL runtime [runtime] No such key: status
record:alert properties.visible P envelope legacy JS "has" is not a function

The action path preserves the envelope. The record:alert component path does not.

Where the envelope is lost — it is client-side

The envelope is intact everywhere up to the browser:

  1. objectstack build artifact dist/objectstack.jsonproperties.visible is {"dialect":"cel","source":"has(record.duplicate_status) && record.duplicate_status == \"confirmed\""}.
  2. GET /api/v1/meta/pages/lead_detail_page — same value.
  3. GET /api/v1/meta/page (the list endpoint the console actually calls, confirmed in the browser network log) — same value.

By the time it reaches the renderer it is a bare string. In the shipped bundle, record:alert is registered to the component that computes h = normalizeVisible(props.visible); that normalizer returns the envelope unchanged only when dialect === 'cel', and otherwise wraps a bare string as the ${...} template spelling. evaluateCondition routes ${...} to this.evaluate(...), i.e. the legacy path — which is exactly the stack the browser prints:

Failed to evaluate expression: ${has(record.duplicate_status) && record.duplicate_status == "confirmed"}
Error: Failed to evaluate expression "has(record.duplicate_status) && record.duplicate_status == "confirmed"": "has" is not a function
    at e.evaluateExpression
    at e.evaluate
    at e.evaluateCondition
    at Ds                      (useCondition)
    at cs                      (record:alert renderer)

The console's own diagnostic states the consequence verbatim:

[ObjectUI] A visibility predicate could not be evaluated - node "record:alert" (id: "lead_duplicate_alert_confirmed")
The node was treated as its safe default, which on this surface means the
gate did NOT bite - a predicate that cannot be evaluated reads on screen
exactly like one that said yes.

Note also the component's own registration declares { name: 'visible', type: 'string' }, while @objectstack/spec's ComponentPropsMap['record:alert'].visible accepts boolean | string | { dialect, source }. The two contracts disagree.

Reproduction (measured, not inferred)

App: hotcrm at 9840c23b, two record:alert nodes on lead_detail_page gated on crm_lead.duplicate_status.

record duplicate_status suspected banner confirmed banner
Nimbus Aerospace confirmed shown shown
Vertex Analytics suspected shown shown
Foundry Robotics null shown shown

Plus a sweep before any value was written: 6 of 6 leads, all duplicate_status: null, both banners shown. All 21 leads in the instance carry duplicate_status: null.

Ablation that isolates has()

Replacing only the confirmed banner's predicate with a bare non-envelope string carrying no CEL stdlib call — visible: 'record.duplicate_status == "confirmed"' — and rebuilding, the gate starts biting correctly:

record duplicate_status confirmed banner (bare string)
Nimbus Aerospace confirmed shown (correct)
Vertex Analytics suspected hidden (correct)
Foundry Robotics null hidden (correct)

The suspected banner, left in the P envelope with has(), stayed wrongly shown on all three in the same run. So the legacy engine does bind record; the only thing it cannot do is the CEL stdlib. That isolates the fault to envelope routing, not to scope binding. (The mutation was reverted; the file is byte-identical to HEAD.)

Why the consumer cannot work around it

The working spelling is the one that drops has(). That is not available to an app:

  • It reintroduces the fault the guard exists to prevent the moment this bug is fixed and the predicate reaches CEL again — on driver-memory / driver-mongodb the column is absent and strict CEL aborts with No such key, which since 17.0.0-rc.2 is itself a rejection.
  • A predicate that is correct only while the platform is broken is not a fix.

Suggested fix

Preserve the { dialect, source } envelope on the component-props path so evaluateCondition reaches evaluateCelCondition, as the action path already does; and align the record:alert input declaration (visible: type 'string') with the spec's boolean | string | envelope.

Worth a look as a class rather than a single node: any component whose props carry a row predicate through the same normalizer is exposed to the same flattening, and the failure is silent by construction — fail-soft means a broken gate looks exactly like a gate that said yes.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions