…cord (objectui#8674)
`ObjectGrid` takes one grid-level `onDelete` and derives
`{ update: !!onEdit, delete: !!onDelete }`, so the row action was drawn for
every row. A host whose refusal is per RECORD had nowhere to put it and put it
in the callback instead: `FieldDesigner`'s delete handler returned early on a
system field, after the button had been drawn and clicked — no dialog, no
toast, no console message. `readOnly` was honest in the same component (the
callback is withheld, so no button is drawn); `isSystem` was not.
`ObjectGridComponentProps` gains `rowOperations?: (record) => { update?, delete? }`
— the grid-level `operations` vocabulary, resolved for one row — ANDed into the
same expression that already folds the #2614 predicates and the #4296
record-level verdict. An INTERSECTION like every layer around it: `false`
withholds, and nothing it returns can re-open what the object's bucket, its
`userActions`, the server's effective operations, the principal's grant or the
record verdict closed. A caller that passes nothing renders what it rendered
before.
`FieldDesigner` is its first caller; the guard inside `handleDelete` stays for
direct callers of the prop value but is no longer the only refusal.
Pins are rendered-affordance assertions in both packages — the withheld entry
read next to the surviving one on the same row and next to the neighbouring
row that keeps both, plus a no-predicate control grid.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
Fixes #8674
ObjectGridtakes one grid-levelonDeleteand derives{ update: !!onEdit, delete: !!onDelete }, so the row action is drawn for every row. A host whose refusal is per RECORD had nowhere to put it and put it in the callback instead:FieldDesigner'shandleDeletereturned early onfield.isSystem, after the button had been drawn and clicked — no dialog, no toast, no console message.readOnlywas honest in the same component (the callback is withheld, so no button is drawn);isSystemwas not.This adds the per-row operation predicate triage ruled the real content of the card, with
FieldDesigneras its first caller.packages/plugin-grid—ObjectGridComponentPropsgainsrowOperations, plus the exportedObjectGridRowOperationstype. Additive, opt-in, narrowing-only.packages/plugin-designer—FieldDesignerpasses it, so a system field's row draws no Delete. Edit is untouched.plugin-gridminor (new published prop on a published block),plugin-designerpatch (the user-visible fix). Judged on the stated test — no existing caller and no stored document renders differently; only the designer's own system rows change, which is the defect being fixed.M1 — the shape, measured against the derivation that already exists
The prop is
read once per row inside the actions cell and ANDed into the verdict expression that is already there:
What
ObjectGridalready does, which decided this:resolveRowCrudAffordancesfolds the ADR-0103 lifecycle bucket, the object'suserActions, the server's effective API operations (#3720) and the principal's grant (#4096) into one object-level{ canEdit, canDelete }; the actions cell then narrows that per row twice already — the #2614visibleWhen/disabledWhenpredicates, and the #4296 record-level explain verdict throughresolveRowRecordCrudAffordance. There was already a per-row seam; what was missing was a channel for the CALLER to speak into it. So this is one more factor in the same conjunction, in the same vocabulary, at the same site — not a parallel mechanism beside it.falsewithholds;true, an omitted member, anull/undefinedreturn and an absent prop all leave the verdict untouched. Never a union: nothing it returns can re-open what the layers above closed, pinned by its own test.Against a per-row
operationscallback (widening the existing key to accept a function):operationsis an AUTHORED schema key — read as'operations' in schema, declared onObjectGridSchema, and accepted byComponentPropsMap['object-grid'], astrictObject. A function cannot be serialised into a view document, so the function form could only ever arrive from code while the same key arrives from JSON: two dialects for one key, one of which the save gate refuses (AGENTS.md #0 / #0.1). Rejected.Against a
disabledOperationsset: two independent refusals. It renders the entry and declines the click, which is arm 2 in arm 1's clothes and is what the ruling excludes — the repo's own posture here is hide, not disable (#4296 pins "a fully denied row grows no trigger at all"). And a record-keyed set cannot address these rows:FieldDesigner's own comment records that the grid's$selectstripsid, which is why it keys fields byname. A set keyed on record id would have gated nothing, silently. Rejected.M2 — blast radius, enumerated and then measured
Every
ObjectGridcall site in the tree, non-test:packages/plugin-designer/src/FieldDesigner.tsxpackages/plugin-designer/src/ObjectManager.tsxpackages/plugin-view/src/ObjectView.tsxpackages/plugin-grid/src/index.tsx(the SDUI renderer)packages/plugin-grid/demo/bulk-actions.tsxapps/console/src/dev/DevRowActions.tsx(A seventh occurrence, in
packages/fields/src/widgets/RecordPickerDialog.tsx, is inside a docblock, not a render.) The rest of the 125 occurrences of the string are tests and type-level pins.The control is measured, not asserted. A throwaway harness rendered two no-predicate callers — the real, untouched
ObjectManager, and a bare grid withonEdit/onDelete— and dumped every byte each produces: the container HTML plusdocument.bodywith each row's kebab actually open. It was run twice: once on this branch, once with the three changed source files checked out at the base commitb775500af(mutation proved on disk first — therowOperationsmarker went 6→0 inObjectGrid.tsxand 3→0 inFieldDesigner.tsx— and restored by blob hash afterwards).Lit control for the instrument:
row-action-builtin-deleteoccurs 4 times in each dump (two callers, two rows each), so the capture really did contain the affordance the change is about, rather than agreeing about an empty page.Behavioural half, same claim:
packages/plugin-view— a real consumer — is green unchanged, 37 files / 316 tests.M3 — the card's anchors, re-measured on today's tree (located by text, never by line)
packages/plugin-designer/src/FieldDesigner.tsxstill carriesif (!field || field.isSystem) return;as the FIRST statement pair ofhandleDelete, above theconfirmDialog.confirmcall — the card's quote is current.packages/plugin-grid/src/ObjectGrid.tsxstill declaresonDelete?: (record: any) => voidon the renderer props, and still derives{ update: !!onEdit, delete: !!onDelete }when the schema declares nooperations.rowOperations|operationsFor|disabledOperationsacrosspackages/plugin-grid/src/= 0, withObjectGridat 578 across the same file set as a lit control. The zero was a probe that was looking.M4 — the neighbouring honest case
The drawer form's
isSystemuse is two inline reads —disabled: readOnly || (editingField?.isSystem ?? false)on thenameandtypecontrols. There is no reusable predicate there to reuse, so the concept's SPELLING is the reuse: this fix reads the samefield.isSystemflag (the one objectui#6044 made read the spec'ssystemkey), and it stays scoped todelete.isSystemhas never meant "this row is untouchable" — the drawer opens for a system field and everything butname/typeis editable — so withholding Edit as well would have been a capability regression wearing a bug fix's clothes. Pinned in both directions.Ablation — the pins die with the wiring they name
Each leg: mutate, prove the mutation is on disk, run both pins, restore, prove the restore by blob hash against HEAD.
rowOps?.… !== falseconjunctions deleted fromObjectGridrowOperations={rowOperations}deleted fromFieldDesignerLeg B leaving the grid pin green is the correct signature, not a gap: that pin passes its own predicate and does not go through the designer. The two grid tests that survive leg A are the no-predicate CONTROLS, which assert the behaviour the ablation restores — a control that reddened there would be the broken one.
Leg A's first run reddened only 3 of 6: the intersection test set both members the same way, so "a row-level
truecannot re-open what the grid closed" was also satisfied with the mechanism deleted, an unconsulted predicate being unable to widen anything either. That leg is the second commit here — the members now point in opposite directions, so the same test reads the wiring and dies with it.Verification
Run at
8f6848e3e, the head of this branch.pnpm exec vitest run packages/plugin-grid/ packages/plugin-designer/ packages/plugin-view/— 182 files, 1572 tests, 0 failed.pnpm --filter @object-ui/plugin-grid --filter @object-ui/plugin-designer type-check— bothDone, after building the dependency closure (the first attempt was a PRECONDITION failure on an unbuilt tree, not a red).eslint ., per each package'slintscript), both affected packages in full: 217 files, 0 errors (plugin-grid 162, plugin-designer 55). Narrowed to the two packages the diff touches; the narrowing is sound because this config is not type-aware —projectService/parserOptions/project:match 0 times ineslint.config.js, against 8 for the lit controlrules:— so no untouched file's verdict can move. The other 45 workspace projects go to CI.check-changeset-presenceOK (1 changeset for 2 released packages),check-control-bytesOK,check-readme-exportsOK (535/535 self-imports real, this PR's new README import among them),check-doc-snippet-typesOK (644/644 blocks compile against the BUILTdist/*.d.ts— which is also what proves the new prop reached the shipped types),check-doc-fencesOK,check-doc-example-idsOK,check-new-cross-file-line-citations0 new,check-phantom-dependenciesOK,check-vi-mock-specifiersOK.check-governed-queue-guard --testover the 8 changed paths: NOT GOVERNED.turbo run lint/type-checkfarm, the fullpnpm testshards, E2E, andapps/console(anObjectGridconsumer whose suite was not run here).Acceptance notes
Found while working, deliberately not fixed here:
ObjectManagerdraws a delete action on system objects and silently drops the click — objectui#8674's shape, one component over #9219 —ObjectManagerhas this defect verbatim (if (!obj || obj.isSystem) return;under a grid-levelonDelete, with system objects shown by default). The repair is now three lines, and it was still kept out of this PR for a measured reason:ObjectManageris one of the untouched call sites this PR uses as its byte-identical control, and fixing it here would have consumed the control.check-readme-exports.mjs --listcrashes with a TypeError on exactly the unbuilt tree its own failure message tells you to inspect #9220 —scripts/check-readme-exports.mjs --listcrashes with a TypeError on an unbuilt tree, i.e. exactly the state its own failure message tells you to inspect. Named repro in the card; the plain run on the same tree reports cleanly, which is the control.packages/plugin-designer/src/__tests__/__mocks__/plugin-grid.tsxdraws a delete button per row wheneveronDeleteis wired and knows nothing aboutrowOperations, so a pin written through that double cannot see this fix. Not filed separately because it has a named taker: it is written into finding(plugin-designer):ObjectManagerdraws a delete action on system objects and silently drops the click — objectui#8674's shape, one component over #9219 as the hazard for whoever fixesObjectManager, which is the next PR that will touch it. This PR's designer pin renders the REAL grid for that reason.⛔ Left as a draft deliberately, not enqueued, no auto-merge — the dispatching seat arms it.
🤖 Generated with Claude Code
https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
Generated by Claude Code