Skip to content

fix(designer): keep a relationship target stored only as referenceTo (objectui#8896) - #8918

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-8896-referenceto-carryover
Sep 10, 2026
Merged

fix(designer): keep a relationship target stored only as referenceTo (objectui#8896)#8918
os-warren merged 3 commits into
mainfrom
claude/issue-8896-referenceto-carryover

Conversation

@os-warren

@os-warren os-warren commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Fixes #8896

Both sites the card names are closed, and the tombstone sentence it falsifies is
rewritten to be true per site rather than restated unconditionally. Every reading
below was measured on this branch — the post-merge ones on 378ef4ec5.

⚠️ objectui#8058 landed mid-task (#8899, 3f4b45886) and touches the same
file. This branch is merged onto it and the reproduction was re-measured on the
merged head
, so nothing here rests on a pre-merge reading.

The defect, reproduced before anything was changed

carryOver strips referenceTo because FieldSchema refuses that spelling BY
NAME. On the designable branch that now costs nothing: #8899 taught
toDesignerField to read the target under either spelling
(storedRelationshipTarget), and fromDesignerField re-emits it as reference.
The two sites in this card have no read door in front of them.

Site 1 — MetadataFieldsPage's carried-through half. Stored
{ parent_id: { type: 'master_detail', label: 'Parent', referenceTo: 'invoice' } },
author relabels an unrelated name field. On the unmodified tree, with the new
pin in place:

Tests  3 failed | 4 passed (7)
AssertionError: expected [] to have a length of 1 but got +0
[MetadataFieldsPage] cannot save the field `parent_id`: a `master_detail` field
needs a `reference` naming the object it links to, and this one has none.
`@objectstack/spec` requires it (17.3.0), so the server refuses the whole object
document with 422 `INVALID_METADATA` — which then blocks EVERY later save of
this object, not just this field. Pick the target object, or change the field to
a non-relationship type.

puts is empty: no PUT at all. The author edited a different field, and a
preserved field is rendered read-only on this page by design (objectui#8060), so
"Pick the target object" names a control that does not exist there. #8899's own
docblock names this path and says it is not addressed there.

Site 2 — metadata-admin's readFields. The card filed this one as a shape to
measure rather than a finding. Measured: the same loss, one door earlier. On the
unmodified tree, Tests 4 failed | 20 passed (24) — the value is deleted on load
with nothing left behind, and writeFields commits the loss on the next save.
Every reader downstream reads the canonical key alone, because objectui#6837's
ruling deleted the per-reader legacy arms (resolveReferenceTo is literally
def.reference), so the inspector's target editor rendered empty.

Re-measured after the merge (ablation leg A below — the one line restored to
main's carryOver(keep.raw)): Tests 4 failed | 3 passed (7). The defect is
live on current main.

What the repair is — and the four things it is not

Both sites lift the value onto the spec spelling reference before dropping
the retired key.

Site 1 does it through storedRelationshipTarget#8899's own reader, not a
second one. That is the point of the merge resolution: toFieldsMap and
carryOver are already a sibling-writer family where a difference is a defect
waiting to be found twice, so the two branches of this one writer now state ONE
spelling rule, argued once, in the function #8899 added. This also sits with
#8868 (objectui#7650), which canonicalizes reference keys at the metadata
provider's serve door — recover at the door, read canonically everywhere else.

  • The gate is untouched. assertRelationshipTargetPresent is byte-for-byte
    main's. carryPreservedField adopts what the document holds and judges
    nothing; whether a value can be a target stays entirely the guard's question,
    exactly as on the designable half. A stored referenceTo: ' ' is therefore
    refused on the guard's blank branch, by name, before the request.
  • The retired key still never reaches the wire. Re-emitting referenceTo
    would be the hard 422 the strip exists to prevent.
  • Not a specEquivalent migration. The registry says of that field:
    "Documentation for the reader, NEVER an instruction to migrate a value
    mechanically". objectui#6043 refused exactly that for formula, whose value is
    a LANGUAGE; isSystem's strip IS the whole write half of objectui#6044.
  • Not normalizeFieldReferenceKeys from core at these sites. That helper
    also stamps reference_to, a second spelling FieldSchema refuses and this
    carry-over does not strip: on a write path it trades one 422 for another.

The two sites differ in one recorded way, and the asymmetry is deliberate: site 1
adopts an unusable value and lets the guard refuse it by name; site 2 is a read
door with no guard behind it, feeding client validation and every reader, so it
never invents a reference key where the draft holds no usable target. Both are
pinned, and both pins were made to fail.

Pins, each with a firing control that was made to fail

Three ablations on the merged head, run from the committed state. Every mutation
was proven on disk by a marker grep before its run; every restore by blob-hash
equality against the HEAD blob plus an empty git diff HEAD, ending 0 file(s) differ from HEAD.

leg mutation result
A — reproduction preserved branch back to main's carryOver(keep.raw) Tests 4 failed | 3 passed (7)
B — remove the relationship gate return; at the top of assertRelationshipTargetPresent Tests 2 failed | 5 passed (7) — both still-refused controls go red, the positive cases stay green
C — site-2 recovery made unconditional drop the isUsableTarget conjuncts Tests 2 failed | 7 passed (9) — canonical-first and no-invented-target go red

Leg B is the control the card asked for by name: without it the pin would pass on
a build that simply deleted the gate. Restore proof:
F1 397759dc3b516a9bb74316198fefc59cf2eef0bc,
F2 680d32659843bbf7da2e862904d5c7a442bb999d, both matching HEAD after restore.

Fixtures re-judged, not batch-edited

Three cases in object-fields-io.retiredKeys.test.ts asserted the old lossy shape
with toEqual({ type: 'lookup', label: 'Owner' }). They were judged one at a time
and the asymmetry is now recorded as data (RESIDUE): indexed and isSystem
leave nothing behind for two different recorded reasons, and referenceTo is the
one key whose VALUE the draft may hold nowhere else. Every key in out
assertion — the thing those cases are named for — is unchanged.

Gates, exit codes captured before any pipe — all on 378ef4ec5

gate exit verdict line
check:designer-field-key-parity 0 designer-field-key-parity: OK
check:control-bytes 0 scanned 7101 tracked text files
check:vi-mock-inherit 0 681 call sites judged, 0 auto-mocked
check:vi-mock-specifiers 0 912 relative specifiers resolved
node scripts/check-changeset-presence.mjs 0 6 source files of 3 released packages, 1 changeset
check:governed-queue-guard --test 0 NOT GOVERNED — 7 paths, none matched
type-check — types, app-shell, plugin-designer 0 three Done lines
plugin-designer + types + app-shell/views/metadata-admin 0 Test Files 430 passed (430) · Tests 5939 passed | 1 skipped
packages/app-shell + apps/console (required sweep) 0 Test Files 768 passed (768) · Tests 7619 passed | 1 skipped

Pre-merge, on 5465cfffd, these also read 0: census:cross-file-line-citations,
census:tsconfig-test-parity, check:doc-fences, check:comment-mask-corpus,
check:unreferenced-sources.

Every vitest run reports RUN v4.1.10 /home/user/objectui-issue-8896 — this
worktree, not a sibling's copy.

⚠️ Two results that are NOT MEASURED rather than green or red, stated as such:
check:doc-examples answered 2 with PREREQUISITE NOT MET
(plugin-markdown / plugin-timeline dist/index.d.ts absent in a fresh
worktree — packages this diff does not touch). And pnpm run check:changeset-presence
answered 1 because no script by that name exists in package.json; the gate
itself is the node scripts/… row above, which answered 0.

⚠️ Package type-check does not compile *.test.ts(x): measured with
tsc --listFiles, 0 hits for both new pins, 1 hit each for the source files they
cover (positive control). This repo's recorded shape, not a regression.

objectui#8614 ledger hazard — checked, with a control either way

scripts/check-doc-example-types.mjs keys UNGATED_EXAMPLES by file:line and
this diff shifts lines in three source files. Zero of the 74 files that ledger
cites is a file this diff touches, and a tree-wide grep for any FILENAME:LINE
citation to those four files returns 0 — re-run after the merge, same reading.
Both instruments fire: injecting a known-cited path into the intersection returns
a hit, and the control spelling returns 3. ⇒ no row needs re-keying.

Pre-flight and live branches — measured

Base 348725a7corigin/main: the window was not empty, so the control can
fire. It caught #8899 landing in this very file, which is why this branch carries
a merge. git merge-tree --write-tree origin/main HEAD against 4fa0eb9c4 now
exits 0 with no conflict. claude/kanban-gantt-family-retirement touches 49
files, 15 under packages/types/, and none is
packages/types/src/internal/retired-field-keys.ts;
claude/issue-8071-member-pins-slice-5 touches 3, none shared; #8868 touches 5,
none shared. Positive control on the collision instrument: injecting one of this
diff's paths into the other side returns exactly 1 hit.

Clause-② stays no

Nothing is declared and no accept set moves. ServerFieldSchema gains no member —
the retired spelling is read through the index signature, exactly as #8899
established and for the same reason (a declared property is what the
designer-field-key parity gate reads to prove no payload can emit a refused key).
No schema, no spec pin, no new exported symbol.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w

os-warren and others added 3 commits September 10, 2026 00:08
…` (objectui#8896)

Two field-IO sites dropped the retired `referenceTo` key without keeping its
value, so a lookup or master-detail field whose target survived only under the
pre-objectui#6041 spelling lost the target on the way through.

`MetadataFieldsPage`'s carried-through half (objectui#8060) re-emits a stored
field verbatim with no read door in front of it, so `carryOver`'s strip was the
last thing to touch the field: the target left with the key and
`assertRelationshipTargetPresent` then refused the WHOLE save — a save the
author triggered by relabelling an unrelated field, on a page that renders the
offending field read-only, so "Pick the target object" named a control that does
not exist there. metadata-admin's `readFields`, the single read door for
`draft.fields`, deleted the same value on load and committed the loss on the
next save.

Both sites now lift the value onto the spec spelling before dropping the retired
key, which is the shape the designable half has always used. The strip itself is
unchanged: `FieldSchema` refuses `referenceTo` by name and no site emits it.
The recovery is written per site and keyed to this one key — NOT driven off the
registry's `specEquivalent`, which objectui#6043 refused as a mechanism — and
shares `assertRelationshipTargetPresent`'s own predicate, so it can never hand
the guard a target the guard would refuse.

The tombstone registry's unconditional "The strip loses nothing" and the read
door's "Nothing is lost on the way out" are corrected to say where they hold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
…erenceto-carryover

# Conflicts:
#	packages/plugin-designer/src/MetadataFieldsPage.tsx
…ating it unconditionally (objectui#8896)

The third site's read path belongs to its caller, so `saveFields` has no
in-repo caller to measure. Recording that as UNMEASURED rather than folding it
into a fresh unconditional claim is the whole point of this card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3487.6 KB 3512.7 KB
Main entry chunk (gzip) 144.1 KB 350 KB
Entry file index-DWcSrO6M.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) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
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) 11.08KB 4.58KB
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) 500.20KB 114.67KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 28.04KB 9.46KB
data-objectstack (index.js) 200.01KB 55.77KB
fields (index.js) 246.97KB 62.30KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
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.99KB 0.87KB
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 (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.03KB 13.93KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 194.54KB 46.34KB
plugin-dashboard (index.js) 132.41KB 34.84KB
plugin-designer (index.js) 215.69KB 44.34KB
plugin-detail (index.js) 252.45KB 65.33KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.79KB 34.19KB
plugin-gantt (index.js) 166.96KB 40.93KB
plugin-grid (index.js) 210.86KB 57.28KB
plugin-kanban (index.js) 57.58KB 16.47KB
plugin-list (index.js) 112.54KB 27.65KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.55KB 3.32KB
plugin-view (index.js) 84.42KB 20.80KB
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) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
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 (expression.js) 0.20KB 0.18KB
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.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
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 (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.27KB 5.47KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
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

@os-warren
os-warren marked this pull request as ready for review September 10, 2026 01:15
@os-warren
os-warren added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit 93127bd Sep 10, 2026
35 checks passed
@os-warren
os-warren deleted the claude/issue-8896-referenceto-carryover branch September 10, 2026 01:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant