Skip to content

fix(app-shell): stop declaring and writing a field-level sortOrder - #6463

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-6045-field-payload-sortorder
Aug 26, 2026
Merged

fix(app-shell): stop declaring and writing a field-level sortOrder#6463
os-support-ai merged 1 commit into
mainfrom
claude/issue-6045-field-payload-sortorder

Conversation

@claude

@claudeclaudeBot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Fixes#6045

toFieldPayload copied sortOrder onto the payload saveFields PUTs, and
FieldSchema refuses that key by name. Field-level sibling of #6223, same
#5761 family. Removed from the wire shape, its writer, and the UI model.

The premise, re-derived on today's tree — and it holds

The card says the key is latent only because nothing populates it. Confirmed
on origin/main854222c5e, with a positive control so the search is not taken
on faith. Only two sites construct a DesignerFieldDefinition
FieldDesigner's create/update handlers and MetadataFieldsPage.toDesignerField
— and the same search that finds a key those sites do populate finds nothing
for this one:

$ git grep -n 'referenceTo' 854222c5e -- FieldDesigner.tsx MetadataFieldsPage.tsx
FieldDesigner.tsx:221 / :241 / :309 / :368 <- positive control: 4 real writers
MetadataFieldsPage.tsx:123 / :211 / ...
$ git grep -n 'sortOrder' 854222c5e -- FieldDesigner.tsx MetadataFieldsPage.tsx
(no hits)
$ git grep -n 'sortOrder' 854222c5e -- MetadataService.ts
:95 sortOrder?: number; <- the declaration
:141 sortOrder: field.sortOrder, <- the copier
(everything else is prose)

So toFieldPayload emitted sortOrder: undefined, JSON.stringify dropped it,
and the key never reached the wire. Nothing populates it today — the card's shape
is unchanged and this is a removal, not a live key.

plugin-designer's own save path is separate and was already clean:
fromDesignerField never names the key, and its carryOver spread cannot carry
one back out because no writer ever put one into the stored document — so no
RETIRED_FIELD_KEYS entry is needed there.

Resolution: deletion, not a rename — #4687's shape, not #6041's

The spec has no field-level ordering key at all. It models field order by
declaration order in the object's fields record, so a designer that wants
explicit ordering reorders that record rather than carrying an index. There was
nothing to map onto, and nothing was invented to map onto.

The near-spelling is not a rename target, and that is now asserted rather than
left as prose:

FieldSchema.safeParse({ type:'text', label:'L' }) => success = true (control)
FieldSchema.safeParse({ type:'text', label:'L', sortOrder: 3 }) => unrecognized_keys ["sortOrder"]
FieldSchema.safeParse({ type:'text', label:'L', sortable: true }) => success = true
FieldSchema.safeParse({ type:'text', label:'L', sortable: 3 }) => success = false

sortable is a boolean ("whether field is sortable in list views") — a
different concept. The control on the first line is what makes this a key-by-key
result rather than a schema refusing everything.

Removed in one go so nothing is left declared-but-unwritten:
FieldMetadataPayload (wire), toFieldPayload (writer), DesignerFieldDefinition
(UI model), plus the KNOWN_UNPARSEABLE_KEYS entry — that ledger ratchets in both
directions, so an entry left behind for a resolved key is as red as a missing one.

Two keys share this spelling and are NOT touched

The census was on the shape (a field-metadata payload key FieldSchema
refuses), not on the identifier — a grep on the bare name hands you both of these:

Evidence: before/after on the gate, with the controls in the same output

A removal has no new behaviour to pin, so the structural claim is a measured
before/after
rather than an invented assertion. Both runs end in the gate's own
verdict line, designer-field-key-parity: OK:

- FieldMetadataPayload 16 declared [wire] vs FieldSchema
+ FieldMetadataPayload 15 declared [wire] vs FieldSchema
- DesignerFieldDefinition 19 declared [ui] vs FieldSchema
+ DesignerFieldDefinition 18 declared [ui] vs FieldSchema
Ledgered — refused, filed, resolution owned by its card:
- sortOrder objectui#6045 [FieldSchema] (no spec equivalent)
enabled objectui#6238 [ObjectSchema] (no spec equivalent)

Three positive controls sit in that same unchanged output, which is what makes the
two disappearances a removal rather than a blinded scanner: enabled is still
ledgered (the ledger was not emptied), sortOrder (ObjectDefinition, vs ObjectSchema) is still reported uiOnly (the object-level half is untouched),
and referenceTo (DesignerFieldDefinition, vs FieldSchema) is still reported
uiOnly (the UI-model scan still finds refused keys there).

Evidence: reverse verification, on the committed fix

Predicted direction stated before the run: red. The mutation was proven on
disk before anything was measured (injected-text grep -c = 1, and
git hash-object differing from the HEAD blob), and restored by hash:

HEAD_BLOB=19a8a1d4666769f3ba6476d8ccf71a5d939afdba
injected_text_hits=1 MUT_HASH=ea2d5eb6684dbf7d4fe081cd788cd78a8db948a4
MUTATED_VITEST_EXIT=1
AssertionError: expected true to be false <- expect('sortOrder' in def).toBe(false)
AssertionError: expected [ 'sortOrder' ] to deeply equal []
Test Files 1 failed (1) - Tests 2 failed | 4 passed (6)
RES_HASH=19a8a1d4666769f3ba6476d8ccf71a5d939afdba (== HEAD_BLOB)
git diff HEAD -> empty

The second predicted direction is the interesting one, and it also held: the
parity gate stayed GREEN on the mutated tree
(MUTATED_GATE_EXIT=0,
designer-field-key-parity: OK). The mutation restored only the copier, not the
declaration, and the gate reads declarations. So the runtime pin is not
redundant with the gate — it covers the half the gate's own coverage notes say it
cannot see. That is why the new test is a runtime assertion on the PUT bytes
rather than a second reading of the gate.

Which assertion carries that weight is stated in the test file's header rather
than left implicit: the smuggled case is the one that reds on a revert. The
plain "a normal field PUTs no sortOrder" case would still pass on a revert —
deliberately, exactly as #6223's half-filled case does — because the key was
latent precisely because JSON.stringify drops the undefined.

One fixture was replaced, not respelled

MetadataService.specKeyObjectPayload.test.ts carried
it('leaves the FIELD-level sortOrder alone - that key is objectui#6045...'),
which asserted saveFields still put the key on the wire. That was correct when
#6223 landed: the object half had to be provable without quietly resolving the
field half. It pins exactly the branch this card deletes, so it is replaced with
the claim it was really making — the object half is judged on the object document,
and reverting this card cannot make that case green or red.

Verification

All exit codes captured before any pipe; every gate result quoted from the verdict
line the gate itself printed. Union run on the final commit c324804c9.

checkresult
node scripts/check-designer-field-key-parity.mjsexit 0 — designer-field-key-parity: OK
pnpm exec vitest run packages/app-shell/src/services/ scripts/__tests__/check-designer-field-key-parity.test.ts packages/plugin-designer/exit 0 — Test Files 20 passed (20) / Tests 164 passed (164)
pnpm --filter @object-ui/types type-checkexit 0
pnpm --filter @object-ui/app-shell type-checkexit 0
pnpm --filter @object-ui/plugin-designer type-checkexit 0
pnpm --filter @object-ui/app-shell lint (eslint .)exit 0 — 0 errors, 2684 pre-existing warnings
pnpm --filter @object-ui/types lint (eslint .)exit 0 — 0 errors, 244 pre-existing warnings
node scripts/check-changeset-presence.mjsexit 0
node scripts/check-changeset-no-major.mjsexit 0
node scripts/check-control-bytes.mjsexit 0
node scripts/check-phantom-dependencies.mjsexit 0
node scripts/check-spec-symbol-derivation.mjsexit 0
node scripts/check-vi-mock-specifiers.mjsexit 0
node scripts/check-package-self-import.mjsexit 0

Two notes, so the scope of the table is not over-read:

  • The typechecks are only meaningful after the dependency closure is built.
    Run cold they were exit 2 with a wall of TS2307: Cannot find module '@object-ui/...' in files this PR never touches. After
    turbo run build --filter='@object-ui/app-shell^...' --filter='@object-ui/plugin-designer^...'
    (28 tasks, exit 0) all three are exit 0. app-shell's tsconfig.test.json sets
    paths: {}, so it resolves @object-ui/types through the built.d.ts
    --listFiles confirms both edited test files are in that program (2 hits of
    4441), and packages/types/dist/designer.d.ts now carries sortOrder only
    inside ObjectDefinition, with DesignerFieldDefinition going group? ->
    description? with nothing between.
  • node scripts/check-readme-exports.mjs exits 1 locally, and it is a
    prerequisite, not a finding.
    All 69 items read its type entry ./dist/index.d.ts is not on disk -- run pnpm build first, for 8 packages
    outside the closure built above. Zero of them name packages/types,
    DesignerFieldDefinition or sortOrder. CI builds the whole workspace, so it
    measures what this run could not.

Declared narrowing: the other packages' lint and the remaining check:*
gates were left to CI, which runs the farm exactly once regardless. The gates
above are this card's own family plus those derived by hand from the changed
paths (objectui has no dispatch-gates deriver; the one in
objectstack/scripts/pm/ answers only about its own tree).

Draft on purpose — the PM lands this. Not marked ready, no auto-merge.

Generated by Claude Code


Generated by Claude Code

`FieldSchema` refuses `sortOrder` by name and the spec has no field-level
ordering key at all — it models field order by declaration order in the
object's `fields` record. `toFieldPayload` copied the key onto the payload
`saveFields` PUTs, so it was one reorder feature away from the hard 422
`INVALID_METADATA` that blocks every subsequent save of an object; it stayed
latent only because nothing populated it and `JSON.stringify` drops the
`undefined`.
Removed in one go from the wire shape (`FieldMetadataPayload`), its writer
(`toFieldPayload`) and the UI model (`DesignerFieldDefinition`), per
objectui#4687's resolution rather than objectui#6041's rename — the
near-spelling `sortable` is a boolean ("whether field is sortable in list
views"), a different concept. The `KNOWN_UNPARSEABLE_KEYS` entry goes with it;
that ledger ratchets in both directions.
The object-level `sortOrder` (objectui#6223, kept on `ObjectDefinition`) and
the saved-view `sortOrder` in `ObjectView` share the spelling and nothing else,
and are untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3233.7 KB3266.6 KB
Main entry chunk (gzip)157.4 KB350 KB
Entry fileindex-CitF8tGc.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.30KB4.28KB
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)505.99KB114.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.66KB12.84KB
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.10KB62.31KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)131.78KB32.19KB
plugin-gantt (index.js)164.14KB39.87KB
plugin-grid (index.js)201.79KB54.60KB
plugin-kanban (index.js)53.16KB14.65KB
plugin-list (index.js)112.63KB27.45KB
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.55KB20.74KB
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)56.69KB19.03KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.05KB1.04KB
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

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding: FieldMetadataPayload.sortOrder is a key FieldSchema rejects, written by toFieldPayload — latent only because nothing populates it

2 participants

@os-support-ai@claude