Skip to content

Record detail header ignores userActions.delete.visibleWhen — the BOOLEAN form of the same key reaches all three delete surfaces, the predicate form only reaches the row kebab #4419

Description

@baozhoutao

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

Activity

  1. self-assigned this
    on Aug 13, 2026
  2. yinlianghui commented on Aug 13, 2026

    @yinlianghui
    Collaborator

    CLAIM — session session_017Qqyix2QcnpUC9XeYVDzx3, branch claude/issue-4419-detail-header-predicates.

    Ruling (delegated authority; open veto window): fix as the card suggests — the detail header folds userActionPredicates(objectSchema?.userActions?.delete / .edit) into showDelete/showEdit, evaluated against the open record, through the same helper family the row kebab and RelatedList already use (one evaluator, one answer across surfaces — no fresh predicate-evaluation code on this path). disabledWhen maps to a DISABLED header button, kebab symmetry. The card's ADR-0078 framing is adopted: a key whose scope shrinks when the author upgrades boolean → predicate is silently-inert metadata.

    Scope guards: (1) #4420 (selection-bar half) is NOT here — it needs the per-record-predicate-over-a-SET semantics decision first and stays its own card. (2) #4296 (row kebab's permission-layer gap) is NOT here. (3) The header's existing gates (gatedSchema.showDelete ∧ objectAllowsDelete ∧ canDeleteRecord) all REMAIN — the predicate is a fourth conjunct, not a replacement.

    Red-first (the card's own repro shape): record where the predicate is FALSE (paid invoice) → pre-fix the detail header still offers Delete (capture), post-fix hidden. Must-not-change (green both sides): boolean delete: false still hides all three surfaces; predicate-TRUE record keeps Delete; disabledWhen on edit renders the header Edit disabled (new behavior — red-first it too); permissions/writability gates unchanged.

    Grading: patch @object-ui/plugin-detail (.d.ts measured both ways). Never major.

    Surface (mutual exclusion): packages/plugin-detail/src/DetailView.tsx + tests + one changeset. Coordination: in-flight #4468 (date-locale census) was instructed to REPORT rather than edit unexpected surfaces — you own DetailView.tsx; if its census names a date site in your file, it reports and you are unaffected. ⛔ NOT: RelatedList.tsx (already correct — read-only reference), plugin-grid (in-flight #4501 armed), selection-bar components (#4420), console/**, content/docs/releases/.


    Generated by Claude Code


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions