Repository navigation
Conditional-visibility predicates (visibleWhen / visibleOn) fail OPEN and silently — a broken predicate is indistinguishable from no predicate #4051
Description
Migrated from objectstack-ai/objectstack#5149 under the file-at-destination ruling (objectstack-ai/objectstack#7167, maintainer 2026-08-10). Originally filed 2026-08-04T06:27:01Z. The full prior thread — including triage rulings and hold/restart conditions — remains on the source issue and MUST be read before acting on this card.
Summary
When a form field's visibleWhen / visibleOn predicate cannot be evaluated, the console renders the field visible, with no error, no console.warn, and no diagnostic anywhere. Since "visible" is also what you get with no predicate at all, a predicate that never works looks exactly like a predicate that was never written — including to the author who just wrote it and is looking at the screen.
The natural consequence is that conditional-visibility bugs are undetectable by inspection and survive indefinitely. We found two live instances in one app in one afternoon, one of them shipped and unnoticed for months.
Measured 2026-08-04 against @objectstack/console shipped with 17.0.0-rc.1, zh-CN console, fresh database, object create dialog (type: 'tabbed' form view with authored sections).
The failing-open path
In framework-*.js:
function A(e, t, n, r, i) { // (predicate, values, DEFAULT, previous, extra)
if (e == null || (typeof e === 'string' && !e.trim())) return n;
try {
let o = a.evaluate(Ie(e), { record: t, previous: r, ... });
return o.ok ? o.value === true : n; // not-ok -> DEFAULT
} catch { return n; } // throw -> DEFAULT
}and both call sites pass true as that default:
// ui-components-*.js, per-field renderer
if (!C.visible || (m != null && !x(m, L, /* default */ true))) return null;
// framework-*.js
visible: e.visibleWhen == null || A(e.visibleWhen, t, /* default */ true, r, i)So every evaluation failure — parse error, unbound identifier, type error — resolves to "show it".
Repro 1 — an unbound identifier fails open (and the docs invite you to write one)
Values are bound under record. A predicate written with bare field names therefore references unbound identifiers and never works:
// src/views/<object>.view.ts
sections: [{ label: '审批', fields: [
// deliberately IMPOSSIBLE — this can never be true
{ field: 'status', visibleOn: 'rating_number == "__never__"' },
]}]Expected: field never rendered.
Actual: field rendered on every form, create and edit. Nothing logged.
Add the prefix and it works correctly:
{ field: 'status', visibleOn: 'record.rating_number != null && record.rating_number != ""' }→ hidden on create (autonumber still empty), shown on edit. Correct, and this is the behaviour we now depend on.
The bare-name spelling is not a strawman — it is what a reader would infer, and we had five of them shipped in one view file (disqualification_reason, three duplicate-link lookups, a rating gate), all silently inert, so a lead create dialog was unconditionally showing four fields that were supposed to be conditional. Nobody noticed because the screen looks identical to "not implemented yet".
Repro 2 — a shipped, correctly-prefixed object-level predicate also fails open on an empty create form
crm_account.listed_market declares, on the object:
visibleWhen: P`has(record.is_listed_company) && record.is_listed_company`This survives to the wire intact — GET /api/v1/meta/objects/crm_account returns:
"listed_market": { "...": "...", "visibleWhen": { "dialect": "cel", "source": "has(record.is_listed_company) && record.is_listed_company" } }Expected: on the create dialog, with 是否上市公司 unchecked, 上市板块 is hidden.
Actual: it renders, unconditionally.
We did not confirm the evaluator's internal failure, so treat this part as a hypothesis: the renderer seeds every rendered field name to null before merging form values, so on a blank create form record.is_listed_company is null rather than false, and CEL rejects a null in a boolean position — which lands in the same fail-open branch. If that is right, then every has(record.x) && record.x predicate over a boolean is inert on create forms, which is the single most obvious way to write one.
What we would like
- Do not fail open. For visibility specifically, a predicate that cannot be evaluated should either default to hidden or, better, be surfaced rather than swallowed. A field that wrongly disappears gets reported in minutes; a field that wrongly appears gets shipped.
- Say something. At minimum
console.warnonce per predicate with the source text and the evaluation error. The currentcatch { return n }discards the only evidence that anything is wrong. - Validate predicates at build time.
objectstack validatealready walks expressions (ADR-0032). An identifier that resolves to nothing in the{ record, previous, extra }scope is statically detectable, and would have caught all five of our inert predicates before they shipped. - Document the binding.
record.<field>is the contract; theFormFieldSchemadoc block does not say so, and nothing steers an author away from the bare name. - Decide and document what a null-valued field means in a boolean position on a create form —
nullvsfalseis the difference between working and inert for the most common predicate shape there is.
Related, on the unification of these keys: objectstack-ai/objectstack#2642, objectstack-ai/objectstack#2902, objectstack-ai/objectstack#2904.
Activity
yinlianghui commentedon Aug 10, 2026
State correction (objectui seat PM, session
session_017Qqyix2QcnpUC9XeYVDzx3, 2026-08-10):pm:queue→needs-user-decision;target:v17kept.Per the source thread (objectstack-ai/objectstack#5149 — authoritative history per this card's migration note):
record.binding docs) already SHIPPED via objectui#3541 (merged 2026-08-07); the 2026-08-08 rc.5 browser measurement on the thread confirms the warning is live on the page-component path too.A card whose whole remaining scope is a maintainer decision must not sit in the dispatch queue;
needs-user-decisionis the inbox label.target:v17stays because flipping a shipped default is precisely a release-window call — the maintainer may also rule "keep fail-open, not a v17 blocker" and de-board it. No dev will be dispatched on this card until the ruling.Generated by Claude Code
yinlianghui commentedon Aug 10, 2026
Delegated ruling (objectui seat PM, session
session_017Qqyix2QcnpUC9XeYVDzx3, 2026-08-10): appeal 1 is ruled KEEP FAIL-OPEN. Nothing actionable remains on this card — closing as completed.Authorization provenance: maintainer, 2026-08-10, in the seat session chat, verbatim: 「等你拍板的是不是可以你站点产品长远稳定的角度帮我拍板,如果你不好决定就先等着也行」 — decisions pending their ruling may be decided by this seat from the product long-term-stability standpoint. Veto window open: comment or reopen to overturn.
Three-axis analysis:
description且无subtitle—— 与 layout 侧刚收敛的拼写再度分叉 #4761) is "fail-open 或 fail-closed 都可以裁,静默不可以". The silence is gone: the per-predicate loud warn shipped in objectui#3541 (rc.5-verified on the page-component path too, 2026-08-08 measurement on the source thread). Flipping a shipped default right before GA is the opposite of stability; the standing 2026-08-06 ruling also already leaned this way (「维持 fail-open,不翻转」).Disposition of the card's four appeals, final: 1 = keep fail-open (this ruling; revisitable in a future major with the hit-surface data the warn is now accumulating — reopen then if the data argues otherwise); 2 & 4 = shipped (objectui#3541, merged 2026-08-07); 3 = tracked as objectstack#6128 (devx lane).
target:v17resolves with the close — no v17 action outstanding.Generated by Claude Code