Skip to content

Record the declined verdict for the mirror null-guard gate on the sparse action face - #8979

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-8881-mirror-null-guard-eval
Aug 16, 2026
Merged

Record the declined verdict for the mirror null-guard gate on the sparse action face#8979
os-project-manager merged 1 commit into
mainfrom
claude/issue-8881-mirror-null-guard-eval

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#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. The ledger in packages/lint/src/validate-null-guards.ts recorded 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: usePredicateRecordContext binds the row as { ...row, record: row, data: row }, and evalRowPredicate binds the same three ways. So a record.x predicate 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 + objectui 2e82ab2

1. != null is 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:

record.a == 'pending' | SPARSE: FAULT [runtime] No such key: a
record.a != null | SPARSE: FAULT [runtime] No such key: a
record.a != 'owner' | SPARSE: FAULT [runtime] No such key: a
!record.a | SPARSE: FAULT [runtime] No such key: a
record.a > 1 | SPARSE: FAULT [runtime] No such key: a

The ledger's mirror table reads as though != null were 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 use has(). 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 $select projection — and the two hits from (2) prove it inside one file: identical != null guards on identical locations, where f_lookup/f_lookups are not default-list columns (so it faults on list_item) while f_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 != true never 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:

predicate{} absent{a: null} projected-null{a: 5}
record.a != null && record.a > 1FAULTfalsetrue
has(record.a) && record.a > 1falseFAULT no such overloadtrue
has(record.a) && record.a != null && record.a > 1falsefalsetrue

A list row is both sparse-capable and null-capable, so both failure modes are live on this one face. Swapping != null for has() 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:devx read was anchored on the gate landing in packages/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 != null is the measured-correct idiom, and neither declared-fields.ts nor 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 — clean
  • pnpm --filter @objectstack/lint test — 73 files, 2041 passed / 4 skipped

Gates re-derived with node scripts/pm/dispatch-gates.mjs against the actual changed paths; it returned the same two families the dispatch named, plus the any-edit check:nul-bytes.

skip-changeset: comments only in packages/lint, nothing user-visible is released.


Generated by Claude Code

…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
@vercel

vercelBot commented Aug 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 16, 2026 3:42am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint.

3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx(via @objectstack/lint)
  • content/docs/deployment/validating-metadata.mdx(via packages/lint)
  • content/docs/permissions/authorization.mdx(via @objectstack/lint)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/v17.mdx(via @objectstack/lint)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-project-manageros-project-manager added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 16, 2026 — with Claude
@os-project-manager
os-project-manager marked this pull request as ready for review August 16, 2026 03:45
@os-project-manager
os-project-manager added this pull request to the merge queueAug 16, 2026
Merged via the queue into main with commit 818fcafAug 16, 2026
31 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-8881-mirror-null-guard-eval branch August 16, 2026 04:13
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/sskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@os-project-manager@claude