Skip to content

fix(designer,app-shell): spell the designer's lookup target and system marker as the spec does (#6041, #6044) - #6225

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-6041-designer-spec-key-parity
Aug 25, 2026
Merged

fix(designer,app-shell): spell the designer's lookup target and system marker as the spec does (#6041, #6044)#6225
yinlianghui merged 2 commits into
mainfrom
claude/issue-6041-designer-spec-key-parity

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#6041
Fixes#6044

A two-card fold from the #5761 parity family: both members are spelling repairs toward the key the spec declares, each independently verifiable against the gate's ledger. One branch, one PR, one commit per card1eabaa2f6 for the lookup target, 8c09d055d for the system-field marker.

Issue #6043 (formulaexpression) is excluded from this fold and stays open — a bare rename there would ship non-CEL expressions under a valid key, a silent runtime failure worse than the loud 422. Nothing in this branch touches it, and its ledger entry is untouched.

What was wrong

carddesigner spelledspec declaresconsequence
#6041referenceToreferenceFieldSchema refuses it by name → saving a lookup field hard-blocks the object
#6044isSystemsystemdetection read a key the server never sends; the key round-tripped out as a 422

Measured against the installed @objectstack/spec 17.2.0 (ESM build, the one the app bundles), through the whole object document that PUT /api/v1/meta/object/:name validates:

ObjectSchema.safeParse({ …, fields: { rel: { type:'lookup', label:'Owner', referenceTo:'user' } } })
=> success = false, unrecognized_keys at ["fields","rel"] keys=["referenceTo"]
ObjectSchema.safeParse({ …, fields: { f: { type:'text', label:'S', isSystem:true } } })
=> success = false, unrecognized_keys at ["fields","f"] keys=["isSystem"]

Both spec spellings parse green, so this is a key-by-key result rather than a schema that refuses everything.

Both directions, per key

Fixing only the write side would have left every already-saved field unreadable, so each card moves both.

#6041 · reference

  • WRITE — MetadataService.toFieldPayload and MetadataFieldsPage.fromDesignerField (the gate's two wire shapes) now emit reference; FieldMetadataPayload and ServerFieldSchema declare it.
  • READ — toDesignerField now reads raw.reference. A spec-parsed server sends that key, so before this the reference box loaded empty for every existing lookup field.
  • referenceTo joins RETIRED_FIELD_KEYS: carryOver spreads the previous server def verbatim, so a stored misspelling would otherwise ride straight back out to the same 422 and keep a blocked object blocked.

#6044 · system

  • READ — toDesignerField now reads raw.system. The flag is optional, so the dead read never went red — undefined is a valid "not a system field" — while organization_id, created_at and friends presented as ordinary editable, deletable business fields.
  • WRITE — this half has no emit site at all; fromDesignerField never names the key, so its only route out is the verbatim carryOver spread. That answers the open question on the card: the round-trip and the detection read are separate sites, and neither is in MetadataService.tsFieldMetadataPayload never declared isSystem, so toFieldPayload had nothing to change.
  • The tombstone is deliberately paired with the read repair, never a substitute for it. system itself is not stripped: it is a real FieldSchema key, and carrying it through is what feeds the repaired read.

The ledger moved, per key

pnpm check:designer-field-key-parity, exit 0 before and after — the oracle both cards are measured against.

Before:

 Ledgered — refused, filed, resolution owned by its card:
referenceTo objectui#6041 (spec spells it `reference`)
formula objectui#6043 (spec spells it `expression (+ returnType)`)
isSystem objectui#6044 (spec spells it `system`)
sortOrder objectui#6045 (no spec equivalent)

After:

 UI-only keys (declared on no wire-bound shape, so out of reach of a PUT):
id · validationRules · isSystem · referenceTo (all DesignerFieldDefinition)
Ledgered — refused, filed, resolution owned by its card:
formula objectui#6043 (spec spells it `expression (+ returnType)`)
sortOrder objectui#6045 (no spec equivalent)

Both entries had to be removed, not edited: the gate's ratchet reds a ledger entry that no longer applies. The two keys stay declared on DesignerFieldDefinition and move into the gate's uiOnly list beside id and validationRules. That is deliberate — referenceTo is the internal prop name every other UI surface in this repo already uses (LookupField, filter-builder, ObjectChart, ListView, UserFilters), it reaches no wire-bound shape, and the gate catches it the moment anyone adds it back to one. Keeping it also leaves the published DesignerFieldDefinition signature unmoved, so both changesets are patch.

The behavioural edge: what a half-filled draft does

The spec's prose calls reference "Required for relationship types". That is not enforced by the parse at 17.2.0, measured rather than assumed:

FieldSchema.safeParse({ type:'lookup', label:'L' }) => success = true
FieldSchema.safeParse({ type:'master_detail', label:'L' }) => success = true
ObjectSchema.safeParse({ …fields:{ rel:{ type:'lookup', label:'L' } } }) => success = true

And undefined is dropped by JSON.stringify under either spelling, so a half-filled draft — type lookup, target box left empty — puts byte-identical bytes on the wire before and after this change, and saves in both. The rename blocks no draft it did not already block. (Worth stating precisely because the in-memory literal disagrees with the wire: zod's strict object counts a key whose value is undefined, JSON.stringify drops it — which is why every assertion here is made on the captured PUT bytes rather than on the object handed to the client.)

Verification

Gates by name, each exit code read from the gate's own verdict line, never a bare $? behind a pipe. Run at 8c09d055d, the final commit.

gatecommandexita red would have meant
parity gate (the oracle)pnpm check:designer-field-key-parity0a refused key on a wire shape, or a stale ledger entry
type-checkpnpm --filter @object-ui/plugin-designer --filter @object-ui/app-shell type-check0 (type-check: Done echoed for both)the narrowed payload types do not compile
testspnpm exec vitest run over plugin-designer, app-shell services/utils/metadata-admin, and the gate's self-test0 — 240 files, 2570 passeda regression in the two files, or the gate self-test rejecting the edited ledger
downstream sweeppnpm --filter '...@object-ui/app-shell' --filter '...@object-ui/plugin-designer' build (+ each consumer's own closure)0 — 38 packagesa consumer of the tightened FieldMetadataPayload no longer compiles
eslintpnpm --filter @object-ui/plugin-designer --filter @object-ui/app-shell lint (each is a full eslint .)0 — 0 errors (67 / 2673 pre-existing warnings)a lint error in the diff
phantom depspnpm check:phantom-deps0the tests' @objectstack/spec import undeclared
vi.mock specifierspnpm check:vi-mock-specifiers0an inert vi.mock('./FieldDesigner')
control bytespnpm check:control-bytes0a raw control byte in the diff
doc fencespnpm check:doc-fences0an unlabelled TypeScript fence in a changeset
changesetscheck-changeset-presence / -no-major / -fixed0 / 0 / 0a missing changeset, or a major bump
coveragecheck-type-check-coverage / check-lint-coverage / check-entry-guard / check-pre-install-import-graph0a new file outside a checked project

Direction of the downstream sweep, demonstrated rather than assumed:--filter '...@object-ui/plugin-designer' selects app-shell, console and both example consoles (consumers); the suffix form selects types, core, components … (dependencies). The prefix form is the consumer direction, which is where a contract tightening lands. The first attempt was red with Cannot find module '@object-ui/plugin-map' — diagnosed as an unbuilt-closure red rather than a missing-node_modules one (apps/console/node_modules/@object-ui/plugin-map resolves to packages/plugin-map, which had no dist/), and green once each consumer's own ^... closure was built.

Reverse verification — the narrowed type must actually reject the old spelling, not silently widen. Pasting referenceTo: field.referenceTo back into the FieldMetadataPayload literal:

src/services/MetadataService.ts(112,5): error TS2561: Object literal may only specify known
properties, but 'referenceTo' does not exist in type 'FieldMetadataPayload'.
Did you mean to write 'reference'?
Exit status 2

Restored under trap … EXIT INT TERM; git diff HEAD --stat empty afterwards. Independently, the rebuilt packages/app-shell/dist/services/MetadataService.d.ts:58 now reads reference?: string; — so the assertion is against a fresh build, not a cached declaration.

Per-key ablation — the fold's whole justification

Each card's fix was reverted alone, with the other left in place, mutations proven on disk by grepping the injected and the removed text separately, anchors asserted unique before writing, restore under trap:

Each mutated tree was checked to still contain the other card's fix as a literal string present in both versions of the file, so neither run could pass by accident.

Which assertions would still pass on a revert

Stated because a suite that cannot distinguish the two states of the world is worth nothing, and these were measured in the ablation runs rather than guessed:

Everything else fails on a revert of its own card.

Out-of-scope findings — filed, not repaired here

The object-shape isSystem in MetadataObjectsPage.tsx / ObjectManager.tsx was measured separately, as the card asked: ObjectSchemaacceptsisSystem (42-key accept set), so it is not the same defect and nothing was changed there.


Generated by Claude Code

… the spec accepts (objectui#6041)
`referenceTo` is not in `FieldSchema`'s accept set. Measured against the
installed `@objectstack/spec` 17.2.0, through the whole object document that
`PUT /api/v1/meta/object/:name` validates:
ObjectSchema.safeParse({ …, fields: { rel: { type: 'lookup',
label: 'Owner',
referenceTo: 'user' } } })
=> success = false
=> unrecognized_keys at ["fields","rel"] keys=["referenceTo"]
so authoring a lookup field in the designer returned a hard 422
`INVALID_METADATA` and, because the key is then stored, blocked every later
save of that object.
Both directions move, because a write-only repair would leave every
already-saved lookup field unreadable:
WRITE `MetadataService.toFieldPayload` and
`MetadataFieldsPage.fromDesignerField` — the parity gate's two
`wire` shapes — now emit `reference`.
READ `toDesignerField` now reads `raw.reference`. A spec-parsed server
sends that key, so before this the reference box loaded EMPTY for
every existing lookup field.
`referenceTo` also joins `RETIRED_FIELD_KEYS`: `carryOver` spreads the previous
server def verbatim, so a stored misspelling would otherwise ride straight back
out to the same 422 and keep a blocked object blocked.
The designer's in-memory `DesignerFieldDefinition` keeps `referenceTo` — the
internal prop name every other UI surface in this repo already uses, out of
reach of any wire-bound shape, and classified `uiOnly` by the gate. Its ledger
entry is removed, which the gate's both-directions ratchet requires.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
…d-tripping `isSystem` (objectui#6044)
`isSystem` is not in `FieldSchema`'s accept set; the spec spells it `system`.
Measured against the installed `@objectstack/spec` 17.2.0:
FieldSchema.safeParse({ type: 'text', label: 'L', isSystem: true })
=> success = false
=> unrecognized_keys keys=["isSystem"]
Two defects, one misspelling, and they are two DIFFERENT sites.
READ — the quieter, worse half. `toDesignerField` read `raw.isSystem` while a
spec-parsed server sends `system`, so the flag was always `undefined`. Nothing
went red: the flag is optional, and `undefined` is a valid "not a system
field". But it is load-bearing — `FieldDesigner` refuses to delete a system
field and disables its name and type inputs — so `organization_id`,
`created_at` and friends presented as ordinary editable, DELETABLE business
fields.
WRITE — no emit site at all. `fromDesignerField` never names the key; its only
route out is the verbatim `carryOver` spread, so a stored misspelling
round-tripped back as a hard 422 that blocks every later save. The repair is a
`RETIRED_FIELD_KEYS` tombstone, deliberately paired with the read fix and never
a substitute for it: stripping alone would close the 422 and fossilize the dead
detection. `system` itself is NOT stripped — it is a real `FieldSchema` key, and
carrying it through is what feeds the repaired read.
`app-shell`'s `FieldMetadataPayload` never declared the key, so `toFieldPayload`
had nothing to fix. The ledger entry is removed, as the gate's ratchet requires.
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.6 KB3990.2 KB
Main entry chunk (gzip)153.8 KB350 KB
Entry fileindex-BRJ_mnU3.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.15KB114.53KB
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.32KB42.81KB
plugin-detail (index.js)244.14KB61.94KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)126.07KB30.78KB
plugin-gantt (index.js)164.15KB39.88KB
plugin-grid (index.js)201.05KB54.38KB
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.57KB20.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 02:42
@yinlianghui
yinlianghui added this pull request to the merge queueAug 25, 2026
Merged via the queue into main with commit 2cf69e4Aug 25, 2026
26 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-6041-designer-spec-key-parity branch August 25, 2026 02:53
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment