Repository navigation
Row kebab offers Edit/Delete on records the server refuses — the list ANDs only the OBJECT-level grant, the detail header also folds the RECORD-level one #4296
Description
Activity
Triage:
pm:queue— concrete defect with a measured three-surface contradiction (kebab shows / detail hides / server 403s) and a named landing.Premise verified this round on
origin/main@6d8231c:packages/plugin-grid/src/rowCrudAffordances.ts:107/:119— layer (d) is the object-level verdict only (usePermissions().can(objectName,'update'|'delete'));useRecordEditable(consumed bypackages/app-shell/src/views/RecordDetailView.tsx) is the record-grained chain the kebab never consults. The asymmetry the card describes is present in the code as written.Dedup: repo-scoped search returns only this card. objectui#4096 (the object-level hole) is closed and explicitly not regressed per the card's own contrast measurement; no open PR touches
rowCrudAffordances.ts.Not
target:v17(whole-repo seat produces that label here, noting the read for its pass): affordance-only — the server correctly refuses (fail-closed); the defect wastes a click, it does not corrupt or leak.本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
CLAIM — session_017Qqyix2QcnpUC9XeYVDzx3, branch
claude/issue-4296-kebab-record-grant. Dispatching a dev agent now. (The comment above is the triage seat's routine note, not a claim — verified.)PM ruling (delegated decision authority; maintainer veto window open — record objections here):
- One resolver, extracted — not reimplemented. plugin-grid cannot depend on app-shell, so measure where
useRecordEditable's record-level fold actually lives and what it reads (owner/sharing fields on the record + the writeScope dials from /me/permissions). If the fold is app-shell-local, EXTRACT the pure record-level predicate into the shared package both surfaces can reach (whereverusePermissionslives), switch the detail header to consume the extracted predicate with behavior pinned byte-identical, and fold it into the kebab as the next documented layer of rowCrudAffordances' intersection chain. If the fold turns out to need detail-only data a list row cannot carry, STOP and report the measurement — do not silently fall back to the card's disabled-with-reason option without a re-rule. - Hide, don't disable — consistency with the detail header and with 列表行内建【编辑】【删除】没接权限门:只与 apiOperations 求交,而它与用户无关 ⇒ 无写权账号恒可见 #4096's precedent (absent means absent). The server already fail-closes; the UI's job is to stop offering what cannot succeed.
- Missing-data fail direction: degrade to today, never over-hide. writeScope=own needs the row's owner field, and a list view may not select it. When the record-level inputs are absent on a row, the verdict falls back to the object-level answer (today's rendering), and that degradation is pinned. Hiding capability on missing data is worse than the bug.
- Pins: 列表行内建【编辑】【删除】没接权限门:只与 apiOperations 求交,而它与用户无关 ⇒ 无写权账号恒可见 #4096's object-level behavior unchanged on every surface (allowDelete:false hides kebab/bulk/detail; entitled admin sees all three); an owner's own rows still offer Edit/Delete under writeScope=own; the card's truth-table cell (same record, same user: kebab answer == detail-header answer); detail header byte-identical after the extraction swap.
- Red-first with the card's exact repro shape: object grant true + writeScope 'own' + non-owned row → kebab currently offers Edit/Delete.
- Changesets by measurement: extraction into the shared package is entry-reachable growth ⇒ minor there; plugin-grid per its own .d.ts diff. Never major.
Sequencing: gate worktree creation on PR #4531 (issue #4490) being visible in
origin/main— it just changed ObjectGrid.tsx and is armed on green CI. Disjoint from in-flight #4302/#4306 (metadata-admin/studio-design + inspector hosts), #4520 (plugin-timeline), #4529 (plugin-charts).
Generated by Claude Code
Generated by Claude Code
- One resolver, extracted — not reimplemented. plugin-grid cannot depend on app-shell, so measure where
PM re-rule after the dev's measured escalation (session_017Qqyix2QcnpUC9XeYVDzx3) — my extraction ruling is withdrawn as falsified:
useRecordEditablelives in plugin-detail (not app-shell) and is not a pure predicate at all — it is a per-record server probe (POST /api/v1/security/explain, fail-open, memoised), server-authored by design (ADR-0090 D6 / ADR-0095 C2) so buttons and enforcement cannot diverge. There is nothing to extract, and the explain endpoint has no batch form, so a per-page fold costs 2N POSTs.New ruling:
- Destination: option C. The batch explain form is filed as objectstack#8326; when it lands, this card's objectui half folds the batched verdict synchronously at page level into the grid's existing per-row predicate channel (RowActionMenu's Row CRUD actions (Edit/Delete) should support per-record visible/disabled CEL predicates #2614 editPredicates/deletePredicates), the original rulings 2/3/4 apply as written (the in-flight-window question dissolves — the verdict exists before rows render), and the kebab/detail truth table is pinned as a literal agreement test.
- Option B is permanently rejected: a client dial predicate over-hides on sharing-rule grants — the exact case useRecordEditable's own doc header opens with. Capability regression is strictly worse than the wasted click this card describes.
- No interim option A. Lazy per-row probing with post-verdict retraction puts a misclick hazard under the user's pointer (entries shift as the menu retracts) and still 403s inside the pre-verdict window — a worse interaction than today's fail-closed 403 dialog, purchased with machinery that C's landing makes throwaway. The triage measurement ("wastes a click, does not corrupt or leak") does not justify that trade.
Status: ⛔ Blocked-by objectstack#8326. Claim released; back to the pool with the blocked marker. The dev's measurement (grid resolver takes no record argument; dials unreachable from PermissionContextValue and not equivalent; batch endpoint absent) stands as the implementation brief's factual base for whoever picks this up after #8326.
Generated by Claude Code
Generated by Claude Code
UNBLOCKED — the blocker landed. objectstack#8326 closed completed via objectstack PR #8452 (
feat(spec,rest): batch recordIds on security/explain, MERGED 2026-08-13). The 2026-08-13 re-rule's option C is now executable, with its factual base (the dev measurement recorded above) and rulings 2/3/4 applying as written.Claim: PM loop round 8
Session:session_01RnQd8iMMUwXQEV1crFmQiQ
Branch:claude/issue-4296-kebab-record-verdict(fresh name; the 08-13 claim was released and its branch never pushed)
Worktree:objectui-issue-4296
Domain:repo:objectui
File surface:packages/plugin-grid/src/(rowCrudAffordances.ts, the RowActionMenu per-row predicate channel, the page-level fold + a batch-explain fetch hook) + tests;plugin-detail'suseRecordEditableis READ-ONLY reference (the truth-table agreement pin consumes both surfaces' verdicts — the detail side stays byte-identical) (stop on breach; explain in the report)
Container & model: M,mode:subagent,model: opus
Serial constraints cleared: #4672 holds plugin-chartsAdvancedChartImplcartesian click; #4673 holds corechart-series.ts— disjoint; no open dev PR touches plugin-grid (checked this round).
Generated by Claude Code
{ "issue": 4296, "status": "done", "branch": "claude/issue-4296-kebab-record-verdict", "pr": "https://github.com/objectstack-ai/objectui/pull/4689", "premise_still_valid": true, "summary": "Premises re-derived on origin/main @ b8818171f before implementing: rowCrudAffordances.ts:147/:148 still ANDed only the object-level verdict (line numbers moved from the :107/:119 recorded at 6d8231c), the #2614 predicate channel is intact at :157/:158, and rows carry stable ids via the existing rowRecordId helper. objectstack PR #8452's merged diff confirms the batch contract (recordIds 1-200, mutually exclusive with recordId, records[i] answers recordIds[i], missing records fail closed to visible:false). Implemented option C as ruled: a new layer (e) in the intersection chain folds the explain engine's record-grained verdict, fetched by a new page-level hook (packages/plugin-grid/src/hooks/useRecordCrudVerdicts.ts) that issues one batched POST per (object, operation) for the rows on screen and paginates under the server's 200-id cap. TWO DEVIATIONS TO REVIEW, both in the PR body: (1) the verdict rides the canEdit/canDelete props of the same per-row RowActionMenu call rather than a synthesized visibleWhen, because planRowActionMenu conjoins the two in one expression (same decision, same ⋮ guard, same #3562 consistency) and RowCrudPredicates['visibleWhen'] is spec-typed Expression|ExpressionInput, so a boolean verdict is not assignable and a synthetic CEL string would fabricate an authoring artifact to carry a server answer; (2) per premise C the pinned spec@17.0.0-rc.6 exports neither EXPLAIN_BATCH_MAX_RECORD_IDS nor the batch types (verified against the installed package), so the hook declares a narrow local constant and reads the wire payload as unknown before narrowing - the #4636 pin bump supersedes it. Bulk delete (objectCanDelete) untouched; no public entry exports change; plugin-detail is read-only reference, consumed unmocked by the agreement test.", "tests": "All at final commit e3c3dc331 (union re-run after the last commit). pnpm --filter @object-ui/plugin-grid test: 72 files / 645 tests pass. pnpm --filter @object-ui/plugin-grid run type-check: clean (dependency closure built first via --filter '@object-ui/plugin-grid^...' build). New rowRecordCrudVerdict.test.tsx (15 tests) covers all four pin families: the card's exact repro shape (object grant true + writeScope own + non-owned row loses both entries), an owner's own row keeping both, the fully denied row growing no ⋮ trigger while the permitted row on the same screen keeps its own, the truth-table cell as a literal agreement test driving BOTH surfaces against ONE fake explain service from ONE verdict table with the real useRecordEditable imported unchanged, five degradation cases (non-OK, network failure, pre-batch backend answering no records[], row with no id, and the pre-answer window with the response HELD open so the window is a real observed state), and the batch-shape pins (50 rows to 2 calls not 100; 250 rows to 2 calls per operation each under the cap; zero calls when the object verdict already hides both). rowCrudAffordances.test.ts gains 5 unit tests on the pure layer. REVERSE VERIFICATION, direction predicted first then observed: reverting only ObjectGrid.tsx to origin/main turned 12 of 15 red, 3 green - the 3 survivors assert that NO explain call happens and the object answer stands, correct pre-fix too. One prediction was WRONG and is recorded rather than smoothed over: the degradation cases went red as well, not green, because they wait for the batched request to be issued before asserting its failure changes nothing; a degradation pin over an absent mechanism is vacuous, so the coupling is honest but not what I predicted. Static gates all pass at e3c3dc331: eslint --quiet on all 9 changed files, check:control-bytes, check:phantom-deps, check:spec-symbols, check-changeset-presence.mjs (1 changeset, @object-ui/plugin-grid patch), check-changeset-no-major.mjs. No scripts/pm/dispatch-gates.mjs exists in this repo (that tool is objectstack-side), so the union was re-derived by inspecting the root package.json check:* scripts against my changed paths - check:phantom-deps and check:spec-symbols are the two I added beyond the dispatch's named list, because the diff adds a cross-package test import and a locally-declared mirror of a spec constant. NO skip-changeset label (a real changeset is present). CONSUMER SWEEP, downstream direction (prefix filter): plugin-view 11 files / 111 tests pass, app-shell + plugin-designer 409 files / 3843 tests pass.", "open_questions": [], "out_of_scope_findings": [ "filed as #4688: the new batched explain probe rides apiFetch ?? fetch (matching useRecordEditable exactly, deliberately - dropping the global fallback would reintroduce this card's kebab-vs-detail contradiction for same-origin hosts that do not wire apiFetch), so suites rendering a grid with no host fetch escape to the real network under happy-dom. Measured 0 lines before, 180 after. The three plugin-grid suites are FIXED in this PR with a recorded double (src/__tests__/explainDouble.ts, the objectui#3339 / PR #4105 shape - never a global error sink), taking plugin-grid back to 0. The same escape lands in consumer packages (plugin-view 96 lines, app-shell + plugin-designer 180 lines, all tests still passing), which are outside this card's declared file surface, so they are filed unassigned rather than edited here." ] }
Generated by Claude Code
ACCEPT — PR #4689 (reviewer of record; readings taken on GitHub at head
e3c3dc331, matching the report's gate-union HEAD).- Shape: draft →
main, first lineFixes #4296— correct, the full ruled deliverable. No closing keyword near any other open card's number. - Scope (10 changed files): all inside plugin-grid + a real
patchchangeset. The three pre-existing suite files touched are the in-package cleanup of the network escape this PR's own probe introduced — that is a direct consequence handled at the source, not scope creep; the consumer-package half is correctly filed as plugin-view / app-shell / plugin-designer suites escape to the real network on the grid's record-level explain probe (ECONNREFUSED noise, same class as #3339) #4688 instead of edited across the boundary. - Ruling conformance: option C as re-ruled — batched page-level fold (2 calls per page, never 2N; the 200-id cap paginated under, per the bug(core):
not_containson a non-string stored value fails the operator AND its negation — against the platform's objectstack#14079 ruling #8452 contract read from the merged diff), hide-don't-disable, fail-open on every uncertainty with the degradation pinned (including the pre-answer window held open as a real observed state),objectCanDeleteuntouched so 列表行内建【编辑】【删除】没接权限门:只与 apiOperations 求交,而它与用户无关 ⇒ 无写权账号恒可见 #4096's object-level behavior is unchanged, and the truth-table cell as a literal agreement test driving both surfaces from one verdict table with the real unmockeduseRecordEditable— the strongest form that pin could take. - Deviation 1 endorsed: riding
canEdit/canDeletein the sameplanRowActionMenuconjunction instead of the ruling's literalvisibleWhenchannel is the more honest mechanism — the predicate channel is spec-typed for authored expressions, and synthesizing a CEL string to carry a server boolean would fabricate an authoring artifact. Same decision, same kebab guard, console: 平台内置「审批请求」(sys_approval_request) 行「更多操作」菜单展开后 0 个菜单项——渲染成 128×10 的空白方块 #3562's invariant kept (fully denied row grows no trigger — pinned). The ruling's intent ("ride the existing per-row visibility decision") is met exactly; publicly recorded, correctly. - Deviation 2 accepted: the local batch-cap constant +
unknown-narrowed wire read, with the chore: raise the@objectstack/specpin from^17.0.0-rc.6to 17.0.0 GA (lockfile resolution + green-keeping adaptations) #4636 supersession note — precisely what the dispatch's premise C authorized for the rc.6 pin. - Evidence: 72 files / 645 tests in-package; consumer sweep in the downstream direction green (plugin-view, app-shell, plugin-designer); the two extra gates (
phantom-deps,spec-symbols) self-derived from the diff's shape — right call. Reverse verification predicted 12 red / 3 green and the one wrong prediction (degradation cases red too, because a degradation pin over an absent mechanism is vacuous) is recorded as measured rather than smoothed — the honest shape. - Out-of-scope finding: plugin-view / app-shell / plugin-designer suites escape to the real network on the grid's record-level explain probe (ECONNREFUSED noise, same class as #3339) #4688 (consumer-suite network escapes, remedy pre-settled by test(plugin-detail): serve the record-level explain probe from a double, not the network #4105's precedent + the ready-made recorded double) — graded on its own card.
CI on this head is converging — ready + auto-merge flips when Lint / Type Check / Test shards conclude green (timer armed). Card closes on merge;
pm:dispatchedcomes off then.
Generated by Claude Code
- Shape: draft →
- added a commit that references this issue
on Aug 17, 2026 - added a commit that references this issue
on Sep 12, 2026 - added a commit that references this issue
on Sep 12, 2026 - added a commit that references this issue
on Sep 28, 2026
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.