Guard the authored action predicates for the sparse record face (#8990) - #9166
Conversation
…ction predicates for the sparse record face (#8990) An action `visible`/`disabled` predicate binds whatever record the client already fetched — a record-detail read, or a LIST ROW carrying only the view's `$select` projection. That binding stays sparse by decision (#4953 item 2), and CEL aborts the whole expression at key resolution when a key is absent. The abort is fail-closed, so the button is simply not offered — indistinguishable to the user from the gate having said no, and reported nowhere. Migrated the 22 authored record-scoped action predicates on `sys_user`, `sys_invitation`, `sys_member`, `sys_oauth_application`, `sys_approval_request` and the CRM example to the #8975 guard rule, per predicate rather than as one blanket rewrite: a bare equality against a literal needs `has()` alone, because CEL compares heterogeneously and answers `false` on a projected-null column rather than faulting. Measured against the canonical `@objectstack/formula` engine, not assumed. Two predicates change what a user sees, both on `sys_oauth_application`, whose `disabled` column is nullable upstream: `!record.disabled` FAULTED on a projected-null row (`no such overload: !null`), so the Disable button was missing from every never-toggled application, and `record.disabled` answered a non-boolean. Both became the equality form, which CEL answers on null. `sys_approval_request` traverses an attached `record.viewer` block, and there the canonical two-term conjunction is measurably NOT sufficient: it still faults when the block is present but the flag is absent or null. Guarding the LEAF subsumes the parent `!= null` half, so that is the minimal safe form. The showcase specimens are untouched on purpose — `predicate-matrix.action.ts` is a live browser fixture and #8990 remains open for them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj
📓 Docs Drift CheckThis PR changes 3 package(s): 15 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
…he guard rule and its readers (#8990) The docs sweep found three pages and one canonical doc comment that the migration moves: - `declared-fields.ts` is the CANONICAL statement of the sparse-face guard (#8975). Its two-term conjunction guards ONE read; measured while migrating `sys_approval_request`, it is insufficient the moment the predicate traverses — `has(record.v) && record.v != null && record.v.a` still faults on `{v: {}}` and on `{v: {a: null}}`. Added the nested-read row: guard the LEAF, which subsumes the parent `!= null`, with the outer `has()` and the `== true` both load-bearing. Doc comment only. - `automation/flows.mdx` defers to that rule for the sparse face; its one-line restatement now says "per READ" and carries the traversal form. - `protocol/objectui/actions.mdx` quoted `visible: "!record.disabled"` verbatim — a predicate this PR changed, so the page contradicted the shipped metadata AND taught the shape that faults on a NULL column. Requoted, and the page that teaches action predicates now states the sparse-face rule it had never mentioned at all. - `automation/approvals.mdx` describes browsing the raw table as producing "a correct, completely inert list". Still true, and now true by design rather than by fault — recorded in that callout. `content/docs/releases/**` untouched (release-owned). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj
…he sparse face (objectstack-ai#8990) (objectstack-ai#9280) * fix(example-showcase): guard the authored action predicates against the sparse face (objectstack-ai#8990) Every record-scoped `visible` / `disabled` predicate in app-showcase now carries the `has()` guard the sparse action face requires. A row action's predicate binds a LIST ROW carrying only the view's `$select` projection, and CEL aborts with `No such key` on a column that row never projected -- fail-closed, so the button silently is not offered, which is indistinguishable from the gate saying no. Measured against the running app's own payloads (showcase booted, real seeded records, real view projections): 40 of the 53 predicates in predicate-matrix.action.ts aborted on a default-list row before this change and 0 do after, while every verdict on a record-detail binding is unchanged -- so the Full-vs-Minimal contrast the fixture exists to demonstrate is preserved exactly. The guard is minimal per predicate rather than blanket: has() alone where the read is only compared by == / != , the full has(x) && x != null conjunction only where an operand can fault (traversal, method call, ordering, arithmetic, `in`, bare !). Census correction: the remainder was 57, not the 12 carried forward from PR objectstack-ai#9166. `zooTypeGate(name, label, visible)` passes its predicate POSITIONALLY, so the `visible:`-key grep behind both the original 34 and the 12 saw 8 of these and missed 45. The new test reads predicates off the exported actions instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj * docs(objectql): carry objectstack-ai#8990's three sparse-face measurements to the canonical rule The showcase migration (PR objectstack-ai#9280) measured three shapes the two-level nested table at :79 reads as already covered and does not reach. Recorded additively at the definition site, because a rule stated only in a reader is how objectstack-ai#8975 came to exist: 1. A three-level path needs has() at EVERY segment. Guarding the leaf subsumes the parent's `!= null` but NOT the parent's has(): `has(record.r) && has(record.r.p.s)` still faults `No such key: p` on `{r: null}` / `{r: {}}`. 2. A nested leaf used for ORDERING still needs its own `!= null` -- the leaf has() proves presence, not value, and the equality exception does not extend to `< <= > >=`. 3. Indexing faults on an EMPTY list, not only a null one: `record.b[0]` on `{b: []}` gives `No such key: index out of bounds`, so `!= null` is not the guard for a subscript; a `.size()` test is. Every cell of all three tables was evaluated against the canonical @objectstack/formula engine before being written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj * chore(gates): narrow the showcase source-resolution registries after the formula alias Both shrink-only registries recorded @objectstack/example-showcase as still reaching @objectstack/formula through dist/. It no longer does -- this PR added the vitest alias and the tsconfig `paths` rule so the new sparse-face test evaluates and typechecks against engine SOURCE. Dropping the entry is a SHRINK, the permitted direction; the gates printed the exact replacement lists and both were applied verbatim. Deliberately NOT fixed by removing the alias to make the registries true again: that would trade a green gate for the hazard the gates exist to name -- a test that runs green against a stale engine build, and a typecheck that PASSES over a contract that has since moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #8990. Backlink: #8975 (the guard rule this migrates to, merged as
ccc917cb5/ PR #8997).An action
visible/disabledpredicate binds whatever record the client already fetched — a record-detail read, or a LIST ROW carrying only the view's$selectprojection. That binding stays sparse by decision (#4953 item 2), and CEL aborts the whole expression at key resolution when a key is absent. The abort is fail-closed, so the button is simply not offered — indistinguishable to the user from the gate having said no, and reported nowhere.This migrates the authored record-scoped action predicates on the platform objects and the CRM example to that rule.
Census, re-measured on the merged ref
The card's figure was 34 authored record-scoped action predicates across both repos, 0 using
has(). Re-measured atorigin/main@3851f87f0:objectstack— authored action predicates readingrecord.*objectui— same, excluding tests / fixtures / doc commentsThe delta: the 34 is entirely in this repo, not split across two. objectui's 104
record.*predicate hits are test cases, renderer fixtures and doc comments; its four non-test hits are all prose inside comments (containers.tsx,data-table.tsx,action-group.tsx,predicate-record.ts). There is no authored objectui metadata to migrate — the objectui half of this problem is the RENDERER's (how it evaluates and what it does with a fault), which is a different question from this card.0 of 34 used has()is confirmed unchanged.Of the 34, this PR migrates 22 and leaves 12 — all in
app-showcase, all deliberately out of the dispatched surface:predicate-matrix.action.ts(8) — the live browser specimen, untouched on purpose; changing it changes what the fixture demonstrates and wants a dogfood re-verification, not a rewrite.ui/actions/index.ts(3) andui/views/field-zoo.view.ts(1) — showcase specimens outside the declared file surface.So #8990 stays open, hence
Part of.The scoping decision is per predicate
Measured against the canonical
@objectstack/formulaCEL engine, on the three bindings a sparse row can present:has()alonehas(record.a) && record.a == "high"isfalse, not a fault, on{a: null}has(x) && x != nullhas(record.a) && record.a.size()faultsno such overloadon{a: null}!x/ bare truthyxx != true/x == true, thenhas()alone!and the logical operators need a bool and fault on null; the equality form does not, and it keeps the intended reading of a null columnhas(x) && has(x.leaf) && x.leaf == trueThe full conjunction was NOT applied to all 22. Nineteen of them are equality-only and take
has()alone.Two measured findings the card did not have
1. The canonical two-term conjunction is not sufficient over a nested read.
sys_approval_requestgates on the service-attachedrecord.viewerblock. Measured:Guarding the LEAF is total, and it subsumes the parent
!= nullhalf —has()on a path whose parent is null answersfalserather than faulting:So the minimal safe form here is leaf-first, and
== trueis load-bearing while the parent!= nullis not. Both claims are pinned as tests so the next author applying the canonical rule literally cannot "complete" it back into a faulting form. This is a partial falsification of the prescription #8975 landed, so the canonical statement itself is updated — see the docs section.2.
has()alone never flips a hidden button to offered. For an equality-only predicate a fault and ahas()-false both mean "not offered", so the migration converts a fault into a deterministicfalsewithout changing visibility. The visibility flips come from the null-capable operand classes — which is exactly where the one user-visible change in this PR lives.The behaviour change, demonstrated
sys_oauth_application. Both toggles reachlist_item, anddisabledis nullable upstream (better-auth writes the column only when it is set), so a list row carries it PROJECTED AND NULL for every application nobody has ever toggled — the ordinary state.{disabled: null}!record.disabled⇢has(record.disabled) && record.disabled != trueFAULT no such overload: !null⇒ hiddentrue⇒ offeredrecord.disabled⇢has(record.disabled) && record.disabled == truenull(a non-boolean; the renderer decided what that meant)falseThe rest of the truth table is unchanged, and on a row that never projected the column both stay closed.
Reverse verification
Reverted
disable_oauth_applicationto!record.disabledon the committed tree and re-ran the pin. Direction predicted before running: RED. Observed:Restored with
git checkout HEAD -- ...and re-ran the union at the head commit.Bounding sweep named
sys_oauth_applicationis not in the dispatched enumeration (sys-user,sys-invitation,sys-member,sys-approval-request) but is a platform object in the same directory, the same defect class, mechanical, and under the same gate families — and the card's own body says "every platform object's actions". It is also the only site where the defect is user-visible today, so leaving it would have shipped the migration without its demonstration. Named here rather than folded in silently.Tests
Three new files, all driving the real
@objectstack/formulaengine rather than asserting spellings alone:packages/platform-objects/src/identity/action-predicate-sparse-face.test.ts— a SWEEP that discovers every record-scoped action predicate across 16 identity objects and evaluates each on every binding a sparse row can present (so a NEW unguarded action goes red without anyone updating a list), plus per-site verdicts so a guard cannot be an always-false wrapper, plus the oauth flip with the old spelling pinned as faulting.packages/plugins/plugin-approvals/src/action-predicate-sparse-face.test.ts— the fail-closed intent as a realfalse, every lever still opening for approver / override admin / submitter, and the four measurements behind the leaf-first form.examples/app-crm/test/action-predicate-sparse-face.test.ts— the example authors copy.examples/app-crmgained@objectstack/formulaas a devDependency, plus thevitest.config.tsalias andtsconfigpathsrule thatcheck:test-source-aliasandcheck:type-source-resolutionrequire, so the new pin is a verdict about the source in the checkout and not about build state. Neither gate's shrink-only registry was touched.rootDir/outDircame off that tsconfig as a consequence (it istsc --noEmit; the app is built by the CLI), the same correctionpackages/qa/downstream-contractmade.packages/platform-objects/src/platform-objects.test.ts— the #2874 feature-gate lowering matrix pins the composed predicate verbatim, so six rows moved with the residuals. Its header now says what the matrix still pins (the gate term, the composition, the operand order) and that the residuals changed under #8990.Docs the migration moves
Swept the whole
content/docs/**corpus for the probe tokens rather than trusting a page list. Four surfaces moved, and one of them is the rule itself:packages/objectql/src/declared-fields.ts— the CANONICAL statement of the guard rule (The sparse actionvisible/disabledface is given two contradictory guard idioms by two platform documents, and measured, neither one alone is correct #8975). Its two-term conjunction guards ONE read and is measurably insufficient the moment a predicate traverses, so it now carries the nested-read row: guard the leaf, which subsumes the parent!= null, with the outerhas()and the== trueboth load-bearing. Doc comment only, no runtime change. Leaving it as it stood would have shipped metadata whose literal reading contradicts the platform's own canonical rule — the two-documents-disagree failure The sparse actionvisible/disabledface is given two contradictory guard idioms by two platform documents, and measured, neither one alone is correct #8975 existed to end.content/docs/automation/flows.mdx— its one-line restatement of the sparse-face rule now says "per READ" and carries the traversal form.content/docs/protocol/objectui/actions.mdx— quotedvisible: "!record.disabled"verbatim, a predicate this PR changes, so the page both contradicted the shipped metadata and taught the shape that faults on a NULL column. Requoted; and the page that teaches action predicates now states the sparse-face guard rule, which it had never mentioned at all.content/docs/automation/approvals.mdx— its callout on browsing the rawsys_approval_requesttable ("a correct, completely inert list") is still true and is now true BY DESIGN rather than by fault; that distinction is recorded there.Not changed, reported instead:
content/docs/ui/actions.mdx:94teachesvisible: '!record.done'— the faulting negation shape — but it mirrors a showcase predicate this PR deliberately does not migrate, so changing it would desync the doc from the code the follow-up card owns. Same for the unguarded examples ingetting-started/quick-start.mdxandgetting-started/build-with-claude-code.mdx, and forprotocol/objectui/record-alert.mdx, which is the surface of finding #9167.content/docs/references/**(auto-generated) andcontent/docs/releases/**(release-owned) untouched.Verification
Everything below was re-run at
f5e13cc1f, the head commit — the numbers are from that run, not carried forward from the first commit.The gate list was re-derived with
scripts/pm/dispatch-gates.mjsagainst the full two-commit changed-path set (git diff --name-only origin/main...HEAD), not against the first commit's. The second commit widened the union by six families the first derivation could not see:check:docs-audit-scope,check:docs-redirectsandcheck:role-wordfrom the threecontent/docspages;check:durability-log-levelandcheck-engine-split-ratiofrompackages/objectql/src; andcheck:liveness— CI's Spec property liveness job — which the local path derivation does not name for apackages/objectqltouch, so it was run directly rather than inferred. All six pass.Standing refusals honored, not re-opened
visible/disabledbinding — #4953's clause-2 remainder, whose premise moved when objectui#4079 landed #8881 / PR Record the declined verdict for the mirror null-guard gate on the sparse action face #8979) on measured grounds: sparseness is a property of the view's$selectprojection and of row data, not of the metadata a linter sees, so a sound static rule would flag 34 of 34.predicate-matrix.action.tsis a live browser specimen, and the PR for The sparse actionvisible/disabledface is given two contradictory guard idioms by two platform documents, and measured, neither one alone is correct #8975 left it alone on purpose and says so in the file.Generated by Claude Code