Record the declined verdict for the mirror null-guard gate on the sparse action face - #8979
Conversation
…e on the sparse action face (#8881) The maintainer's 2026-08-06 ruling on #4953 asked, as item 2, for an EVALUATION of the mirror of `checkNullGuards` — flag `!= null` on a sparse binding — for the action `visible`/`disabled` face. This records that evaluation's answer in the surface ledger that poses the question, so the next reader does not re-derive it: DECLINED, do not build it. Measured against current main of both repos (objectui's three-way row binding from objectui#4079 is live, so the premise shift the card flagged is confirmed rather than inherited): 1. `!= null` is not a distinctive fault. On a sparse binding the abort is at key resolution, before any operator is reached, so `record.a == 'pending'`, `!record.a` and `record.a > 1` all fault identically with `No such key: a`. The mirror table reads as though `!= null` were the special case; it is one spelling of nine. 2. Yield is 2 of 34 authored record-scoped action predicates (5.9%), both in one showcase file. Zero of the 34 use `has()`. 3. Sparseness is a property of the view's `$select` projection and — via `&&` short-circuiting — of row data, neither of which this pass can see. A sound static rule would flag 34 of 34. 4. The prescription would be wrong: `has()` guards absence, not nullness, and faults on a projected-but-null column. Comment-only; no behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011RB4waLuNbdruCo6X9oobm
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
…tion face (objectstack-ai#8997) * docs(objectql,lint,examples): state one guard idiom for the sparse action face (objectstack-ai#8975) The action `visible`/`disabled` face binds a sparse record (a list row carries only the view's `$select` projection), and two platform documents gave it opposite guard idioms: `declared-fields.ts` said `has()`, "not `!= null`", and the showcase fixture said `record.x != null` "is the portable form". Re-measured against the canonical `@objectstack/formula` CEL engine: neither half alone is a guard, because two failure modes are live at once. On `{}` the `!= null` form faults with `No such key: a`; on `{a: null}` the `has()` form faults with `no such overload`. Only the conjunction `has(record.x) && record.x != null` is safe across all three bindings. Stated once, authoritatively, in `materializeDeclaredFields`'s doc comment; the showcase fixture and the lint surface ledger now defer to it instead of each carrying its own wording. Comment-only — no runtime behaviour changes, and the 34 authored predicates that still spell only `!= null` are untouched (their migration is behaviour-visible and is tracked in objectstack-ai#8990). Both standing refusals are honored, not re-opened: objectstack-ai#4953 clause 2 (the binding stays sparse) and objectstack-ai#8881 / PR objectstack-ai#8979 (no lint gate; DECLINED on measured grounds). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj * docs(flows): scope the null-guard callout to TOTAL bindings (objectstack-ai#8975) The flow `condition` callout closed with "Guard emptiness with `record.x != null` / `isBlank(record.x)`, same as everywhere else CEL runs". The callout is correct about flows — it explicitly scopes itself to the total binding — but that trailing generalization is exactly what the rest of this PR makes false: on the sparse action `visible`/`disabled` face, `!= null` alone faults with `No such key` on a column the list row never projected. Amended to "the same rule every other TOTAL binding carries", plus one paragraph naming the single sparse face and its conjunction guard, so an author reading this page cannot carry the flows rule across to a face it breaks on. The flows rule itself is unchanged and still holds here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #8881
The maintainer's 2026-08-06 ruling on #4953 asked, as item 2, for an evaluation of the mirror of
checkNullGuards— flag!= nullon a sparse binding — for the actionvisible/disabledface. The ledger inpackages/lint/src/validate-null-guards.tsrecorded that evaluation as owed, with no answer.The answer is: declined. Do not build it. This PR is comment-only and records the verdict with its measurements in the ledger that poses the question, so the next reader does not re-derive it and so a closed card does not leave a stale "owed" behind.
The premise shift is real — re-measured, not inherited
The card flagged its own renderer evidence as dated. Re-read on objectui
2e82ab2:usePredicateRecordContextbinds the row as{ ...row, record: row, data: row }, andevalRowPredicatebinds the same three ways. So arecord.xpredicate does now resolve the namespace on all four action renderers and does reach the sparse-binding fault for real. Confirmed, not carried forward.Four measurements against
origin/main@585edf738+ objectui2e82ab21.
!= nullis not a distinctive fault — it is one spelling of nine. On a sparse binding the abort happens at key resolution, before any operator is reached:The ledger's mirror table reads as though
!= nullwere the special case. It is not special at all.2. Yield: 2 of 34 authored action predicates (5.9%). Census over production + examples in both repos — 34 record-scoped action predicates, all members of the fault class, exactly 2 matching
record.x [!=]= null, both in one file (examples/app-showcase/.../predicate-matrix.action.ts). Zero of the 34 usehas(). The 32 missed include every platform object's actions (sys-user,sys-invitation,sys-member,sys-approval-request).3. Sparseness is not decidable from the metadata this linter sees. It is a property of the view's
$selectprojection — and the two hits from (2) prove it inside one file: identical!= nullguards on identicallocations, wheref_lookup/f_lookupsare not default-list columns (so it faults onlist_item) whilef_textareais one (so it does not). Nothing in the action metadata distinguishes them.&&short-circuiting makes it worse —record.id == ctx.user.id && record.two_factor_enabled != truenever reaches the absent key when the first conjunct is false, so faulting depends on row data. A sound static rule would flag 34 of 34, which is a platform-contract finding rather than a lint rule.4. The prescription would be wrong.
has()guards absence, not nullness:{}absent{a: null}projected-null{a: 5}record.a != null && record.a > 1falsetruehas(record.a) && record.a > 1falsetruehas(record.a) && record.a != null && record.a > 1falsefalsetrueA list row is both sparse-capable and null-capable, so both failure modes are live on this one face. Swapping
!= nullforhas()would trade one silent fail-closed vanish for another — the exact error this row already refuses in the opposite direction, mirrored.Routing
The card's
domain:devxread was anchored on the gate landing inpackages/lint. Since the recommendation is to build no gate, the residual work is not devx: it is the authoring-contract contradiction filed as #8975, which belongs to the metadata/docs lane. Flagging for re-routing as the triage comment invited.Not done here
has(record.x) && record.x != nullis the measured-correct idiom, and neitherdeclared-fields.tsnor the showcase says it. Correcting that changes what authors are told to write, so it is filed separately (#8975) rather than ridden in on this PR. #4953 clause 2 is untouched — the binding stays sparse permanently, and nothing here re-opens it.Verification
Comment-only change; no behavior change. Gate union re-run at final head
a2ceecb38:pnpm check:nul-bytes— OK (5935 files, self-test 75 assertions)pnpm check:cross-package-test-inputs— OK (26 self-test cases, 12 packages)pnpm --filter @objectstack/lint typecheck— cleanpnpm --filter @objectstack/lint test— 73 files, 2041 passed / 4 skippedGates re-derived with
node scripts/pm/dispatch-gates.mjsagainst the actual changed paths; it returned the same two families the dispatch named, plus the any-editcheck:nul-bytes.skip-changeset: comments only inpackages/lint, nothing user-visible is released.Generated by Claude Code