Summary
On one screen, for one record and one user, the list row kebab and the record detail header give opposite answers to "may I write this record":
- Row kebab — shows Edit and Delete, because it ANDs only the object-level verdict (
usePermissions().can(obj,'update'|'delete'), rowCrudAffordances.ts layer (d), objectui#4096).
- Detail header — hides both, because
useRecordEditable also folds the record-level verdict (sharing model / writeScope / RLS).
The server agrees with the detail header: the same verbs answer 403 "You do not have access to this record" for that row. So the kebab offers two buttons that cannot succeed.
Repro (real runtime, framework examples/app-showcase, console at the pinned SHA 6314e87)
- Boot the showcase (
objectstack dev), sign in as admin.
- Create a member and grant it a permission set carrying object-level
showcase_project: { allowRead, allowEdit, allowDelete: true }. Leave writeScope at its default (own) — every seeded project row is owned by someone else.
- Confirm the split server-side, as that member:
GET /api/v1/auth/me/permissions → showcase_project: { allowRead: true, allowEdit: true, allowDelete: true, … } (object level: granted)
PATCH /api/v1/data/showcase_project/<foreign row> → 403 PERMISSION_DENIED, "You do not have access to this record…"
DELETE /api/v1/data/showcase_project/<foreign row> → 403, same record-grained message
- In the console as that member, on
Legacy Sunset (owner linus@example.com):
- list → row kebab → Edit and Delete are both offered
- open the same record's detail page → no Edit button, and the
⋯ menu contains only Share
Measured cells (UI, same session, same record):
| surface |
Edit |
Delete |
| list row kebab |
shown |
shown |
| detail header |
hidden |
hidden |
server (PATCH / DELETE) |
403 |
403 |
Why it matters
The object-level grant is not the write verdict on a record — writeScope, the sharing model and RLS narrow it per row. The kebab treating the object grant as final means a user with a legitimately broad object grant sees Edit/Delete on every row they can read, including rows the server will refuse. Clicking ends in a permission error dialog on a button the UI itself put there.
rowCrudAffordances.ts documents the intersection chain (bucket → userActions → apiOperations → per-principal object permission) and notes that layer (c) "fails OPEN for every unprivileged account" without layer (d). The same argument applies one level down: layer (d) fails open for every record the principal does not own.
Suggested direction
Feed the row kebab the same record-grained verdict the detail header uses (the list already holds each row, and /me/permissions already reports the scope dials), or — if a per-row check is too costly in the grid — render the entries disabled with the reason rather than as live actions.
Notes
- Not a regression of objectui#4096 — that issue closed the object-level hole, which is verified working: with
allowDelete:false the kebab Delete, the bulk Delete and the detail Delete are all correctly absent, while an entitled admin sees all three on the same screen.
- The selection bar could not be measured for this cell on
showcase_project: that view declares custom inline bulk defs, so the built-in bulk Delete is absent for every persona there.
Summary
On one screen, for one record and one user, the list row kebab and the record detail header give opposite answers to "may I write this record":
usePermissions().can(obj,'update'|'delete'),rowCrudAffordances.tslayer (d), objectui#4096).useRecordEditablealso folds the record-level verdict (sharing model /writeScope/ RLS).The server agrees with the detail header: the same verbs answer 403 "You do not have access to this record" for that row. So the kebab offers two buttons that cannot succeed.
Repro (real runtime, framework
examples/app-showcase, console at the pinned SHA6314e87)objectstack dev), sign in as admin.showcase_project: { allowRead, allowEdit, allowDelete: true }. LeavewriteScopeat its default (own) — every seeded project row is owned by someone else.GET /api/v1/auth/me/permissions→showcase_project: { allowRead: true, allowEdit: true, allowDelete: true, … }(object level: granted)PATCH /api/v1/data/showcase_project/<foreign row>→ 403PERMISSION_DENIED,"You do not have access to this record…"DELETE /api/v1/data/showcase_project/<foreign row>→ 403, same record-grained messageLegacy Sunset(ownerlinus@example.com):⋯menu contains only ShareMeasured cells (UI, same session, same record):
PATCH/DELETE)Why it matters
The object-level grant is not the write verdict on a record —
writeScope, the sharing model and RLS narrow it per row. The kebab treating the object grant as final means a user with a legitimately broad object grant sees Edit/Delete on every row they can read, including rows the server will refuse. Clicking ends in a permission error dialog on a button the UI itself put there.rowCrudAffordances.tsdocuments the intersection chain (bucket →userActions→apiOperations→ per-principal object permission) and notes that layer (c) "fails OPEN for every unprivileged account" without layer (d). The same argument applies one level down: layer (d) fails open for every record the principal does not own.Suggested direction
Feed the row kebab the same record-grained verdict the detail header uses (the list already holds each row, and
/me/permissionsalready reports the scope dials), or — if a per-row check is too costly in the grid — render the entries disabled with the reason rather than as live actions.Notes
allowDelete:falsethe kebab Delete, the bulk Delete and the detail Delete are all correctly absent, while an entitled admin sees all three on the same screen.showcase_project: that view declares custom inline bulk defs, so the built-in bulk Delete is absent for every persona there.