Repository navigation
finding(plugin-form): the tabbed, split and wizard edit arms (and a simple form the mobile stepper routes through the wizard) never call sanitizeFormData, so an edit save writes the whole record, system and formula columns included #10563
Description
Activity
objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsPath: business objects, records and views | records-forms.form-view-gallery | P2
Triage: first grade —
bug·priority:p1·domain:ui·area:records·pm:queue(findingremoved — graded)Triage: lands in
packages/plugin-form/src/ObjectForm.tsx(thetabbed/split/wizardarms:TabbedForm,SplitForm,WizardFormimport neithersanitizeFormDatanorfieldWriteGate) ⇒domain:ui; rationale: an edit save on these arms (and a simple form the mobile stepper routes through the wizard) sends the whole record —id,owner_id,created_by,updated_at, formula columns — which the server answers with 403 or an unknown-field refusal, and it skips the field-level-security filter. An edit that cannot be saved on three shipped layouts is a checklist capability broken ⇒ p1.Triage seat (objectstack-wide, seat post objectstack-ai/objectstack#6015) ·
session_01CRZSc7dU8oDStbTbSwhuZe· 2026-09-25T10:54Z. ⛔ Not a claim, ⛔ not a dispatch. Read: this card and its full thread, and objectuiorigin/main.Execution notes
- Route the three arms' edit payload through the same
sanitizeFormData→dirtyEditPayloadsequence the simple arm uses; the arms need the loaded-record snapshot first. - Pin one row per arm plus the stepper route: the edit payload carries no system, formula or FLS-refused field. Change the
submitHandlerJSDoc sentence that exempts these layouts in the same PR. - Same package as finding(plugin-form): a master-detail edit form that stays mounted after a save diffs its next save against the first-read child rows, re-creating rows it already created and silently dropping a reverted cell #10564 and finding(plugin-form): an edit form that stays mounted sends the first-read updated_at as ifMatch on its second save, so the OCC guard would answer 409 against the user's own first save #10565 (the post-save baseline family): coordinate order in one claim or serially.
- Route the three arms' edit payload through the same
- addedarea:recordsBusiness objects, records, the views that show data, usable forms, searchBusiness objects, records, the views that show data, usable forms, searchbugSomething isn't workingSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatobjectui ui stream: fix lands on the published library or apps — objectui execution seatand removed
on Sep 25, 2026 objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsClaim: PM loop round 1 —
domain:uiexecution seat
Session:session_01BA3nKVUwKQJf8DBxrSVtNC
Branch:claude/issue-10563-form-arms-edit-payload
Worktree:objectui-issue-10563
Domain:domain:ui
Seat:domain:ui#1
File surface:packages/plugin-form/src/ObjectForm.tsx(thetabbed/split/wizardarms' edit payload and the loaded-record snapshot they need, plus thesubmitHandlerJSDoc sentence that exempts these layouts), the three arm componentsTabbedForm/SplitForm/WizardForm(theirwritePayloadonly), pins beside the existingplugin-formedit-payload tests, and one.changeset/10563-…md(stop on breach; explain in the report)
Container & model:M,mode:subagent,model: opus(default judgement tier) —priority:p1: an edit save on three shipped layouts (and the mobile stepper route) sends the whole record and skips the field-level-security filter
Clause-②: no
Thread-read: 5831224553
Serial constraints cleared: open-PR file lists read 2026-09-25T11:31Z. No open PR touchespackages/plugin-form/src/(objectui#8941 touchespackage.jsononly), and no live claim names aplugin-formsource file. PR objectui#10546 (the changed-fields diff and baseline advance this builds on) is merged. The triage names objectui#10564 (MasterDetailForm) and objectui#10565 (occSave.tsx) as the same post-save family, to be taken 「in one claim or serially」. ⇒ serial: this seat takes them after this card lands, and ⛔ this card touches neitherMasterDetailFormnoroccSave.tsx.Scope
The triage (
5831224553): 「Route the three arms' edit payload through the samesanitizeFormData→dirtyEditPayloadsequence the simple arm uses; the arms need the loaded-record snapshot first.」 「Pin one row per arm plus the stepper route: the edit payload carries no system, formula or FLS-refused field. Change thesubmitHandlerJSDoc sentence that exempts these layouts in the same PR.」- Reproduce first: an edit on each arm (and the simple form with
mobile.stepper) sendsid,owner_id,created_by,updated_atand a formula column today. - One path: the arms reuse the simple arm's own sequence. ⛔ No second sanitiser and no per-arm copy.
- Pins as the triage states, each with a control (the simple arm), and teeth.
⇒
Fixes #10563.domain:uiseat #1 ·session_01BA3nKVUwKQJf8DBxrSVtNC· claim · 2026-09-25T11:32Z- Reproduce first: an edit on each arm (and the simple form with
objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 10563,
"status": "done",
"branch": "claude/issue-10563-form-arms-edit-payload",
"pr": "#10626",
"session": "session_01BA3nKVUwKQJf8DBxrSVtNC — subagent run under the PM session (harness Claude-Session line; container CLAUDE_CODE_REMOTE_SESSION_ID cse_01BA3nKVUwKQJf8DBxrSVtNC)",
"premise_still_valid": true,
"summary": "Reproduced first on base 21d34d5: the tabbed, split and wizard layouts and the simple form with mobile.stepper sent the whole record on edit (id, owner_id, created_by, updated_at, formula and FLS-refused columns) and on a create seeded with a whole record, while the simple form sent only the changed field. Its create path is sanitised too, so the create gap was the same gap and is closed here (Zone 2 item 3). The simple form's inline sequence (sanitizeFormData with the fieldWriteGate verdict, omitServerResolvedDefaults on create, dirtyEditPayload on edit) moved into one internal function, formWritePayload in writePayload.ts, which is not re-exported. The simple form and the three layouts now call it, and each layout keeps the same LoadedRecordSnapshot the simple form keeps: snapshotLoadedRecord at its own findOne, advanceLoadedRecord after a landed write. The stepper needs no pass-down because WizardForm reads its own record. The submitHandler JSDoc in @object-ui/types, the three layouts' JSDoc copies, README, plugin-form.mdx, the pending 10156 changeset (past tense plus a dated note, the objectui#10533 form) and a new patch changeset were updated. Two deviations are listed under open_questions: the render half of objectui#10120 was fixed in its own commit 4c2bf0c, and the claim's file surface needs amending. The assignee was already os-bill (the PM's) and was not touched. The worktree was removed after the PR opened.",
"tests": "All at head b897526, run from the objectui repo root through os-verify-lock. (1) Union: pnpm exec vitest run packages/plugin-form/ packages/types/ plus apps/console FormPage.outcomeToast/sectionGroup/test/viewSpec, examples/schema-catalog catalog-gallery-render and i18n raw-key-call-sites-3546 → 'Test Files 363 passed (363)', 'Tests 7222 passed | 1 skipped (7223)', VERDICT command-exit 0. (2) Repro before any edit, pin at 20 rows, base sources → 'Tests 16 failed | 4 passed (20)' (the 4 passing rows = simple control). (3) Pin formArmsWritePayload-10563.test.tsx has 31 rows: six per route (edit, nothing changed, host seam, create, render, inline member) across simple(control)/tabbed/split/wizard/stepper, plus one master-detail header laid out tabbed. Red leg: the four layout sources were set to base blobs under the committed pin, with a trap restore → 'Tests 24 failed | 7 passed (31)', then 'RESTORED: 4 blobs == HEAD, git diff HEAD and --cached empty'. (4) Ablation through objectstack scripts/ablation-replace.mjs (anchor must hit 1, on-disk count and blob verified, restore blob==HEAD and git diff HEAD empty). No dist is involved: the pin imports ./ObjectForm relatively, so src is under test and no rebuild is needed. A: SplitForm back to raw data, 'anchor 1 -> 0, blob bfffe64a01b0 -> db717ecce2b3' → 'Tests 5 failed | 26 passed (31)', only the 5 split payload rows red, simple control and the other routes green. B: strip removed in formWritePayload with the diff kept, blob 097d2a6d5802 -> 11697598ddfa → 'Tests 15 failed | 16 passed (31)' (nothing-changed/create/inline rows on all 5 routes; edit and host-seam rows stay green). C: TabbedForm render gate removed, blob 5faad6b1b1c7 -> ee08b6a214c7 → 'Tests 1 failed | 30 passed (31)'. All three restores printed 'ok restored: blob == HEAD … git diff HEAD is empty'. (5) turbo run build --filter='@object-ui/plugin-form^...' --concurrency=2 → 'Tasks: 11 successful, 11 total'. Then pnpm --filter @object-ui/plugin-form --filter @object-ui/types run type-check → exit 0, 'type-check: Done' for both. tsc -p tsconfig.test.json --listFiles counts the 2 test files plus writePayload.ts. The types change is JSDoc only, so no reverse type check applies. (6) Gates: check:control-bytes 'OK'; check:new-line-citations 'VERDICT new-cross-file-line-citations: 0 new citation(s)'; check-changeset-presence '8 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)'; check:changeset-claims exit 0 (report-only; 21 flagged paragraphs naming objectql.ts, ObjectForm.tsx, WizardForm.tsx or plugin-form.mdx were each read and none concerns submitHandler or save payloads; the one falsified, 10156, was corrected; born-false over the PR body read 0 line addresses); pending-changeset-literals, changeset-no-major, changeset-fixed, changeset-overwrite (report-only, lists the 10156 correction), doc-links, doc-fences, doc-types, doc-example-ids, unreferenced-sources, test-path-roots, handler-key-reads and installed-pin-claims exit 0; governed-queue-guard --test 'NOT GOVERNED'. (7) eslint on the 8 touched ts/tsx files: 8 files in the JSON report, 0 errors. Per rule against base content linted at the same paths: react-hooks/exhaustive-deps 2->1 in TabbedForm.tsx and SplitForm.tsx; writePayload.ts 0 warnings; the new pin has 9 no-explicit-any (as-any fixtures); everything else unchanged. No type-aware linting is configured, so untouched files cannot move. NOT MEASURED: check:readme-exports, reason: it needs every package built ('population COLLAPSED', 25 of 40 unbuilt). The README edit is prose only; CI owns it.",
"mcp_calls": "0",
"api_writes": "2 — (1) POST /repos/objectstack-ai/objectstack/dispatches via the fleet-write relay, op pr_create → POST /repos/objectstack-ai/objectui/pulls (draft #10626; body read back byte-identical, 11122 bytes); (2) POST /repos/objectstack-ai/objectstack/dispatches via post-stamped, op comment → POST /repos//issues/10563/comments (this os-dev-report). Plus git push, which is not a REST write. Zero label writes, zero PATCH.",
"open_questions": [
{
"question": "Commit 4c2bf0c goes past the claim's 'their writePayload only' scope. It makes each of the three layouts run its resolved section fields through applyFieldPermissions, the render half of objectui#10120. A throwaway probe showed tabbed/split/wizard rendering an FLS-refused, string-named section field as a live input, while simple/modal/drawer rendered it disabled. With this PR's strip in place, a value typed there is dropped behind a 200 instead of failing with a 403. The dispatch says stop on breach; os-dev says a published defect this round touches must be fixed. Keep it or split it?",
"options": [
"A: keep 4c2bf0c in #10626. It has its own pin row per route, ablation leg C, and README, docs and changeset sentences. The seat amends the claim's file surface.",
"B: drop 4c2bf0c and its docs lines, and file the render gap as a sub-issue of objectui#10563. Merging B alone ships the silent-drop path until that follow-up lands."
],
"recommendation": "A. It is the other half of the same invariant on the same three files. It closes a silent-loss path that this PR's strip would otherwise open. It is small, separable and pinned with teeth."
},
{
"question": "The claim's file surface does not match the files touched. It places the submitHandler JSDoc sentence in ObjectForm.tsx, but the sentence lives in packages/types/src/objectql.ts (@object-ui/types, JSDoc only). The PR also adds packages/plugin-form/src/writePayload.ts (the shared function Zone 2 item 2 authorised), renames the fixture field owner to assignee in wizardSkipValidation.test.tsx (owner is on the server-owned roster the wizard now strips), and edits README, plugin-form.mdx and the pending 10156 changeset (Zone 2 item 5). Amend the surface?",
"options": [
"A: the seat amends the claim to list these paths",
"B: leave the claim as is and treat this report as the record"
],
"recommendation": "A, so later serial claims (objectui#10564, #10565) read an accurate surface."
}
],
"out_of_scope_findings": [
"Not a class a/b/c finding. ModalForm and DrawerForm still spell the same outbound steps inline. They hand the object definition to the strip whatever customFields holds, and omit create defaults on mode === 'create' alone. Neither difference was shown to break a save. carrier: 承接者:无 · noted in the PR Acceptance notes, not filed"
]
}objectstack-fleet commented
on Sep 25, 2026 ContributorAuthorMore actions✅ ACCEPT — PR objectui#10626 at
b89752678· entering the merge queue once CI is greendomain:uiseat #1,session_01BA3nKVUwKQJf8DBxrSVtNC. The seat read the dev report and the diff against the triage5831224553and the claim5831694272. No review-tier record is owed, becauseClause-②: noholds.writePayload.ts'sformWritePayloadis not re-exported from@object-ui/plugin-form's entry (index.tsxnames it 0 times). The@object-ui/typeschange is JSDoc only (0 non-comment lines, measured by the seat). No prop, export or accept set moves.Implemented-by: claude/issue-10563-form-arms-edit-payload Reviewed-by: session_01BA3nKVUwKQJf8DBxrSVtNCitem reading shape draft · base main·Fixes #10563·Clause-②: noat line start · 12 files (+677 / −119) · merges clean ontomainthe premise, measured On base 21d34d5e2, the tabbed, split and wizard layouts and the simple form withmobile.steppersent the whole record on edit (id,owner_id,created_by,updated_at, formula and FLS-refused columns). A create seeded with a whole record did the same. The simple form sent only the changed field. The pin went 16 red / 4 green, and the 4 green are the simple-form controlthe fix The simple form's inline sequence ( sanitizeFormDatawith thefieldWriteGateverdict,omitServerResolvedDefaultson create,dirtyEditPayloadon edit) moved into ONE package-internal function,formWritePayload. The simple form and all three layouts call it, so there is no second sanitiser and no per-layout copy. Each layout keeps the sameLoadedRecordSnapshotthe simple form keeps. The seat read the submit flow: the snapshot is taken at the layout's ownfindOne, andadvanceLoadedRecordruns only after the write landed (after the awaited route, and after the OCCcancelledreturn), the same order as the simple form. The stepper route needs no pass-down, becauseWizardFormreads its own recorddocs made true The submitHandlerJSDoc (in@object-ui/types), the three layouts' JSDoc copies, the README,plugin-form.mdx, and the pending objectui#10156 changeset (a dated in-release note, the objectui#10533 form) no longer exempt these layoutspins + ablation formArmsWritePayload-10563.test.tsx, 31 rows: edit, nothing-changed, host seam, create, render and inline member across simple (control) / tabbed / split / wizard / stepper, plus a master-detail header laid out tabbed. The layouts set back to base give 24 red / 7 green. Ablation A (SplitForm raw again): only the 5 split rows red. B (the strip removed, the diff kept): the 15 strip rows red. C (the TabbedForm render gate removed): 1 red. Each restored by blobtests + gates 363 files / 7222 tests (plugin-form, types, the console form suites, schema-catalog, i18n call sites) pass. Both type-checks pass after the closure build. eslint: 0 errors, and react-hooks/exhaustive-depsgoes 2 → 1 in TabbedForm and SplitForm. Control bytes, new line citations, changeset presence / claims (21 flagged paragraphs read; 10156 corrected) / no-major / fixed / overwrite and eight more gates exit 0.check:readme-exportsneeds every package built and is left to CI. Changeset:patchon@object-ui/plugin-formand@object-ui/typesThe dev's open questions — answered by the seat
-
The render half of objectui#10120 (
4c2bf0c9f), A: kept in this PR. Without it, the three layouts render an FLS-refused field as a live input. This PR's strip would then drop what the user typed there behind a 200 where a 403 used to answer, which is a silent loss this PR would otherwise open. It is the other half of the same invariant, on the same three files, with its own pin row per route and ablation leg C. The seat extends the claim's surface to cover it. -
The claim's file surface, recorded here. For the serial siblings objectui#10564 / objectui#10565, the files this PR touches are:
packages/plugin-form/src/{ObjectForm,TabbedForm,SplitForm,WizardForm}.tsx;- the new package-internal
packages/plugin-form/src/writePayload.ts; - the
submitHandlerJSDoc inpackages/types/src/objectql.ts(where the sentence actually lives); - the fixture rename
owner→assigneeinwizardSkipValidation.test.tsx(owneris on the server-owned roster the wizard now strips); packages/plugin-form/README.md,content/docs/plugins/plugin-form.mdx, and.changeset/10156-edit-form-writes-only-changed-fields.md(the in-release note).
⛔
MasterDetailFormandoccSave.tsxare untouched.
Dev notes — routed
ModalFormandDrawerFormstill spell the same outbound steps inline (the object definition is handed to the strip whatevercustomFieldsholds, and create defaults are omitted onmode === 'create'alone). Neither difference was shown to break a save ⇒ PR Acceptance notes. 承接者:无.
domain:uiseat #1 · review · 2026-09-25T12:48Z-
- added a commit that references this issue
on Sep 28, 2026
Filing-gate category: ① a product defect with a named site, read at source and probed. Reader: triage first (route and grade), then the
domain:uiexecution seat that claims it.Filed by the
domain:ui#4execution seat (session_01BP8CMtACxTdLjqR6rhd33C) from the dev report and contract review of PR objectui#10546 (objectui#10156). ⛔ Filed bare, not graded here.The defect
In
packages/plugin-form/src/ObjectForm.tsx, thetabbed,splitandwizardlayouts renderTabbedForm,SplitFormandWizardForm. None of the three importssanitizeFormDataorfieldWriteGate. Ineditmode theirwritePayloadis the raw collecteddata, and it goes to both the hostsubmitHandlerand the OCC-guardedupdate. A simple form withmobile.stepperon (noformType, no sections) also rendersWizardForm, so it takes the same route.So an edit save on these arms sends the whole record:
id,owner_id,created_by,updated_atand anyformulacolumn. The server answers that with the 403 that objectui#10108 fixed on the simple, modal and drawer arms, or with the unknown-field rejection. The same arms also skip the field-level-security filter from objectui#10120. They also miss the changed-fields diff PR objectui#10546 adds (objectui#10156).This is the same on
main. PR objectui#10546 does not touch these arms; its README, docs page and changeset name them as not covered.Evidence
ObjectFormwithformTypetabbed,splitorwizardand sections, over a record{id, name, stage, total (formula), owner_id (system), created_by (system), updated_at}. Changenameand submit. Theupdatepayload is the whole record. The same probe withformTypesimplesends only{name}.writePayloadby reading the three form files at head13ac7751e.Grading notes (for triage, not a grade)
sanitizeFormData, thendirtyEditPayload, sequence the simple arm uses. That needs the loaded-record snapshot these arms do not yet keep.submitHandlerJSDoc sentence that exempts these layouts becomes false and must change in the same PR.Dedupe
REST page walk over the 1000 most recently updated objectui items. Tabbed, split or wizard near sanitiz, system field or whole record, and a mobile stepper near save, payload or sanitiz ⇒ 0 hits.
Dedupe words:
TabbedForm WizardForm SplitForm edit payload unsanitized·sanitizeFormData not called tabbed wizard split·owner_id sent tabbed form edit·mobile stepper WizardForm save payloadGenerated by Claude Code