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.

Conditional-visibility predicates (visibleWhen / visibleOn) fail OPEN and silently — a broken predicate is indistinguishable from no predicate #4051

Closed

Description

@os-zhuang

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

  1. 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.
  2. Say something. At minimum console.warn once per predicate with the source text and the evaluation error. The current catch { return n } discards the only evidence that anything is wrong.
  3. Validate predicates at build time. objectstack validate already 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.
  4. Document the binding. record.<field> is the contract; the FormFieldSchema doc block does not say so, and nothing steers an author away from the bare name.
  5. Decide and document what a null-valued field means in a boolean position on a create form — null vs false is 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

  1. yinlianghui commented on Aug 10, 2026

    @yinlianghui
    Contributor

    State correction (objectui seat PM, session session_017Qqyix2QcnpUC9XeYVDzx3, 2026-08-10): pm:queue → needs-user-decision; target:v17 kept.

    Per the source thread (objectstack-ai/objectstack#5149 — authoritative history per this card's migration note):

    • Appeals 2 & 4 (loud per-predicate warn + 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.
    • Appeal 3 (build-time predicate lint) is split out as lint(devx): view/page 谓词(visibleWhen/visibleOn 等)的裸标识符构建期静态校验 —— #5149 裁决拆单 objectstack#6128 (devx lane).
    • The only remaining substance on this card is appeal 1 — whether to flip the fail-open default to fail-closed — which the 2026-08-06 fleet half-ruling explicitly reserved for a separate maintainer decision, with the now-landed diagnostics accumulating hit-surface data as its input.

    A card whose whole remaining scope is a maintainer decision must not sit in the dispatch queue; needs-user-decision is the inbox label. target:v17 stays 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

  2. yinlianghui commented on Aug 10, 2026

    @yinlianghui
    Contributor

    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:

    1. 实际业务需求 — Flipping to fail-closed makes fields that currently render (because their predicate is broken) disappear from shipped apps the day the console updates. The card itself measured five such live instances in one app. That is a visible behavioral regression for existing users, days before the v17 window, with no user-facing gain: the harm of fail-open ("a broken predicate shows the field") is already mitigated because it is no longer silent.
    2. 项目长远合理性 / 稳定性 — The repo's own tradition (per the 2026-08-06 ruling chain: fix(cli): resolve a directory argument through file-system routing (#5037) #5146/fix(app-shell): word every dropped-fields reason on its own instead of calling the rest "Read-only" (#3935) #4936/[finding] app-shell 还有第二个 PageHeader,副标题键拼写 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,不翻转」).
    3. 防 AI 写错元数据 — The structural protection for authors is authoring-time rejection, not runtime hiding: the loud warn names the predicate and reason at the moment the author looks at the screen, and appeal 3's build-time lint (objectstack#6128, devx lane) makes a bad predicate fail to ship at all. Fail-closed adds little on top of those two and pays for it with production behavior change.

    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:v17 resolves with the close — no v17 action outstanding.


    Generated by Claude Code

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