Skip to content

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

@baozhoutao

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)

  1. Boot the showcase (objectstack dev), sign in as admin.
  2. 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.
  3. 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
  4. 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.

Activity

  1. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    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 by packages/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

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

    @yinlianghui
    Collaborator

    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):

    1. 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 (wherever usePermissions lives), 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.
    2. 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.
    3. 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.
    4. 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.
    5. Red-first with the card's exact repro shape: object grant true + writeScope 'own' + non-owned row → kebab currently offers Edit/Delete.
    6. 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

  4. yinlianghui commented on Aug 13, 2026

    @yinlianghui
    Collaborator

    PM re-rule after the dev's measured escalation (session_017Qqyix2QcnpUC9XeYVDzx3) — my extraction ruling is withdrawn as falsified: useRecordEditable lives 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

  5. removed their assignment
    on Aug 13, 2026
  6. self-assigned this
    on Aug 15, 2026
  7. yinlianghui commented on Aug 15, 2026

    @yinlianghui
    Collaborator

    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's useRecordEditable is 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-charts AdvancedChartImpl cartesian click; #4673 holds core chart-series.ts — disjoint; no open dev PR touches plugin-grid (checked this round).


    Generated by Claude Code

  8. yinlianghui commented on Aug 15, 2026

    @yinlianghui
    Collaborator
    {
      "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

  9. yinlianghui commented on Aug 15, 2026

    @yinlianghui
    Collaborator

    ACCEPT — PR #4689 (reviewer of record; readings taken on GitHub at head e3c3dc331, matching the report's gate-union HEAD).

    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:dispatched comes off then.


    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

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions