Repository navigation
Commit 6866e2e
fix(ui): the record form's edit dialog reads the record's field projection (#27137)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} -->
_Requested by **Yibo Wang** · [project
thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBFrHTwT2queiHnTbzxnSn3Y)_
Fixes #26594
Fixes #27147
Ruling-ref: 6457467702
Clause-②: yes (widening)
Metadata-protocol: none
Skills-docs: updated here —
content/docs/permissions/field-level-security.mdx (the record-scoped
projection bullet now says the answer is field-level only and a row the
caller may read but not write is refused as a whole);
skills/objectstack-data/rules/security.md names no reader of the lock,
so no skills/** edit (this PR is not governed)
## 维护者速读
- 改了什么:记录表单的编辑对话框(console 的 `ModalForm`、`ObjectForm`
及其全部布局、`DrawerForm`)在编辑已存在的记录时,向服务端询问「这条记录」的字段投影(#27063 的 `GET
/data/{object}/{id}/field-access`),只禁用该答案锁定的字段;保存时外发闸读同一个答案。
- 为什么改:裁决 B。带 `when` 的字段锁(批准后锁定)在草稿上服务端允许写,但表单只读无行的
`/auth/me/permissions`,在草稿上也把字段禁用了。
- 风险与回滚:只替换「字段级锁」这一项输入;对象授权兜底、owner
转移、read/create、其它对象都沿用原答案;答案未到或无答案(404/501/拒绝)时保持原锁;客户端从不求值 `when`。回滚即
revert。
-
先看:`ui/packages/permissions/src/RecordFieldAccessScope.tsx`、`ui/packages/plugin-form/src/recordFieldAccess.tsx`、`ui/packages/data-objectstack/src/index.ts`
的 `getRecordFieldAccess`。
- 两分钟验证:`cd ui && npx vitest run
packages/plugin-form/src/recordFieldAccess-26594.test.tsx`,预期 10/10(草稿上
summary 可编辑且随保存发送,ref_no 与已批准行上的 summary 保持锁定)。
- 需要你做的:无,席位落地。
## What changes
**The edit dialog asks for this record's projection.**
`RecordFieldAccessForForm`
(`ui/packages/plugin-form/src/recordFieldAccess.tsx`) wraps the three
containers a record form is rendered through directly: `ModalForm` (the
console's edit dialog, mounted by `AppContent`), `ObjectForm` (the
`/record/:id/edit` page and every layout it dispatches to, master-detail
header included) and `DrawerForm`. Only an `edit` form with an object
and a record id, on a data source that has `getRecordFieldAccess`, asks.
A container nested in another for the same record reuses the outer
answer (asked once).
**One input replaced, nothing evaluated client-side.**
`RecordFieldAccessScope` (`@object-ui/permissions`) re-provides the
permission context below the form. For the scoped object,
`checkField(object, field, 'write')` answers `editable.includes(field)`
only where `/auth/me/permissions` reports an explicit field-level lock
(the reader that holds no row, which serves a `when`-conditioned entry
as its lock). Every other input keeps the enclosing answer: a field with
no lock entry (the object-grant fallback), the owner field (a transfer
needs a grant the projection does not carry), `read` and `create`, and
every other object. Until the answer arrives, and when there is none,
the row-blind lock stands, so a conditioned field shows locked rather
than briefly open. `gateFormFields`, `applyFieldPermissions` and the
outbound `fieldWriteGate` all ask `usePermissions()`, so what the dialog
draws and what its save sends read the same answer. The derived value is
cached in a module-level `WeakMap` keyed on the enclosing value and the
answer (the `ServerReadScope` shape), never a `useMemo` (AGENTS.md #10).
**Adapter.** `DataSource.getRecordFieldAccess?(resource, id)` and the
`RecordFieldAccess` type in `@object-ui/types`;
`ObjectStackAdapter.getRecordFieldAccess` calls
`client.data.fieldAccess`. The record read's 404 and a server without
the route (501 `NOT_IMPLEMENTED`) resolve to `null`; any other refusal
is thrown.
**Measured: `editable` on a row the caller may read but not write.** The
projection is field-level only: the object grant and the record's
row-level write scope (sharing, RLS, ownership) are not in it, and the
response schema already says so
(`GetRecordFieldAccessResponseSchema.editable`, #27063). The new dogfood
leg pins it on a real stack: a `public_read` row another user owns
answers `GET` 200, `field-access` lists `title` and `summary` editable,
and `PATCH title` answers 403. **The ui does not gate the dialog on
record-level edit permission anywhere.** The form-wide lock
(`managedModeLocked` / `gateFormFields` in
`ui/packages/plugin-form/src/fieldWriteGate.ts`) reads the managed
bucket, `apiOperations` and `can(object, 'update')`, which is the object
grant only. On such a row the dialog therefore offers its fields and the
server refuses the save as a whole, exactly as before this PR (the
projection never widens it: on that row the row-blind lock was the only
field input it replaces). This PR says so where a reader looks: the
route's schema (already), `RecordFieldAccess.editable` in
`@object-ui/types`, the `RecordFieldAccessScope` doc, and the
field-level-security doc's projection bullet. Gating the dialog on row
write scope would need a record-level `canEdit` from the server; that is
filed as #27138.
**Other surfaces keep their current reader.** Surfaces that hold one row
and still read the row-blind answer: the record detail page's inline
edit (`plugin-detail` `DetailSection.tsx:507` and
`HeaderHighlight.tsx:211`, through `resolveFieldAffordance('recordEdit',
…)`), filed as #27146. Surfaces that hold many rows decide per column,
not per row: list/grid inline edit (`plugin-grid`
`objectGridPersistedColumns.ts:89`,
`resolveFieldAffordance('listInlineEdit', …)`). Surfaces that hold no
stored row: the new-record form (create mode asks nothing) and `view`
forms (write nothing).
Files:
- `ui/packages/permissions/src/RecordFieldAccessScope.tsx` (new),
exported from `index.ts`.
- `ui/packages/plugin-form/src/recordFieldAccess.tsx` (new);
`ObjectForm.tsx`, `ModalForm.tsx`, `DrawerForm.tsx`: each exported
component wraps its unchanged body.
- `ui/packages/types/src/data.ts`, `index.ts`: `RecordFieldAccess`,
`DataSource.getRecordFieldAccess?`.
- `ui/packages/data-objectstack/src/index.ts`: the adapter method.
- `packages/qa/dogfood/test/fixtures/record-field-access-fixture.ts`,
`record-field-access-when.dogfood.test.ts`: the `rfa_notice`
(`public_read`) object and the row-not-writable measurement.
- `content/docs/permissions/field-level-security.mdx`: one sentence on
the projection's scope.
- Changesets: `@object-ui/types`, `@object-ui/data-objectstack`,
`@object-ui/permissions` (patch, `Clause-②: yes (widening)`),
`@object-ui/plugin-form` (patch, `Clause-②: no`), all
`Metadata-protocol: none`.
- Round 2 (#27147, claimed for this branch in comment 6464455969): the
projection follows the stored row. `RecordFieldAccessScope` takes a
`revision`; when it moves, the scope asks again and holds the row-blind
lock until the new answer lands. `RecordFieldAccessForForm` moves it on
the data-invalidation bus for the record (the signal the form re-reads
on) and on the data source's `onMutation` for the record (the form's own
save, with or without the console's bridge). Two new tests:
`recordFieldAccess-26594.test.tsx` "a save that moves the row into the
state the condition names locks the field again" (both containers, so
the file is 12; with the revision wiring ablated these 2 go red) and
`RecordFieldAccessScope-26594.test.tsx` "a new revision asks again and
holds the lock until the new answer lands" (the file is 8). The
`@object-ui/permissions` and `@object-ui/plugin-form` changesets each
gain one sentence on it.
## Tests
- `ui/packages/plugin-form/src/recordFieldAccess-26594.test.tsx` (12):
for the console's edit dialog (`ModalForm` rendered directly) and for
`ObjectForm`'s edit page, under `MePermissionsProvider` with the
contract persona (`summary` locked from `approved` onward, served
row-blind as a lock; `ref_no` locked on every row):
- on the draft shape the gate admits, `summary` is live and `ref_no`
locked, and the projection is asked once with `('rfa_contract', 'c1')`;
- the save sends the edited `summary` and withholds `ref_no`;
- on the approved row both stay locked;
- a create form asks nothing; a data source without the method keeps the
row-blind lock.
-
`ui/packages/permissions/src/__tests__/RecordFieldAccessScope-26594.test.tsx`
(8): the truth table above, case-insensitive object match,
owner/read/create/other object unchanged, empty answer, pending / `null`
/ rejected answer keeps the lock, nested scope asks once, no provider
asks nothing.
- `ui/packages/data-objectstack/src/getRecordFieldAccess-26594.test.ts`
(4): URL and encoding, envelope unwrap, 404 → `null`, 501 → `null`, 403
thrown.
- `packages/qa/dogfood/test/record-field-access-when.dogfood.test.ts`:
the new measurement leg above (8/8 with #27063's seven).
## Acceptance notes
- The dialog test is the ui half of the card's acceptance ("the edit
dialog on hotclm's draft shape enables the fields the gate admits"): the
draft/approved projections it feeds are the shapes #27063's dogfood file
reads off the real route for the same persona.
- The dogfood suite is not on the PR's CI path; its run is recorded
under Verification.
- The row-not-writable gap this PR measures (the dialog never asks
whether the row itself is writable, so on a row the PATCH refuses as a
whole it still offers the fields and the save answers 403) is tracked as
#27138. This PR neither causes nor widens it.
## Verification
Head `90f1ea4df`, one commit on `origin/main` `a98daf75f`.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`: 93 derived families, each run and its exit
recorded; `--ran` reconciliation: 93 run, 0 NOT-MEASURED, 0 UNRUN. 92
exited 0 on the first run. `pnpm --dir ui exec vitest run scripts/`
exited 1: `check-handler-key-read-sites` found the `view:form` /
`object-form` handler reads gone (56 → 51), because the new `ObjectForm`
wrapper handed its body the document through `{...props}`, and the
census follows a document only through a named `schema=` attribute. The
three wrappers now pass `schema={schema}` by name; the census reads 56
again with only line numbers moved, and the gate reran exit 0 (5019
passed). Nothing else changed between the two runs.
- Under `os-verify-lock`: the ui build of every dependency of
`@object-ui/plugin-form` and `@object-ui/data-objectstack` exit 0;
type-check of `@object-ui/types`, `@object-ui/permissions`,
`@object-ui/data-objectstack`, `@object-ui/plugin-form` exit 0
(plugin-form rerun after the wrapper fix, exit 0);
`@object-ui/permissions` test 158 passed, `@object-ui/data-objectstack`
test 1209 passed, `@object-ui/plugin-form` test 2423 passed (rerun after
the wrapper fix: 204 files, 2423 passed).
- The plugin-form write census (`affordanceGrantMap-12082.test.tsx`)
keys write sites by enclosing function: the `ModalForm` / `DrawerForm`
`handleSubmit` rows moved to `ModalFormBody` / `DrawerFormBody`, same
disposition.
- Dogfood, under `os-verify-lock`: `pnpm --filter @objectstack/dogfood
exec vitest run test/record-field-access-when.dogfood.test.ts` 8/8;
dogfood typecheck exit 0.
- Ablation: with `RecordFieldAccessForForm` forced to pass through,
`recordFieldAccess-26594.test.tsx` goes 6 red / 4 green (in both
containers: the draft unlock, the save, and the approved row, which
waits for the ask); restored (`cmp` identical), 10/10.
## Human Minimum
Each item is one rule from
[CONTRIBUTING.md › Human
Minimum](https://github.com/objectstack-ai/objectstack/blob/main/CONTRIBUTING.md#human-minimum),
which links the AGENTS.md text that states it.
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(No `packages/spec` change.)
- [x] 2. Every name I added uses the casing for its kind.
- [ ] 3. No formatter ran; `pnpm lint` passes. (No formatter ran. `pnpm
lint` is CI's run.)
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed. (Not touched.)
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English. (The 维护者速读 block is in
Chinese by the repo's dispatch rule.)
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01WGufzfXsZBPnnuWWQGXm48
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 0f1045b commit 6866e2e
20 files changed
Lines changed: 926 additions & 9 deletions
File tree
- .changeset
- content/docs/permissions
- packages/qa/dogfood/test
- fixtures
- ui/packages
- data-objectstack/src
- permissions/src
- __tests__
- plugin-form/src
- types/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
64 | 64 | | |
65 | 65 | | |
66 | 66 | | |
67 | | - | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
68 | 72 | | |
69 | 73 | | |
70 | 74 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
14 | 14 | | |
15 | 15 | | |
16 | 16 | | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
17 | 21 | | |
18 | 22 | | |
19 | 23 | | |
| |||
54 | 58 | | |
55 | 59 | | |
56 | 60 | | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
57 | 72 | | |
58 | 73 | | |
59 | 74 | | |
| |||
63 | 78 | | |
64 | 79 | | |
65 | 80 | | |
66 | | - | |
| 81 | + | |
67 | 82 | | |
68 | 83 | | |
69 | 84 | | |
| |||
81 | 96 | | |
82 | 97 | | |
83 | 98 | | |
| 99 | + | |
84 | 100 | | |
85 | 101 | | |
86 | 102 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
31 | 31 | | |
32 | 32 | | |
33 | 33 | | |
| 34 | + | |
34 | 35 | | |
35 | 36 | | |
36 | 37 | | |
| |||
43 | 44 | | |
44 | 45 | | |
45 | 46 | | |
| 47 | + | |
| 48 | + | |
46 | 49 | | |
47 | 50 | | |
48 | 51 | | |
| |||
118 | 121 | | |
119 | 122 | | |
120 | 123 | | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
121 | 141 | | |
122 | 142 | | |
123 | 143 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
47 | 47 | | |
48 | 48 | | |
49 | 49 | | |
| 50 | + | |
50 | 51 | | |
51 | 52 | | |
52 | 53 | | |
| |||
1018 | 1019 | | |
1019 | 1020 | | |
1020 | 1021 | | |
| 1022 | + | |
| 1023 | + | |
| 1024 | + | |
| 1025 | + | |
| 1026 | + | |
| 1027 | + | |
| 1028 | + | |
| 1029 | + | |
| 1030 | + | |
| 1031 | + | |
| 1032 | + | |
1021 | 1033 | | |
1022 | 1034 | | |
1023 | 1035 | | |
| |||
7231 | 7243 | | |
7232 | 7244 | | |
7233 | 7245 | | |
| 7246 | + | |
| 7247 | + | |
| 7248 | + | |
| 7249 | + | |
| 7250 | + | |
| 7251 | + | |
| 7252 | + | |
| 7253 | + | |
| 7254 | + | |
| 7255 | + | |
| 7256 | + | |
| 7257 | + | |
| 7258 | + | |
| 7259 | + | |
| 7260 | + | |
| 7261 | + | |
| 7262 | + | |
| 7263 | + | |
| 7264 | + | |
| 7265 | + | |
| 7266 | + | |
| 7267 | + | |
7234 | 7268 | | |
7235 | 7269 | | |
7236 | 7270 | | |
| |||
0 commit comments