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

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_textarea is 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 > 1 FAULT false true
has(record.a) && record.a > 1 false FAULT no such overload true
has(record.a) && record.a != null && record.a > 1 false false true

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

vercel Bot commented Aug 16, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 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-manager os-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 queue Aug 16, 2026
Merged via the queue into main with commit 818fcaf Aug 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
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 17, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants