Skip to content

fix(app-shell,designer): stop putting three object-level keys the spec refuses on the wire (#6223) - #6253

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-6223-object-payload-spec-key-parity
Aug 25, 2026
Merged

fix(app-shell,designer): stop putting three object-level keys the spec refuses on the wire (#6223)#6253
yinlianghui merged 2 commits into
mainfrom
claude/issue-6223-object-payload-spec-key-parity

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#6223

ObjectSchema refuses group, sortOrder and relationshipsby name, and both object-level designer writers were putting them on the wire. The #5761 parity gate could not see it: its PAYLOAD_SHAPES had three field shapes and no object shape, so the parent document the designers actually PUT was unchecked.

The measurement — re-derived on this branch, not inherited

Measured against the installed @objectstack/spec 17.2.0, ESM build, on origin/main @ 0409b766d (the card measured @ 7da7b8a1d, before #6225 landed; everything below was re-run):

const base = { name: 'account', label: 'Account', fields: { n: { type: 'text', label: 'N' } } };
ObjectSchema.safeParse(base) => success = true (control)
ObjectSchema.safeParse({ ...base, isSystem: true }) => success = true (control)
ObjectSchema.safeParse({ ...base, pluralLabel: 'A' }) => success = true (control)
ObjectSchema.safeParse({ ...base, group: 'Sales' }) => unrecognized_keys ["group"]
ObjectSchema.safeParse({ ...base, sortOrder: 3 }) => unrecognized_keys ["sortOrder"]
ObjectSchema.safeParse({ ...base, relationships: [ … ] }) => unrecognized_keys ["relationships"]

Accept set: 42 keys, containing none of the three. The two controls are what make this a key-by-key result rather than a schema that refuses everything, and they are asserted first in every test file here.

Unlike #6041's referenceTo, none of the three has a near-spelling in the accept set — there is no "did you mean" suggestion for any of them, because the spec has no object-level counterpart at all.

No live 422 is claimed. The card explicitly declined to reproduce one and so does this PR: whether the deployed route rejects these today depends on what that route parses with. The schema fact is the ground for the fix.

Three keys, three resolutions

Per the #5761 family ruling — the spec is the authority, each key resolved on its own.

group — UI-only; the control stays, the value is derived.
The spec has no object-level grouping key. fieldGroups is on ObjectSchema, but it groups the fields inside one object, so it is not a mapping target. The Object Manager's grouping is a display category: metadataConverters derives it from the sys_ prefix and MetadataObjectsPage was reading raw.group — a key the schema refuses, so the server never stored one and that column rendered empty anyway. The grouping control and column are untouched; the value now derives from isSystem, a key the spec does accept.

Two halves, and the second is why a write-only fix would not do: merged is built by spreading the raw server document, so an object that already had group stored from an earlier build would spread it straight back out and stay permanently unsaveable. delete merged.group strips it on the way out — the #4644 strip-on-load shape, applied where the spread is.

sortOrder — the declaration is deleted.
No spec equivalent at object level. What populated it was the array index the converter happened to be at (sortOrder: index) — the order the list was already in, not a fact about the object. Removed from ObjectMetadataPayload / toObjectPayload.

This is not#6045. That card is the field-level sortOrder, a different key under a different oracle with its own resolution; it stays open and untouched, and there is an assertion here that field-level sortOrder still makes the trip so the two cards stay independently measurable.

relationships — the wire declaration is deleted; the data-model question is named, not answered.
The spec models relationships on the FIELD (reference / master_detail) plus object-level indexes. The payload stops declaring and sending an object-level relationship array. What the designer should author for a relationship is a data-model question this PR does not settle — ObjectDefinition.relationships (the UI model) and its read converter are deliberately left alone rather than guessed at.

No spec-side addition is needed for any of the three, so the fork clause was not triggered. Nothing in packages/spec is touched.

The gate extension — a second oracle

Every entry in PAYLOAD_SHAPES now names the schema that judges it, and three object-level shapes join the three field ones:

shapereachoracle
FieldMetadataPayloadwireFieldSchema
ServerFieldSchemawireFieldSchema
DesignerFieldDefinitionuiFieldSchema
ObjectMetadataPayloadwireObjectSchema
ServerObjectSchemawireObjectSchema
ObjectDefinitionuiObjectSchema

Reach is resolved within an oracle, never across one.group is a legal FieldSchema key and a refused ObjectSchema key at the same time, so a single pooled accept set would have stayed green on exactly the three keys this card is about. There is an executable control for that.

The ledger hole this PR's own ablation found

Reverting the object-level sortOrder alone left the gate green: the ledger is keyed by key name, so #6045's field-level entry absorbed the object-level reappearance in silence — the ledger becoming the hiding place its header says it must never be. Entries are now scoped to their oracle and matched on (key, oracle), with staleness scoped the same way in both directions. Second commit, two controls, plus an assertion that every real entry names an oracle rather than defaulting into one.

Two more keys surfaced, filed and not fixed

Verification

Run at fcca348f5, the branch head.

whatresult
packages/app-shell + packages/plugin-designer full suites536 files, 5186 passed, 1 skipped, 0 failed (at 9a674307a; the second commit touches only scripts/)
the three files here, at head3 files, 56 passed
check:designer-field-key-paritydesigner-field-key-parity: OK
check:phantom-deps / self-import / esm-specifiers / spec-symbolsall
check-control-bytes / check-vi-mock-specifiers / check-changeset-no-major / check-changeset-fixedall
type-check (both packages, incl. tsconfig.test.json) + type-check:scriptsDone, exit 0

Lint — a declared narrowing, not a full run.pnpm lint (turbo, 47 tasks) was cut off by the container's foreground cap at 7 successful tasks, so it is not reported as a pass. Instead, eslint --no-inline-config --format json over the 7 changed files: 7 files linted, 0 errors, 2 warnings, both pre-existing on origin/main (raw: any at main:238 of MetadataService.ts; void reload() at main:146 of MetadataObjectsPage.tsx). The narrowing is sound because the config extends tseslint.configs.recommendednotrecommendedTypeChecked — and sets no parserOptions.project / projectService, so no rule reads another file's types and this diff cannot move the verdict on any file it does not touch. CI runs the full farm regardless.

The gate is green before and after — so the evidence is elsewhere

pnpm check:designer-field-key-parity exits 0 on both trees. The evidence that this change did anything is the ledger/shape diff plus a per-key ablation, each leg mutating one key's resolution alone, proving the mutation on disk by grepping the injected text and separately the removed text, and restoring under trap … EXIT INT TERM. git diff HEAD --stat was empty after every leg. No rebuild is involved: every mutated file is imported by relative specifier from its test, so no exports/dist resolution is in play.

leg (one key reverted)failuresthe other keys
group10 failed / 44 passed — both group cases in MetadataService, all four group cases in MetadataObjectsPage, gate redsortOrder, relationships fully green
sortOrder6 failed / 50 passed, gate redgroup, relationships fully green; MetadataObjectsPage suite fully green
relationships6 failed / 50 passed, gate redgroup, sortOrder fully green
second oracle removed (object shapes → FieldSchema)3 failed, gate red with both STALE LEDGER ENTRIES and violationsproves the oracle assignment is load-bearing, not decorative

A fourth leg is worth naming: the oracle leg first aborted on its own anchor-uniqueness assertion (3 matches, not 1) and measured nothing rather than measuring the wrong thing.

Which assertions would still pass on a revert

Stated plainly, because they are the ones that prove nothing about the fix:

  1. Every "the instrument" case in all three files. They assert facts about ObjectSchema / FieldSchema — strictness, the 42-key accept set, the controls, that each key is refused by name. They are the non-vacuity floor and are true on any tree.
  2. a half-filled object … puts identical bytes, as it always didundefined is a key zod's strict object counts and JSON.stringify drops, so an object that never had these keys populated produced byte-identical output before and after. It is here to prove the fix did not newly break the untouched half, which is a claim about what did not change.
  3. leaves the FIELD-level sortOrder alone — that key is #6045 — unchanged by this PR in either direction, and deliberately so.
  4. the fixture really did carry the key (the LEGACY.group non-vacuity control) — an assertion about the fixture, not the code.
  5. ObjectSchema "no near-spelling" case — a property of the spec, not of this diff.
  6. On the group and oracle legs only, every sortOrder and relationships case still passes — that separation is the per-key result.

Everything else in the three objectui#6223 · describes reds on the revert of its own key, which is what the per-key ablation above measures.

Notes

Draft on purpose — not marking ready, not enqueueing, not enabling auto-merge.

Generated by Claude Code


Generated by Claude Code

…c refuses on the wire (objectui#6223)
`ObjectSchema` refuses `group`, `sortOrder` and `relationships` BY NAME.
Measured against the installed `@objectstack/spec` 17.2.0 (ESM build), whose
accept set is 42 keys:
const base = { name: 'account', label: 'Account',
fields: { n: { type: 'text', label: 'N' } } };
ObjectSchema.safeParse(base) => success = true (control)
ObjectSchema.safeParse({ ...base, isSystem: true }) => success = true (control)
ObjectSchema.safeParse({ ...base, pluralLabel: 'A' }) => success = true (control)
ObjectSchema.safeParse({ ...base, group: 'Sales' }) => unrecognized_keys ["group"]
ObjectSchema.safeParse({ ...base, sortOrder: 3 }) => unrecognized_keys ["sortOrder"]
ObjectSchema.safeParse({ ...base, relationships: [] }) => unrecognized_keys ["relationships"]
The two controls are what make that a key-by-key result rather than a schema
refusing everything.
Each key is resolved on its own, as the objectui#5761 family ruling requires:
group UI-only. The spec has no object-level grouping key
(`fieldGroups` groups the fields INSIDE one object), so the
Object Manager's grouping control and column stay and the
value is DERIVED from the accepted key `isSystem` instead of
round-tripped. `MetadataObjectsPage` also strips a `group`
already stored by an earlier build — its save-back spreads the
server document verbatim, so not writing the key is not the
same as removing it.
sortOrder Declaration removed. What populated it was the array index the
converter happened to be at. The field-level `sortOrder` is a
different key with its own card (objectui#6045) and is
untouched.
relationships Declaration removed from the payload. The spec models
relationships on the FIELD (`reference` / `master_detail` plus
object-level `indexes`); what the designer should author for a
relationship is a data-model question this does not settle.
The objectui#5761 parity gate gains a SECOND ORACLE: every entry in
`PAYLOAD_SHAPES` names the schema that judges it, and reach is resolved WITHIN
an oracle rather than across one — `group` is a legal `FieldSchema` key and a
refused `ObjectSchema` key at the same time, so a pooled accept set would have
stayed green on exactly these three.
That extension surfaced two more, filed and not fixed here: `enabled`
(objectui#6238, written by the soft-delete path) is ledgered, and `fields` sent
as an array where the spec wants a map (objectui#6240) is a value-level
rejection the key-name check cannot see — pinned as an assertion so it cannot
change silently.
Assertions are on captured PUT bytes, not the in-memory object: `undefined` is
a key zod's strict object counts and `JSON.stringify` drops.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
…es its key
Found by objectui#6223's own per-key ablation, and it is a hole the second
oracle opened. Re-declaring the OBJECT-level `sortOrder` left the gate GREEN:
the ledger is keyed by key NAME, so objectui#6045's FIELD-level entry absorbed
an object-level reappearance in silence — the ledger becoming the hiding place
the gate's header says it must never be.
Entries now name their oracle (defaulting to `FieldSchema`) and are matched on
`(key, oracle)`. Staleness is scoped the same way in both directions: an entry
whose oracle no shape declares the key under is stale even when a shape of the
OTHER oracle still declares that spelling.
Two executable controls carry it, both derived from the measurement above, plus
an assertion that every real entry names an oracle rather than defaulting into
one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3222.7 KB3266.6 KB
Main entry chunk (gzip)153.8 KB350 KB
Entry fileindex-BFemEoLC.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)10.38KB3.90KB
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.63KB114.68KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)171.74KB47.48KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
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.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.45KB
plugin-designer (index.js)212.33KB42.81KB
plugin-detail (index.js)244.13KB61.93KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)126.39KB30.80KB
plugin-gantt (index.js)164.17KB39.89KB
plugin-grid (index.js)201.14KB54.40KB
plugin-kanban (index.js)52.89KB14.59KB
plugin-list (index.js)111.94KB27.24KB
plugin-map (index.js)20.11KB6.64KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
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)54.84KB18.43KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
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)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.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.49KB2.14KB
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

@yinlianghui
yinlianghui marked this pull request as ready for review August 25, 2026 04:48
@yinlianghui
yinlianghui added this pull request to the merge queueAug 25, 2026
Merged via the queue into main with commit d18a0d3Aug 25, 2026
28 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-6223-object-payload-spec-key-parity branch August 25, 2026 05:01
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@yinlianghui@claude