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:
objectstack build artifact dist/objectstack.json — properties.visible is {"dialect":"cel","source":"has(record.duplicate_status) && record.duplicate_status == \"confirmed\""}.
GET /api/v1/meta/pages/lead_detail_page — same value.
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.
Measured on
@objectstack/console17.4.0 (.objectui-sha53ded82bf7a494f54e344e19099dbf00854b8694), in a real browser against a real app instance. Found from the consumer side by hotcrm#1887.Summary
A
record:alertnode whoseproperties.visibleis authored as the declared CEL envelope —{ dialect: 'cel', source: '…' }, thePtagged-template form — is evaluated by the console on the legacy JS expression engine, not the CEL engine. Any CEL stdlib call in the predicate (herehas()) is then undefined, the predicate throws, andevaluateCondition'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
Penvelope form, against the same object. They take different engines:page:headeractionvisiblePenvelope[runtime] No such key: statusrecord:alertproperties.visiblePenvelope"has" is not a functionThe action path preserves the envelope. The
record:alertcomponent path does not.Where the envelope is lost — it is client-side
The envelope is intact everywhere up to the browser:
objectstack buildartifactdist/objectstack.json—properties.visibleis{"dialect":"cel","source":"has(record.duplicate_status) && record.duplicate_status == \"confirmed\""}.GET /api/v1/meta/pages/lead_detail_page— same value.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:alertis registered to the component that computesh = normalizeVisible(props.visible); that normalizer returns the envelope unchanged only whendialect === 'cel', and otherwise wraps a bare string as the${...}template spelling.evaluateConditionroutes${...}tothis.evaluate(...), i.e. the legacy path — which is exactly the stack the browser prints:The console's own diagnostic states the consequence verbatim:
Note also the component's own registration declares
{ name: 'visible', type: 'string' }, while@objectstack/spec'sComponentPropsMap['record:alert'].visibleacceptsboolean | string | { dialect, source }. The two contracts disagree.Reproduction (measured, not inferred)
App: hotcrm at
9840c23b, tworecord:alertnodes onlead_detail_pagegated oncrm_lead.duplicate_status.duplicate_statusconfirmedsuspectednullPlus a sweep before any value was written: 6 of 6 leads, all
duplicate_status: null, both banners shown. All 21 leads in the instance carryduplicate_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:duplicate_statusconfirmedsuspectednullThe suspected banner, left in the
Penvelope withhas(), stayed wrongly shown on all three in the same run. So the legacy engine does bindrecord; 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 toHEAD.)Why the consumer cannot work around it
The working spelling is the one that drops
has(). That is not available to an app:driver-memory/driver-mongodbthe column is absent and strict CEL aborts withNo such key, which since 17.0.0-rc.2 is itself a rejection.Suggested fix
Preserve the
{ dialect, source }envelope on the component-props path soevaluateConditionreachesevaluateCelCondition, as the action path already does; and align therecord:alertinput declaration (visible: type 'string') with the spec'sboolean | 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.