Summary
userActions.delete reaches the record detail header in its boolean form but not in its predicate form. Same key, same button, two different scopes — and nothing warns the author.
userActions: { delete: false } → Delete disappears from the row kebab, the selection bar and the detail header.
userActions: { delete: { visibleWhen: … } } → the row kebab honours the predicate; the detail header keeps rendering Delete on records the predicate excludes.
So an author who upgrades from "nobody may delete this object" to "these rows may not be deleted" — the exact reason the object form exists — silently loses two of the three surfaces they had.
Repro (framework examples/app-showcase, console at pinned 6314e87, signed in as ADMIN so permissions are not a variable)
showcase_invoice already ships the declaration (src/data/objects/invoice.object.ts:58):
userActions: {
edit: { disabledWhen: P`record.status == 'paid'` },
delete: { visibleWhen: P`record.status != 'paid'` },
}
On a paid invoice (INV-1011, predicate false):
| surface |
Delete |
| list row kebab |
hidden ✅ (and Edit correctly renders greyed via disabledWhen) |
record detail header ⋯ |
still offered ❌ |
| selection bar with only that row ticked |
still offered ❌ (filed separately) |
Control on a non-paid invoice (INV-1001, predicate true): Delete present on all three — so the absence above is the predicate, not a render failure.
Control on the boolean form — temporarily delete: false, artifact rebuilt:
{"form":"boolean false","rowDelete":0,"bulkDelete":0,"detailDelete":0}
All three gone. That asymmetry is the finding.
Where it diverges
- Row kebab evaluates the predicates:
plugin-grid/src/components/RowActionMenu.tsx:299-301 → isBuiltinRowActionVisible(input.deletePredicates, 'delete', row, scope, objectFields), fed by resolveRowCrudAffordances (ObjectGrid.tsx:1659). The child-row menu does the same in plugin-detail/src/RelatedList.tsx:1111-1112.
- Detail header does not:
plugin-detail/src/DetailView.tsx:306-307 resolves
showDelete: gatedSchema.showDelete && objectAllowsDelete && canDeleteRecord — bucket ∧ object affordance ∧ record writability. userActionPredicates(objectSchema?.userActions?.delete) is never called on this path, even though RelatedList in the same package already imports it.
The record is in hand on this surface and the CEL evaluator is already in context (the header renders readonlyWhen-driven field states from the same record), so the input the predicate needs is present.
Why this is a defect and not "per spec"
The predicate block's doc-comment (spec/src/data/object.zod.ts:1053-1070) says "the row's button", which the current implementation matches literally. But the key it modifies is documented one level up as governing "the generic CRUD affordances that the UI surfaces" (object.zod.ts:1410-1421), and its boolean form demonstrably does reach all three. A key whose scope shrinks when you add a predicate to it is not something an author can predict, and the failure is silent — ADR-0078 (no silently-inert metadata) is the rule this lands on.
Consequence in practice: visibleWhen is advisory UI gating (the spec says so — enforcement stays with permissions/hooks), so a hidden-on-the-list record stays deletable from its own detail page. An author who tested the list believes it is gated.
Suggested fix
Fold userActionPredicates(objectSchema?.userActions?.delete / .edit) into DetailView's showDelete / showEdit the way RelatedList already does, evaluated against the open record. disabledWhen should map to a disabled header button for symmetry with the kebab.
Related
Summary
userActions.deletereaches the record detail header in its boolean form but not in its predicate form. Same key, same button, two different scopes — and nothing warns the author.userActions: { delete: false }→ Delete disappears from the row kebab, the selection bar and the detail header.userActions: { delete: { visibleWhen: … } }→ the row kebab honours the predicate; the detail header keeps rendering Delete on records the predicate excludes.So an author who upgrades from "nobody may delete this object" to "these rows may not be deleted" — the exact reason the object form exists — silently loses two of the three surfaces they had.
Repro (framework
examples/app-showcase, console at pinned6314e87, signed in as ADMIN so permissions are not a variable)showcase_invoicealready ships the declaration (src/data/objects/invoice.object.ts:58):On a paid invoice (
INV-1011, predicate false):disabledWhen)⋯Control on a non-paid invoice (
INV-1001, predicate true): Delete present on all three — so the absence above is the predicate, not a render failure.Control on the boolean form — temporarily
delete: false, artifact rebuilt:All three gone. That asymmetry is the finding.
Where it diverges
plugin-grid/src/components/RowActionMenu.tsx:299-301→isBuiltinRowActionVisible(input.deletePredicates, 'delete', row, scope, objectFields), fed byresolveRowCrudAffordances(ObjectGrid.tsx:1659). The child-row menu does the same inplugin-detail/src/RelatedList.tsx:1111-1112.plugin-detail/src/DetailView.tsx:306-307resolvesshowDelete: gatedSchema.showDelete && objectAllowsDelete && canDeleteRecord— bucket ∧ object affordance ∧ record writability.userActionPredicates(objectSchema?.userActions?.delete)is never called on this path, even thoughRelatedListin the same package already imports it.The record is in hand on this surface and the CEL evaluator is already in context (the header renders
readonlyWhen-driven field states from the same record), so the input the predicate needs is present.Why this is a defect and not "per spec"
The predicate block's doc-comment (
spec/src/data/object.zod.ts:1053-1070) says "the row's button", which the current implementation matches literally. But the key it modifies is documented one level up as governing "the generic CRUD affordances that the UI surfaces" (object.zod.ts:1410-1421), and its boolean form demonstrably does reach all three. A key whose scope shrinks when you add a predicate to it is not something an author can predict, and the failure is silent — ADR-0078 (no silently-inert metadata) is the rule this lands on.Consequence in practice:
visibleWhenis advisory UI gating (the spec says so — enforcement stays with permissions/hooks), so a hidden-on-the-list record stays deletable from its own detail page. An author who tested the list believes it is gated.Suggested fix
Fold
userActionPredicates(objectSchema?.userActions?.delete / .edit)intoDetailView'sshowDelete/showEditthe wayRelatedListalready does, evaluated against the open record.disabledWhenshould map to a disabled header button for symmetry with the kebab.Related
userActions.delete.visibleWhen— and what a per-record predicate means over a SET needs deciding first #4420