Skip to content

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

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-kfl9v0
Oct 28, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-kfl9v0

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Warren · project thread
Fixes #27138

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:记录编辑对话框读取服务端对“这一行”的 writable 判决与审批锁;不可写的行整表只读、说明原因、不显示保存。
  • 为什么改:只读共享或审批锁定的记录,对话框仍可编辑,填完点保存被服务端整单拒绝。
  • 风险与回滚:仅在 writable: false 或审批锁时收紧;缺 writable 的旧服务端行为不变;回滚即 revert 本 PR。
  • 先看:ui/packages/permissions/src/RecordFieldAccessScope.tsx(refusedRow)、ui/packages/plugin-form/src/fieldWriteGate.ts(closedFormAffordance)、ui/packages/app-shell/src/console/AppContent.tsx(formApprovals)。
  • 两分钟验证:pnpm --dir ui exec vitest run packages/plugin-form/src/recordFieldAccess.rowWritable-27138.test.tsx,预期 10 个用例全绿。
  • 需要你做的:无,席位落地。

What changes

The record edit surfaces now ask the server whether THIS row is writable, and read the approvals record lock the projection leaves out. No client-side re-derivation of sharing (ruling 6457467702, letter B).

  • Type. RecordFieldAccess (@object-ui/types) gains writable?: boolean, the record projection's row verdict (spec GetRecordFieldAccessResponseSchema, landed by PR feat(security): the record field-access answer says whether the record itself is writable #27232). ObjectStackAdapter.getRecordFieldAccess already returns the client's answer untouched, so the adapter is unchanged.
  • Scope. RecordFieldAccessScope (@object-ui/permissions): on writable: false, refusedRow answers write refused on every field of the record and check(object, 'update') refused with the reason record-not-writable. read, create, other objects and a check naming another record keep the enclosing answer; an object grant the provider already refuses keeps its own reason. A new lockedBy: 'approval' prop answers the same way with record-locked-by-approval, before any answer and on a data source without the projection. writable: true or an absent key changes nothing. A scope nested under one for the same record adds a host lock without asking again.
  • Form. Every gate below the scope reads it with no call-site change: gateFormFields (field locks plus the form-wide lock), the outbound fieldWriteGate, and the detail page's inline edit (RecordFieldAccessForRecord). closedFormAffordance reports record / approval for such a row, read off the scoped check's reason. ClosedAffordanceNotice says "You don't have permission to edit this record. The fields are read-only." (new key form.noPermissionToEditRecord, all ten packs) or the record page's approval sentence (detail.lockedTooltip, reused). ModalForm and DrawerForm offer no Save on it (closedForRecord). ModalForm takes lockedBy as a React prop, and RecordFieldAccessForForm forwards it.
  • Approval lock in the console's edit dialog. AppContent reads the approvals state of the record the overlay edits with useRecordApprovals (the record page's read, joined on the wire when the page already asked) and the page's rule. The rule's input is now one helper, openApprovalOf, used by both RecordDetailView and AppContent, so the dialog cannot offer an edit the page locks. The record page's Edit action already read the lock (disabled while approval-locked) and is unchanged.
  • ObjectForm.tsx is untouched (serial constraint with feat(spec,plugin-form)!: a form view's buttons hold submit and cancel only — buttons.reset is retired, because no form ever drew a reset button #27269 / ui(plugin-form): ObjectForm drops the split / drawer / modal branches and SplitForm retires; showcase task views re-spelled (ADR-0050 cutover, renderer half of #27155) #27159).

Why

On a public_read row the caller may read but not write, the edit dialog drew every field live and the server refused the save whole. That breaks "a client never advertises an edit the gate refuses" (permission.zod.ts). Seat note 6468378207: writable does not cover the approvals record lock, so a row under approval must read that lock too.

Tests

  • ui/packages/permissions/src/__tests__/RecordFieldAccessScope.rowWritable-27138.test.tsx (7 tests).
  • ui/packages/plugin-form/src/recordFieldAccess.rowWritable-27138.test.tsx (10 tests, ModalForm and DrawerForm): writable: false gives read-only fields, the record notice and no Save. CONTROL writable: true and CONTROL no key keep the fields live and Save offered. An object grant refusal keeps the object notice. The dialog under lockedBy: 'approval' (writable row, and a data source without the projection) is read-only with the lock sentence and no Save.
  • ui/packages/plugin-detail/src/renderers/__tests__/record-blocks.rowWritable-27138.test.tsx (4 tests): record:details / record:highlights offer no in-place editor on writable: false; CONTROL writable.
  • ui/packages/app-shell/src/console/__tests__/AppContent.editDialogApprovalLock-27138.test.tsx: a real AppContent mount hands ModalForm lockedBy: 'approval' for a reader outside the slate (record lock answers) and for a participant on a locking node. It hands nothing for a lockRecord: false node, nothing when no approval runs, and nothing (with no approvals read) on a create dialog.

Acceptance notes

  • The record page's Edit button still opens the dialog on a writable: false row; the dialog explains and offers no Save. Disabling the button itself would need the page host to fetch the projection, which no block above it does today.
  • The master-detail arm (subforms) of ModalForm / DrawerForm draws its Save bar through ObjectForm / MasterDetailForm, which this PR does not touch: its fields lock, its own Save bar stays.
  • DrawerForm reads writable but takes no host approval lock: no host renders it with a known lock today.
  • content/docs/permissions/field-level-security.mdx already states the writable contract ("a client need not offer the edit at all"); it is outside the claimed file surface and unchanged.

Verification

All readings on head 695f5164 (main merged at 5b5cff15).

  • New tests: scope 7/7, dialog and drawer 10/10, inline edit 4/4, AppContent approval lock 5/5.
  • Affected suites (lock run, pnpm --dir ui exec vitest run packages/permissions/ packages/plugin-form/ packages/i18n/ packages/plugin-detail/src/renderers/ packages/app-shell/src/console/ packages/app-shell/src/hooks/): 705/710 files green on the first run. The 5 red files were older AppContent edit-dialog tests whose dialog now issues the approvals read with no fetch double (the network-escape guard). Each now installs one file-wide approvals double; AppContent re-run 36/36 files, 266/266 tests. RecordDetailView (the openApprovalOf refactor) 77/77 files, 512/512 tests.
  • Type-check: @object-ui/types, permissions, i18n, plugin-form, plugin-detail and app-shell type-check exit 0 after the closure builds.
  • Derived gates: dispatch-gates --commands derived 34 families, all exit 0 (check-sdui-manifest included); --ran reconciliation: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. pnpm --dir ui exec vitest run scripts/ timed out twice (15 s) while the suite above held the box; re-run alone under the lock it was 174/174 files green. check:type-check-debt exit 3 (lock queue) on the first pass; it ran under the lock and reported OK.
Ablations (one-shot, restored; blob == HEAD after each)

node scripts/ablation-replace.mjs against RecordFieldAccessScope.tsx / ModalForm.tsx, tests resolved from src through the ui/vitest.config.mts alias, so no dist leg:

  • the writable read (answer?.writable === false to a constant false): 6 red, exactly the writable: false cases (scope 2, dialog/drawer 2, inline edit 2); the CONTROL cases and the approval cases stay green.
  • the host lock (lockedBy === 'approval' to a constant false): 4 red (scope 2, dialog 2).
  • the Save hiding (!closedForRecord(closedAffordance) to true in ModalForm): 3 red.

An earlier attempt at the first one was a refused no-op: the replacement was a substring of the anchor, the tool refused it, and nothing ran.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint is the repo-wide run CI owns (Lint UI, Lint & Repo Gates).
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed. (not touched)
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JQwxbYqMqySPCeFRdqDyHp


Generated by Claude Code

@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tooling labels Oct 28, 2026
…roval-lock test

Lint UI refused it as no-unused-vars.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JQwxbYqMqySPCeFRdqDyHp
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:ui#2 · session_01JQwxbXpF772qWJjojJvRXw · head e97571a938 · 2026-10-28T17:03Z

席位意见:记录编辑对话框现在读服务端对“这一行”的 writable 判决和审批锁;只读共享或被审批锁定的记录整表只读、说明原因、不给保存。可写行和没有 writable 的旧服务端行为不变。

  1. Diff read: 30 files, +1053 / −59.
    • RecordFieldAccessScope.tsx: refusedRow narrows only. It refuses update on THIS record of THIS object while the enclosing answer allows it, and refuses write on that object's fields. read, create, other objects and a check naming another record keep the enclosing answer, and an object-grant refusal keeps its own reason.
    • The lockedBy cache key is separate from the plain scope key, so a locked and an unlocked mount of the same record do not share a memoised value.
    • A nested scope for the same record adds only a host lock and asks nothing again.
    • fieldWriteGate.ts: closedFormAffordance maps the two row reasons to record / approval. ModalForm and DrawerForm hide Save through closedForRecord, and the notice is computed once and shared by the notice and Save.
    • AppContent.tsx: the edit dialog reads approvals only when it edits a stored record. A create dialog asks nothing, as the test pins. It passes lockedBy: 'approval' through the record page's own rule.
    • openApprovalOf is the page's former inline expression, extracted unchanged. RecordDetailView's resolveApprovalLock input is byte-equivalent, which its 77/77 files re-run backs.
  2. Checked against the order and seat note 6468378207: the row verdict is read from the projection (PR feat(security): the record field-access answer says whether the record itself is writable #27232's writable), with no client re-derivation of sharing (ruling 6457467702 B). The approvals lock is read from the existing door. ObjectForm.tsx is untouched, per the serial constraint with ui(plugin-form): ObjectForm drops the split / drawer / modal branches and SplitForm retires; showcase task views re-spelled (ADR-0050 cutover, renderer half of #27155) #27159. The adapter passes writable through, so data-objectstack is unchanged, which the claim allowed.
  3. Tests and proof (os-dev report on Record edit dialog: on a row the caller may read but not write (sharing / RLS / ownership), the fields are offered as editable and the save is refused whole — the dialog never asks whether the row is writable #27138): 7 + 10 + 4 + 5 new tests, with writable: true and absent-key CONTROLs. Ablations: the writable read gives 6 red, the host lock 4 red, Save hiding 3 red, all restored. The affected ui suites are green, 6 type-checks are green, and 34/34 derived gates exit 0. The one follow-up commit e97571a9 drops an unused screen import that red Lint UI at 695f5164; it changes no other line.
  4. Accepted deviations:
  5. Scope and changeset: the changeset names types, permissions, plugin-form and i18n as patches. @object-ui/app-shell is private: true, and plugin-detail is touched by tests only, so neither needs a row. I read the changeset prose against the diff: its three bullets (row verdict, approval lock, what the form draws) match the code.
  6. Shape: Fixes #27138, Clause-②: no, Metadata-protocol: none. Not governed: no skills/**, spec or CI path is touched.
  7. Findings carried, not blocking:
  8. Landing clause: all check-runs on e97571a9 concluded (44 success, 11 skipped, 0 failed), Lint & Repo Gates and TypeScript Type Check included; mergeable clean. The seat readies and arms now and tracks the PR to MERGED.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tooling

Projects

None yet

2 participants