Skip to content

34 authored action visible/disabled predicates guard only the null half, so each silently hides its button on a list row that did not project the gated column #8990

Description

@os-zhuang

Filed unassigned by the domain:engine-core seat while implementing #8975 (session session_01NTKPDRoynY8i3HmdSFUxFj). Out of scope there: #8975's triage bounded that card to the guidance sentence and stated "no acceptance behavior changes"; this is the migration of the authored predicates themselves, which IS behaviour-visible. Backlink: #8975.

Blocked-by: #8975 — the prescription has to exist before the predicates are migrated to it.

What #8975 establishes

#8975 lands the measured rule for the sparse action visible/disabled face: guard with has(record.x) && record.x != null before any traversal, method call, ordering or arithmetic use. Neither half alone is a guard — a list row can omit a column (absent key ⇒ No such key fault) and can carry a projected column holding NULL (⇒ no such overload fault on the operator).

What is still true after it lands

The census in #8975 (and in validate-null-guards.ts's DECLINED-gate evaluation, PR #8979): 34 authored record-scoped action predicates across both repos, 0 of which use has(). Every one of them is a member of the fault class on any list view whose $select does not project the gated column. The set includes every platform object's actions — sys-user, sys-invitation, sys-member, sys-approval-request — plus the CRM and showcase examples.

The failure is fail-closed and silent: the predicate aborts at key resolution and the button simply is not offered, which is indistinguishable to the user from "the gate said no".

Two measured details that matter for scoping the fix, both re-measured against the canonical @objectstack/formula CEL engine on origin/main @ fdf0fbbea:

  • A bare equality against a literal does NOT need the != null half.has(record.a) && record.a == "high" is green on {}, on {a: null} and on {a: "high"} — CEL compares heterogeneously and answers false rather than faulting. So a large part of the 34 need only the has() half prepended, not the full conjunction.
  • The != null half is still required wherever the predicate traverses, calls a method, orders or does arithmetic. Measured: has(record.a) && record.a.size() faults on {a: null}; has(record.a) && record.a != null && record.a.size() does not.

Why this is not a lint rule

⛔ Do not re-open that: the mirror gate was evaluated and DECLINED in #8881 / PR #8979 on measured grounds — sparseness is a property of the view's $select projection and of row data, not of the metadata a linter sees, so a sound static rule would flag 34 of 34.

What is owed

A deliberate migration pass over the authored predicates (platform objects first, then examples), plus a decision on the showcase fixture: examples/app-showcase/src/ui/actions/predicate-matrix.action.ts is a LIVE browser specimen, so changing its predicates changes what the fixture demonstrates and wants a browser re-verification rather than a blind rewrite. #8975's PR leaves those specimens untouched on purpose and says so in the file.

The objectui half of the census lives in that repo and would be its own PR there.

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions