Skip to content

fix(core,app-shell): resolve a relationship target from a reference STRING only — the carrier axis - #6718

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-6648-relationship-target-carriers
Aug 29, 2026
Merged

fix(core,app-shell): resolve a relationship target from a reference STRING only — the carrier axis#6718
os-sales merged 1 commit into
mainfrom
claude/issue-6648-relationship-target-carriers

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes #6648

objectui#6528 narrowed both relationship-target resolvers to the single spec
SPELLING reference and deliberately left the CARRIER — the shape the value may
take — for its own census, because cutting on an unfinished census is exactly
what #6528 was filed to stop. That census is now run, to #6528's standard, and
it forces the removal.

The census — method first

Not a grep. A bare grep for reference conflates tiers (PR #6670's options
row: 71 bare hits, zero on a ListColumn), so this walked STRUCTURE:

  • JSON / YAML — parsed, then walked recursively, recording each hit's
    ancestor property chain.
  • TS / TSX / JS — read through the TypeScript compiler API, recording
    each hit's ancestor property chain, the classification of its initializer by
    AST node kind (string literal / array literal / object literal / dynamic),
    and the sibling keys of its enclosing object literal.

Sibling keys are what make it key-position aware without depending on how a
literal is nested. Tier A = the key position these two resolvers actually
read — a FIELD DEF: a sibling type naming a relationship, or an ancestor chain
running through fields. Tier B = every other place the word reference is
a key.

Trees: objectui @ 98188c284 and objectstack @ 8cb96ec (both origin/main;
the framework tree was checked out fresh for this, not read off a stale
working copy). 10,439 files parsed. 16 parse failures, every one a tsconfig*.json
written as JSONC — enumerated and none of them a metadata document.

The census — population and result

objectui objectstack total
reference KEY hits, all tiers 184 506 690
Tier A (field-def key position) 146 461 607
Tier A, bare-string carrier — the positive control 138 449 587
Tier A, ARRAY carrier 1 0 1
Tier A, { object } carrier 3 5 8
Tier A, dynamic initializer 4 7 11

The 449 in the framework tree corroborates #6528's independently-taken count
(445 of 565 lookup / master_detail defs).

Every non-string Tier-A hit was run down individually.

  • The one Tier-A array is chart-series.test.ts:202 — the green pin that
    asserted this very tolerance.
  • Of the 8 { object } hits: 2 are the other green pins; 4 are translation
    entries for a metadata-form field literally NAMED reference; 1 is a
    JSON-Schema property descriptor that itself says type: 'string'; 1 is a
    field named reference of type: 'text'; and 1 is
    objectstack packages/lint/src/runtime-gate.object-writes.test.ts:358, a lint
    fixture — whose own rule's reader (refOf) is already string-only, so the
    carrier resolves to undefined there and the fixture's assertion (about
    finding paths) is unaffected. Filed separately, see below.
  • All 11 dynamic initializers resolve to string-typed sources: PARENT /
    ACCOUNT string consts, foreignKey.referencedTable (declared string),
    OUTPUT_LOOKUP_OBJECTS (Record(string, string)), the two designer writers'
    referenceTo (declared string), and null in blueprint tests whose schema
    is z.string().nullable().
  • Independently: every reference TYPE declaration in either tree declares
    string
    (28 of them), except three unknown at tolerant read sites and one
    string | string[] in auditHistoryDisplay.ts whose runtime reader refuses
    non-strings anyway.

Producers of an array or { object } carrier at the field-def key position:
ZERO.

The zero is not a blind query

Per this seat's rule, a zero does not exist until a positive control fires in
the same query shape — and here there are two:

  1. The string row fires at 587 in the same Tier-A query shape.
  2. The array/{ object } detector itself demonstrably fires — it reported
    array and { object } carriers, at exactly the field-def key position. It is
    just that every one it found was a test asserting the tolerance, or the one
    lint fixture above. The detector can see the shape; there is nothing else
    emitting it.

The open question the card named, run down

The card asked specifically whether any polymorphic / multi-target lookup is
intended to carry a list
. It is not:

  • Field.reference is type: string in the spec's own JSON Schema, described
    as "Target object name (snake_case)". Singular.
  • Polymorphic lookup is an acknowledged, unbuilt gap in the spec's own audit
    report: "Salesforce's WhoId/WhatId can point to multiple objects. Current
    reference only supports a single target
    " — listed under Tier 3
    (T3-2), i.e. future work, not a shipped shape.
  • The spec's multiple key means multiple values of the SAME target, not
    multiple targets; reference stays one string.
  • The platform's one implemented polymorphic reference (ADR-0018 xRef,
    approval approver values) is a string with a sibling discriminator, never
    a list.
  • A third consumer that already met this question —
    auditHistoryDisplay.lookupTarget, commented "skip polymorphic" — chose to
    refuse the non-string carrier rather than take element zero.

If multi-target lookups ever land, they land as a declared spec shape. Nothing
today declares one, and ObjectSchema.safeParse refuses one.

What changed

FieldSchema.reference is a plain z.string(). Re-measured here on spec
17.2.0, one lookup field:

carrier verdict
reference: 'crm_account' ACCEPTED, value survives
reference: ['crm_account'] REFUSED — invalid_type: expected string, received array
reference: ['crm_account','crm_lead'] REFUSED — same
reference: { object: 'crm_account' } REFUSED — expected string, received object

Both resolvers branched on all three. Both are narrowed to the string carrier,
in one pass, in both files, so the two canonicalizations cannot diverge:

  • packages/core/src/utils/chart-series.tsresolveRelationshipTarget
  • packages/app-shell/src/views/metadata-admin/inspectors/useDatasetFields.tsresolveReferenceTo

The array branch was a silent product decision, not a compatibility shim.
Handed a multi-target value it returned raw[0] and discarded the rest — no
warning, no error, a plausible-looking answer. That is data loss, and it is the
precise shape AGENTS.md #0.1 forbids: the lenient consumer is where the wrong
producer hides.

The two existing tests were cover, not evidence

The carrier assertions in useDatasetFields.test.ts and chart-series.test.ts
read .toBe('account') / .toBe('crm_account') — they were the tolerance
written down. They are converted to refusal pins extending #6528's existing
refuses the legacy spelling ... shape
(same it.each, same
"a producer emitting it is the bug" wording, same toBeUndefined()), with a
third case added per file for the multi-element array — the discarded-rest case
the old branch was silently answering.

Verification

Ablation, run after the fix was committed so the restore leg had a real
reference point. Precondition checked and stated: both test files import the
source relatively (../chart-series, ./useDatasetFields), not through the
package exports, so no dist is in the resolution path and no rebuild leg is
required. Mutation = git checkout origin/main -- the two resolvers; proved on
disk
by anchored counts, not by an editor exit code (Array.isArray(raw)
reintroduced 1/1, narrowed return gone 0/0, plus a non-empty git diff HEAD --stat).
The script carried trap ... EXIT INT TERM.

Direction predicted before running: RED, and specifically red on the six new
carrier pins only.

vitest exit: 1
 × refuses the 'array' carrier on `reference` ...                      (x2 files)
 × refuses the 'multi-element array (the discarded-rest case)' ...     (x2 files)
 × refuses the '`{ object }`' carrier on `reference` ...               (x2 files)
 Test Files  2 failed (2)
      Tests  6 failed | 56 passed (62)

Exactly the six new pins moved; the other 56 stayed green. Restore proved by
observing state, not exit codes: git hash-object of each path equals its HEAD
blob, git diff HEAD empty, git status --porcelain empty.

Green union, re-run after the final commit, at adb1c9ec8:

  • pnpm exec vitest run on both pinned files — Test Files 2 passed (2),
    Tests 62 passed (62)
  • pnpm --filter @object-ui/core --filter @object-ui/app-shell type-check
    (tsc --noEmit && tsc -p tsconfig.test.json) — Done for both. --listFiles
    confirms both edited test files are inside the test program, so "typecheck
    clean" actually covers them.
  • pnpm --filter '@object-ui/core^...' --filter '@object-ui/app-shell^...' build
    — dependency closures built before any of the above.
  • pnpm check:control-bytes — its own verdict line:
    check-control-bytes: OK (scanned 5570 tracked text file(s); skipped 85 binary),
    plus a direct control-byte scan of the four edited files.

Lint was narrowed, and the narrowing is measured rather than assumed:
(1) the population comes from eslint's own config resolution, not a guess about
which files count; (2) --format json reports 4 files linted, 0 errors, 4
warnings
, all four warnings pre-existing in useDatasetFields.ts at lines
193/277/324, outside this diff; (3) the root config extends
tseslint.configs.recommended with no parserOptions.project and no
projectService — type-aware linting is off — so every file's verdict is a
function of its own text plus the shared config, and a diff touching four
source files and no config file cannot move any untouched file's verdict. CI
runs the full farm regardless.

Changeset

patch for @object-ui/app-shell and @object-ui/core. Reasoning: this
removes branches that no producer can reach on any document the spec accepts, so
no user-visible behaviour changes for any valid input — the census is the
evidence for that claim, not an assumption. It is not minor (no new
capability, nothing additive) and not major (the only inputs whose result
changes are ones ObjectSchema already refuses, i.e. producer-side defects that
should surface rather than be absorbed). This matches the score #6528 took for
the same class of narrowing on the spelling axis.

Filed separately, not fixed here

  • objectstack — the lint fixture at
    packages/lint/src/runtime-gate.object-writes.test.ts:358 spells a carrier the
    spec refuses; its own rule already reads string-only.
  • objectuipackages/app-shell/src/utils/auditHistoryDisplay.ts declares
    reference?: string | string[] and reference_to?: string | string[] and
    reads reference_to ?? reference: a third site carrying both a widened carrier
    type and finding(app-shell): resolveReferenceTos referenceTo branch is unreachable now that the read door strips the key #6528's legacy-spelling chain. Its runtime read already refuses
    non-strings, so there is no live defect — hygiene on both axes, and a
    different axis from this card, so it is filed rather than ridden along.

Generated by Claude Code


Generated by Claude Code

… STRING only

`resolveReferenceTo` (dataset designer) and its sibling
`resolveRelationshipTarget` (`chart-series.ts`) each branched on three CARRIERS
for the canonical `reference` key: a bare string, a one-element array (taking
element zero), and `{ object }`. objectui#6528 narrowed the SPELLING axis and
left the carrier axis for its own census; that census is now run.

`FieldSchema.reference` is a plain `z.string()`. `ObjectSchema.safeParse`
(spec 17.2.0) ACCEPTS `reference: 'crm_account'` and REFUSES both
`['crm_account']` (`expected string, received array`) and
`{ object: 'crm_account' }` (`expected string, received object`).

The carrier census walked STRUCTURE, not text — JSON/YAML parsed and walked,
TS/TSX through the TypeScript compiler API — recording each hit's ancestor
property chain and its enclosing object's sibling keys, so a FIELD DEF is
separated from the other tiers that also spell `reference`. Across both trees:
607 hits at the field-def key position, 587 of them the bare-string carrier,
and ZERO array or `{ object }` carriers from any producer. Every dynamic
initializer at that position resolved to a string-typed source, and every
`reference` TYPE declaration in either tree declares `string`. The detector
demonstrably fires on the shape it hunted: the only array / `{ object }`
carriers found were the two green pins asserting this tolerance, plus one
framework lint fixture whose own rule (`refOf`) already reads string-only.

The array branch additionally made a silent PRODUCT decision — element zero
returned, the rest discarded. Nothing declares a multi-target value:
polymorphic lookup is an open Tier-3 gap in the spec's own audit report
("Current `reference` only supports a single target"), and the platform's one
polymorphic reference (ADR-0018 `xRef`) is a string with a sibling
discriminator, never a list.

Removed in BOTH files in one pass so the two canonicalizations cannot diverge.
The two carrier assertions were green pins on the tolerant behaviour — they
were the tolerance written down, not evidence for it — so they are converted to
refusal pins extending objectui#6528's `refuses the legacy spelling ...`
pattern, and re-widening the carrier turns them red.

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

Copy link
Copy Markdown
Collaborator Author

The two census by-catch items the body refers to, now filed (neither is ridden along here):

  • objectui#6719 — AuditFieldDef in packages/app-shell/src/utils/auditHistoryDisplay.ts is a third relationship-target reader that neither finding(app-shell): resolveReferenceTos referenceTo branch is unreachable now that the read door strips the key #6528 nor this pass touched: it declares reference?: string | string[] / reference_to?: string | string[] and reads reference_to ?? reference. Observation-class — its runtime reader already narrows with typeof target === 'string', and that third author's choice to REFUSE the non-string carrier rather than take element zero is corroborating evidence for this PR.
  • objectstack#13053 — packages/lint/src/runtime-gate.object-writes.test.ts:358 spells reference: { object: 'shop_invoice' } on a master_detail def. Filed in the tree the fix lands in. It was the ONLY non-string carrier at a field-def key position in either tree that was not itself a test asserting the tolerance this PR removes.

Session for this work: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49

Generated by Claude Code


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 49 chunks) 3232.7 KB 3266.6 KB
Main entry chunk (gzip) 157.3 KB 350 KB
Entry file index-Dai0vmuh.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.89KB 4.50KB
app-shell (runtime-config.js) 20.61KB 7.35KB
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) 510.58KB 116.01KB
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) 239.20KB 60.12KB
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.89KB 12.91KB
plugin-charts (index.js) 64.66KB 18.32KB
plugin-chatbot (index.js) 190.33KB 45.10KB
plugin-dashboard (index.js) 133.44KB 34.48KB
plugin-designer (index.js) 212.87KB 43.19KB
plugin-detail (index.js) 245.29KB 62.39KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 132.01KB 32.23KB
plugin-gantt (index.js) 165.20KB 40.37KB
plugin-grid (index.js) 201.51KB 54.54KB
plugin-kanban (index.js) 53.11KB 14.62KB
plugin-list (index.js) 113.01KB 27.57KB
plugin-map (index.js) 20.17KB 6.66KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.51KB 11.94KB
plugin-timeline (index.js) 26.44KB 7.59KB
plugin-tree (index.js) 9.00KB 3.08KB
plugin-view (index.js) 85.87KB 21.12KB
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) 67.73KB 22.54KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 2.44KB 1.21KB
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) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
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

2 participants