Skip to content

fix(ui): the record form's edit dialog reads the record's field projection - #27137

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

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-dyw8uu

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

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)

维护者速读

  • 改了什么:记录表单的编辑对话框(console 的 ModalForm、ObjectForm 及其全部布局、DrawerForm)在编辑已存在的记录时,向服务端询问「这条记录」的字段投影(feat(spec,plugin-security,rest,client): a record-scoped field projection judges a when lock on the stored row #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 (Record form field-access scope: the record projection is asked once and not again when the form re-reads the record, so a field a save just locked is drawn enabled until remount #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 feat(spec,plugin-security,rest,client): a record-scoped field projection judges a when lock on the stored row #27063's seven).

Acceptance notes

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,
which links the AGENTS.md text that states it.

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (No packages/spec change.)
  • 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.)
  • 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 维护者速读 block is in Chinese by the repo's dispatch rule.)
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WGufzfXsZBPnnuWWQGXm48

…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>
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 28, 2026
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing 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): node scripts/docs-audit/affected-docs.mjs --json c4ad55ca00bee01993b1329aedb1fe6f8b0ea30b → packageMentionDocs.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 90f1ea4dfcd887396d1f092d77298ee6e07ce463
Local-runs: none
Scope: ui/packages/types/src/data.ts, ui/packages/types/src/index.ts, ui/packages/data-objectstack/src/index.ts, ui/packages/data-objectstack/src/getRecordFieldAccess-26594.test.ts, ui/packages/permissions/src/RecordFieldAccessScope.tsx, ui/packages/permissions/src/index.ts, ui/packages/permissions/src/tests/RecordFieldAccessScope-26594.test.tsx, ui/packages/plugin-form/src/recordFieldAccess.tsx, ui/packages/plugin-form/src/ModalForm.tsx, ui/packages/plugin-form/src/ObjectForm.tsx, ui/packages/plugin-form/src/DrawerForm.tsx, ui/packages/plugin-form/src/recordFieldAccess-26594.test.tsx, ui/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx, packages/qa/dogfood/test/record-field-access-when.dogfood.test.ts, packages/qa/dogfood/test/fixtures/record-field-access-fixture.ts, the four .changeset/26594-record-form-field-access-*.md files, content/docs/permissions/field-level-security.mdx + cited code read on origin/main at the head's base: ui/packages/permissions/src/MePermissionsProvider.tsx (checkField, getFieldPermissions), PermissionContext.ts, ServerReadScope.tsx, ui/packages/core/src/utils/affordanceGrants.ts (resolveFieldAffordance, formFieldsAffordance, AFFORDANCE_GRANTS rows), ui/packages/plugin-form/src/fieldWriteGate.ts, recordInvalidation.ts, MasterDetailForm.tsx (header ObjectForm, row editor), ObjectForm.tsx SimpleObjectForm (record read effect, handleSubmit), ModalForm.tsx body (record read, handleSubmit), ui/packages/app-shell/src/console/AppContent.tsx and hooks/useActionModal.tsx and views/RecordFormPage.tsx and views/ScreenView.tsx (the ModalForm / ObjectForm mounts), ui/packages/plugin-designer/src/ObjectManager.tsx and FieldDesigner.tsx, ui/scripts/check-handler-key-read-sites.mjs (documentCarryingChildren, schemaAttributeOf, documentIdentifiers), packages/client/src/index.ts (data.fieldAccess, fetch headers, getRoute), packages/rest/src/data-action-routes.ts (the merged route), packages/spec/src/system/constants/system-names.ts (OWNER_ID), skills/objectstack-data/rules/security.md, .changeset/config.json and ui/.changeset/README.md, ui/packages/data-objectstack/package.json and ui/packages/permissions/package.json.

Reviewed head 90f1ea4dfc, the head /pulls/27137 reported when this review began and still reports at posting; it did not move. The net diff against main (20 files, 1111 lines) was read whole. Inputs: card #26594 body and all 12 comments (triage 6438806231, lane question 6442500339, retriage 6443782293, director brief 6455620844, ruling 6457467702 letter B, claim 6458235349, amendment 6458412501, PR 1 os-dev-report 6459978209, ACCEPT 6461821476, landing note 6462337782, PR 2 os-dev-report 6463775639); PR #27063 body, its contract review 6460514485 and the merged route and client on main; PR #27137 body, file list, its one comment (docs-drift 6463772805) and the diff; the head's check-runs. The dispatch order and the dispatching seat's conclusions were not read. The tooling's --paths face reads these paths as TIER_DEFAULT (no path in its schema register); the brief asked for the schema face on the widened published @object-ui/types surface, and this record is rendered at CONTRACT_REVIEW_TIER with the floor at every path of the diff.

① Derived judgments

No second judge (ruling B) — holds. Nothing under ui/ in the diff evaluates when; the only when touch under ui/ on main remains the Studio editor preserving the key. RecordFieldAccessScope.scoped (RecordFieldAccessScope.tsx:398-447) replaces exactly one input of checkField: for action === 'write', the scoped object (case-folded, as MePermissionsProvider folds), a field that is not SystemFieldName.OWNER_ID ('owner_id', system-names.ts:173) and that /auth/me/permissions reports with an explicit write === false entry (lockedByEntry over parent.getFieldPermissions, which is the raw fields map with editable !== false, MePermissionsProvider.tsx:407-424), the answer is editable.has(field) from the server's record projection. Every other case returns parent.checkField: a field with no entry keeps the object-grant fallback (allowEdit, MePermissionsProvider.tsx:360-371), the owner field keeps the transfer-grant rule (objectui#12103, lines 324-339), read and create keep the enclosing answer, other objects are untouched, and check, can, getFieldPermissions, getObjectApiOperations, effectiveObjects, isLoaded are spread through. Edit-dialog consumers all reach it through usePermissions(): fieldWriteGate and gateFormFields → applyFieldPermissions → gateByPermission → resolveFieldAffordance('editFormFields', …) → checkField(object, field, 'write') (fieldWriteGate.ts, affordanceGrants.ts:132,320-328); managedModeLocked reads can(object, 'update') plus the managed bucket and apiOperations, none of which the scope touches, so the form-wide lock is unchanged. The three wrappers (ModalForm.tsx:316-321, ObjectForm.tsx:248-253, DrawerForm.tsx:236-241) mount RecordFieldAccessForForm above the unchanged bodies, which is above every usePermissions() in ObjectFormBody, SimpleObjectForm, TabbedForm, SplitForm, WizardForm, MasterDetailForm, ModalFormBody, DrawerFormBody. RecordFieldAccessForForm (recordFieldAccess.tsx:40-54) asks only when schema.mode === 'edit', objectName and a non-empty recordId are present and the data source has the method; a create form, a view form, a form with no record id and a non-ObjectStack data source render their children unwrapped. The master-detail header ObjectForm (MasterDetailForm.tsx:2088, parentSchema carries mode and recordId) and ObjectFormBody's own modal / drawer dispatch carry the same (object, record) key and are detected as nested (SCOPE_OF), so one ask per record; the master-detail row editor (MasterDetailForm.tsx:2145, mode: 'edit', no recordId) asks nothing. Cases walked for a field and row where the dialog and the server's field-level answer disagree: summary conditioned, draft row → entry locked, editable has it → enabled, saved (pinned, both containers); summary, approved row → editable lacks it → locked; ref_no unconditioned → entry locked, editable lacks it on both rows → locked; title no entry → object grant, editable has it → enabled; a field in editable whose entry says editable: true (a set the server's fold still refuses) → the row-blind answer stands, as before; a field the answer omits that no entry locks → object grant, as before (pinned in RecordFieldAccessScope-26594 case 4); owner_id in editable → transfer rule (pinned). None found where the dialog enables a field the server's field-level answer locks while an answer is held, nor the reverse. The designer forms (ObjectManager.tsx:228, FieldDesigner.tsx:410) have mode: 'edit' and no recordId, so they ask nothing.

The outbound save gate — holds. ModalFormBody.handleSubmit (ModalForm.tsx:703-724 on main) and SimpleObjectForm.handleSubmit build canEdit = fieldWriteGate(perms, objectName, mode) from the same perms the render pass reads, then sanitizeFormData(data, objectSchema, { canEdit }) and dirtyEditPayload. A field the answer locks is stripped (ref_no, pinned absent from update's payload), a field it unlocks passes (summary, pinned present with the edited value). dirtyEditPayload sends only changed fields, so an unlocked field left unchanged is omitted by design, not dropped. MasterDetailForm's batch reads usePermissions() below the scope (MasterDetailForm.tsx:871). No path found where an unlocked, edited field is silently dropped.

Fallbacks — fail-closed, with one stale-answer window (③ flag 1). Pending: answer is null until load settles → scoped(parent, …, null) → { ...parent }, the row-blind lock (pinned). No answer: null/undefined/non-array editable and a rejection all set null (RecordFieldAccessScope.tsx:486-487). The adapter maps the record read's 404 (is404Error: httpStatus/status/statusCode 404 or OBJECT_NOT_FOUND/RECORD_NOT_FOUND) and a 501 / NOT_IMPLEMENTED to null, rethrows anything else (a 403, pinned), which the scope then also treats as no answer — a refused projection keeps the lock silently (③ flag 4). Record id change while open: the effect is keyed on key = JSON.stringify([object.toLowerCase(), recordId]), current reads answer.key === key ? value : null, so the new record is locked until its own answer lands, and the alive flag discards a superseded resolution. Two dialogs: each instance holds its own state; the module cache is keyed on (parent value, key, answer object), so they share nothing but a derived value. The answer is keyed by object and record id. The in-place record re-read is the gap: both ModalFormBody and SimpleObjectForm re-read the record through useRecordInvalidation (recordRefetch, and recordSaved() after their own save) while the scope's effect depends on [!!parent, nested, key] alone, so a re-read that moves the row into the state when names leaves the earlier editable standing. See ③ flag 1 for the window and the fix.

The row-not-writable case — honest, not worse. The dev's measurement is real (dogfood new leg: GET /data/rfa_notice/:id 200, field-access lists title and summary, PATCH 403) and the ui reading is confirmed in the code: no ui surface gates the edit dialog on row-level write scope; managedModeLocked reads can(object, 'update'), the object grant only. On such a row the only field input the scope replaces is the explicit field-level lock, which on rfa_notice has no entries, so the dialog's answer is byte-identical to before this PR. Documented where asked: RecordFieldAccess.editable (data.ts:1066-1072: "The object grant, the record's row-level write scope and a field's own rules … are not part of it"), the scope's doc (RecordFieldAccessScope.tsx:464-469, a ⛔ paragraph), the plugin-form changeset's last sentence, and the FLS doc bullet (field-level-security.mdx:75-79). No sentence in the diff claims the dialog never offers an edit the server refuses; the scope doc's "disabled exactly where the write gate refuses it" is scoped by the paragraph below it to field-level, and the test headers say "disables only what that answer locks".

Contract use — holds. ObjectStackAdapter.getRecordFieldAccess(resource, id) calls client.data.fieldAccess(resource, String(id)), which on main builds ${baseUrl}${getRoute('data')}/${encodeURIComponent(object)}/${encodeURIComponent(id)}/field-access and unwraps the { success, data } envelope (client index.ts:7415-7421); the pinned URL …/data/rfa_contract/c%2F1/field-access matches the merged route GET {dataPath}/:object/:id/field-access (data-action-routes.ts:354-400), whose answer is { object, recordId, readable, editable } and which the RecordFieldAccess type mirrors field for field. The client's private fetch adds Authorization, X-Environment-Id and Accept-Language on every request (index.ts:7520-7544), so tenancy rides the same path as every other data read. Error mapping read above.

The handler-key census fix — honest. documentCarryingChildren (check-handler-key-read-sites.mjs:1023-1038) follows a child only when its schema= attribute carries the parent's own document (documentIdentifiers seeds schema); a {...props} spread is a declared non-answer of that gate (header boundary 1). The wrappers' first spelling spread the document, so the hop to the body was lost and the view:form / object-form reads fell out of the census; passing schema={schema} by name beside {...rest} restores the hop to the same body with the same reads. Nothing was declared, exempted or baselined; the three wrappers read no handler key themselves. Type Check UI runs the source-parsing gates on the PR and is the CI reading of the 56 (③ gate reading).

Declarations — hold. Clause-②: yes (widening) and Metadata-protocol: none on the PR body and on the three widening changesets; Clause-②: no on @object-ui/plugin-form, which adds no public surface. Ruling-ref: 6457467702. Skills-docs: updated here — …: the value's first word is a recognised form; its claim is true on main — skills/objectstack-data/rules/security.md:182-208 teaches when and names no reader of the lock (no /auth/me/permissions, no projection), and no skills/** path is touched, so the PR is not governed. Changesets live in the root .changeset/, which is the configured root (.changeset/config.json lists the @object-ui/* packages in its fixed groups; ui/.changeset/ holds legacy files and a README with no config.json), and Check Changeset is green. Fixes #26594: the card's Expected ("the record form's field projection judges when against the record being edited") is delivered for the edit dialog (ModalForm, the console's dialog), the edit page (ObjectForm and every layout) and DrawerForm. Left of the card: the record detail page's inline edit (plugin-detail recordEdit) holds one row and still reads the row-blind lock — the dev names it as an out-of-scope finding but files no issue (③ flag 2).

Tests. recordFieldAccess-26594.test.tsx (10 = 5 × 2 containers) renders ModalForm directly as AppContent mounts it and ObjectForm's edit page under MePermissionsProvider with the row-blind persona and a stubbed data source, and pins what the ruling asks: draft unlock + unconditioned lock + one ask with ('rfa_contract', 'c1'); the save's payload; the approved row locked after the ask; create asks nothing; no method keeps the lock. RecordFieldAccessScope-26594.test.tsx (7) is the truth table including pending / null / rejected → locked, nested asks once, no provider asks nothing. getRecordFieldAccess-26594.test.ts (4): URL encoding, envelope, 404 → null, 501 → null, 403 thrown. affordanceGrantMap-12082.test.tsx: the write census keys sites by FILE :: enclosing symbols :: VERB and fails on an unlisted or gone site (lines 1060-1069); the four ModalForm/DrawerForm handleSubmit rows move to ModalFormBody/DrawerFormBody with the same disposition (mapped([...], FIELD_WRITE_GATE)) — nothing weakened, and the rename is what the census demands. The dogfood leg is honest and does not run on the PR path (③ flag 3). The reported ablation (pass-through wrapper → 6 red / 4 green) is the predicted shape: draft unlock, save, approved-row-after-ask in both containers depend on the scope; create and no-method do not.

Gates. The dev's 93 derived / 93 run / 0 NOT-MEASURED reading was taken on this head (90f1ea4df, one commit on a98daf75f), with the one first-run red (pnpm --dir ui exec vitest run scripts/, the census above) rerun green after the by-name fix that is in the head. The head has not moved, so the reading carries; the check-runs are the verdicts of record and are read in ③.

② Semver level

patch on @object-ui/types, @object-ui/data-objectstack, @object-ui/permissions, @object-ui/plugin-form — correct under the repo's rule: the level follows the declared kind alone, and Metadata-protocol: none takes patch (AGENTS.md Post-Task Checklist 3). Read as plain semver the first three add public surface (an optional DataSource member and a type, an adapter method, an exported component) and would be minor; the four packages sit in the root fixed groups, so the published number is the group's either way. No BREAKING banner, no ADR-0087 marker owed: nothing is removed, narrowed or re-meant.

③ Boundary flags

  1. Stale projection after an in-place record re-read (ask: fix in a follow-up card before or at landing; not a FAIL). RecordFieldAccessScope asks once per (object, record) and never again, while the form it wraps re-reads the record in place — useRecordInvalidation moves recordRefetch on a bus change while pristine and replays a held one on recordSaved() after the form's own save (recordInvalidation.ts; ModalForm.tsx:517-600; ObjectForm.tsx:889-973). A form that stays mounted after a save that moves the row into the state when names (an SDUI view:form or ScreenView's form with no onSuccess, which stay mounted and filled), or a pristine dialog whose record the bus re-reads after a same-tab write, then draws the conditioned field enabled from the earlier editable and the server refuses the save — the opposite polarity of the card's symptom, in a window that did not exist under the row-blind lock. The console's own paths close or navigate on success (AppContent keys the dialog by record id and closes it; RecordFormPage passes handleSuccess), so the window is narrow there. The fix is small and local: re-ask when the record is re-read (hand the scope the form's recordRefetch nonce or the loaded record snapshot as part of its key, or clear the answer on recordSaved), and pin it with one more leg of recordFieldAccess-26594.test.tsx. The PR body's fail-closed claim ("until the answer arrives, and when there is none, the row-blind lock stands") is true as written; it does not claim the answer follows the record, and the docs do not say it does.
  2. Residual of the card, not filed (ask). The record detail page's inline edit (plugin-detail DetailSection.tsx:507, HeaderHighlight.tsx:211, resolveFieldAffordance('recordEdit', …)) holds one row and shows the card's symptom (a conditioned lock drawn on a draft). The dev lists it under out_of_scope_findings with no issue number. AGENTS.md chore: version packages #10: a reproducible defect is filed, not noted. The owning seat should file it as the card's residual (and the list inline edit's per-column lock as a declared limitation, since one column lock cannot split per row). Fixes #26594 stands for the edit dialog, which is what the card measured.
  3. Dogfood not on the PR path (acknowledged). record-field-access-when.dogfood.test.ts runs on main's full battery only; its 8/8 is the dev's local reading. The new leg's assertions were read against the fixture (rfa_notice public_read, inserted by SYS, member holds the object grant) and are consistent with the route's declared scope. The first full-battery run after merge is its gate reading.
  4. A refused projection is silent. A 403 (or any non-404/501 refusal) from getRecordFieldAccess is rethrown by the adapter and swallowed by the scope into "no answer"; the lock stands, which is the safe side, but nothing logs it. Acceptable for a UX-only input whose server boundary is unchanged; noted so a later reader does not mistake the lock for the server's verdict.
  5. Dev deviations accepted. Branch claude/project-thread-dyw8uu in place of claude/issue-26594-<slug> (measured platform constraint, declared in the claim). Human Minimum item 3 left unchecked with pnpm lint named as CI's run: Lint UI and Lint · ESLint are the readings (③ 7). The first-run red of the ui scripts/ gate was fixed in the head and rerun green; the fix is the by-name schema= read above.
  6. Semver reading as in ②: patch by the repo's rule, minor by plain semver on three of the four; the rule binds.
  7. Gate reading on 90f1ea4dfc. Check-runs read at 2026-10-28T08:00:34Z (59 runs: 7 complete, the rest in progress), 2026-10-28T08:05:08Z, 2026-10-28T08:05:41Z, then every 30 s until 2026-10-28T08:10:11Z (first all-complete reading), and finally 2026-10-28T08:10:29Z: 67 runs, 58 success, 9 skipped, 0 failure, 0 in progress. The eight required contexts are all success: Lint & Repo Gates, TypeScript Type Check, Test Core (aggregate and shards 1/8 to 7/8; the plan derived seven), Build Core, Governed Surface Queue Guard, Lint UI, Type Check UI (the ui source-parsing gates, the handler-key census among them), Build & E2E UI. Also green: Test UI and all 16 shards (the three new ui test files and the edited census run here), Check Changeset, Check PR Size, Check Documentation Links, PR-body guards, Spec property liveness, Flag docs affected, Build Docs, Build UI Deps, Dogfood Regression Gate (summary; no attestation on a PR event). Skipped, as on every PR event: the dogfood shards, Dogfood Verify CLI, Temporal Conformance, UI Published Artifacts, the opt-in harness and tarball smoke, the schedule gate. The dev's 93/93 reading carries to this head, which has not moved. The one docs-drift comment (6463772805) has no opinion (zero documentable anchors derived); the FLS doc was read by hand above.

Implemented-by: claude/project-thread-dyw8uu
Reviewed-by: session_01WGufzgbJhFoXYsLSe5WSPP

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6ad9e982b6e46f95e0426729d40587cef6a5d878
Local-runs: none
Scope: ui/packages/permissions/src/RecordFieldAccessScope.tsx, ui/packages/permissions/src/tests/RecordFieldAccessScope-26594.test.tsx, ui/packages/plugin-form/src/recordFieldAccess.tsx, ui/packages/plugin-form/src/recordFieldAccess-26594.test.tsx, .changeset/26594-record-form-field-access-permissions.md, .changeset/26594-record-form-field-access-plugin-form.md (the round-2 commit, git diff 90f1ea4d 6ad9e982, 6 files, +104/-21), on top of the round-1 floor (every path of the 20-file diff, record 6463995320, unchanged at this head) + cited code read on origin/main: ui/packages/react/src/data-invalidation.ts (useDataInvalidation, dataChangeMatches, notifyDataChanged, useMutationInvalidationBridge) and its test, ui/packages/types/src/data.ts (DataSource.onMutation, DataSourceMutationEvent), ui/packages/data-objectstack/src/index.ts (emitMutation sites, update at :4405-4418), ui/packages/plugin-form/src/recordInvalidation.ts, ModalForm.tsx and ObjectForm.tsx (record read effect, handleSubmit, recordSaved), ui/packages/app-shell/src/console/AppContent.tsx:705 (the bridge mount), ui/packages/plugin-form/package.json; the PR body as rewritten (Fixes lines, Round 2 bullet), cards #27146 and #27147, and the head's check-runs including the PR-body guards annotations.

Round 2 of the review of PR #27137 (card #26594, PR 2 of 2). Round 1 (6463995320) passed 90f1ea4dfc with flag 1 (a stale projection after an in-place record re-read) and flag 2 (the detail page's inline edit, the card's residual). The seat filed both (#27147, #27146); the dev answered flag 1 in one commit, 6ad9e982, whose parent is 90f1ea4d. This record judges that commit and re-reads the gates on the new head; every round-1 judgment stands, since the round-2 diff touches none of the files they were made on except the two it extends. The dispatch order and the dispatching seat's conclusions were not read.

① Derived judgments

The window is closed, fail-closed on both paths. RecordFieldAccessScope gains revision (default 0). The ask effect now depends on [!!parent, nested, key, revision], each resolution is tagged with the revision it was asked under, and current reads answer.key === key && answer.revision === revision ? value : null (RecordFieldAccessScope.tsx:168-190). So on the very render in which revision moves, current is null, scoped(parent, …, null) is { ...parent }, and every conditioned field is drawn from the row-blind lock until the new answer lands: there is no render between the revision moving and the new answer in which the earlier editable is read. A superseded resolution cannot land: the effect's cleanup sets alive = false for the earlier run, and a resolution that did slip through would carry an older revision and be ignored by the equality. RecordFieldAccessForForm moves the revision on two signals (recordFieldAccess.tsx:56-71): (a) useDataInvalidation(objectName, id) — the same bus nonce useRecordInvalidation reads for the form's re-read (data-invalidation.ts: a change for this record, for the object, or '*'; a sibling record does not match), so every bus re-read of the record is a re-ask too, and (b) the data source's onMutation, filtered to events whose resource is this object (case-folded) and whose id, when present, is this record; an id-less event (the adapter's bulk update/delete emit { type, resource } only) also moves it, which is the conservative direction. The adapter emits update after the client write resolves and before update() returns (data-objectstack index.ts:4405-4414), so on the form's own save the revision moves before advanceLoadedRecord and recordSaved() run, independent of whether a host mounts the console's bridge (useMutationInvalidationBridge, AppContent.tsx:705). A different record's write: filtered by id on onMutation; filtered by dataChangeMatches on the bus. Covered by both signals: the own-save path (onMutation, and the bridge on the console), the bus re-read path (any same-tab writer that notifies this record or object). Not covered, as before: a change made by another client — no signal reaches this tab, the form does not re-read either, and the server refuses the save; this is the pre-existing shape of every client-side reader and is not new to this PR.

No storm, no loop. The re-ask is a GET through getRecordFieldAccess; the adapter emits no mutation for it and nothing notifies the bus, so a re-ask cannot trigger itself. Both revision sources are React state nonces moved only by a subscription callback, never by render. Nested wrappers (the master-detail header ObjectForm under ObjectForm, ObjectFormBody's modal/drawer dispatch) do run the two hooks, but their scope is nested and renders children unwrapped, so they issue no ask; only the outermost scope for the record asks. The one redundancy: on the console a form's own save moves the revision twice (the adapter's onMutation directly, and the bridge fanning the same event onto the bus), so two asks are issued per save, the first cancelled (alive = false) when the second effect run starts; two GETs per save, bounded by writes, no feedback. An object-wide bus change (notifyDataChanged({ objectName }) from a list action in the same tab) re-asks every open edit form of that object once; bounded by writes.

Behavioural notes, not defects. (i) While the form is dirty, useRecordInvalidation holds the record re-read but the scope re-asks at once, so a field may lock under in-progress input before the form's values move; the direction is fail-closed and the save would be refused by the server anyway. (ii) Every revision move shows the row-blind lock until the new answer lands, a brief lock on a conditioned field after each save or bus event for the record, which the scope doc and changeset state in words.

Tests pin it. RecordFieldAccessScope-26594.test.tsx "a new revision asks again and holds the lock until the new answer lands": first answer DRAFT → summary writable; rerender with revision={1} → a second load call, summary locked before it resolves, APPROVED lands → summary locked, title still the object grant, load called exactly twice. recordFieldAccess-26594.test.tsx "a save that moves the row into the state the condition names locks the field again", in both containers: the data-source double now emits onMutation from update as the adapter does, the second getRecordFieldAccess answer is the approved projection, and after the submit the ask count is 2 and summary is drawn locked with ref_no still locked. The dev's ablation reading (wiring removed → these 2 red) is the predicted shape: without the revision the ask count stays 1 and summary stays enabled. Not pinned at the form level: the bus signal through RecordFieldAccessForForm (the form test exercises onMutation only); useDataInvalidation's matching is pinned in @object-ui/react's own test and the scope's handling of any revision move is pinned above, so the gap is one wiring line. The PR body's Tests section still says (10) and (7) for the two files while the Round 2 bullet says 12 and 8; the bullet is right.

Changesets honest. @object-ui/permissions: "When its revision prop moves (the row was saved or re-read), it asks again and the lock stands until the new answer lands" — true of the component. @object-ui/plugin-form: "The form asks again after a write to the record or a re-read of it, so a save that approves the row locks the field again" — true of the two signals wired. Levels unchanged (patch, kind none); no new public surface beyond an optional prop on an already-new component. Clause-② and Metadata-protocol lines unchanged and still right. Fixes #26594 and Fixes #27147 on the body; #27146 cited as the filed residual.

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 level

Unchanged from round 1: patch on all four packages, correct under the repo's kind rule (Metadata-protocol: none → patch); the round-2 commit adds one optional prop to a component introduced in the same PR and no new export.

③ Boundary flags

  1. Fixes #27147 with no Claim: on Record form field-access scope: the record projection is asked once and not again when the form re-reads the record, so a field a save just locked is drawn enabled until remount #27147 (ask, before landing). PR-body guards is red on this head: "PR fix(ui): the record form's edit dialog reads the record's field projection #27137 closes Record form field-access scope: the record projection is asked once and not again when the form re-reads the record, so a field a save just locked is drawn enabled until remount #27147 with no Claim: on the card naming claude/project-thread-dyw8uu" (plus two standing warnings: Record form: a row-conditioned field lock (when) disables the field on every row, though the write gate admits it on rows the condition does not name #26594's newest claim carries no parseable Branch: line, and the branch name follows no claude/issue-N convention — both measured platform constraints already declared in the claim 6458235349). The guard is advisory, not one of the eight required contexts, so it does not block the queue; the claim rule it reads is binding on seats regardless. The owning seat should post a Claim: on Record form field-access scope: the record projection is asked once and not again when the form re-reads the record, so a field a save just locked is drawn enabled until remount #27147 naming the branch and session (the card is its own filing and is being fixed here under its authorization), after which a re-run of the guard clears the red; the two warnings are the known constraints and need no act.
  2. Two asks per save on the console (bridge + direct onMutation), the first cancelled; bounded, no loop. A later tidy could prefer one signal when the host bridges; not owed here.
  3. Round-1 flags carried. Flag 2 is filed as Record detail page inline edit: a row-conditioned field lock (when) still disables the field on every row — the detail page does not ask the record projection the edit dialog now asks #27146 (the detail page's inline edit) and cited in the body; flag 3 (dogfood not on the PR path) and flag 4 (a refused projection is silent) stand as acknowledged; the dogfood file is untouched by round 2.
  4. PR body Tests section counts lag the Round 2 bullet (10/7 vs 12/8); cosmetic, the bullet governs.
  5. Gate reading on 6ad9e982b6. Check-runs read at 2026-10-28T08:36:02Z (63 runs: 13 success, 9 skipped, 1 failure, 40 in progress), then every 30 s until the first all-complete reading at 2026-10-28T08:51:03Z, and finally at 2026-10-28T08:51:44Z: 71 runs, 59 success, 11 skipped, 1 failure, 0 in progress; the head still 6ad9e982, the PR open and not merged. The eight required contexts are all success: Lint & Repo Gates, TypeScript Type Check, Test Core (aggregate and its shards), Build Core, Governed Surface Queue Guard, Lint UI, Type Check UI (the ui source-parsing gates, the handler-key census among them), Build & E2E UI. Test UI is success with all 16 shards green (the two extended test files run here: 12 and 8). Also green: Check Changeset, Check Documentation Links, Spec property liveness, Flag docs affected, Build Docs, Build UI Deps, Plan Test Core shards, Dogfood Regression Gate (summary, no attestation on a PR event), every Lint · and Type Check · leg. The one red is PR-body guards, advisory, read in flag 1: the missing Claim: on Record form field-access scope: the record projection is asked once and not again when the form re-reads the record, so a field a save just locked is drawn enabled until remount #27147, and nothing about the code. Skipped as on every PR event: the dogfood shards, Dogfood Verify CLI, Temporal Conformance, UI Published Artifacts, the opt-in harness and tarball smoke, the schedule gate, and the second Auto Label / Check PR Size attempts.

Implemented-by: claude/project-thread-dyw8uu
Reviewed-by: session_01WGufzgbJhFoXYsLSe5WSPP

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读 (席位终稿)

  • 改了什么:控制台的编辑框、编辑页和抽屉表单改读 feat(spec,plugin-security,rest,client): a record-scoped field projection judges a when lock on the stored row #27063 的按记录字段投影(GET /api/v1/data/:object/:id/field-access)。新增的 RecordFieldAccessScope 只替换一个输入:/auth/me/permissions 里显式的字段写锁,换成服务器对这条记录的 editable。对象授权、归属转移规则、读和新建、其它对象都不变。第二轮补上了重读:表单原地重读记录时(数据失效总线、或自己保存后的 onMutation),作用域会重新询问,新答复回来之前沿用不看行的锁。
  • 为什么改:按裁决 B(6457467702)。hotclm 的财务、档案角色在自己的草稿上,编辑框把明明能保存的字段置灰了。
  • 风险与回滚:失败一律关闭。答复未到、没有答复、接口 404/501 或被拒,都退回原来不看行的锁。客户端从不自己判 when。保存时的出站闸门和渲染读的是同一个答复。回滚 = revert 本 PR。
  • 先看:ui/packages/permissions/src/RecordFieldAccessScope.tsx、ui/packages/plugin-form/src/recordFieldAccess.tsx,以及 ModalForm / ObjectForm / DrawerForm 三个外层包装。
  • 两分钟验证:pnpm --dir ui --filter @object-ui/plugin-form exec vitest run recordFieldAccess-26594 预期 12 条全绿(草稿上可编辑、批准后锁住、保存后重新询问)。
  • 需要你做的:无,席位落地。

席位意见:接受。契约复核两轮都是 PASS(6463995320、6464723871)。第一轮提出的“重读后投影过期”在本 PR 第二轮修掉,立卡 #27147 并由本 PR 关闭。详情页行内编辑的残留立了卡 #27146。行本身不可写的缺口在 #27138。

席位核对 · ACCEPT · domain:spec seat 3 · os-support-ai · session session_01WGufzgbJhFoXYsLSe5WSPP · 2026-10-28T08:54Z

Head: 6ad9e982b6e46f95e0426729d40587cef6a5d878 · card #26594 (PR 2 of 2) and #27147 · claims 6458235349 (amendment 6458412501) and 6464455969 · Ruling-ref 6457467702 (letter B)

  1. 形态: a draft PR to main carrying Fixes #26594 and Fixes #27147. 20 paths, +926 / −9, all under ui/ plus root .changeset/, inside the claimed PR 2 surface. check-governed-merges --pr 27137 reports NOT governed (0 of 20 paths; 935 lines), so the ordinary queue landing applies. The assignee is os-support-ai.
  2. 前提实测: the premise holds on main. The edit dialog's field lock reads /auth/me/permissions, which reports a when-conditioned entry as its lock on every row. Round 1 (6463995320, head 90f1ea4d) confirmed four things:
    • Only the explicit field-level write lock for the scoped (object, record) is replaced by the server's editable.
    • The render pass and the outbound fieldWriteGate read the same usePermissions() answer.
    • Every fallback is fail-closed.
    • The adapter matches the merged route.
      Round 2 (6464723871, head 6ad9e982) confirmed two more:
    • A moved revision draws the row-blind lock in the same render, with no fail-open moment, on both the bus re-read and the own-save path.
    • There is no re-ask loop.
  3. 门禁 check-runs (on this head): 71 runs read to completion at 2026-10-28T08:51Z. All eight required contexts are green, and Test UI passed 16/16 shards. The one red was the advisory PR-body guards run of 08:35 (Record form field-access scope: the record projection is asked once and not again when the form re-reads the record, so a field a save just locked is drawn enabled until remount #27147 had no claim yet). The seat posted the claim (6464455969), and the guard re-ran on the body edit green at 2026-10-28T08:39Z. Dev-side runs on round 2:
    • permissions 159, plugin-form 2425, ui scripts gate 5019, ESLint 0 errors;
    • the 93-gate reading from round 1, with the path set unchanged.
  4. 接受的偏差:
    • (a) The branch name is the platform-assigned thread branch.
    • (b) Clause-②: yes (widening) on the three widening ui changesets and no on @object-ui/plugin-form, all graded patch under Metadata-protocol: none.
    • (c) The dogfood leg pinning the row-not-writable reading does not run on the PR path; the dev's local reading is 8/8.
    • (d) Accepted as named, not owed here:
      • Two GETs per save on the console (the direct onMutation plus the bridge onto the bus), bounded, with the first cancelled.
      • A refused projection (a 403) keeps the lock silently.
      • The bus leg is pinned by the scope and bus tests rather than at form level.
  5. 范围外去向: the record detail page's inline edit still reads the row-blind lock; it is filed as Record detail page inline edit: a row-conditioned field lock (when) still disables the field on every row — the detail page does not ask the record projection the edit dialog now asks #27146. The dialog never asks whether the row itself is writable; that is 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. The list inline edit locks per column, which is a declared limitation.
  6. 落地条款: the seat checked the prose faces against the diff on this head: the PR body (test counts corrected to 12/8 in this round), the four changesets, the RecordFieldAccess type doc, the scope doc and field-level-security.mdx. The seat readies the PR and arms auto-merge in this act, then follows it to MERGED, and lands Record form: a row-conditioned field lock (when) disables the field on every row, though the write gate admits it on rows the condition does not name #26594 and Record form field-access scope: the record projection is asked once and not again when the form re-reads the record, so a field a save just locked is drawn enabled until remount #27147.

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/l tests tooling

Projects

None yet

2 participants