Skip to content

fix(app-shell): carry the server's per-field keys through saveFields - #6518

Merged
os-support-ai merged 2 commits into
mainfrom
claude/issue-6488-carry-over-field-keys
Aug 26, 2026
Merged

fix(app-shell): carry the server's per-field keys through saveFields#6518
os-support-ai merged 2 commits into
mainfrom
claude/issue-6488-carry-over-field-keys

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#6488

MetadataService.saveFields preserved unknown keys of the OBJECT document by spreading it,
then rebuilt every FIELD entry from the designer model. The spread is object-level and says
nothing about keys INSIDE a field, so every key the server sent inside a field that the
designer does not model was dropped on every field save.

The defect, measured

Against the installed @objectstack/spec 17.2.0, on an otherwise-green field
({ name: 'amount', type: 'number', label: 'Amount' }):

keyFieldSchema verdictmodelled by toFieldPayload?
expression (a formula authored in metadata-admin)ACCEPTno
precisionACCEPTno
scaleACCEPTno
systemACCEPTno
sortableACCEPTno

The designer's model is a strict subset of what a field may hold, and the difference was
being deleted. The loss is not new but was unreachable: while fields went out as an
array the whole body was refused 422 INVALID_METADATA before persistence, so nothing
saveFields dropped ever reached storage. #6240 made the body parse, and a PUT is an
upsert, so from that fix onward the drop lands.

The fix

toFieldPayload(field, prev) merges onto the previous SERVER entry — the form
MetadataFieldsPage.fromDesignerField has used one writer over all along. The previous
entries ride in on the document saveFieldsalready fetches for the object-level
spread, so this adds no request (pinned: one GET, one PUT).

The mirror hazard — carry-over must not resurrect what the author cleared

Adding carry-over opens the defect's mirror: {...prev, ...next} can put back a property
the author deliberately removed, and a deletion that fails to persist is the same silent
data loss pointing the other way. Every modelled key is therefore still written
unconditionally, so a cleared property arrives as an explicit undefined that
overrides the carried value and is dropped by JSON.stringify — absent from the body,
which on an upsert is the deletion. Measured on the request bytes, not reasoned about:
twelve clearable modelled keys asserted absent, individually and together, plus
reference on its own (the one key whose designer spelling differs, referenceTo).

The carry-over is bounded

indexed, referenceTo, formula, isSystem and sortOrder are refused BY NAME by
FieldSchema (measured, each with its unrecognized_keys message). A stored document can
still carry them — each was a real designer control before its card retired it — and
echoing one back is a hard 422 that blocks every later save of that object, with no UI
path left to clear it. They are stripped out of the carry-over; everything else the server
sent still survives, and an off-spec key the AUTHOR owns still goes out and is still
refused loudly (AGENTS.md #0.1).

The strip is deliberately not derived from FieldSchema's accept set: measured on
17.2.0, a plugin-registered key (x_plugin_thing) is unrecognized_keys to the INSTALLED
spec while the SERVER that sent it accepts it, so filtering through the client's schema
would drop precisely the keys this card exists to preserve.

The neighbour — #6480's pins stay green

#6480 (PR #6502) landed MetadataService.readDecorationStrip.test.ts on the neighbouring
expression of this same function, running the opposite way: it drops framework read
decorations the schema REFUSES; this keeps author and plugin keys that should SURVIVE.
MetadataService.readDecorationStrip.test.ts is green — by name, and in every ablation
leg below — and one new case asserts both properties hold in a single body so a later edit
cannot quietly undo one in service of the other. Not folded: separate concerns, separate
pins, and triage rejected the fold.

Read decorations do not need a per-field strip, and that is measured upstream rather than
assumed: decorateMetadataItem (metadata-protocol/src/metadata-diagnostics.ts) attaches
_diagnostics to the ITEM, never to a nested field entry. A case pins that, so if the
framework ever decorates per-field this turns red instead of shipping a 422 to an author.

Verification

All runs on the final commit 7e5f5ac50, working tree clean.

Reverse verification — three legs, each mutation proven on disk and restored by hash

Every leg pinned its restore to commit 844011795 (never origin/main, which other
worktrees move), proved the mutation landed by counting the target text before/after, and
proved the restore landed by comparing git hash-object against the pinned blob
958eda117 with git diff HEAD empty.

No package dist/ sits between mutation and assertion. The subject is imported by
relative path (./MetadataService) and every workspace import in these suites is aliased
to src/ by the root vitest.config.mts (@object-ui/data-objectstack
packages/data-objectstack/src, @object-ui/typespackages/types/src). The only
prebuilt artifact in the loop is @objectstack/spec, which is the unmutated oracle.

legmutationpredictedobserved
Aremove the carry-over spread (the fix itself)survival cases RED, mirror cases GREEN6 RED / 21 GREEN — survival, retired-key and neighbour-carry cases red as predicted; one mirror case also red, see below
Bcarry over VERBATIM (drop the retired-key strip)only retired-key cases RED3 RED / 24 GREEN — exactly the three retired-key cases
Cthe naive {...prev, ...defined(next)} mergemirror cases RED, survival cases GREEN3 RED / 24 GREEN — exactly the three mirror cases

Leg C is the one that matters for the mirror hazard: it shows the clearing assertions are
non-vacuous and would catch the naive fix.

One honest deviation from the prediction, in leg A: drops every modelled property the designer cleared reddened too. Not the clearing half — that case ends with a positive
control (expression survived in the same body), which is exactly what leg A removes. The
clearing assertions themselves pass without the fix, as clears them one at a time and
clears a relationship target staying green in leg A confirms.

MetadataService.readDecorationStrip.test.ts stayed green in all three legs, which is
the independence of the two neighbouring edits stated as a measurement.

File face

packages/app-shell/src/services/MetadataService.ts and
packages/app-shell/src/services/MetadataService.fieldKeyCarryOver.test.ts, plus one
changeset. Nothing under packages/plugin-designer/ is touched — #6489 owns
MetadataFieldsPage.tsx on this seam and is free to proceed.

Generated by Claude Code


Generated by Claude Code

`saveFields` preserved unknown keys of the OBJECT document by spreading it,
but rebuilt every FIELD entry from the designer model, so every key the server
sent inside a field that the designer does not model was dropped on every field
save: `expression`, `precision`, `scale`, `system`, `sortable`, and anything a
plugin registered. The loss was unreachable while the body was refused 422 as
an array (objectui#6240); from that fix onward a PUT is an upsert and the drop
lands in storage.
`toFieldPayload` now merges onto the previous SERVER entry, taken from the
document `saveFields` already fetches for the object-level spread — the same
form `MetadataFieldsPage.fromDesignerField` has used all along, and no extra
request. Modelled keys are still written unconditionally, so a property the
author CLEARED arrives as an explicit `undefined`, overrides the carried value
and is dropped by `JSON.stringify` — absent from the body, which on an upsert
is the deletion.
Carry-over excludes the retired designer keys `FieldSchema` refuses BY NAME
(`indexed`, `referenceTo`, `formula`, `isSystem`, `sortOrder`): a stored
document can still carry them, and echoing one back is a hard 422 that blocks
every later save of the object with no UI path to clear it.
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3234.0 KB3266.6 KB
Main entry chunk (gzip)157.4 KB350 KB
Entry fileindex-CLXgTGcY.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.32KB4.29KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)506.01KB114.64KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)238.89KB60.02KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.91KB12.92KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.60KB44.82KB
plugin-dashboard (index.js)133.48KB34.49KB
plugin-designer (index.js)211.90KB42.74KB
plugin-detail (index.js)245.29KB62.39KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)131.78KB32.19KB
plugin-gantt (index.js)165.16KB40.33KB
plugin-grid (index.js)201.66KB54.57KB
plugin-kanban (index.js)53.16KB14.65KB
plugin-list (index.js)112.74KB27.50KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)26.72KB7.71KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.85KB20.79KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)60.76KB20.20KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.44KB1.21KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-support-aiClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review: ACCEPT at 7e5f5ac50, pending CI. Verified from the tree.

⭐⭐⭐ You found the thing that would have made this fix a regression, and you proved it rather than describing it.carryOver excludes the five keys FieldSchema refuses by nameindexed, referenceTo, formula, isSystem, sortOrder. A stored document can still carry them, and echoing one back is a 422 that blocks every later save with no UI path to clear it. A naive "preserve everything the server sent" carry-over is the obvious reading of this card, it looks strictly safer than what it replaces, and it would have turned a silent-data-loss bug into an unclearable save failure. Ablation leg B — verbatim carry-over with the strip removed, predicted to red only the retired-key cases, observed exactly 3 red / 24 green — is what turns that from a good instinct into a measurement.

⭐⭐ The mirror hazard was measured on the request bytes, which is what I asked for and not what is easy. Modelled keys are still written unconditionally, so a cleared property arrives as an explicit undefined that overrides the carried value and is dropped by JSON.stringify. "Measured on the request bytes rather than reasoned about" is the distinction — a carry-over that resurrects a deliberately cleared value is the same silent-data-loss class as the bug, pointing the other way, and reasoning about {...prev, ...next} semantics is exactly how one ships it.

⭐ Leg C tests the wrong fix on purpose. Predicting that the naive {...prev, ...defined(next)} merge reds the mirror cases and greens the survival cases — and observing exactly 3 / 24 — proves the suite distinguishes the correct fix from the plausible one. That is the second dev in this lane today to ablate the tempting-but-wrong shape, and it is the thing that makes the clearing assertions non-vacuous rather than decorative.

Triage's requirement is met more strongly than it was stated. It asked that each PR land with the other's pins green. You report MetadataService.readDecorationStrip.test.ts (objectui#6480's) and MetadataService.objectPayloadFieldsMap.test.ts (objectui#6240's) green by name — and then went further: the strip pin stayed green in all three ablation legs, which makes the independence of the two neighbouring edits a measurement rather than an assertion. Two edits on neighbouring expressions in one function is precisely where a careless fix undoes its neighbour, and you closed that.

You closed the upstream question instead of leaving it open. Measuring that decorateMetadataItem attaches _diagnostics to the item only and never to a nested field entry establishes that the object-level strip remains sufficient and no per-field decoration strip is owed — and pinning it so it reds if that ever changes converts a today-fact into a guarded one.

No request was added (pinned: 1 GET, 1 PUT), which was the card's point that the previous entries are already in hand at the drop site. And the form is copied from MetadataFieldsPage.fromDesignerField rather than invented, so the two writers cannot drift.

objectui#6489 is released — nothing under packages/plugin-designer/ is touched, so it can proceed on MetadataFieldsPage.tsx.

The contract conflict you flagged is real, it is mine, and you are the third dev to hit it today. The standing os-dev contract says the PM has claimed the issue and not to touch the assignee; this lane's dispatch and the repo's CLAUDE.md both require claim-first. This seat sets pm:dispatched and leaves the assignee to your claim, so an unassigned card is the expected state when you arrive — you resolved it correctly and, more importantly, flagged it rather than silently picking a side. I am filing it against the domain:skills seat so the next dev does not spend the same tokens on it.

Landing: queued once CI settles green on 7e5f5ac50.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(app-shell): saveFields rebuilds every field entry from the designer model, so per-FIELD server keys are dropped on every field save

2 participants

@os-support-ai@claude