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

os-support-ai merged 1 commit into
mainfrom
claude/issue-6045-field-payload-sortorder

Conversation

@claude

@claude claude Bot 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/main 854222c5e, 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.

check result
node scripts/check-designer-field-key-parity.mjs exit 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-check exit 0
pnpm --filter @object-ui/app-shell type-check exit 0
pnpm --filter @object-ui/plugin-designer type-check exit 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.mjs exit 0
node scripts/check-changeset-no-major.mjs exit 0
node scripts/check-control-bytes.mjs exit 0
node scripts/check-phantom-dependencies.mjs exit 0
node scripts/check-spec-symbol-derivation.mjs exit 0
node scripts/check-vi-mock-specifiers.mjs exit 0
node scripts/check-package-self-import.mjs exit 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

Metric Value Budget
Eager closure (gzip, 52 chunks) 3233.7 KB 3266.6 KB
Main entry chunk (gzip) 157.4 KB 350 KB
Entry file index-CitF8tGc.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) 11.30KB 4.28KB
app-shell (runtime-config.js) 18.10KB 6.51KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
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) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 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) 505.99KB 114.64KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 173.10KB 47.96KB
fields (index.js) 238.89KB 60.02KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.95KB 10.97KB
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.55KB 0.62KB
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) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.53KB 3.38KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.64KB 1.50KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 1.93KB 0.88KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.66KB 12.84KB
plugin-charts (index.js) 64.66KB 18.32KB
plugin-chatbot (index.js) 188.60KB 44.82KB
plugin-dashboard (index.js) 133.48KB 34.49KB
plugin-designer (index.js) 211.90KB 42.74KB
plugin-detail (index.js) 245.10KB 62.31KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 131.78KB 32.19KB
plugin-gantt (index.js) 164.14KB 39.87KB
plugin-grid (index.js) 201.79KB 54.60KB
plugin-kanban (index.js) 53.16KB 14.65KB
plugin-list (index.js) 112.63KB 27.45KB
plugin-map (index.js) 20.09KB 6.62KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.51KB 11.94KB
plugin-timeline (index.js) 26.72KB 7.71KB
plugin-tree (index.js) 9.26KB 3.13KB
plugin-view (index.js) 84.55KB 20.74KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
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) 56.69KB 19.03KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 2.05KB 1.04KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 12.13KB 3.65KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 7.54KB 2.63KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
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 (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
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) 4.72KB 2.24KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
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 (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 3.40KB 1.71KB
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

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