Repository navigation
fix(ui): the record form's edit dialog reads the record's field projection - #27137
Conversation
…ction The edit dialog (ModalForm), ObjectForm and DrawerForm ask the data source for the stored record's field projection (GET /data/:object/:id/field-access) and answer their field locks from it through RecordFieldAccessScope, so a field locked only from a stage onward is live on the rows the lock does not name. Only the explicit field-level lock input is replaced; the object grant, owner transfer, read/create and other objects keep the row-blind answer, and no answer keeps the lock. The dogfood file measures a row the caller may read but not write: the projection lists fields editable and the PATCH refuses the row, which the field-level-security doc now says. Claude-Session: https://claude.ai/code/session_01WGufzfXsZBPnnuWWQGXm48 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
Contract reviewServed-tier: Reviewed head ① Derived judgmentsNo second judge (ruling B) — holds. Nothing under The outbound save gate — holds. Fallbacks — fail-closed, with one stale-answer window (③ flag 1). Pending: The row-not-writable case — honest, not worse. The dev's measurement is real (dogfood new leg: Contract use — holds. The handler-key census fix — honest. Declarations — hold. Tests. Gates. The dev's 93 derived / 93 run / 0 NOT-MEASURED reading was taken on this head ( ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
RecordFieldAccessScope takes a revision; when it moves the scope asks again and holds the row-blind lock until the new answer lands. The form moves it on the data-invalidation bus for the record and on the data source's onMutation for the record, so a save that moves the row into the state a field permission's when names locks the field again instead of leaving the earlier answer standing. Claude-Session: https://claude.ai/code/session_01WGufzfXsZBPnnuWWQGXm48 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Round 2 of the review of PR #27137 (card #26594, PR 2 of 2). Round 1 (6463995320) passed ① Derived judgmentsThe window is closed, fail-closed on both paths. No storm, no loop. The re-ask is a GET through Behavioural notes, not defects. (i) While the form is dirty, Tests pin it. Changesets honest. Gates. The dev did not rerun the 93 derived families and says so; the path set is unchanged (six files, all already in the round-1 list), so the derivation is unchanged and the CI check-runs on this head are the reading of record. Read in ③. ② Semver levelUnchanged from round 1: ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
维护者速读 (席位终稿)
席位意见:接受。契约复核两轮都是 PASS(6463995320、6464723871)。第一轮提出的“重读后投影过期”在本 PR 第二轮修掉,立卡 #27147 并由本 PR 关闭。详情页行内编辑的残留立了卡 #27146。行本身不可写的缺口在 #27138。 席位核对 · ACCEPT ·
|
Requested by Yibo Wang · project thread
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)
维护者速读
ModalForm、ObjectForm及其全部布局、DrawerForm)在编辑已存在的记录时,向服务端询问「这条记录」的字段投影(feat(spec,plugin-security,rest,client): a record-scoped field projection judges awhenlock on the stored row #27063 的GET /data/{object}/{id}/field-access),只禁用该答案锁定的字段;保存时外发闸读同一个答案。when的字段锁(批准后锁定)在草稿上服务端允许写,但表单只读无行的/auth/me/permissions,在草稿上也把字段禁用了。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 byAppContent),ObjectForm(the/record/:id/editpage and every layout it dispatches to, master-detail header included) andDrawerForm. Only aneditform with an object and a record id, on a data source that hasgetRecordFieldAccess, 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')answerseditable.includes(field)only where/auth/me/permissionsreports an explicit field-level lock (the reader that holds no row, which serves awhen-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),readandcreate, 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,applyFieldPermissionsand the outboundfieldWriteGateall askusePermissions(), so what the dialog draws and what its save sends read the same answer. The derived value is cached in a module-levelWeakMapkeyed on the enclosing value and the answer (theServerReadScopeshape), never auseMemo(AGENTS.md #10).Adapter.
DataSource.getRecordFieldAccess?(resource, id)and theRecordFieldAccesstype in@object-ui/types;ObjectStackAdapter.getRecordFieldAccesscallsclient.data.fieldAccess. The record read's 404 and a server without the route (501NOT_IMPLEMENTED) resolve tonull; any other refusal is thrown.Measured:
editableon 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: apublic_readrow another user owns answersGET200,field-accessliststitleandsummaryeditable, andPATCH titleanswers 403. The ui does not gate the dialog on record-level edit permission anywhere. The form-wide lock (managedModeLocked/gateFormFieldsinui/packages/plugin-form/src/fieldWriteGate.ts) reads the managed bucket,apiOperationsandcan(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.editablein@object-ui/types, theRecordFieldAccessScopedoc, and the field-level-security doc's projection bullet. Gating the dialog on row write scope would need a record-levelcanEditfrom 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-detailDetailSection.tsx:507andHeaderHighlight.tsx:211, throughresolveFieldAffordance('recordEdit', …)), filed as #27146. Surfaces that hold many rows decide per column, not per row: list/grid inline edit (plugin-gridobjectGridPersistedColumns.ts:89,resolveFieldAffordance('listInlineEdit', …)). Surfaces that hold no stored row: the new-record form (create mode asks nothing) andviewforms (write nothing).Files:
ui/packages/permissions/src/RecordFieldAccessScope.tsx(new), exported fromindex.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: therfa_notice(public_read) object and the row-not-writable measurement.content/docs/permissions/field-level-security.mdx: one sentence on the projection's scope.@object-ui/types,@object-ui/data-objectstack,@object-ui/permissions(patch,Clause-②: yes (widening)),@object-ui/plugin-form(patch,Clause-②: no), allMetadata-protocol: none.RecordFieldAccessScopetakes arevision; when it moves, the scope asks again and holds the row-blind lock until the new answer lands.RecordFieldAccessForFormmoves it on the data-invalidation bus for the record (the signal the form re-reads on) and on the data source'sonMutationfor 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) andRecordFieldAccessScope-26594.test.tsx"a new revision asks again and holds the lock until the new answer lands" (the file is 8). The@object-ui/permissionsand@object-ui/plugin-formchangesets each gain one sentence on it.Tests
ui/packages/plugin-form/src/recordFieldAccess-26594.test.tsx(12): for the console's edit dialog (ModalFormrendered directly) and forObjectForm's edit page, underMePermissionsProviderwith the contract persona (summarylocked fromapprovedonward, served row-blind as a lock;ref_nolocked on every row):summaryis live andref_nolocked, and the projection is asked once with('rfa_contract', 'c1');summaryand withholdsref_no;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 feat(spec,plugin-security,rest,client): a record-scoped field projection judges awhenlock on the stored row #27063's seven).Acceptance notes
whenlock on the stored row #27063's dogfood file reads off the real route for the same persona.Verification
Head
90f1ea4df, one commit onorigin/maina98daf75f.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 93 derived families, each run and its exit recorded;--ranreconciliation: 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-sitesfound theview:form/object-formhandler reads gone (56 → 51), because the newObjectFormwrapper handed its body the document through{...props}, and the census follows a document only through a namedschema=attribute. The three wrappers now passschema={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.os-verify-lock: the ui build of every dependency of@object-ui/plugin-formand@object-ui/data-objectstackexit 0; type-check of@object-ui/types,@object-ui/permissions,@object-ui/data-objectstack,@object-ui/plugin-formexit 0 (plugin-form rerun after the wrapper fix, exit 0);@object-ui/permissionstest 158 passed,@object-ui/data-objectstacktest 1209 passed,@object-ui/plugin-formtest 2423 passed (rerun after the wrapper fix: 204 files, 2423 passed).affordanceGrantMap-12082.test.tsx) keys write sites by enclosing function: theModalForm/DrawerFormhandleSubmitrows moved toModalFormBody/DrawerFormBody, same disposition.os-verify-lock:pnpm --filter @objectstack/dogfood exec vitest run test/record-field-access-when.dogfood.test.ts8/8; dogfood typecheck exit 0.RecordFieldAccessForFormforced to pass through,recordFieldAccess-26594.test.tsxgoes 6 red / 4 green (in both containers: the draft unlock, the save, and the approved row, which waits for the ask); restored (cmpidentical), 10/10.Human Minimum
Each item is one rule from
CONTRIBUTING.md › Human Minimum,
which links the AGENTS.md text that states it.
packages/specholds no runtime logic. (Nopackages/specchange.)pnpm lintpasses. (No formatter ran.pnpm lintis CI's run.)main, one pull request.packages/spectouched: its generated artifacts are regenerated and committed. (Not touched.)content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.🤖 Generated with Claude Code
https://claude.ai/code/session_01WGufzfXsZBPnnuWWQGXm48