feat(grid): gate list row Edit/Delete and bulk delete on the effective operation set (objectstack#3720) - #2889
Merged
Conversation
…e operation set (#3720)
The fourth surface #3391 left open. The toolbar (objectui#2823), detail/form
(#3546) and related lists (#3546) all route through `resolveCrudAffordances`;
the main list's row CRUD has its own resolver and none of those rounds reached
it. Its gate was `operations ?? { update: !!onEdit, delete: !!onDelete }`, and
ObjectView wires onEdit/onDelete unconditionally while view JSON rarely declares
`operations` — so it was effectively always-on. A caller whose effective set
carried neither `update` nor `delete` still got the row kebab's Edit/Delete and
the bulk delete.
- plugin-grid `resolveRowCrudAffordances` takes `managedBy` +
`effectiveApiOperations` and resolves the object verdict through the shared
`resolveCrudAffordances` policy, so the row gate is the same decision every
other face makes. It also returns `objectCanDelete` — bulk delete rides
`onBulkDelete`, a different callback from the row `onDelete`, so it must not
be judged by whether the row handler happens to be wired.
- plugin-grid `ObjectGrid` threads its existing `effectiveApiOps` (until now fed
only to Export) into the row gate, and applies the delete verdict to bulk
delete: the implicit `['delete']`, a declared `bulkActions: ['delete']`, and
any `bulkActionDefs` entry with `operation: 'delete'`. A declared bulk action
is a wiring declaration, not a permission grant. Custom ids and non-delete
operations pass through untouched.
- plugin-list `ListView`'s own bulk bar (the non-grid views; the grid path
delegates to ObjectGrid) drops its built-in `delete` under the same verdict.
Also closes the ADR-0103 gap on this chain: `rowCrudAffordances` documented the
bucket lock as "applied upstream via the view's `operations.*`", but the
all-open default meant it never was — an engine-owned system / append-only /
better-auth object leaked a generic row Edit/Delete the engine rejects. The
shared policy applies it; a `userActions` opt-in still re-opens it (sys_user).
Semantics match the earlier rounds: intersection, never union. A missing
effective set (unrestricted object / older backend / no PermissionProvider)
preserves current behavior.
Tests: 14 component-level ObjectGrid probes over the issue's matrix (A full set,
B read-only, C the userActions opt-out control group) covering the row kebab,
multi-select, declared bulkActions and bulkActionDefs, plus the bucket cases;
28 unit cases on the resolver. Verified the probes fail without the wiring —
6 fail without the effective-set arg, 2 without `managedBy`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQQ42DcujEPonLQ3hort4t
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
os-zhuang
marked this pull request as ready for review
July 28, 2026 02:46
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes objectstack-ai/objectstack#3720 — the fourth surface objectstack#3391 left open.
The gap
The three earlier rounds all route through
resolveCrudAffordances:ObjectViewImport,ListView/ObjectGridExport)RelatedRecordActionsBridge)The main list's row CRUD does not — it has its own resolver
(
plugin-grid/src/rowCrudAffordances.ts), so none of those rounds ever reached it.Its gate was:
ObjectViewwiresonEdit/onDeleteunconditionally and view JSON rarely declaresoperations, so the gate was effectively always-on.ObjectGrid'seffectiveApiOpswascomputed but fed only to Export. Net effect: a caller whose effective set carried neither
updatenordeletestill got the row kebab's Edit/Delete and the bulk delete — themost destructive affordance on the list.
Changes
plugin-grid·resolveRowCrudAffordances— takesmanagedByandeffectiveApiOperations, and resolves the object verdict through the sharedresolveCrudAffordancespolicy, so the row gate is now the same decision the toolbar,record header, form and related lists make. Also returns
objectCanDelete: bulk deleterides
onBulkDelete, a different callback from the rowonDelete, so it must not bejudged by whether the row handler happens to be wired.
plugin-grid·ObjectGrid— threads the existingeffectiveApiOpsinto the rowgate, and applies the delete verdict to bulk delete across all three entry points: the
implicit
['delete'], an author-declaredbulkActions: ['delete'], and anybulkActionDefsentry withoperation: 'delete'. A declared bulk action is a wiringdeclaration, not a permission grant. Custom action ids and non-delete operations pass
through untouched — they route through the action runner with their own gates.
plugin-list·ListView— its own bulk bar (the non-grid views: kanban / calendar /gallery; the grid path delegates to
ObjectGrid) drops its built-indeleteunder thesame verdict.
Checklist item 4 — the ADR-0103 bucket lock
Confirmed genuinely missing, and fixed here.
rowCrudAffordancesdocumented it as"applied upstream via the view's
operations.*", but the all-open default meant it neverwas, and the server's
apiOperationsdoesn't carry it either —annotateEffectiveApiOperationsderives from
apiMethods+ the export axis, not frommanagedBy. So an engine-ownedsystem/append-only/better-authobject leaked a generic row Edit/Delete that theengine rejects (
assertEngineOwnedWriteAllowed). Running the shared policy applies it;a
userActionsopt-in still re-opens it (e.g.sys_user'sedit).objects lose their generic row Edit/Delete kebab entries in the main list. That aligns them
with the toolbar, detail header, form and related lists, which already hide those.
Checklist item 3 — other
rowActionsconsumersListView— forwards toObjectGridfor the grid path (inherits the gate); its ownbulk bar is gated above. Note it is currently inert in the console: no first-party
consumer passes
onBulkAction, so it is covered by the shared-policy unit tests ratherthan a DOM probe.
ObjectGrid's stacked-card branch filters the_actionscolumn out entirely, so it never surfaced row CRUD.plugin-view/ObjectView— passesonEdit/onDelete/onBulkDeleteintoObjectGridand inherits the gate.
components/data-table— the generic, object-less table; no effective set exists for it.Tests
14 component-level probes against the real
ObjectGridwithusePermissionsmocked,asserting the user-visible outcome (menu items / selection checkboxes / bulk button) —
the issue's matrix, with its control group kept:
['get','list','create','update','delete']['get','list']userActions:{edit:false,delete:false}plus independent update/delete gating, the
undefinedfallback, bothintersection-never-union invariants, the bucket matrix, and the declared
bulkActions/bulkActionDefsfilters.28 unit cases on the resolver (the 10 pre-existing ones kept).
Probes verified to actually observe the bug: reverting the wiring turns 6 red without
the effective-set argument and 2 red without
managedBy.Suites run green:
plugin-grid279,plugin-list163,app-shell60,plugin-view91.Lint 0 errors; the 4 TS declaration errors
plugin-gridemits are pre-existing onmain(
ObjectGrid.tsx:677-678i18n defaults,importParsers.ts:352).Not done
No end-to-end browser check — this environment has no backend/preview stack. The evidence
is component-level plus the server-side code walk in the issue.
Generated by Claude Code