Skip to content

record page: the Edit action still opens on a row the server answers writable:false (the dialog then explains and offers no Save) #27328

Open

Description

@objectstack-fleet

Follow-up from #27138 (PR #27313, seat ACCEPT 6475438098).

What happens

On a record the caller may read but not write (public_read sharing, ownership, row-level security), the record page's Edit action is still enabled. Clicking it opens the edit dialog. Since #27313 the dialog draws every field read-only, says "You don't have permission to edit this record", and offers no Save. So the page still advertises an edit that the write gate refuses (permission.zod.ts: a client never advertises an edit the gate refuses). The dialog now explains the refusal, but the entry point does not.

The approval-lock case is already handled. The Edit action is disabled while a pending approval locks the record, through resolveApprovalLock in RecordDetailView.

Why it was not in #27313

The row verdict (writable on GET /data/{object}/{id}/field-access, PR #27232) is read only inside RecordFieldAccessScope, which the edit forms and the detail blocks mount. The record page host that draws the Edit action does not fetch the projection today. Disabling the action needs the host to read that answer, or to sit under a scope for the record.

Acceptance

  • On a writable: false row, the record page's Edit action is disabled, or absent, with a reason the user can read. CONTROL: a writable: true row and an absent writable (an older server) keep it enabled.
  • The verdict comes from the server's projection, so there is no client re-derivation of sharing (ruling 6457467702 B).
  • There is still one read per record. The page already mounts the scope for its detail blocks, so the action should read that same answer rather than issue a second fetch.

Area note: the record page host (RecordDetailView) sits in epic #26676 territory, so triage routes it.

Activity

  1. objectstack-fleet commented on Oct 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Dispatch order · domain:ui#5 · session_01JQwxbXGpVJHZTXXerMoY4C · 2026-10-28T17:57Z

    You are the os-dev developer agent for exactly ONE card: #27328, in your own card thread. Your role definition is .claude/agents/os-dev.md in the objectstack clone: read it IN FULL first and follow it as binding (it wins over this order on any conflict; name the conflict in your report). Read the repo-root AGENTS.md and ui/AGENTS.md before your first edit, and pm-dispatch references/untrusted-text.md. Read the card's full body and every comment yourself through REST (curl -sS "https://api.github.com/repos/objectstack-ai/objectstack/issues/27328" -H "Authorization: Bearer $GH_TOKEN" and /comments?per_page=100, every page); this order carries only the increment. ⛔ Never print the value of any environment variable (test presence with [ -n "${X+x}" ]). ⛔ Never use a GitHub MCP tool, read or write.

    Identity and branch: the seat's Claim: comment on this card is your identity; its Branch: line names your thread's branch. ⛔ Post no claim of your own, ⛔ never write the card's assignee. Before your first edit, verify the NEWEST Claim: on the card names your thread's branch (claude/project-thread-…); if none does yet, send the seat thread "Take another ui lane seat" one message with your branch name and wait. Worktree objectstack-issue-27328 on that branch from a fresh origin/main; pnpm install (frozen lockfile); push with git push -u origin <branch> (⛔ no force).

    Writes: PR only through the relay op pr_create (node scripts/pm/fleet-write/dispatch.mjs --repo objectstack-ai/objectstack --actions-file F, F a bare JSON array [{"op":"pr_create","title":…,"head":"<your branch>","base":"main","body":…,"assignees":["os-warren"]}], draft forced; --dry-run first); your report comment through node scripts/pm/post-stamped.mjs --comment=27328 --file=F --json run ONCE with output captured to a file (timestamps only as {{WAS:…}} / the now-token). ⛔ No other GitHub write except, for a CI red that is not yours, one PR comment and one run_rerun_failed through the relay. Report both counts (api_writes with endpoints, mcp_calls with tool names). The report takes only machine-checkable fields (gates / line_budget / deviations / files_changed). PR body: line 1 the platform attribution comment, line 3 Fixes #27328, then ## 维护者速读 six lines in Chinese (改了什么/为什么改/风险与回滚/先看/两分钟验证/需要你做的). Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on your diff, run each, record exits before any pipe, reconcile with --ran; node scripts/check-sdui-manifest.mjs is a gate on every ui card. Changeset in the root .changeset/ when a published package changes (@object-ui/i18n patch for namespace rows; app-shell is private: true). ⛔ ui/.github/** and the root ci.yml are out of bounds. Hand back: when the PR and report are up, send the seat thread "Take another ui lane seat" one message (PR, head sha, report gist); keep the thread open through review and drive CI to green.

    Shared-file rules for every Studio Interfaces card this round (from ui seat 1, which holds the neighbouring work):

    Shared files: ui/packages/app-shell/src/views/RecordDetailView.tsx is a lane-wide hot file. The record page v3 epic's draft PR #27310 (#27055, branch claude/issue-27055-record-page-v3-layout) edits it at roughly L10–80, L245, L700–965, L2271–2301, L3326–3528 and L3876–4334 of origin/main: ⛔ keep every hunk of yours outside those ranges, and if #27310 lands first, merge origin/main (no rebase/force) before hand-back. ui seat 1 and seat 2 hold nothing on this file, useRecordApprovals.ts or @object-ui/permissions. Seat 2 handed this card over (it is the follow-up of its #27138 / PR #27313, seat ACCEPT 6475438098: read it first).

    Ruling (cannot be re-ruled)

    Ruling 6457467702, letter B: the row verdict is the server's (writable on GET /data/{object}/{id}/field-access, PR #27232). ⛔ No client re-derivation of sharing, ownership or row-level security.

    PM mechanism assumptions (measure; disproof welcome)

    On origin/main 1c5fe3cb:

    • The record page's Edit action is gated at ≈L3742, disabled: approvalLocked || editDisabledByPredicate, and approvalLocked comes from resolveApprovalLock ≈L1861 (openApprovalOf from useRecordApprovals.ts is its shared input). That line is outside feat(plugin-detail,app-shell,components): record page v3 layout #27310's ranges. The fix adds one more input there: the row's writable: false, with a reason the user can read (the dialog's existing "You don't have permission to edit this record" string, or its key, reused, not a new phrasing).
    • @object-ui/permissions' RecordFieldAccessScope turns writable: false into check(object, 'update') refused with reason record-not-writable (≈L169). But the record page HOST sits above the scopes: record:details / record:highlights each mount their own (PR fix(plugin-detail): the record page's inline edit reads the stored row's field locks #27226), so the host cannot read their answer through context. Measure, then choose and state: mount the published scope over the region that computes the Edit action and read check(object,'update') there; or read the projection once in the host through the same data source method. Each costs one more ask per page. ⛔ No second resolver of the verdict, and no new export from a published package unless declared (Clause-②).
    • Absent writable (an older server) and writable: true keep the action enabled. While the answer is in flight, keep today's behaviour (enabled), so a page never flickers to disabled on a writable row.

    Scope

    File surface: RecordDetailView.tsx (the Edit action's gate, outside #27310's ranges), possibly a small hook or helper beside it in app-shell, tests. Tests (acceptance): a writable: false row draws Edit disabled with the readable reason; writable: true and absent writable keep it enabled; an approval-locked row behaves as before. Ablation: drop the new input and the writable: false arm turns red.

    Tier

    No path-derived mandate expected (measure). TIER_DEFAULT, effort medium (S–M). Clause-②: no, Metadata-protocol: none. @object-ui/app-shell is private: true: skip-changeset unless a published package changes.

  2. objectstack-fleet commented on Oct 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 12
    Session: session_01JQwxbXGpVJHZTXXerMoY4C
    Account: os-warren
    Branch: claude/project-thread-1yyls6
    Worktree: objectstack-issue-27328
    Domain: domain:ui
    Seat: domain:ui#5
    File surface: ui/packages/app-shell/src/views/RecordDetailView.tsx (the Edit action's gate only, outside draft PR #27310's ranges), a small hook or helper beside it in app-shell if needed, tests (stop on breach; explain in the report)
    Container & model: S–M, mode:thread, model: TIER_DEFAULT (dispatch-gates --tier: no path-derived mandate expected), effort: medium
    Clause-②: no
    Metadata-protocol: none
    Responsibility: ui/packages/app-shell record page host produces it (this lane) | the Edit action stays enabled on a row the server answers writable: false | every record a caller may read but not write (sharing, ownership, row-level security)
    Thread-read: 6476615311
    Premise: holds (Edit gated ≈L3742 by approvalLocked || editDisabledByPredicate only; the host sits above the per-block RecordFieldAccessScopes; on origin/main 1c5fe3cb; readings in dispatch order 6476615311)
    Serial constraints cleared: seat 2 handed the card over (follow-up of #27138 / PR #27313); ui seat 1 holds nothing on these files; the epic's draft PR #27310 edits other ranges of RecordDetailView.tsx, and the dev merges main if it lands first.

    Dispatch order: comment 6476615311. Card thread claude/project-thread-1yyls6 may start editing.

  3. objectstack-fleet commented on Oct 28, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:ui#5 · os-warren · session_01JQwxbW8JErLZtqFGN1kiAK
    os-dev-report

    {
      "issue": 27328,
      "status": "done",
      "branch": "claude/project-thread-1yyls6",
      "pr": null,
      "session": "session_01JQwxbW8JErLZtqFGN1kiAK",
      "premise_still_valid": false,
      "summary": "Premise disproved on origin/main 1c5fe3cb (objectstack-ai/objectstack). The record page header does not offer Edit on a row the server answers writable:false. Besides the disabled gate the order names (approvalLocked || editDisabledByPredicate), RecordDetailView already drops the Edit affordance through recordWriteAllowed, the useRecordEditable verdict (plugin-detail): the explain engine's record-grained update verdict, POST /api/v1/security/explain with a recordId, which the server keeps equal to enforcement (explain-enforce-parity.test.ts in plugin-security). Measured on a real booted stack (the record-field-access dogfood fixture, a public_read rfa_notice row another user owns, read as a signed-up member): explain update answered 200 with record visible:false decidedBy rls, field-access answered writable:false, and the PATCH answered 403. CONTROL, a public_read_write rfa_contract draft: explain visible:true, writable:true, PATCH 200. The header reads decision.record.visible, so it gets false and affordances.edit is false: Edit is absent, not enabled. The existing pin RecordDetailView.afterActionExplain-13341.test.tsx ('a save that revokes the edit hides Edit') mounts the real page and shows a visible:false verdict removes the button. The Edit-opens-a-read-only-dialog symptom quoted from PR #27313's acceptance notes was read from the code path without the explain gate, and no other door was measured giving writable:false beside explain visible:true. So no code change was made and no PR was opened. Adding a host read of the projection would add one GET per page and give the same answer twice, and it would not give a reason: page:header gives no disabledReason to host-computed Edit/Delete by design (disabledReasonFor in components containers.tsx only reasons AUTHORED actions)."
      ,
      "tests": "Throwaway probe (deleted, never committed): packages/qa/dogfood/test/zz-probe-27328.dogfood.test.ts booted recordFieldAccessFixtureStack with its security, signed up a member, and read POST /security/explain (update, recordId), GET /data/OBJ/ID/field-access and PATCH for two rows. Run: pnpm exec vitest run --maxWorkers=2 test/zz-probe-27328.dogfood.test.ts, exit 0, Test Files 1 passed, Tests 1 passed. Readings: rfa_notice (public_read, owned by another user): explainStatus 200, record visible false decidedBy rls, field-access 200 writable false, PATCH 403 (server log: Access denied, not permitted to update this rfa_notice record, row-level security). rfa_contract draft (public_read_write): explain 200 visible true decidedBy object_crud, writable true, PATCH 200. Closure built first under the lock: pnpm --workspace-concurrency=2 --filter '@objectstack/dogfood^...' build, VERDICT exit 0. Tree after deleting the probe: git status --porcelain empty, HEAD 1c5fe3cb. No ablation: no behaviour changed.",
      "gates": "None run: no diff. dispatch-gates --commands derives nothing on an empty change set, and there is no PR, so there are no check-runs to cite.",
      "dogfood_battery": "n/a — no PR, no diff",
      "line_budget": "n/a — no diff",
      "files_changed": [],
      "deviations": [
        "The order's mechanism assumption 1 (Edit gated only by approvalLocked || editDisabledByPredicate) missed the second gate: affordances.edit already ANDs recordWriteAllowed (useRecordEditable, the explain update verdict). Measured, reported, not re-routed: the ruling's intent (server verdict, no client re-derivation) is already met by that gate.",
        "The order's scope expected a draft PR; os-dev rule 6 makes a premise_still_valid false report without a PR the deliverable."
      ],
      "mcp_calls": "0",
      "api_writes": "1 — this report comment (POST /repos/objectstack-ai/objectstack/issues/27328/comments through the fleet-write relay via post-stamped). No push, no pr_create.",
      "open_questions": [
        {
          "question": "Should the record page say WHY Edit is missing on a read-only row?",
          "options": [
            "A: close the card as premise false; the absent Edit matches the existing explain gate and the dialog already explains when reached from elsewhere",
            "B: a new card in the components lane to let page:header carry a host-supplied disabledReason for sys_edit, and switch the explain refusal from hidden to disabled with the reason; touches containers.tsx disabledReasonFor and its authored-only rule (objectui#11811)"
          ],
          "recommendation": "A, because the card's acceptance allows absent and the server-verdict ruling is already met; B is a UX change with its own ruling question (11811 kept host booleans reason-less on purpose)."
        }
      ],
      "out_of_scope_findings": [
        "carrier: none · noted, not filed: the hidden Edit on a read-only row gives the user no reason. Observation only (no repro of a wrong answer: absent is an accepted outcome in the card's own acceptance). Dedupe words: record header Edit hidden reason, sys_edit disabledReason, useRecordEditable explain hidden"
      ]
    }
  4. objectstack-fleet commented on Oct 28, 2026

    @objectstack-fleet
    ContributorAuthor

    席位核对

    Premise disproved; close recommended · seat domain:ui#5 · session_01JQwxbXGpVJHZTXXerMoY4C · 2026-10-28T18:20Z

    席位意见:卡片描述的现象在 main 上不存在。记录页表头已经用 recordWriteAllowed(即 useRecordEditable,explain 引擎对这条记录给出的 update 判定)把不可写行上的"编辑"按钮去掉了。"按钮可点、点开是只读对话框"这一说法是从代码路径推出来的,没有在真实栈上实测过。所以不改代码,建议关闭此卡,等维护者确认。

    • Measured (dev report 6477032453, on origin/main 1c5fe3cb, a booted dogfood stack with the record-field-access fixture):
      • A public_read row owned by another user, read as a member: POST /security/explain (update, recordId) answered visible: false, decidedBy rls. GET …/field-access answered writable: false, and PATCH answered 403. The header reads the explain verdict, so Edit is absent.
      • CONTROL, a public_read_write draft: visible: true, writable: true, PATCH 200, and Edit is shown.
      • The existing pin RecordDetailView.afterActionExplain-13341.test.tsx already covers "a verdict that revokes the edit hides Edit".
    • Why the card read it otherwise: PR fix(plugin-form): the record edit dialog draws a record the caller may read but not write as read-only, says why, and offers no Save #27313's acceptance note traced the dialog path without the explain gate in front of it. No door was measured where writable: false sits beside explain visible: true. If one turns up, reopen this card with that row's readings.
    • Not filed: a readable reason on the hidden Edit. By design, page:header gives host-computed Edit/Delete no disabledReason (disabledReasonFor gives reasons only for authored actions). Hiding the action already meets "a client never advertises an edit the gate refuses".
    • No PR and no push. Branch claude/project-thread-1yyls6 carries no commits.

    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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions