Skip to content

fix(plugin-form): an Edit form saves the owner the user changed or cleared, and still never echoes an untouched one - #12218

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-12205-edit-form-owner-change
Oct 12, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-12205-edit-form-owner-change

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #12205

Clause-②: no

What was wrong

Changing or clearing the owner in a record's Edit form was silently dropped. sanitizeFormData (packages/plugin-form/src/sanitize.ts) removed owner_id from every form payload, so the value never reached the server. When the owner was the only change, the edit's dirty diff (objectui#10156) found nothing else and sent the whole unchanged payload. That is exactly the PATCH the card measured: every other field, and no owner_id.

Reproduced on the base commit 9646e2e through the console's Edit dialog (ModalForm) and DrawerForm, with the real lookup widget: reassigning the owner sent { subject, status } and no owner_id.

The fix

dirtyEditPayload is the one place that can tell a change from an echo, because it holds the record the form read. It now also receives the values the form holds (an optional EditOwnerSource). It adds owner_id back to the diff when, and only when, all of these hold:

  • a loaded-record snapshot applies to this edit. With none (a create, a caller-supplied record, a record swap in flight) the owner stays dropped, exactly as before;
  • the form holds a value for it, and changedFields reports it as different from the read. A clear arrives as null; undefined is not a clear;
  • everything else sanitizeFormData asks still allows it: the caller's field-level security (canEdit), the object declaring the field, and the field not being readonly or computed.

The strip loop moved into a module-private stripUnwritable, which takes an admitOwner flag that only dirtyEditPayload sets. sanitizeFormData's signature and output are unchanged, and its output still never carries the owner. SERVER_OWNED_FIELD_NAMES is unchanged: no second strip list, and no new admit list beyond the single constant naming owner_id. The server stays the authority. A role without the transfer grant gets a 403, and the form shows it as its error.

Dispatch hypotheses, measured

  • H1 (the drop route). Both routes fire on the HotCRM-shaped edit, where a definition is present. The name roster drops owner_id first. The spec system flag branch would drop it too, because the platform serves owner_id as OWNER_FIELD_DEF (system: true, readonly: false). Both are bypassed, for owner_id alone and only on the admission path.
  • H2 (the dirty diff).
    • isSameStoredValue(null, 'u1') is false, so a clear is a change.
    • An untouched owner keeps the shape it was read with. LookupField calls onChange only on a pick or a remove, so the untouched value compares equal, whether the record holds an id or an expanded object (both are pinned).
    • A new pick over an expanded stored owner reads as a change, which is the side that sends.
    • With no snapshot, or a snapshot read for another record, the owner is not sent (pinned).
  • H3 (which columns). Only owner_id. The spec declares owning_business_unit_id and organization_id readonly (scope anchors stamped by the server), and no form edits a tenancy column. So owning_business_unit_id, organization_id, tenant_id, company_id and space stay refused even when they differ (pinned). The audit columns are untouched.
  • H4 (the callers).
    • ModalForm.tsx and DrawerForm.tsx call sanitizeFormData and dirtyEditPayload directly. Each now passes { values: data, objectSchema, canEdit }.
    • formWritePayload (writePayload.ts) passes the same inputs. That covers ObjectForm's simple layout and the tabbed, split and wizard layouts, with no edit to ObjectForm.tsx (objectui#12209 holds it).
    • The master-detail batch (masterDetailTx.ts, MasterDetailForm.tsx) is unchanged. Its child rows and its parent operation still drop the owner by name, so an untouched owner stays off every operation of the batch (pinned through a real MasterDetailForm whose header renders the owner).
  • H5 (a refusal surfaces). It already did. A rejected update goes through the form renderer's submit catch, which shows form.noPermissionToSave as a banner and a toast, and the dialog stays open. This is now pinned for an owner change: a 403 shows the message, and neither onSuccess nor onOpenChange(false) is called.
  • H6 (auto-layout). filterSystemFields still reads the unchanged roster, so an auto-laid-out form renders no owner input. The owner is written only when its value differs from the read, which only a rendered input can cause. Pinned: an auto-laid-out Edit dialog has no lookup-trigger-owner_id, and a business-field change sends only that field.

Tests

New file: packages/plugin-form/src/editOwnerChange-12205.test.tsx, 28 rows.

  • Unit rows on dirtyEditPayload and formWritePayload: reassign, clear, a change beside other fields, untouched (beside a change and in a save with nothing changed), an untouched expanded owner, no snapshot or another record's snapshot, a create, field-level security refusing it, an object declaring it readonly, an undefined value, the rest of the family plus the audit columns, a caller passing no owner source, and inline members.
  • Component rows with the real lookup widget:
    • ModalForm (the console's Edit dialog) and DrawerForm: reassign, clear, untouched beside a change, untouched with nothing changed;
    • the 403 surfacing;
    • the auto-layout render;
    • ObjectForm simple: reassign and untouched;
    • MasterDetailForm: untouched owner, header change, no operation carries owner_id.

Reverse verification at 78486a7. The four source files were reset to the base 9646e2e and the new file was run; the mutation was proven on disk by grep -c (0 hits for the fix's markers, 1 hit for the base drop line). Result: Tests 11 failed | 17 passed (28). All 11 red rows assert that a changed or cleared owner is sent (one of them then asserts that the 403 surfaces). The 17 that stay green are the "is not sent" rows and the guards, which the base already satisfied. The restore used git checkout HEAD, was proven by matching each file's HEAD blob hash, and git diff HEAD came back empty.

Final run at 268c7e9, through the verify lock:

  • pnpm --filter @object-ui/plugin-form type-check (the script echoed tsc --noEmit && tsc -p tsconfig.test.json; the test project lists the new file): exit 0.
  • Targeted vitest over 45 files, Test Files 45 passed (45), Tests 541 passed (541):
    • every plugin-form test naming sanitize, writePayload, dirty-edit, owner or master-detail;
    • the objectui#10108 pin systemManagedPayload.test.tsx;
    • the three objectui#12103 pins (permissions, plugin-detail, plugin-grid);
    • the Modal and Drawer save-path suites: field security, OCC, record swap, submit seam, upload gate, custom-fields merge.
  • Dependency build turbo run build --filter='@object-ui/plugin-form^...' --concurrency=2: 11/11 tasks, cached.

Gates (at 268c7e9)

  • All exit 0:
    • pnpm check:vi-mock-specifiers, pnpm check:vi-mock-inherit, pnpm check:test-path-roots;
    • node scripts/check-changeset-presence.mjs, pnpm check:changeset-claims, pnpm check:pending-changeset-literals;
    • pnpm check:control-bytes, pnpm check:new-line-citations (0 new citations), pnpm check:doc-fences.
  • NOT MEASURED: pnpm check:readme-exports. Its prerequisite is a full workspace build (it stopped with "type entry is not on disk" for unbuilt packages and a collapsed population). CI runs it.
  • ESLint, narrowed to the changed files:
    • Population: eslint.config.js lints **/*.{ts,tsx}, so the five changed TS/TSX files are the whole population. The markdown files are outside it.
    • Count: the --format json output lists 5 files, with 0 errors.
    • Why nothing else can move: the config sets no parserOptions.project or projectService, so linting is not type-aware. No rule under eslint-rules/ reads the filesystem. So this diff cannot change the verdict on any untouched file.
    • Warnings per file, base to head: sanitize.ts 8 to 7, writePayload.ts 0 to 0, ModalForm.tsx 20 to 20, DrawerForm.tsx 21 to 21. The new test file has 21, all no-explicit-any in fixtures.
  • The governed-surface guard answers NOT GOVERNED for all 8 paths.

Acceptance notes

  • The master-detail header owner is still not saved. This is the same defect class, and this PR leaves it as it was. buildMasterDetailEditBatch strips the roster from the parent operation by name (parentWritable) and holds no parent baseline. So it cannot tell an owner the header changed from an echoed one, and giving it one is not a bounded change. It would need the header ObjectForm's snapshot, and objectui#12209 holds that file.
    • Measured with a throwaway probe at 78486a7, not committed: when the owner is the header's only change, the parent operation is now data: {}. On the base commit it was the full unchanged business payload. Both are no-op writes as far as the owner goes, and the user-visible outcome is the same as before.
    • The changeset and the README both state that a master-detail header owner change is still not saved.
  • File surface widened, for docs only. packages/plugin-form/README.md ("What an edit save writes") and content/docs/plugins/plugin-form.mdx both said that every layout strips the ownership columns. This change makes that false, so both now state the edit-only exception (the Docs-driven commandment in AGENTS.md).
  • ObjectForm.tsx is not touched. Its routes get the fix through formWritePayload.

Seat session for this change: https://claude.ai/code/session_016djJF12Qt14ejKR5Vjt4sK


Generated by Claude Code

…d, and still never echoes an untouched one

`sanitizeFormData` drops `owner_id` by name and by the spec `system` flag,
so a reassignment or clear made in a record's Edit form never left the
browser: Save answered 200, the dialog closed, the owner was unchanged.

`dirtyEditPayload` is now handed the form's values and adds the owner
back to the diff only when a loaded-record snapshot applies and the
owner differs from it; field-level security, the unknown-key strip and
the readonly/computed branches still judge it. With no snapshot it stays
dropped, so the objectui#10108 echo cannot return. The roster, the
auto-layout render filter and the rest of the transfer-grant family are
unchanged.

Claude-Session: https://claude.ai/code/session_016djJF12Qt14ejKR5Vjt4sK
Co-authored-by: Claude <noreply@anthropic.com>
…r objectui#12205

The README's "What an edit save writes" and the plugin-form docs page
said every layout strips the ownership columns; they now state the one
edit-only exception and its limits, including that the master-detail
batch still strips the header owner.

Claude-Session: https://claude.ai/code/session_016djJF12Qt14ejKR5Vjt4sK
Co-authored-by: Claude <noreply@anthropic.com>
…rmData

The loop now takes and returns exactly what sanitizeFormData does, so
the two cannot drift and no new explicit-any is spelled.

Claude-Session: https://claude.ai/code/session_016djJF12Qt14ejKR5Vjt4sK
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation plugin tests labels Oct 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 291 chunks) 3178.8 KB 3204.6 KB
Main entry chunk (gzip) 73.8 KB 350 KB
Entry file index-CzQ18zWm.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 20.05KB 7.41KB
app-shell (runtime-config.js) 22.59KB 7.89KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 41.19KB 11.12KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.11KB 7.97KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.28KB 2.60KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.50KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 588.51KB 141.71KB
core (index.js) 10.18KB 4.04KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 243.13KB 67.92KB
fields (index.js) 279.78KB 71.29KB
i18n (LocalizationContext.js) 2.92KB 1.42KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.52KB 2.39KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.35KB 12.88KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 36.87KB 9.88KB
i18n (useSafeTranslation.js) 7.14KB 2.92KB
layout (index.js) 40.26KB 11.71KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 15.35KB 5.51KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.82KB 2.38KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.04KB 3.92KB
plugin-calendar (index.js) 54.31KB 15.86KB
plugin-charts (index.js) 84.72KB 23.27KB
plugin-chatbot (index.js) 201.72KB 48.07KB
plugin-dashboard (index.js) 144.20KB 38.95KB
plugin-designer (index.js) 233.77KB 50.02KB
plugin-detail (index.js) 250.93KB 66.24KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 178.25KB 46.15KB
plugin-gantt (index.js) 178.63KB 45.19KB
plugin-grid (index.js) 255.50KB 71.02KB
plugin-kanban (index.js) 53.23KB 16.71KB
plugin-list (index.js) 123.03KB 31.28KB
plugin-map (index.js) 27.24KB 9.03KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.12KB 12.29KB
plugin-timeline (index.js) 39.21KB 11.85KB
plugin-tree (index.js) 15.07KB 5.33KB
plugin-view (index.js) 92.11KB 23.27KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 12.07KB 3.68KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 120.63KB 39.56KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.31KB 2.07KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.50KB 1.99KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.07KB 1.30KB
sdui-parser (index.js) 7.30KB 3.12KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 25.28KB 7.81KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 23.87KB 7.83KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (authoring-nodes.js) 0.20KB 0.19KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (cloud.js) 0.20KB 0.18KB
types (complex.js) 4.44KB 2.07KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (dashboard-widget-layout.js) 2.06KB 0.96KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 1.13KB 0.65KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 5.78KB 2.70KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (node-slots.js) 7.18KB 2.34KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 4.99KB 1.96KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.93KB 7.26KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.48KB 3.50KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

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 plugin tests

Projects

None yet

2 participants