fix(plugin-grid,plugin-dashboard): derive the relational copy set from its consumers, so a lookup cell honours the spec-declared displayField - #7156

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-6875-grid-relational-meta-derive
Sep 1, 2026
Merged

fix(plugin-grid,plugin-dashboard): derive the relational copy set from its consumers, so a lookup cell honours the spec-declared displayField#7156
os-warren merged 1 commit into
mainfrom
claude/issue-6875-grid-relational-meta-derive

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#6875

Verified on dd35800af (origin/main at branch point). Gate + suite numbers below are
from a5a6df951, the final commit.


The card's own first step, run first — and it does not shrink the card

#6875 named one thing triage had to measure before anything else: "Whether the object
schema a live getObjectSchema returns ever carries the camelCase spellings (the server
may normalise on the way out)."
If the producer normalises, widening the copy set fixes
nothing reachable.

It does not normalise, and the answer runs the other way — the camelCase spellings are
the only ones a live path can carry.

  1. @objectstack/spec 17.2.0's FieldSchema is strict, with 71 properties. Enumerated
    off FieldSchema.shape on the installed package, name/type/label as the positive
    control:

    DECLARED : displayField, descriptionField, lookupColumns, lookupFilters, reference
    absent : display_field, description_field, lookup_columns, lookup_filters,
    reference_to, reference_field, id_field, idField, titleFormat
    

    Strict means an absent key does not pass through — it is refused by name:

    safeParse({ name:'account', type:'lookup', reference_field:'name' })
    → success: false, code: 'unrecognized_keys', keys: ['reference_field']
    safeParse({ name:'account', type:'lookup', displayField:'name' })
    → success: true
    
  2. The producer repo emits no snake_case. Whole-repo sweep of objectstack, each zero
    against a control on the same instrument: display_field 3 files / displayField 68;
    description_field 1 / descriptionField 17; lookup_columns 2 / lookupColumns 13;
    lookup_filters 4 / lookupFilters 50; reference_field0. Every snake hit is
    prose — release notes, a CHANGELOG line, liveness-ledger notes describing what objectui
    reads. The platform's published authoring rule states the split outright
    (skills/objectstack-data/rules/naming.md): field KEYS snake_case, schema PROPERTIES
    camelCase, with lookup_filters: [] shown as ❌.

  3. Nothing renames anything on the way in.ObjectStackAdapter.getObjectSchema
    (@object-ui/data-objectstack) is the choke point every schema read passes through. Its
    only key rewrites are normalizeSchemaReferenceKeys — the referencereference_to
    pair, and nothing else — and applyFieldWidgetOverrides, which adds widget. Every
    other property arrives verbatim.

⇒ An author who declares displayField gets a def carrying displayField, and the grid
copied display_field instead. Item 2 of the card is not merely reachable; it is the only
reachable half.

The bug reaching a user

The card is labelled bug with no user report attached, so this is shown rather than
asserted. __tests__/lookupDisplayFieldSpelling-6875.test.tsx renders two columns over one
data source, one referenced record and one cell renderer, differing only in the spelling on
the field def. Referenced record: { id:'p1', name:'Wrong Name', project_code:'ACME-42' }.

columnfield defbeforeafter
code_snake (control)display_field: 'project_code'ACME-42ACME-42
code_cameldisplayField: 'project_code'Wrong NameACME-42

Measured on the unmodified tree: { acme: 1, wrongName: 1, rawId: 0 } — the spec-spelled
column rendered the referenced record's generic .name, because with no display pointer
getRecordDisplayName falls through to the .name/.title heuristic. That is the bug: a
lookup cell showing the wrong display value because the key never arrived. The control
column is what makes it a reading rather than "the fixture never reached the lookup path".

Derived, not restated

The ruling on this card was that the copy set must be derived from the consumers' read
set — a longer hand-kept list is the same defect with a later expiry date. It is also the
card's strongest internal evidence: lookupFilters (camel) was in the list while
displayField and descriptionField were not, though all three come off the same kind of
chain in the same files. Confirmed — that asymmetry is real, and it is the tell that
the list was assembled from defect reports.

  • packages/plugin-grid/src/relationalMetaKeys.ts — a table classifying every key the
    three consumers read off this bag, each with a verdict and a reason.
    RELATIONAL_META_KEYS is Object.entries(...) filtered by verdict. There is no
    hand-kept list left to drift.
  • __tests__/relationalMetaCopySet.derivation.test.ts — re-extracts the read set from the
    consumer sources on every run (LookupCellRenderer's cast reads, bounded to its own
    function body; LookupField's fieldMeta?.k; UserField's meta?.k) and requires the
    table to match exactly, in both directions. An unclassified spelling is red; an
    orphan is red.
  • The gate proves the verdicts against the installed spec rather than against this PR's
    prose: every no-producer key must be absent from FieldSchema.shape, every spec key
    present, and every legacy-alias key absent — so if a future spec version declares one,
    the gate says reclassify.

ObjectGrid.tsx loses 87 lines and gains 8: the literal, the helper and the retirement
docblock move to the new module, where the retirement history for reference_to_field
(#6711) and titleFormat (#6874) is preserved in full. Both are now enforced structurally
too — neither appears in the extracted read set, so re-adding either turns the gate red as
an orphan. Their behavioural pins are untouched and still green.

Which keys were added, and which were deliberately left out

Of the five the card named as never copied, three are added:
displayField, descriptionField, lookupColumns — spec-declared, read on this path.

Two are not, and that is the card shrinking where it should: reference_field and
lookup_columns are read on this path but FieldSchema declares neither, so no producer
can fill them. Copying them would write a member from the def on every column build that
nothing can ever set — precisely what #6711 (reference_to_field), #6625 (decimals) and
#6597 (referenceTo) retired, and what #6531 removed from getRecordDisplayName on the
same reasoning.

plugin-dashboard — the card's unmeasured mention, now measured

The card flagged CELL_RELATIONAL_META_KEYS as having the same gap and marked it explicitly
NOT measured. It does. Its docblock enumerated what LookupCellRenderer reads as three keys
because it was written from the FIRST leg of each chain; the renderer resolves
display_field || displayField || reference_field. displayField is added there too (same
spelling, same reason, same read-only path), reference_field stays out for the same
producer reason, FieldMeta gains the member, the docblock is corrected, and the #6694 pin
is extended in both directions. The four picker-only keys it deliberately does not copy are
untouched — that verdict does not move with this card.

Two of the four assumptions the dispatch asked me to falsify came back changed, both
recorded above: the card's line numbers had moved (#7141 landed in
packages/fields/src/widgets/), and the read set is larger than the five keys named.

Out of scope, filed not fixed

Verification

Run from the repo root with root-relative paths (#3378).

whatresult
vitest run packages/plugin-grid/103 files / 940 tests passed
vitest run packages/plugin-dashboard/85 files / 795 tests passed
type-check (both packages)exit 0 — Scope: 2 of 47, both echoed their tsc --noEmit plus tsc -p tsconfig.test.json; --listFiles confirms all 3 new files are inside the test program
lint (both packages, plain eslint .)exit 0 — 0 errors; the only findings on new files are no-explicit-any on the string-to-any Record signature carried over unchanged
changeset:check✅ All workspace packages are in the changeset fixed group. · ✅ privatePackages declared: version=true, tag=false. · ✅ No changeset declares a 'major' bump.
check-changeset-presence✅ 6 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-overwrite✅ No pre-existing changeset was modified or deleted.
check:control-bytes✅ OK (scanned 5926 tracked text file(s))
check:phantom-deps✅ Every in-scope import is declared by the package that publishes it. (@objectstack/spec was already a plugin-grid dep)
check:self-import · check:spec-symbols · check:vi-mock-specifiers · check:vi-mock-inherit · check:side-effects-array · check:element-data-source-declaration · check:entry-guard · check:esm-specifiersall exit 0

Two readings are absent, not green:

  • check:sdui-registration-pinsexit 2, PREREQUISITE NOT MET: "No console build to
    weigh at apps/console/dist/assets."
    Not run here (a full console build on a shared box,
    for a gate this diff has no exposure to: the new module declares types, a frozen array and
    a pure function, with zero load-time registration, and check:side-effects-array — the
    instrument that judges load-time registration — walked the tree green). CI builds console
    and runs it.
  • check:readme-exportsexit 1, entirely dist/index.d.ts is not on disk — run pnpm build first across 22 packages. After building plugin-grid and
    plugin-dashboard, both drop to 0 findings; the remaining 240 belong to 20 packages
    this diff does not touch and are the unbuilt closure, which CI resolves by building first.

Ablation — the gate can fail, in the way that matters

Predicted red before running; mutation proven on disk by marker count and blob hash,
restore proven by state (git diff HEAD, git diff --cached, git status --short all
empty). No rebuild needed: vitest.config.mts aliases @object-ui/fields to
packages/fields/src, so these runs read source — which leg 2 also demonstrates.

  1. Delete the displayField entry from the table. Marker 1 → 0, blob
    b9b046c22c7724dc. Result: Tests 3 failed | 7 passed — the gate's unclassified
    check, its copy-set exactness check, and the rendering test all go red together.
  2. A new spelling enters a consumer chainfieldMeta?.zzNewSpelling added to
    LookupField.tsx. Marker 0 → 1, blob 8e75d7ce920b18c1. Result:
    Tests 1 failed | 7 passed, message expected [ 'zzNewSpelling' ] to deeply equal [].

Leg 2 is the one that matters: it is the drift that produced this bug, and the gate now
catches it from the consumer side without anyone editing a list.

Clause ② — not engaged

Nothing published moves. RELATIONAL_META_KEYS, applyRelationalMeta and the new
relationalMetaKeys.ts are internal to plugin-grid and are not re-exported from
src/index.tsx (named exports only, verified). FieldMeta and CELL_RELATIONAL_META_KEYS
live in plugin-dashboard/src/recordFields.tsx, which src/index.tsx does not re-export —
so the FieldMeta member is internal too. No parse accept/reject behaviour changes; the
strict FieldSchema is read, never modified.

The producer-side alternative was considered and rejected on the merits, not on budget.
normalizeFieldReferenceKeys in @object-ui/core is the repo's blessed choke-point
normaliser and extending it would have been a one-line fix — but it would run backwards
here. For reference it stamps the objectui-side spelling from the served one; for these
keys the spec spelling is the served one, so stamping display_field from displayField
would manufacture a twin the contract refuses. That is AGENTS.md #0.1's anti-pattern in its
worst direction — the renderer inventing a dialect the spec rejects — and it is a public
export's behaviour change on top. The renderer-side fix forwards what the def already
carries and invents no spelling; the dialect tolerance stays where it already was, in
@object-ui/fields, unchanged, and #7155 records the case for retiring it.

Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM

Generated by Claude Code

…m its consumers, so a lookup cell honours the spec-declared `displayField`
The copy set ObjectGrid writes onto each relational column's fieldMeta was
hand-kept and had become a strict subset of what its own lookup cell renderer
and inline picker read off that bag. displayField, descriptionField and
lookupColumns were read on this grid's path and never copied — and they are the
spellings a spec-compliant producer actually emits, since @objectstack/spec
17.2.0's strict FieldSchema declares them and none of the snake_case twins.
Derived instead of restated: relationalMetaKeys.ts classifies every key the
consumers read, and a gate re-extracts that read set from the consumer sources
so the two cannot drift apart again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3153.5 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-CqdTMAwZ.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)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
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)512.32KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)65.94KB18.64KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)205.53KB55.50KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.21KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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.72KB2.24KB
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

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 07:18
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed and armed

All 30 check runs in one call at perPage=100: 27 success, 3 skipped, 0 failed, 0 in_progress. Undrafted, auto-merge SQUASH enabled at a5a6df951.

The gating measurement came back opposite to the hypothesis — and the card got bigger

I made the card's own first step a ruling: does a live getObjectSchema ever emit the camelCase spellings? The card framed a "no" as shrinking the work. The answer inverts it:

  • @objectstack/spec@17.2.0's FieldSchema is a strict object (71 props — the card said 64) declaring displayField / descriptionField / lookupColumns / lookupFilters / reference and none of the snake twins; reference_field parses to unrecognized_keys.
  • objectstack emits zero snake_case (display_field 3 files against a displayField control of 68; reference_field 0).
  • ObjectStackAdapter.getObjectSchema — the choke point — rewrites only the reference/reference_to pair.

camelCase is the only dialect a live path can carry, and it is exactly the half the grid never copied. The defect is more reachable than the card supposed, not less.

"Derive, do not restate" — delivered as a gate, not a longer list

I ruled that a hand-maintained list is not the fix, because a hand-maintained list is what drifted. The copy set now lives in relationalMetaKeys.ts as a classification table, with a gate that re-extracts the read set from the consumer sources and fails on any unclassified spelling or orphan.

Ablation leg 2 is the load-bearing one: adding fieldMeta?.zzNewSpelling to LookupField.tsx turns the gate red with expected [ 'zzNewSpelling' ] to deeply equal []. That is the exact drift that produced this bug, now mechanically caught. Predicted before running; mutation proven by marker count and blob hash; restore proven by state.

Three of the five keys added, two deliberately notFieldSchema declares neither reference_field nor lookup_columns, so no producer can fill them, and the gate proves that absence against the installed spec rather than asserting it. A measured narrowing with its evidence attached.

The user-visible bug was SHOWN, not asserted

I said "the key isn't copied" is a code fact and "the cell shows the wrong thing" is the bug. You built two columns over one data source and one referenced record, differing only in spelling: on the unmodified tree the control column resolved ACME-42 and the spec-spelled column resolved Wrong Name1 failed | 1 passed (2), with an instrumented dump. After the fix both resolve correctly and Wrong Name is absent.

⚖️ The producer-side direction was rejected on the merits

This is the part I most wanted to see. I fenced against picking the cheap direction and presenting it as the design conclusion. You did not take the internal fix because it was affordable — you rejected the producer fix because extending normalizeFieldReferenceKeys would stamp display_fieldfromdisplayField, manufacturing the dialect the contract refuses. That is AGENTS.md §0.1's anti-pattern running backwards, and it is a substantive reason, not a budget one.

Clause ② verified not engaged rather than assumed — neither RELATIONAL_META_KEYS / applyRelationalMeta / relationalMetaKeys.ts nor FieldMeta / CELL_RELATIONAL_META_KEYS are re-exported from their packages' indexes.

A3.2 falsified: the read set is not the five named

Four more spec-declared keys are read on this grid's path and never copied — multiple, allowCreate, lookupPageSize, dependsOn — found by the derivation itself. Filed as objectui#7154, which unblocks when this lands.

⚖️ Ruling on your open question: option A, keep it filed. Your reasoning is right and it is the same standard this card just met: multiple flips the inline picker's cardinality and dependsOn has a closed prior card (objectui#2215) reporting the table picker bypassing the dependent filter. Folding four behaviour-changing keys in without the rendering proof displayField just got would ship exactly the class of change this card exists to fix. And the usual cost of deferring — that it gets forgotten — is already neutralised: the derivation carries them with verdict deferred, so the gate keeps them visible and they cannot lapse. That is what makes A safe rather than merely cautious.

⚖️ On objectui#7155: agreed, C then likely B, and it is not yours or mine to make. Labelled needs-user-decision. Your decisive argument is the ordering one — the consumer chains read the snake spelling first, so removing a leg changes which value wins, not merely which keys exist. That is a back-compat change on published behaviour, and the measurement available here bounds two repos while a host application outside them is invisible to it. Correct to file rather than act.

Landing will be verified by content with a live control once main advances.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(plugin-grid,plugin-dashboard): derive the relational copy set from its consumers, so a lookup cell honours the spec-declared displayField - #7156

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-6875-grid-relational-meta-derive
Sep 1, 2026
Merged

fix(plugin-grid,plugin-dashboard): derive the relational copy set from its consumers, so a lookup cell honours the spec-declared displayField#7156
os-warren merged 1 commit into
mainfrom
claude/issue-6875-grid-relational-meta-derive

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#6875

Verified on dd35800af (origin/main at branch point). Gate + suite numbers below are
from a5a6df951, the final commit.


The card's own first step, run first — and it does not shrink the card

#6875 named one thing triage had to measure before anything else: "Whether the object
schema a live getObjectSchema returns ever carries the camelCase spellings (the server
may normalise on the way out)."
If the producer normalises, widening the copy set fixes
nothing reachable.

It does not normalise, and the answer runs the other way — the camelCase spellings are
the only ones a live path can carry.

  1. @objectstack/spec 17.2.0's FieldSchema is strict, with 71 properties. Enumerated
    off FieldSchema.shape on the installed package, name/type/label as the positive
    control:

    DECLARED : displayField, descriptionField, lookupColumns, lookupFilters, reference
    absent : display_field, description_field, lookup_columns, lookup_filters,
    reference_to, reference_field, id_field, idField, titleFormat
    

    Strict means an absent key does not pass through — it is refused by name:

    safeParse({ name:'account', type:'lookup', reference_field:'name' })
    → success: false, code: 'unrecognized_keys', keys: ['reference_field']
    safeParse({ name:'account', type:'lookup', displayField:'name' })
    → success: true
    
  2. The producer repo emits no snake_case. Whole-repo sweep of objectstack, each zero
    against a control on the same instrument: display_field 3 files / displayField 68;
    description_field 1 / descriptionField 17; lookup_columns 2 / lookupColumns 13;
    lookup_filters 4 / lookupFilters 50; reference_field0. Every snake hit is
    prose — release notes, a CHANGELOG line, liveness-ledger notes describing what objectui
    reads. The platform's published authoring rule states the split outright
    (skills/objectstack-data/rules/naming.md): field KEYS snake_case, schema PROPERTIES
    camelCase, with lookup_filters: [] shown as ❌.

  3. Nothing renames anything on the way in.ObjectStackAdapter.getObjectSchema
    (@object-ui/data-objectstack) is the choke point every schema read passes through. Its
    only key rewrites are normalizeSchemaReferenceKeys — the referencereference_to
    pair, and nothing else — and applyFieldWidgetOverrides, which adds widget. Every
    other property arrives verbatim.

⇒ An author who declares displayField gets a def carrying displayField, and the grid
copied display_field instead. Item 2 of the card is not merely reachable; it is the only
reachable half.

The bug reaching a user

The card is labelled bug with no user report attached, so this is shown rather than
asserted. __tests__/lookupDisplayFieldSpelling-6875.test.tsx renders two columns over one
data source, one referenced record and one cell renderer, differing only in the spelling on
the field def. Referenced record: { id:'p1', name:'Wrong Name', project_code:'ACME-42' }.

columnfield defbeforeafter
code_snake (control)display_field: 'project_code'ACME-42ACME-42
code_cameldisplayField: 'project_code'Wrong NameACME-42

Measured on the unmodified tree: { acme: 1, wrongName: 1, rawId: 0 } — the spec-spelled
column rendered the referenced record's generic .name, because with no display pointer
getRecordDisplayName falls through to the .name/.title heuristic. That is the bug: a
lookup cell showing the wrong display value because the key never arrived. The control
column is what makes it a reading rather than "the fixture never reached the lookup path".

Derived, not restated

The ruling on this card was that the copy set must be derived from the consumers' read
set — a longer hand-kept list is the same defect with a later expiry date. It is also the
card's strongest internal evidence: lookupFilters (camel) was in the list while
displayField and descriptionField were not, though all three come off the same kind of
chain in the same files. Confirmed — that asymmetry is real, and it is the tell that
the list was assembled from defect reports.

  • packages/plugin-grid/src/relationalMetaKeys.ts — a table classifying every key the
    three consumers read off this bag, each with a verdict and a reason.
    RELATIONAL_META_KEYS is Object.entries(...) filtered by verdict. There is no
    hand-kept list left to drift.
  • __tests__/relationalMetaCopySet.derivation.test.ts — re-extracts the read set from the
    consumer sources on every run (LookupCellRenderer's cast reads, bounded to its own
    function body; LookupField's fieldMeta?.k; UserField's meta?.k) and requires the
    table to match exactly, in both directions. An unclassified spelling is red; an
    orphan is red.
  • The gate proves the verdicts against the installed spec rather than against this PR's
    prose: every no-producer key must be absent from FieldSchema.shape, every spec key
    present, and every legacy-alias key absent — so if a future spec version declares one,
    the gate says reclassify.

ObjectGrid.tsx loses 87 lines and gains 8: the literal, the helper and the retirement
docblock move to the new module, where the retirement history for reference_to_field
(#6711) and titleFormat (#6874) is preserved in full. Both are now enforced structurally
too — neither appears in the extracted read set, so re-adding either turns the gate red as
an orphan. Their behavioural pins are untouched and still green.

Which keys were added, and which were deliberately left out

Of the five the card named as never copied, three are added:
displayField, descriptionField, lookupColumns — spec-declared, read on this path.

Two are not, and that is the card shrinking where it should: reference_field and
lookup_columns are read on this path but FieldSchema declares neither, so no producer
can fill them. Copying them would write a member from the def on every column build that
nothing can ever set — precisely what #6711 (reference_to_field), #6625 (decimals) and
#6597 (referenceTo) retired, and what #6531 removed from getRecordDisplayName on the
same reasoning.

plugin-dashboard — the card's unmeasured mention, now measured

The card flagged CELL_RELATIONAL_META_KEYS as having the same gap and marked it explicitly
NOT measured. It does. Its docblock enumerated what LookupCellRenderer reads as three keys
because it was written from the FIRST leg of each chain; the renderer resolves
display_field || displayField || reference_field. displayField is added there too (same
spelling, same reason, same read-only path), reference_field stays out for the same
producer reason, FieldMeta gains the member, the docblock is corrected, and the #6694 pin
is extended in both directions. The four picker-only keys it deliberately does not copy are
untouched — that verdict does not move with this card.

Two of the four assumptions the dispatch asked me to falsify came back changed, both
recorded above: the card's line numbers had moved (#7141 landed in
packages/fields/src/widgets/), and the read set is larger than the five keys named.

Out of scope, filed not fixed

Verification

Run from the repo root with root-relative paths (#3378).

whatresult
vitest run packages/plugin-grid/103 files / 940 tests passed
vitest run packages/plugin-dashboard/85 files / 795 tests passed
type-check (both packages)exit 0 — Scope: 2 of 47, both echoed their tsc --noEmit plus tsc -p tsconfig.test.json; --listFiles confirms all 3 new files are inside the test program
lint (both packages, plain eslint .)exit 0 — 0 errors; the only findings on new files are no-explicit-any on the string-to-any Record signature carried over unchanged
changeset:check✅ All workspace packages are in the changeset fixed group. · ✅ privatePackages declared: version=true, tag=false. · ✅ No changeset declares a 'major' bump.
check-changeset-presence✅ 6 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-overwrite✅ No pre-existing changeset was modified or deleted.
check:control-bytes✅ OK (scanned 5926 tracked text file(s))
check:phantom-deps✅ Every in-scope import is declared by the package that publishes it. (@objectstack/spec was already a plugin-grid dep)
check:self-import · check:spec-symbols · check:vi-mock-specifiers · check:vi-mock-inherit · check:side-effects-array · check:element-data-source-declaration · check:entry-guard · check:esm-specifiersall exit 0

Two readings are absent, not green:

  • check:sdui-registration-pinsexit 2, PREREQUISITE NOT MET: "No console build to
    weigh at apps/console/dist/assets."
    Not run here (a full console build on a shared box,
    for a gate this diff has no exposure to: the new module declares types, a frozen array and
    a pure function, with zero load-time registration, and check:side-effects-array — the
    instrument that judges load-time registration — walked the tree green). CI builds console
    and runs it.
  • check:readme-exportsexit 1, entirely dist/index.d.ts is not on disk — run pnpm build first across 22 packages. After building plugin-grid and
    plugin-dashboard, both drop to 0 findings; the remaining 240 belong to 20 packages
    this diff does not touch and are the unbuilt closure, which CI resolves by building first.

Ablation — the gate can fail, in the way that matters

Predicted red before running; mutation proven on disk by marker count and blob hash,
restore proven by state (git diff HEAD, git diff --cached, git status --short all
empty). No rebuild needed: vitest.config.mts aliases @object-ui/fields to
packages/fields/src, so these runs read source — which leg 2 also demonstrates.

  1. Delete the displayField entry from the table. Marker 1 → 0, blob
    b9b046c22c7724dc. Result: Tests 3 failed | 7 passed — the gate's unclassified
    check, its copy-set exactness check, and the rendering test all go red together.
  2. A new spelling enters a consumer chainfieldMeta?.zzNewSpelling added to
    LookupField.tsx. Marker 0 → 1, blob 8e75d7ce920b18c1. Result:
    Tests 1 failed | 7 passed, message expected [ 'zzNewSpelling' ] to deeply equal [].

Leg 2 is the one that matters: it is the drift that produced this bug, and the gate now
catches it from the consumer side without anyone editing a list.

Clause ② — not engaged

Nothing published moves. RELATIONAL_META_KEYS, applyRelationalMeta and the new
relationalMetaKeys.ts are internal to plugin-grid and are not re-exported from
src/index.tsx (named exports only, verified). FieldMeta and CELL_RELATIONAL_META_KEYS
live in plugin-dashboard/src/recordFields.tsx, which src/index.tsx does not re-export —
so the FieldMeta member is internal too. No parse accept/reject behaviour changes; the
strict FieldSchema is read, never modified.

The producer-side alternative was considered and rejected on the merits, not on budget.
normalizeFieldReferenceKeys in @object-ui/core is the repo's blessed choke-point
normaliser and extending it would have been a one-line fix — but it would run backwards
here. For reference it stamps the objectui-side spelling from the served one; for these
keys the spec spelling is the served one, so stamping display_field from displayField
would manufacture a twin the contract refuses. That is AGENTS.md #0.1's anti-pattern in its
worst direction — the renderer inventing a dialect the spec rejects — and it is a public
export's behaviour change on top. The renderer-side fix forwards what the def already
carries and invents no spelling; the dialect tolerance stays where it already was, in
@object-ui/fields, unchanged, and #7155 records the case for retiring it.

Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM

Generated by Claude Code

…m its consumers, so a lookup cell honours the spec-declared `displayField`
The copy set ObjectGrid writes onto each relational column's fieldMeta was
hand-kept and had become a strict subset of what its own lookup cell renderer
and inline picker read off that bag. displayField, descriptionField and
lookupColumns were read on this grid's path and never copied — and they are the
spellings a spec-compliant producer actually emits, since @objectstack/spec
17.2.0's strict FieldSchema declares them and none of the snake_case twins.
Derived instead of restated: relationalMetaKeys.ts classifies every key the
consumers read, and a gate re-extracts that read set from the consumer sources
so the two cannot drift apart again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3153.5 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-CqdTMAwZ.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)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
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)512.32KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)65.94KB18.64KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)205.53KB55.50KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.21KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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.72KB2.24KB
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

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 07:18
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed and armed

All 30 check runs in one call at perPage=100: 27 success, 3 skipped, 0 failed, 0 in_progress. Undrafted, auto-merge SQUASH enabled at a5a6df951.

The gating measurement came back opposite to the hypothesis — and the card got bigger

I made the card's own first step a ruling: does a live getObjectSchema ever emit the camelCase spellings? The card framed a "no" as shrinking the work. The answer inverts it:

  • @objectstack/spec@17.2.0's FieldSchema is a strict object (71 props — the card said 64) declaring displayField / descriptionField / lookupColumns / lookupFilters / reference and none of the snake twins; reference_field parses to unrecognized_keys.
  • objectstack emits zero snake_case (display_field 3 files against a displayField control of 68; reference_field 0).
  • ObjectStackAdapter.getObjectSchema — the choke point — rewrites only the reference/reference_to pair.

camelCase is the only dialect a live path can carry, and it is exactly the half the grid never copied. The defect is more reachable than the card supposed, not less.

"Derive, do not restate" — delivered as a gate, not a longer list

I ruled that a hand-maintained list is not the fix, because a hand-maintained list is what drifted. The copy set now lives in relationalMetaKeys.ts as a classification table, with a gate that re-extracts the read set from the consumer sources and fails on any unclassified spelling or orphan.

Ablation leg 2 is the load-bearing one: adding fieldMeta?.zzNewSpelling to LookupField.tsx turns the gate red with expected [ 'zzNewSpelling' ] to deeply equal []. That is the exact drift that produced this bug, now mechanically caught. Predicted before running; mutation proven by marker count and blob hash; restore proven by state.

Three of the five keys added, two deliberately notFieldSchema declares neither reference_field nor lookup_columns, so no producer can fill them, and the gate proves that absence against the installed spec rather than asserting it. A measured narrowing with its evidence attached.

The user-visible bug was SHOWN, not asserted

I said "the key isn't copied" is a code fact and "the cell shows the wrong thing" is the bug. You built two columns over one data source and one referenced record, differing only in spelling: on the unmodified tree the control column resolved ACME-42 and the spec-spelled column resolved Wrong Name1 failed | 1 passed (2), with an instrumented dump. After the fix both resolve correctly and Wrong Name is absent.

⚖️ The producer-side direction was rejected on the merits

This is the part I most wanted to see. I fenced against picking the cheap direction and presenting it as the design conclusion. You did not take the internal fix because it was affordable — you rejected the producer fix because extending normalizeFieldReferenceKeys would stamp display_fieldfromdisplayField, manufacturing the dialect the contract refuses. That is AGENTS.md §0.1's anti-pattern running backwards, and it is a substantive reason, not a budget one.

Clause ② verified not engaged rather than assumed — neither RELATIONAL_META_KEYS / applyRelationalMeta / relationalMetaKeys.ts nor FieldMeta / CELL_RELATIONAL_META_KEYS are re-exported from their packages' indexes.

A3.2 falsified: the read set is not the five named

Four more spec-declared keys are read on this grid's path and never copied — multiple, allowCreate, lookupPageSize, dependsOn — found by the derivation itself. Filed as objectui#7154, which unblocks when this lands.

⚖️ Ruling on your open question: option A, keep it filed. Your reasoning is right and it is the same standard this card just met: multiple flips the inline picker's cardinality and dependsOn has a closed prior card (objectui#2215) reporting the table picker bypassing the dependent filter. Folding four behaviour-changing keys in without the rendering proof displayField just got would ship exactly the class of change this card exists to fix. And the usual cost of deferring — that it gets forgotten — is already neutralised: the derivation carries them with verdict deferred, so the gate keeps them visible and they cannot lapse. That is what makes A safe rather than merely cautious.

⚖️ On objectui#7155: agreed, C then likely B, and it is not yours or mine to make. Labelled needs-user-decision. Your decisive argument is the ordering one — the consumer chains read the snake spelling first, so removing a leg changes which value wins, not merely which keys exist. That is a back-compat change on published behaviour, and the measurement available here bounds two repos while a host application outside them is invisible to it. Correct to file rather than act.

Landing will be verified by content with a live control once main advances.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-grid,plugin-dashboard): derive the relational copy set from its consumers, so a lookup cell honours the spec-declared displayField - #7156

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-6875-grid-relational-meta-derive
Sep 1, 2026
Merged

fix(plugin-grid,plugin-dashboard): derive the relational copy set from its consumers, so a lookup cell honours the spec-declared displayField#7156
os-warren merged 1 commit into
mainfrom
claude/issue-6875-grid-relational-meta-derive

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#6875

Verified on dd35800af (origin/main at branch point). Gate + suite numbers below are
from a5a6df951, the final commit.


The card's own first step, run first — and it does not shrink the card

#6875 named one thing triage had to measure before anything else: "Whether the object
schema a live getObjectSchema returns ever carries the camelCase spellings (the server
may normalise on the way out)."
If the producer normalises, widening the copy set fixes
nothing reachable.

It does not normalise, and the answer runs the other way — the camelCase spellings are
the only ones a live path can carry.

  1. @objectstack/spec 17.2.0's FieldSchema is strict, with 71 properties. Enumerated
    off FieldSchema.shape on the installed package, name/type/label as the positive
    control:

    DECLARED : displayField, descriptionField, lookupColumns, lookupFilters, reference
    absent : display_field, description_field, lookup_columns, lookup_filters,
    reference_to, reference_field, id_field, idField, titleFormat
    

    Strict means an absent key does not pass through — it is refused by name:

    safeParse({ name:'account', type:'lookup', reference_field:'name' })
    → success: false, code: 'unrecognized_keys', keys: ['reference_field']
    safeParse({ name:'account', type:'lookup', displayField:'name' })
    → success: true
    
  2. The producer repo emits no snake_case. Whole-repo sweep of objectstack, each zero
    against a control on the same instrument: display_field 3 files / displayField 68;
    description_field 1 / descriptionField 17; lookup_columns 2 / lookupColumns 13;
    lookup_filters 4 / lookupFilters 50; reference_field0. Every snake hit is
    prose — release notes, a CHANGELOG line, liveness-ledger notes describing what objectui
    reads. The platform's published authoring rule states the split outright
    (skills/objectstack-data/rules/naming.md): field KEYS snake_case, schema PROPERTIES
    camelCase, with lookup_filters: [] shown as ❌.

  3. Nothing renames anything on the way in.ObjectStackAdapter.getObjectSchema
    (@object-ui/data-objectstack) is the choke point every schema read passes through. Its
    only key rewrites are normalizeSchemaReferenceKeys — the referencereference_to
    pair, and nothing else — and applyFieldWidgetOverrides, which adds widget. Every
    other property arrives verbatim.

⇒ An author who declares displayField gets a def carrying displayField, and the grid
copied display_field instead. Item 2 of the card is not merely reachable; it is the only
reachable half.

The bug reaching a user

The card is labelled bug with no user report attached, so this is shown rather than
asserted. __tests__/lookupDisplayFieldSpelling-6875.test.tsx renders two columns over one
data source, one referenced record and one cell renderer, differing only in the spelling on
the field def. Referenced record: { id:'p1', name:'Wrong Name', project_code:'ACME-42' }.

columnfield defbeforeafter
code_snake (control)display_field: 'project_code'ACME-42ACME-42
code_cameldisplayField: 'project_code'Wrong NameACME-42

Measured on the unmodified tree: { acme: 1, wrongName: 1, rawId: 0 } — the spec-spelled
column rendered the referenced record's generic .name, because with no display pointer
getRecordDisplayName falls through to the .name/.title heuristic. That is the bug: a
lookup cell showing the wrong display value because the key never arrived. The control
column is what makes it a reading rather than "the fixture never reached the lookup path".

Derived, not restated

The ruling on this card was that the copy set must be derived from the consumers' read
set — a longer hand-kept list is the same defect with a later expiry date. It is also the
card's strongest internal evidence: lookupFilters (camel) was in the list while
displayField and descriptionField were not, though all three come off the same kind of
chain in the same files. Confirmed — that asymmetry is real, and it is the tell that
the list was assembled from defect reports.

  • packages/plugin-grid/src/relationalMetaKeys.ts — a table classifying every key the
    three consumers read off this bag, each with a verdict and a reason.
    RELATIONAL_META_KEYS is Object.entries(...) filtered by verdict. There is no
    hand-kept list left to drift.
  • __tests__/relationalMetaCopySet.derivation.test.ts — re-extracts the read set from the
    consumer sources on every run (LookupCellRenderer's cast reads, bounded to its own
    function body; LookupField's fieldMeta?.k; UserField's meta?.k) and requires the
    table to match exactly, in both directions. An unclassified spelling is red; an
    orphan is red.
  • The gate proves the verdicts against the installed spec rather than against this PR's
    prose: every no-producer key must be absent from FieldSchema.shape, every spec key
    present, and every legacy-alias key absent — so if a future spec version declares one,
    the gate says reclassify.

ObjectGrid.tsx loses 87 lines and gains 8: the literal, the helper and the retirement
docblock move to the new module, where the retirement history for reference_to_field
(#6711) and titleFormat (#6874) is preserved in full. Both are now enforced structurally
too — neither appears in the extracted read set, so re-adding either turns the gate red as
an orphan. Their behavioural pins are untouched and still green.

Which keys were added, and which were deliberately left out

Of the five the card named as never copied, three are added:
displayField, descriptionField, lookupColumns — spec-declared, read on this path.

Two are not, and that is the card shrinking where it should: reference_field and
lookup_columns are read on this path but FieldSchema declares neither, so no producer
can fill them. Copying them would write a member from the def on every column build that
nothing can ever set — precisely what #6711 (reference_to_field), #6625 (decimals) and
#6597 (referenceTo) retired, and what #6531 removed from getRecordDisplayName on the
same reasoning.

plugin-dashboard — the card's unmeasured mention, now measured

The card flagged CELL_RELATIONAL_META_KEYS as having the same gap and marked it explicitly
NOT measured. It does. Its docblock enumerated what LookupCellRenderer reads as three keys
because it was written from the FIRST leg of each chain; the renderer resolves
display_field || displayField || reference_field. displayField is added there too (same
spelling, same reason, same read-only path), reference_field stays out for the same
producer reason, FieldMeta gains the member, the docblock is corrected, and the #6694 pin
is extended in both directions. The four picker-only keys it deliberately does not copy are
untouched — that verdict does not move with this card.

Two of the four assumptions the dispatch asked me to falsify came back changed, both
recorded above: the card's line numbers had moved (#7141 landed in
packages/fields/src/widgets/), and the read set is larger than the five keys named.

Out of scope, filed not fixed

Verification

Run from the repo root with root-relative paths (#3378).

whatresult
vitest run packages/plugin-grid/103 files / 940 tests passed
vitest run packages/plugin-dashboard/85 files / 795 tests passed
type-check (both packages)exit 0 — Scope: 2 of 47, both echoed their tsc --noEmit plus tsc -p tsconfig.test.json; --listFiles confirms all 3 new files are inside the test program
lint (both packages, plain eslint .)exit 0 — 0 errors; the only findings on new files are no-explicit-any on the string-to-any Record signature carried over unchanged
changeset:check✅ All workspace packages are in the changeset fixed group. · ✅ privatePackages declared: version=true, tag=false. · ✅ No changeset declares a 'major' bump.
check-changeset-presence✅ 6 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-overwrite✅ No pre-existing changeset was modified or deleted.
check:control-bytes✅ OK (scanned 5926 tracked text file(s))
check:phantom-deps✅ Every in-scope import is declared by the package that publishes it. (@objectstack/spec was already a plugin-grid dep)
check:self-import · check:spec-symbols · check:vi-mock-specifiers · check:vi-mock-inherit · check:side-effects-array · check:element-data-source-declaration · check:entry-guard · check:esm-specifiersall exit 0

Two readings are absent, not green:

  • check:sdui-registration-pinsexit 2, PREREQUISITE NOT MET: "No console build to
    weigh at apps/console/dist/assets."
    Not run here (a full console build on a shared box,
    for a gate this diff has no exposure to: the new module declares types, a frozen array and
    a pure function, with zero load-time registration, and check:side-effects-array — the
    instrument that judges load-time registration — walked the tree green). CI builds console
    and runs it.
  • check:readme-exportsexit 1, entirely dist/index.d.ts is not on disk — run pnpm build first across 22 packages. After building plugin-grid and
    plugin-dashboard, both drop to 0 findings; the remaining 240 belong to 20 packages
    this diff does not touch and are the unbuilt closure, which CI resolves by building first.

Ablation — the gate can fail, in the way that matters

Predicted red before running; mutation proven on disk by marker count and blob hash,
restore proven by state (git diff HEAD, git diff --cached, git status --short all
empty). No rebuild needed: vitest.config.mts aliases @object-ui/fields to
packages/fields/src, so these runs read source — which leg 2 also demonstrates.

  1. Delete the displayField entry from the table. Marker 1 → 0, blob
    b9b046c22c7724dc. Result: Tests 3 failed | 7 passed — the gate's unclassified
    check, its copy-set exactness check, and the rendering test all go red together.
  2. A new spelling enters a consumer chainfieldMeta?.zzNewSpelling added to
    LookupField.tsx. Marker 0 → 1, blob 8e75d7ce920b18c1. Result:
    Tests 1 failed | 7 passed, message expected [ 'zzNewSpelling' ] to deeply equal [].

Leg 2 is the one that matters: it is the drift that produced this bug, and the gate now
catches it from the consumer side without anyone editing a list.

Clause ② — not engaged

Nothing published moves. RELATIONAL_META_KEYS, applyRelationalMeta and the new
relationalMetaKeys.ts are internal to plugin-grid and are not re-exported from
src/index.tsx (named exports only, verified). FieldMeta and CELL_RELATIONAL_META_KEYS
live in plugin-dashboard/src/recordFields.tsx, which src/index.tsx does not re-export —
so the FieldMeta member is internal too. No parse accept/reject behaviour changes; the
strict FieldSchema is read, never modified.

The producer-side alternative was considered and rejected on the merits, not on budget.
normalizeFieldReferenceKeys in @object-ui/core is the repo's blessed choke-point
normaliser and extending it would have been a one-line fix — but it would run backwards
here. For reference it stamps the objectui-side spelling from the served one; for these
keys the spec spelling is the served one, so stamping display_field from displayField
would manufacture a twin the contract refuses. That is AGENTS.md #0.1's anti-pattern in its
worst direction — the renderer inventing a dialect the spec rejects — and it is a public
export's behaviour change on top. The renderer-side fix forwards what the def already
carries and invents no spelling; the dialect tolerance stays where it already was, in
@object-ui/fields, unchanged, and #7155 records the case for retiring it.

Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM

Generated by Claude Code

…m its consumers, so a lookup cell honours the spec-declared `displayField`
The copy set ObjectGrid writes onto each relational column's fieldMeta was
hand-kept and had become a strict subset of what its own lookup cell renderer
and inline picker read off that bag. displayField, descriptionField and
lookupColumns were read on this grid's path and never copied — and they are the
spellings a spec-compliant producer actually emits, since @objectstack/spec
17.2.0's strict FieldSchema declares them and none of the snake_case twins.
Derived instead of restated: relationalMetaKeys.ts classifies every key the
consumers read, and a gate re-extracts that read set from the consumer sources
so the two cannot drift apart again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3153.5 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-CqdTMAwZ.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)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
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)512.32KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)65.94KB18.64KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)205.53KB55.50KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.21KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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.72KB2.24KB
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

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 07:18
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed and armed

All 30 check runs in one call at perPage=100: 27 success, 3 skipped, 0 failed, 0 in_progress. Undrafted, auto-merge SQUASH enabled at a5a6df951.

The gating measurement came back opposite to the hypothesis — and the card got bigger

I made the card's own first step a ruling: does a live getObjectSchema ever emit the camelCase spellings? The card framed a "no" as shrinking the work. The answer inverts it:

  • @objectstack/spec@17.2.0's FieldSchema is a strict object (71 props — the card said 64) declaring displayField / descriptionField / lookupColumns / lookupFilters / reference and none of the snake twins; reference_field parses to unrecognized_keys.
  • objectstack emits zero snake_case (display_field 3 files against a displayField control of 68; reference_field 0).
  • ObjectStackAdapter.getObjectSchema — the choke point — rewrites only the reference/reference_to pair.

camelCase is the only dialect a live path can carry, and it is exactly the half the grid never copied. The defect is more reachable than the card supposed, not less.

"Derive, do not restate" — delivered as a gate, not a longer list

I ruled that a hand-maintained list is not the fix, because a hand-maintained list is what drifted. The copy set now lives in relationalMetaKeys.ts as a classification table, with a gate that re-extracts the read set from the consumer sources and fails on any unclassified spelling or orphan.

Ablation leg 2 is the load-bearing one: adding fieldMeta?.zzNewSpelling to LookupField.tsx turns the gate red with expected [ 'zzNewSpelling' ] to deeply equal []. That is the exact drift that produced this bug, now mechanically caught. Predicted before running; mutation proven by marker count and blob hash; restore proven by state.

Three of the five keys added, two deliberately notFieldSchema declares neither reference_field nor lookup_columns, so no producer can fill them, and the gate proves that absence against the installed spec rather than asserting it. A measured narrowing with its evidence attached.

The user-visible bug was SHOWN, not asserted

I said "the key isn't copied" is a code fact and "the cell shows the wrong thing" is the bug. You built two columns over one data source and one referenced record, differing only in spelling: on the unmodified tree the control column resolved ACME-42 and the spec-spelled column resolved Wrong Name1 failed | 1 passed (2), with an instrumented dump. After the fix both resolve correctly and Wrong Name is absent.

⚖️ The producer-side direction was rejected on the merits

This is the part I most wanted to see. I fenced against picking the cheap direction and presenting it as the design conclusion. You did not take the internal fix because it was affordable — you rejected the producer fix because extending normalizeFieldReferenceKeys would stamp display_fieldfromdisplayField, manufacturing the dialect the contract refuses. That is AGENTS.md §0.1's anti-pattern running backwards, and it is a substantive reason, not a budget one.

Clause ② verified not engaged rather than assumed — neither RELATIONAL_META_KEYS / applyRelationalMeta / relationalMetaKeys.ts nor FieldMeta / CELL_RELATIONAL_META_KEYS are re-exported from their packages' indexes.

A3.2 falsified: the read set is not the five named

Four more spec-declared keys are read on this grid's path and never copied — multiple, allowCreate, lookupPageSize, dependsOn — found by the derivation itself. Filed as objectui#7154, which unblocks when this lands.

⚖️ Ruling on your open question: option A, keep it filed. Your reasoning is right and it is the same standard this card just met: multiple flips the inline picker's cardinality and dependsOn has a closed prior card (objectui#2215) reporting the table picker bypassing the dependent filter. Folding four behaviour-changing keys in without the rendering proof displayField just got would ship exactly the class of change this card exists to fix. And the usual cost of deferring — that it gets forgotten — is already neutralised: the derivation carries them with verdict deferred, so the gate keeps them visible and they cannot lapse. That is what makes A safe rather than merely cautious.

⚖️ On objectui#7155: agreed, C then likely B, and it is not yours or mine to make. Labelled needs-user-decision. Your decisive argument is the ordering one — the consumer chains read the snake spelling first, so removing a leg changes which value wins, not merely which keys exist. That is a back-compat change on published behaviour, and the measurement available here bounds two repos while a host application outside them is invisible to it. Correct to file rather than act.

Landing will be verified by content with a live control once main advances.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-grid,plugin-dashboard): derive the relational copy set from its consumers, so a lookup cell honours the spec-declared displayField - #7156

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-6875-grid-relational-meta-derive
Sep 1, 2026
Merged

fix(plugin-grid,plugin-dashboard): derive the relational copy set from its consumers, so a lookup cell honours the spec-declared displayField#7156
os-warren merged 1 commit into
mainfrom
claude/issue-6875-grid-relational-meta-derive

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#6875

Verified on dd35800af (origin/main at branch point). Gate + suite numbers below are
from a5a6df951, the final commit.


The card's own first step, run first — and it does not shrink the card

#6875 named one thing triage had to measure before anything else: "Whether the object
schema a live getObjectSchema returns ever carries the camelCase spellings (the server
may normalise on the way out)."
If the producer normalises, widening the copy set fixes
nothing reachable.

It does not normalise, and the answer runs the other way — the camelCase spellings are
the only ones a live path can carry.

  1. @objectstack/spec 17.2.0's FieldSchema is strict, with 71 properties. Enumerated
    off FieldSchema.shape on the installed package, name/type/label as the positive
    control:

    DECLARED : displayField, descriptionField, lookupColumns, lookupFilters, reference
    absent : display_field, description_field, lookup_columns, lookup_filters,
    reference_to, reference_field, id_field, idField, titleFormat
    

    Strict means an absent key does not pass through — it is refused by name:

    safeParse({ name:'account', type:'lookup', reference_field:'name' })
    → success: false, code: 'unrecognized_keys', keys: ['reference_field']
    safeParse({ name:'account', type:'lookup', displayField:'name' })
    → success: true
    
  2. The producer repo emits no snake_case. Whole-repo sweep of objectstack, each zero
    against a control on the same instrument: display_field 3 files / displayField 68;
    description_field 1 / descriptionField 17; lookup_columns 2 / lookupColumns 13;
    lookup_filters 4 / lookupFilters 50; reference_field0. Every snake hit is
    prose — release notes, a CHANGELOG line, liveness-ledger notes describing what objectui
    reads. The platform's published authoring rule states the split outright
    (skills/objectstack-data/rules/naming.md): field KEYS snake_case, schema PROPERTIES
    camelCase, with lookup_filters: [] shown as ❌.

  3. Nothing renames anything on the way in.ObjectStackAdapter.getObjectSchema
    (@object-ui/data-objectstack) is the choke point every schema read passes through. Its
    only key rewrites are normalizeSchemaReferenceKeys — the referencereference_to
    pair, and nothing else — and applyFieldWidgetOverrides, which adds widget. Every
    other property arrives verbatim.

⇒ An author who declares displayField gets a def carrying displayField, and the grid
copied display_field instead. Item 2 of the card is not merely reachable; it is the only
reachable half.

The bug reaching a user

The card is labelled bug with no user report attached, so this is shown rather than
asserted. __tests__/lookupDisplayFieldSpelling-6875.test.tsx renders two columns over one
data source, one referenced record and one cell renderer, differing only in the spelling on
the field def. Referenced record: { id:'p1', name:'Wrong Name', project_code:'ACME-42' }.

columnfield defbeforeafter
code_snake (control)display_field: 'project_code'ACME-42ACME-42
code_cameldisplayField: 'project_code'Wrong NameACME-42

Measured on the unmodified tree: { acme: 1, wrongName: 1, rawId: 0 } — the spec-spelled
column rendered the referenced record's generic .name, because with no display pointer
getRecordDisplayName falls through to the .name/.title heuristic. That is the bug: a
lookup cell showing the wrong display value because the key never arrived. The control
column is what makes it a reading rather than "the fixture never reached the lookup path".

Derived, not restated

The ruling on this card was that the copy set must be derived from the consumers' read
set — a longer hand-kept list is the same defect with a later expiry date. It is also the
card's strongest internal evidence: lookupFilters (camel) was in the list while
displayField and descriptionField were not, though all three come off the same kind of
chain in the same files. Confirmed — that asymmetry is real, and it is the tell that
the list was assembled from defect reports.

  • packages/plugin-grid/src/relationalMetaKeys.ts — a table classifying every key the
    three consumers read off this bag, each with a verdict and a reason.
    RELATIONAL_META_KEYS is Object.entries(...) filtered by verdict. There is no
    hand-kept list left to drift.
  • __tests__/relationalMetaCopySet.derivation.test.ts — re-extracts the read set from the
    consumer sources on every run (LookupCellRenderer's cast reads, bounded to its own
    function body; LookupField's fieldMeta?.k; UserField's meta?.k) and requires the
    table to match exactly, in both directions. An unclassified spelling is red; an
    orphan is red.
  • The gate proves the verdicts against the installed spec rather than against this PR's
    prose: every no-producer key must be absent from FieldSchema.shape, every spec key
    present, and every legacy-alias key absent — so if a future spec version declares one,
    the gate says reclassify.

ObjectGrid.tsx loses 87 lines and gains 8: the literal, the helper and the retirement
docblock move to the new module, where the retirement history for reference_to_field
(#6711) and titleFormat (#6874) is preserved in full. Both are now enforced structurally
too — neither appears in the extracted read set, so re-adding either turns the gate red as
an orphan. Their behavioural pins are untouched and still green.

Which keys were added, and which were deliberately left out

Of the five the card named as never copied, three are added:
displayField, descriptionField, lookupColumns — spec-declared, read on this path.

Two are not, and that is the card shrinking where it should: reference_field and
lookup_columns are read on this path but FieldSchema declares neither, so no producer
can fill them. Copying them would write a member from the def on every column build that
nothing can ever set — precisely what #6711 (reference_to_field), #6625 (decimals) and
#6597 (referenceTo) retired, and what #6531 removed from getRecordDisplayName on the
same reasoning.

plugin-dashboard — the card's unmeasured mention, now measured

The card flagged CELL_RELATIONAL_META_KEYS as having the same gap and marked it explicitly
NOT measured. It does. Its docblock enumerated what LookupCellRenderer reads as three keys
because it was written from the FIRST leg of each chain; the renderer resolves
display_field || displayField || reference_field. displayField is added there too (same
spelling, same reason, same read-only path), reference_field stays out for the same
producer reason, FieldMeta gains the member, the docblock is corrected, and the #6694 pin
is extended in both directions. The four picker-only keys it deliberately does not copy are
untouched — that verdict does not move with this card.

Two of the four assumptions the dispatch asked me to falsify came back changed, both
recorded above: the card's line numbers had moved (#7141 landed in
packages/fields/src/widgets/), and the read set is larger than the five keys named.

Out of scope, filed not fixed

Verification

Run from the repo root with root-relative paths (#3378).

whatresult
vitest run packages/plugin-grid/103 files / 940 tests passed
vitest run packages/plugin-dashboard/85 files / 795 tests passed
type-check (both packages)exit 0 — Scope: 2 of 47, both echoed their tsc --noEmit plus tsc -p tsconfig.test.json; --listFiles confirms all 3 new files are inside the test program
lint (both packages, plain eslint .)exit 0 — 0 errors; the only findings on new files are no-explicit-any on the string-to-any Record signature carried over unchanged
changeset:check✅ All workspace packages are in the changeset fixed group. · ✅ privatePackages declared: version=true, tag=false. · ✅ No changeset declares a 'major' bump.
check-changeset-presence✅ 6 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-overwrite✅ No pre-existing changeset was modified or deleted.
check:control-bytes✅ OK (scanned 5926 tracked text file(s))
check:phantom-deps✅ Every in-scope import is declared by the package that publishes it. (@objectstack/spec was already a plugin-grid dep)
check:self-import · check:spec-symbols · check:vi-mock-specifiers · check:vi-mock-inherit · check:side-effects-array · check:element-data-source-declaration · check:entry-guard · check:esm-specifiersall exit 0

Two readings are absent, not green:

  • check:sdui-registration-pinsexit 2, PREREQUISITE NOT MET: "No console build to
    weigh at apps/console/dist/assets."
    Not run here (a full console build on a shared box,
    for a gate this diff has no exposure to: the new module declares types, a frozen array and
    a pure function, with zero load-time registration, and check:side-effects-array — the
    instrument that judges load-time registration — walked the tree green). CI builds console
    and runs it.
  • check:readme-exportsexit 1, entirely dist/index.d.ts is not on disk — run pnpm build first across 22 packages. After building plugin-grid and
    plugin-dashboard, both drop to 0 findings; the remaining 240 belong to 20 packages
    this diff does not touch and are the unbuilt closure, which CI resolves by building first.

Ablation — the gate can fail, in the way that matters

Predicted red before running; mutation proven on disk by marker count and blob hash,
restore proven by state (git diff HEAD, git diff --cached, git status --short all
empty). No rebuild needed: vitest.config.mts aliases @object-ui/fields to
packages/fields/src, so these runs read source — which leg 2 also demonstrates.

  1. Delete the displayField entry from the table. Marker 1 → 0, blob
    b9b046c22c7724dc. Result: Tests 3 failed | 7 passed — the gate's unclassified
    check, its copy-set exactness check, and the rendering test all go red together.
  2. A new spelling enters a consumer chainfieldMeta?.zzNewSpelling added to
    LookupField.tsx. Marker 0 → 1, blob 8e75d7ce920b18c1. Result:
    Tests 1 failed | 7 passed, message expected [ 'zzNewSpelling' ] to deeply equal [].

Leg 2 is the one that matters: it is the drift that produced this bug, and the gate now
catches it from the consumer side without anyone editing a list.

Clause ② — not engaged

Nothing published moves. RELATIONAL_META_KEYS, applyRelationalMeta and the new
relationalMetaKeys.ts are internal to plugin-grid and are not re-exported from
src/index.tsx (named exports only, verified). FieldMeta and CELL_RELATIONAL_META_KEYS
live in plugin-dashboard/src/recordFields.tsx, which src/index.tsx does not re-export —
so the FieldMeta member is internal too. No parse accept/reject behaviour changes; the
strict FieldSchema is read, never modified.

The producer-side alternative was considered and rejected on the merits, not on budget.
normalizeFieldReferenceKeys in @object-ui/core is the repo's blessed choke-point
normaliser and extending it would have been a one-line fix — but it would run backwards
here. For reference it stamps the objectui-side spelling from the served one; for these
keys the spec spelling is the served one, so stamping display_field from displayField
would manufacture a twin the contract refuses. That is AGENTS.md #0.1's anti-pattern in its
worst direction — the renderer inventing a dialect the spec rejects — and it is a public
export's behaviour change on top. The renderer-side fix forwards what the def already
carries and invents no spelling; the dialect tolerance stays where it already was, in
@object-ui/fields, unchanged, and #7155 records the case for retiring it.

Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM

Generated by Claude Code

…m its consumers, so a lookup cell honours the spec-declared `displayField`
The copy set ObjectGrid writes onto each relational column's fieldMeta was
hand-kept and had become a strict subset of what its own lookup cell renderer
and inline picker read off that bag. displayField, descriptionField and
lookupColumns were read on this grid's path and never copied — and they are the
spellings a spec-compliant producer actually emits, since @objectstack/spec
17.2.0's strict FieldSchema declares them and none of the snake_case twins.
Derived instead of restated: relationalMetaKeys.ts classifies every key the
consumers read, and a gate re-extracts that read set from the consumer sources
so the two cannot drift apart again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3153.5 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-CqdTMAwZ.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)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
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)512.32KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)65.94KB18.64KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)205.53KB55.50KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.21KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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.72KB2.24KB
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

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 07:18
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed and armed

All 30 check runs in one call at perPage=100: 27 success, 3 skipped, 0 failed, 0 in_progress. Undrafted, auto-merge SQUASH enabled at a5a6df951.

The gating measurement came back opposite to the hypothesis — and the card got bigger

I made the card's own first step a ruling: does a live getObjectSchema ever emit the camelCase spellings? The card framed a "no" as shrinking the work. The answer inverts it:

  • @objectstack/spec@17.2.0's FieldSchema is a strict object (71 props — the card said 64) declaring displayField / descriptionField / lookupColumns / lookupFilters / reference and none of the snake twins; reference_field parses to unrecognized_keys.
  • objectstack emits zero snake_case (display_field 3 files against a displayField control of 68; reference_field 0).
  • ObjectStackAdapter.getObjectSchema — the choke point — rewrites only the reference/reference_to pair.

camelCase is the only dialect a live path can carry, and it is exactly the half the grid never copied. The defect is more reachable than the card supposed, not less.

"Derive, do not restate" — delivered as a gate, not a longer list

I ruled that a hand-maintained list is not the fix, because a hand-maintained list is what drifted. The copy set now lives in relationalMetaKeys.ts as a classification table, with a gate that re-extracts the read set from the consumer sources and fails on any unclassified spelling or orphan.

Ablation leg 2 is the load-bearing one: adding fieldMeta?.zzNewSpelling to LookupField.tsx turns the gate red with expected [ 'zzNewSpelling' ] to deeply equal []. That is the exact drift that produced this bug, now mechanically caught. Predicted before running; mutation proven by marker count and blob hash; restore proven by state.

Three of the five keys added, two deliberately notFieldSchema declares neither reference_field nor lookup_columns, so no producer can fill them, and the gate proves that absence against the installed spec rather than asserting it. A measured narrowing with its evidence attached.

The user-visible bug was SHOWN, not asserted

I said "the key isn't copied" is a code fact and "the cell shows the wrong thing" is the bug. You built two columns over one data source and one referenced record, differing only in spelling: on the unmodified tree the control column resolved ACME-42 and the spec-spelled column resolved Wrong Name1 failed | 1 passed (2), with an instrumented dump. After the fix both resolve correctly and Wrong Name is absent.

⚖️ The producer-side direction was rejected on the merits

This is the part I most wanted to see. I fenced against picking the cheap direction and presenting it as the design conclusion. You did not take the internal fix because it was affordable — you rejected the producer fix because extending normalizeFieldReferenceKeys would stamp display_fieldfromdisplayField, manufacturing the dialect the contract refuses. That is AGENTS.md §0.1's anti-pattern running backwards, and it is a substantive reason, not a budget one.

Clause ② verified not engaged rather than assumed — neither RELATIONAL_META_KEYS / applyRelationalMeta / relationalMetaKeys.ts nor FieldMeta / CELL_RELATIONAL_META_KEYS are re-exported from their packages' indexes.

A3.2 falsified: the read set is not the five named

Four more spec-declared keys are read on this grid's path and never copied — multiple, allowCreate, lookupPageSize, dependsOn — found by the derivation itself. Filed as objectui#7154, which unblocks when this lands.

⚖️ Ruling on your open question: option A, keep it filed. Your reasoning is right and it is the same standard this card just met: multiple flips the inline picker's cardinality and dependsOn has a closed prior card (objectui#2215) reporting the table picker bypassing the dependent filter. Folding four behaviour-changing keys in without the rendering proof displayField just got would ship exactly the class of change this card exists to fix. And the usual cost of deferring — that it gets forgotten — is already neutralised: the derivation carries them with verdict deferred, so the gate keeps them visible and they cannot lapse. That is what makes A safe rather than merely cautious.

⚖️ On objectui#7155: agreed, C then likely B, and it is not yours or mine to make. Labelled needs-user-decision. Your decisive argument is the ordering one — the consumer chains read the snake spelling first, so removing a leg changes which value wins, not merely which keys exist. That is a back-compat change on published behaviour, and the measurement available here bounds two repos while a host application outside them is invisible to it. Correct to file rather than act.

Landing will be verified by content with a live control once main advances.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(plugin-grid,plugin-dashboard): derive the relational copy set from its consumers, so a lookup cell honours the spec-declared displayField - #7156

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-6875-grid-relational-meta-derive
Sep 1, 2026
Merged

fix(plugin-grid,plugin-dashboard): derive the relational copy set from its consumers, so a lookup cell honours the spec-declared displayField#7156
os-warren merged 1 commit into
mainfrom
claude/issue-6875-grid-relational-meta-derive

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#6875

Verified on dd35800af (origin/main at branch point). Gate + suite numbers below are
from a5a6df951, the final commit.


The card's own first step, run first — and it does not shrink the card

#6875 named one thing triage had to measure before anything else: "Whether the object
schema a live getObjectSchema returns ever carries the camelCase spellings (the server
may normalise on the way out)."
If the producer normalises, widening the copy set fixes
nothing reachable.

It does not normalise, and the answer runs the other way — the camelCase spellings are
the only ones a live path can carry.

  1. @objectstack/spec 17.2.0's FieldSchema is strict, with 71 properties. Enumerated
    off FieldSchema.shape on the installed package, name/type/label as the positive
    control:

    DECLARED : displayField, descriptionField, lookupColumns, lookupFilters, reference
    absent : display_field, description_field, lookup_columns, lookup_filters,
    reference_to, reference_field, id_field, idField, titleFormat
    

    Strict means an absent key does not pass through — it is refused by name:

    safeParse({ name:'account', type:'lookup', reference_field:'name' })
    → success: false, code: 'unrecognized_keys', keys: ['reference_field']
    safeParse({ name:'account', type:'lookup', displayField:'name' })
    → success: true
    
  2. The producer repo emits no snake_case. Whole-repo sweep of objectstack, each zero
    against a control on the same instrument: display_field 3 files / displayField 68;
    description_field 1 / descriptionField 17; lookup_columns 2 / lookupColumns 13;
    lookup_filters 4 / lookupFilters 50; reference_field0. Every snake hit is
    prose — release notes, a CHANGELOG line, liveness-ledger notes describing what objectui
    reads. The platform's published authoring rule states the split outright
    (skills/objectstack-data/rules/naming.md): field KEYS snake_case, schema PROPERTIES
    camelCase, with lookup_filters: [] shown as ❌.

  3. Nothing renames anything on the way in.ObjectStackAdapter.getObjectSchema
    (@object-ui/data-objectstack) is the choke point every schema read passes through. Its
    only key rewrites are normalizeSchemaReferenceKeys — the referencereference_to
    pair, and nothing else — and applyFieldWidgetOverrides, which adds widget. Every
    other property arrives verbatim.

⇒ An author who declares displayField gets a def carrying displayField, and the grid
copied display_field instead. Item 2 of the card is not merely reachable; it is the only
reachable half.

The bug reaching a user

The card is labelled bug with no user report attached, so this is shown rather than
asserted. __tests__/lookupDisplayFieldSpelling-6875.test.tsx renders two columns over one
data source, one referenced record and one cell renderer, differing only in the spelling on
the field def. Referenced record: { id:'p1', name:'Wrong Name', project_code:'ACME-42' }.

columnfield defbeforeafter
code_snake (control)display_field: 'project_code'ACME-42ACME-42
code_cameldisplayField: 'project_code'Wrong NameACME-42

Measured on the unmodified tree: { acme: 1, wrongName: 1, rawId: 0 } — the spec-spelled
column rendered the referenced record's generic .name, because with no display pointer
getRecordDisplayName falls through to the .name/.title heuristic. That is the bug: a
lookup cell showing the wrong display value because the key never arrived. The control
column is what makes it a reading rather than "the fixture never reached the lookup path".

Derived, not restated

The ruling on this card was that the copy set must be derived from the consumers' read
set — a longer hand-kept list is the same defect with a later expiry date. It is also the
card's strongest internal evidence: lookupFilters (camel) was in the list while
displayField and descriptionField were not, though all three come off the same kind of
chain in the same files. Confirmed — that asymmetry is real, and it is the tell that
the list was assembled from defect reports.

  • packages/plugin-grid/src/relationalMetaKeys.ts — a table classifying every key the
    three consumers read off this bag, each with a verdict and a reason.
    RELATIONAL_META_KEYS is Object.entries(...) filtered by verdict. There is no
    hand-kept list left to drift.
  • __tests__/relationalMetaCopySet.derivation.test.ts — re-extracts the read set from the
    consumer sources on every run (LookupCellRenderer's cast reads, bounded to its own
    function body; LookupField's fieldMeta?.k; UserField's meta?.k) and requires the
    table to match exactly, in both directions. An unclassified spelling is red; an
    orphan is red.
  • The gate proves the verdicts against the installed spec rather than against this PR's
    prose: every no-producer key must be absent from FieldSchema.shape, every spec key
    present, and every legacy-alias key absent — so if a future spec version declares one,
    the gate says reclassify.

ObjectGrid.tsx loses 87 lines and gains 8: the literal, the helper and the retirement
docblock move to the new module, where the retirement history for reference_to_field
(#6711) and titleFormat (#6874) is preserved in full. Both are now enforced structurally
too — neither appears in the extracted read set, so re-adding either turns the gate red as
an orphan. Their behavioural pins are untouched and still green.

Which keys were added, and which were deliberately left out

Of the five the card named as never copied, three are added:
displayField, descriptionField, lookupColumns — spec-declared, read on this path.

Two are not, and that is the card shrinking where it should: reference_field and
lookup_columns are read on this path but FieldSchema declares neither, so no producer
can fill them. Copying them would write a member from the def on every column build that
nothing can ever set — precisely what #6711 (reference_to_field), #6625 (decimals) and
#6597 (referenceTo) retired, and what #6531 removed from getRecordDisplayName on the
same reasoning.

plugin-dashboard — the card's unmeasured mention, now measured

The card flagged CELL_RELATIONAL_META_KEYS as having the same gap and marked it explicitly
NOT measured. It does. Its docblock enumerated what LookupCellRenderer reads as three keys
because it was written from the FIRST leg of each chain; the renderer resolves
display_field || displayField || reference_field. displayField is added there too (same
spelling, same reason, same read-only path), reference_field stays out for the same
producer reason, FieldMeta gains the member, the docblock is corrected, and the #6694 pin
is extended in both directions. The four picker-only keys it deliberately does not copy are
untouched — that verdict does not move with this card.

Two of the four assumptions the dispatch asked me to falsify came back changed, both
recorded above: the card's line numbers had moved (#7141 landed in
packages/fields/src/widgets/), and the read set is larger than the five keys named.

Out of scope, filed not fixed

Verification

Run from the repo root with root-relative paths (#3378).

whatresult
vitest run packages/plugin-grid/103 files / 940 tests passed
vitest run packages/plugin-dashboard/85 files / 795 tests passed
type-check (both packages)exit 0 — Scope: 2 of 47, both echoed their tsc --noEmit plus tsc -p tsconfig.test.json; --listFiles confirms all 3 new files are inside the test program
lint (both packages, plain eslint .)exit 0 — 0 errors; the only findings on new files are no-explicit-any on the string-to-any Record signature carried over unchanged
changeset:check✅ All workspace packages are in the changeset fixed group. · ✅ privatePackages declared: version=true, tag=false. · ✅ No changeset declares a 'major' bump.
check-changeset-presence✅ 6 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-overwrite✅ No pre-existing changeset was modified or deleted.
check:control-bytes✅ OK (scanned 5926 tracked text file(s))
check:phantom-deps✅ Every in-scope import is declared by the package that publishes it. (@objectstack/spec was already a plugin-grid dep)
check:self-import · check:spec-symbols · check:vi-mock-specifiers · check:vi-mock-inherit · check:side-effects-array · check:element-data-source-declaration · check:entry-guard · check:esm-specifiersall exit 0

Two readings are absent, not green:

  • check:sdui-registration-pinsexit 2, PREREQUISITE NOT MET: "No console build to
    weigh at apps/console/dist/assets."
    Not run here (a full console build on a shared box,
    for a gate this diff has no exposure to: the new module declares types, a frozen array and
    a pure function, with zero load-time registration, and check:side-effects-array — the
    instrument that judges load-time registration — walked the tree green). CI builds console
    and runs it.
  • check:readme-exportsexit 1, entirely dist/index.d.ts is not on disk — run pnpm build first across 22 packages. After building plugin-grid and
    plugin-dashboard, both drop to 0 findings; the remaining 240 belong to 20 packages
    this diff does not touch and are the unbuilt closure, which CI resolves by building first.

Ablation — the gate can fail, in the way that matters

Predicted red before running; mutation proven on disk by marker count and blob hash,
restore proven by state (git diff HEAD, git diff --cached, git status --short all
empty). No rebuild needed: vitest.config.mts aliases @object-ui/fields to
packages/fields/src, so these runs read source — which leg 2 also demonstrates.

  1. Delete the displayField entry from the table. Marker 1 → 0, blob
    b9b046c22c7724dc. Result: Tests 3 failed | 7 passed — the gate's unclassified
    check, its copy-set exactness check, and the rendering test all go red together.
  2. A new spelling enters a consumer chainfieldMeta?.zzNewSpelling added to
    LookupField.tsx. Marker 0 → 1, blob 8e75d7ce920b18c1. Result:
    Tests 1 failed | 7 passed, message expected [ 'zzNewSpelling' ] to deeply equal [].

Leg 2 is the one that matters: it is the drift that produced this bug, and the gate now
catches it from the consumer side without anyone editing a list.

Clause ② — not engaged

Nothing published moves. RELATIONAL_META_KEYS, applyRelationalMeta and the new
relationalMetaKeys.ts are internal to plugin-grid and are not re-exported from
src/index.tsx (named exports only, verified). FieldMeta and CELL_RELATIONAL_META_KEYS
live in plugin-dashboard/src/recordFields.tsx, which src/index.tsx does not re-export —
so the FieldMeta member is internal too. No parse accept/reject behaviour changes; the
strict FieldSchema is read, never modified.

The producer-side alternative was considered and rejected on the merits, not on budget.
normalizeFieldReferenceKeys in @object-ui/core is the repo's blessed choke-point
normaliser and extending it would have been a one-line fix — but it would run backwards
here. For reference it stamps the objectui-side spelling from the served one; for these
keys the spec spelling is the served one, so stamping display_field from displayField
would manufacture a twin the contract refuses. That is AGENTS.md #0.1's anti-pattern in its
worst direction — the renderer inventing a dialect the spec rejects — and it is a public
export's behaviour change on top. The renderer-side fix forwards what the def already
carries and invents no spelling; the dialect tolerance stays where it already was, in
@object-ui/fields, unchanged, and #7155 records the case for retiring it.

Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM

Generated by Claude Code

…m its consumers, so a lookup cell honours the spec-declared `displayField`
The copy set ObjectGrid writes onto each relational column's fieldMeta was
hand-kept and had become a strict subset of what its own lookup cell renderer
and inline picker read off that bag. displayField, descriptionField and
lookupColumns were read on this grid's path and never copied — and they are the
spellings a spec-compliant producer actually emits, since @objectstack/spec
17.2.0's strict FieldSchema declares them and none of the snake_case twins.
Derived instead of restated: relationalMetaKeys.ts classifies every key the
consumers read, and a gate re-extracts that read set from the consumer sources
so the two cannot drift apart again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3153.5 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-CqdTMAwZ.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)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
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)512.32KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)65.94KB18.64KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)205.53KB55.50KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.21KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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.72KB2.24KB
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

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 07:18
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed and armed

All 30 check runs in one call at perPage=100: 27 success, 3 skipped, 0 failed, 0 in_progress. Undrafted, auto-merge SQUASH enabled at a5a6df951.

The gating measurement came back opposite to the hypothesis — and the card got bigger

I made the card's own first step a ruling: does a live getObjectSchema ever emit the camelCase spellings? The card framed a "no" as shrinking the work. The answer inverts it:

  • @objectstack/spec@17.2.0's FieldSchema is a strict object (71 props — the card said 64) declaring displayField / descriptionField / lookupColumns / lookupFilters / reference and none of the snake twins; reference_field parses to unrecognized_keys.
  • objectstack emits zero snake_case (display_field 3 files against a displayField control of 68; reference_field 0).
  • ObjectStackAdapter.getObjectSchema — the choke point — rewrites only the reference/reference_to pair.

camelCase is the only dialect a live path can carry, and it is exactly the half the grid never copied. The defect is more reachable than the card supposed, not less.

"Derive, do not restate" — delivered as a gate, not a longer list

I ruled that a hand-maintained list is not the fix, because a hand-maintained list is what drifted. The copy set now lives in relationalMetaKeys.ts as a classification table, with a gate that re-extracts the read set from the consumer sources and fails on any unclassified spelling or orphan.

Ablation leg 2 is the load-bearing one: adding fieldMeta?.zzNewSpelling to LookupField.tsx turns the gate red with expected [ 'zzNewSpelling' ] to deeply equal []. That is the exact drift that produced this bug, now mechanically caught. Predicted before running; mutation proven by marker count and blob hash; restore proven by state.

Three of the five keys added, two deliberately notFieldSchema declares neither reference_field nor lookup_columns, so no producer can fill them, and the gate proves that absence against the installed spec rather than asserting it. A measured narrowing with its evidence attached.

The user-visible bug was SHOWN, not asserted

I said "the key isn't copied" is a code fact and "the cell shows the wrong thing" is the bug. You built two columns over one data source and one referenced record, differing only in spelling: on the unmodified tree the control column resolved ACME-42 and the spec-spelled column resolved Wrong Name1 failed | 1 passed (2), with an instrumented dump. After the fix both resolve correctly and Wrong Name is absent.

⚖️ The producer-side direction was rejected on the merits

This is the part I most wanted to see. I fenced against picking the cheap direction and presenting it as the design conclusion. You did not take the internal fix because it was affordable — you rejected the producer fix because extending normalizeFieldReferenceKeys would stamp display_fieldfromdisplayField, manufacturing the dialect the contract refuses. That is AGENTS.md §0.1's anti-pattern running backwards, and it is a substantive reason, not a budget one.

Clause ② verified not engaged rather than assumed — neither RELATIONAL_META_KEYS / applyRelationalMeta / relationalMetaKeys.ts nor FieldMeta / CELL_RELATIONAL_META_KEYS are re-exported from their packages' indexes.

A3.2 falsified: the read set is not the five named

Four more spec-declared keys are read on this grid's path and never copied — multiple, allowCreate, lookupPageSize, dependsOn — found by the derivation itself. Filed as objectui#7154, which unblocks when this lands.

⚖️ Ruling on your open question: option A, keep it filed. Your reasoning is right and it is the same standard this card just met: multiple flips the inline picker's cardinality and dependsOn has a closed prior card (objectui#2215) reporting the table picker bypassing the dependent filter. Folding four behaviour-changing keys in without the rendering proof displayField just got would ship exactly the class of change this card exists to fix. And the usual cost of deferring — that it gets forgotten — is already neutralised: the derivation carries them with verdict deferred, so the gate keeps them visible and they cannot lapse. That is what makes A safe rather than merely cautious.

⚖️ On objectui#7155: agreed, C then likely B, and it is not yours or mine to make. Labelled needs-user-decision. Your decisive argument is the ordering one — the consumer chains read the snake spelling first, so removing a leg changes which value wins, not merely which keys exist. That is a back-compat change on published behaviour, and the measurement available here bounds two repos while a host application outside them is invisible to it. Correct to file rather than act.

Landing will be verified by content with a live control once main advances.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-grid,plugin-dashboard): derive the relational copy set from its consumers, so a lookup cell honours the spec-declared displayField - #7156

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-6875-grid-relational-meta-derive
Sep 1, 2026
Merged

fix(plugin-grid,plugin-dashboard): derive the relational copy set from its consumers, so a lookup cell honours the spec-declared displayField#7156
os-warren merged 1 commit into
mainfrom
claude/issue-6875-grid-relational-meta-derive

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#6875

Verified on dd35800af (origin/main at branch point). Gate + suite numbers below are
from a5a6df951, the final commit.


The card's own first step, run first — and it does not shrink the card

#6875 named one thing triage had to measure before anything else: "Whether the object
schema a live getObjectSchema returns ever carries the camelCase spellings (the server
may normalise on the way out)."
If the producer normalises, widening the copy set fixes
nothing reachable.

It does not normalise, and the answer runs the other way — the camelCase spellings are
the only ones a live path can carry.

  1. @objectstack/spec 17.2.0's FieldSchema is strict, with 71 properties. Enumerated
    off FieldSchema.shape on the installed package, name/type/label as the positive
    control:

    DECLARED : displayField, descriptionField, lookupColumns, lookupFilters, reference
    absent : display_field, description_field, lookup_columns, lookup_filters,
    reference_to, reference_field, id_field, idField, titleFormat
    

    Strict means an absent key does not pass through — it is refused by name:

    safeParse({ name:'account', type:'lookup', reference_field:'name' })
    → success: false, code: 'unrecognized_keys', keys: ['reference_field']
    safeParse({ name:'account', type:'lookup', displayField:'name' })
    → success: true
    
  2. The producer repo emits no snake_case. Whole-repo sweep of objectstack, each zero
    against a control on the same instrument: display_field 3 files / displayField 68;
    description_field 1 / descriptionField 17; lookup_columns 2 / lookupColumns 13;
    lookup_filters 4 / lookupFilters 50; reference_field0. Every snake hit is
    prose — release notes, a CHANGELOG line, liveness-ledger notes describing what objectui
    reads. The platform's published authoring rule states the split outright
    (skills/objectstack-data/rules/naming.md): field KEYS snake_case, schema PROPERTIES
    camelCase, with lookup_filters: [] shown as ❌.

  3. Nothing renames anything on the way in.ObjectStackAdapter.getObjectSchema
    (@object-ui/data-objectstack) is the choke point every schema read passes through. Its
    only key rewrites are normalizeSchemaReferenceKeys — the referencereference_to
    pair, and nothing else — and applyFieldWidgetOverrides, which adds widget. Every
    other property arrives verbatim.

⇒ An author who declares displayField gets a def carrying displayField, and the grid
copied display_field instead. Item 2 of the card is not merely reachable; it is the only
reachable half.

The bug reaching a user

The card is labelled bug with no user report attached, so this is shown rather than
asserted. __tests__/lookupDisplayFieldSpelling-6875.test.tsx renders two columns over one
data source, one referenced record and one cell renderer, differing only in the spelling on
the field def. Referenced record: { id:'p1', name:'Wrong Name', project_code:'ACME-42' }.

columnfield defbeforeafter
code_snake (control)display_field: 'project_code'ACME-42ACME-42
code_cameldisplayField: 'project_code'Wrong NameACME-42

Measured on the unmodified tree: { acme: 1, wrongName: 1, rawId: 0 } — the spec-spelled
column rendered the referenced record's generic .name, because with no display pointer
getRecordDisplayName falls through to the .name/.title heuristic. That is the bug: a
lookup cell showing the wrong display value because the key never arrived. The control
column is what makes it a reading rather than "the fixture never reached the lookup path".

Derived, not restated

The ruling on this card was that the copy set must be derived from the consumers' read
set — a longer hand-kept list is the same defect with a later expiry date. It is also the
card's strongest internal evidence: lookupFilters (camel) was in the list while
displayField and descriptionField were not, though all three come off the same kind of
chain in the same files. Confirmed — that asymmetry is real, and it is the tell that
the list was assembled from defect reports.

  • packages/plugin-grid/src/relationalMetaKeys.ts — a table classifying every key the
    three consumers read off this bag, each with a verdict and a reason.
    RELATIONAL_META_KEYS is Object.entries(...) filtered by verdict. There is no
    hand-kept list left to drift.
  • __tests__/relationalMetaCopySet.derivation.test.ts — re-extracts the read set from the
    consumer sources on every run (LookupCellRenderer's cast reads, bounded to its own
    function body; LookupField's fieldMeta?.k; UserField's meta?.k) and requires the
    table to match exactly, in both directions. An unclassified spelling is red; an
    orphan is red.
  • The gate proves the verdicts against the installed spec rather than against this PR's
    prose: every no-producer key must be absent from FieldSchema.shape, every spec key
    present, and every legacy-alias key absent — so if a future spec version declares one,
    the gate says reclassify.

ObjectGrid.tsx loses 87 lines and gains 8: the literal, the helper and the retirement
docblock move to the new module, where the retirement history for reference_to_field
(#6711) and titleFormat (#6874) is preserved in full. Both are now enforced structurally
too — neither appears in the extracted read set, so re-adding either turns the gate red as
an orphan. Their behavioural pins are untouched and still green.

Which keys were added, and which were deliberately left out

Of the five the card named as never copied, three are added:
displayField, descriptionField, lookupColumns — spec-declared, read on this path.

Two are not, and that is the card shrinking where it should: reference_field and
lookup_columns are read on this path but FieldSchema declares neither, so no producer
can fill them. Copying them would write a member from the def on every column build that
nothing can ever set — precisely what #6711 (reference_to_field), #6625 (decimals) and
#6597 (referenceTo) retired, and what #6531 removed from getRecordDisplayName on the
same reasoning.

plugin-dashboard — the card's unmeasured mention, now measured

The card flagged CELL_RELATIONAL_META_KEYS as having the same gap and marked it explicitly
NOT measured. It does. Its docblock enumerated what LookupCellRenderer reads as three keys
because it was written from the FIRST leg of each chain; the renderer resolves
display_field || displayField || reference_field. displayField is added there too (same
spelling, same reason, same read-only path), reference_field stays out for the same
producer reason, FieldMeta gains the member, the docblock is corrected, and the #6694 pin
is extended in both directions. The four picker-only keys it deliberately does not copy are
untouched — that verdict does not move with this card.

Two of the four assumptions the dispatch asked me to falsify came back changed, both
recorded above: the card's line numbers had moved (#7141 landed in
packages/fields/src/widgets/), and the read set is larger than the five keys named.

Out of scope, filed not fixed

Verification

Run from the repo root with root-relative paths (#3378).

whatresult
vitest run packages/plugin-grid/103 files / 940 tests passed
vitest run packages/plugin-dashboard/85 files / 795 tests passed
type-check (both packages)exit 0 — Scope: 2 of 47, both echoed their tsc --noEmit plus tsc -p tsconfig.test.json; --listFiles confirms all 3 new files are inside the test program
lint (both packages, plain eslint .)exit 0 — 0 errors; the only findings on new files are no-explicit-any on the string-to-any Record signature carried over unchanged
changeset:check✅ All workspace packages are in the changeset fixed group. · ✅ privatePackages declared: version=true, tag=false. · ✅ No changeset declares a 'major' bump.
check-changeset-presence✅ 6 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-overwrite✅ No pre-existing changeset was modified or deleted.
check:control-bytes✅ OK (scanned 5926 tracked text file(s))
check:phantom-deps✅ Every in-scope import is declared by the package that publishes it. (@objectstack/spec was already a plugin-grid dep)
check:self-import · check:spec-symbols · check:vi-mock-specifiers · check:vi-mock-inherit · check:side-effects-array · check:element-data-source-declaration · check:entry-guard · check:esm-specifiersall exit 0

Two readings are absent, not green:

  • check:sdui-registration-pinsexit 2, PREREQUISITE NOT MET: "No console build to
    weigh at apps/console/dist/assets."
    Not run here (a full console build on a shared box,
    for a gate this diff has no exposure to: the new module declares types, a frozen array and
    a pure function, with zero load-time registration, and check:side-effects-array — the
    instrument that judges load-time registration — walked the tree green). CI builds console
    and runs it.
  • check:readme-exportsexit 1, entirely dist/index.d.ts is not on disk — run pnpm build first across 22 packages. After building plugin-grid and
    plugin-dashboard, both drop to 0 findings; the remaining 240 belong to 20 packages
    this diff does not touch and are the unbuilt closure, which CI resolves by building first.

Ablation — the gate can fail, in the way that matters

Predicted red before running; mutation proven on disk by marker count and blob hash,
restore proven by state (git diff HEAD, git diff --cached, git status --short all
empty). No rebuild needed: vitest.config.mts aliases @object-ui/fields to
packages/fields/src, so these runs read source — which leg 2 also demonstrates.

  1. Delete the displayField entry from the table. Marker 1 → 0, blob
    b9b046c22c7724dc. Result: Tests 3 failed | 7 passed — the gate's unclassified
    check, its copy-set exactness check, and the rendering test all go red together.
  2. A new spelling enters a consumer chainfieldMeta?.zzNewSpelling added to
    LookupField.tsx. Marker 0 → 1, blob 8e75d7ce920b18c1. Result:
    Tests 1 failed | 7 passed, message expected [ 'zzNewSpelling' ] to deeply equal [].

Leg 2 is the one that matters: it is the drift that produced this bug, and the gate now
catches it from the consumer side without anyone editing a list.

Clause ② — not engaged

Nothing published moves. RELATIONAL_META_KEYS, applyRelationalMeta and the new
relationalMetaKeys.ts are internal to plugin-grid and are not re-exported from
src/index.tsx (named exports only, verified). FieldMeta and CELL_RELATIONAL_META_KEYS
live in plugin-dashboard/src/recordFields.tsx, which src/index.tsx does not re-export —
so the FieldMeta member is internal too. No parse accept/reject behaviour changes; the
strict FieldSchema is read, never modified.

The producer-side alternative was considered and rejected on the merits, not on budget.
normalizeFieldReferenceKeys in @object-ui/core is the repo's blessed choke-point
normaliser and extending it would have been a one-line fix — but it would run backwards
here. For reference it stamps the objectui-side spelling from the served one; for these
keys the spec spelling is the served one, so stamping display_field from displayField
would manufacture a twin the contract refuses. That is AGENTS.md #0.1's anti-pattern in its
worst direction — the renderer inventing a dialect the spec rejects — and it is a public
export's behaviour change on top. The renderer-side fix forwards what the def already
carries and invents no spelling; the dialect tolerance stays where it already was, in
@object-ui/fields, unchanged, and #7155 records the case for retiring it.

Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM

Generated by Claude Code

…m its consumers, so a lookup cell honours the spec-declared `displayField`
The copy set ObjectGrid writes onto each relational column's fieldMeta was
hand-kept and had become a strict subset of what its own lookup cell renderer
and inline picker read off that bag. displayField, descriptionField and
lookupColumns were read on this grid's path and never copied — and they are the
spellings a spec-compliant producer actually emits, since @objectstack/spec
17.2.0's strict FieldSchema declares them and none of the snake_case twins.
Derived instead of restated: relationalMetaKeys.ts classifies every key the
consumers read, and a gate re-extracts that read set from the consumer sources
so the two cannot drift apart again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3153.5 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-CqdTMAwZ.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)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
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)512.32KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)65.94KB18.64KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)205.53KB55.50KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.21KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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.72KB2.24KB
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

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 07:18
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed and armed

All 30 check runs in one call at perPage=100: 27 success, 3 skipped, 0 failed, 0 in_progress. Undrafted, auto-merge SQUASH enabled at a5a6df951.

The gating measurement came back opposite to the hypothesis — and the card got bigger

I made the card's own first step a ruling: does a live getObjectSchema ever emit the camelCase spellings? The card framed a "no" as shrinking the work. The answer inverts it:

  • @objectstack/spec@17.2.0's FieldSchema is a strict object (71 props — the card said 64) declaring displayField / descriptionField / lookupColumns / lookupFilters / reference and none of the snake twins; reference_field parses to unrecognized_keys.
  • objectstack emits zero snake_case (display_field 3 files against a displayField control of 68; reference_field 0).
  • ObjectStackAdapter.getObjectSchema — the choke point — rewrites only the reference/reference_to pair.

camelCase is the only dialect a live path can carry, and it is exactly the half the grid never copied. The defect is more reachable than the card supposed, not less.

"Derive, do not restate" — delivered as a gate, not a longer list

I ruled that a hand-maintained list is not the fix, because a hand-maintained list is what drifted. The copy set now lives in relationalMetaKeys.ts as a classification table, with a gate that re-extracts the read set from the consumer sources and fails on any unclassified spelling or orphan.

Ablation leg 2 is the load-bearing one: adding fieldMeta?.zzNewSpelling to LookupField.tsx turns the gate red with expected [ 'zzNewSpelling' ] to deeply equal []. That is the exact drift that produced this bug, now mechanically caught. Predicted before running; mutation proven by marker count and blob hash; restore proven by state.

Three of the five keys added, two deliberately notFieldSchema declares neither reference_field nor lookup_columns, so no producer can fill them, and the gate proves that absence against the installed spec rather than asserting it. A measured narrowing with its evidence attached.

The user-visible bug was SHOWN, not asserted

I said "the key isn't copied" is a code fact and "the cell shows the wrong thing" is the bug. You built two columns over one data source and one referenced record, differing only in spelling: on the unmodified tree the control column resolved ACME-42 and the spec-spelled column resolved Wrong Name1 failed | 1 passed (2), with an instrumented dump. After the fix both resolve correctly and Wrong Name is absent.

⚖️ The producer-side direction was rejected on the merits

This is the part I most wanted to see. I fenced against picking the cheap direction and presenting it as the design conclusion. You did not take the internal fix because it was affordable — you rejected the producer fix because extending normalizeFieldReferenceKeys would stamp display_fieldfromdisplayField, manufacturing the dialect the contract refuses. That is AGENTS.md §0.1's anti-pattern running backwards, and it is a substantive reason, not a budget one.

Clause ② verified not engaged rather than assumed — neither RELATIONAL_META_KEYS / applyRelationalMeta / relationalMetaKeys.ts nor FieldMeta / CELL_RELATIONAL_META_KEYS are re-exported from their packages' indexes.

A3.2 falsified: the read set is not the five named

Four more spec-declared keys are read on this grid's path and never copied — multiple, allowCreate, lookupPageSize, dependsOn — found by the derivation itself. Filed as objectui#7154, which unblocks when this lands.

⚖️ Ruling on your open question: option A, keep it filed. Your reasoning is right and it is the same standard this card just met: multiple flips the inline picker's cardinality and dependsOn has a closed prior card (objectui#2215) reporting the table picker bypassing the dependent filter. Folding four behaviour-changing keys in without the rendering proof displayField just got would ship exactly the class of change this card exists to fix. And the usual cost of deferring — that it gets forgotten — is already neutralised: the derivation carries them with verdict deferred, so the gate keeps them visible and they cannot lapse. That is what makes A safe rather than merely cautious.

⚖️ On objectui#7155: agreed, C then likely B, and it is not yours or mine to make. Labelled needs-user-decision. Your decisive argument is the ordering one — the consumer chains read the snake spelling first, so removing a leg changes which value wins, not merely which keys exist. That is a back-compat change on published behaviour, and the measurement available here bounds two repos while a host application outside them is invisible to it. Correct to file rather than act.

Landing will be verified by content with a live control once main advances.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-grid,plugin-dashboard): derive the relational copy set from its consumers, so a lookup cell honours the spec-declared displayField - #7156

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-6875-grid-relational-meta-derive
Sep 1, 2026
Merged

fix(plugin-grid,plugin-dashboard): derive the relational copy set from its consumers, so a lookup cell honours the spec-declared displayField#7156
os-warren merged 1 commit into
mainfrom
claude/issue-6875-grid-relational-meta-derive

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#6875

Verified on dd35800af (origin/main at branch point). Gate + suite numbers below are
from a5a6df951, the final commit.


The card's own first step, run first — and it does not shrink the card

#6875 named one thing triage had to measure before anything else: "Whether the object
schema a live getObjectSchema returns ever carries the camelCase spellings (the server
may normalise on the way out)."
If the producer normalises, widening the copy set fixes
nothing reachable.

It does not normalise, and the answer runs the other way — the camelCase spellings are
the only ones a live path can carry.

  1. @objectstack/spec 17.2.0's FieldSchema is strict, with 71 properties. Enumerated
    off FieldSchema.shape on the installed package, name/type/label as the positive
    control:

    DECLARED : displayField, descriptionField, lookupColumns, lookupFilters, reference
    absent : display_field, description_field, lookup_columns, lookup_filters,
    reference_to, reference_field, id_field, idField, titleFormat
    

    Strict means an absent key does not pass through — it is refused by name:

    safeParse({ name:'account', type:'lookup', reference_field:'name' })
    → success: false, code: 'unrecognized_keys', keys: ['reference_field']
    safeParse({ name:'account', type:'lookup', displayField:'name' })
    → success: true
    
  2. The producer repo emits no snake_case. Whole-repo sweep of objectstack, each zero
    against a control on the same instrument: display_field 3 files / displayField 68;
    description_field 1 / descriptionField 17; lookup_columns 2 / lookupColumns 13;
    lookup_filters 4 / lookupFilters 50; reference_field0. Every snake hit is
    prose — release notes, a CHANGELOG line, liveness-ledger notes describing what objectui
    reads. The platform's published authoring rule states the split outright
    (skills/objectstack-data/rules/naming.md): field KEYS snake_case, schema PROPERTIES
    camelCase, with lookup_filters: [] shown as ❌.

  3. Nothing renames anything on the way in.ObjectStackAdapter.getObjectSchema
    (@object-ui/data-objectstack) is the choke point every schema read passes through. Its
    only key rewrites are normalizeSchemaReferenceKeys — the referencereference_to
    pair, and nothing else — and applyFieldWidgetOverrides, which adds widget. Every
    other property arrives verbatim.

⇒ An author who declares displayField gets a def carrying displayField, and the grid
copied display_field instead. Item 2 of the card is not merely reachable; it is the only
reachable half.

The bug reaching a user

The card is labelled bug with no user report attached, so this is shown rather than
asserted. __tests__/lookupDisplayFieldSpelling-6875.test.tsx renders two columns over one
data source, one referenced record and one cell renderer, differing only in the spelling on
the field def. Referenced record: { id:'p1', name:'Wrong Name', project_code:'ACME-42' }.

columnfield defbeforeafter
code_snake (control)display_field: 'project_code'ACME-42ACME-42
code_cameldisplayField: 'project_code'Wrong NameACME-42

Measured on the unmodified tree: { acme: 1, wrongName: 1, rawId: 0 } — the spec-spelled
column rendered the referenced record's generic .name, because with no display pointer
getRecordDisplayName falls through to the .name/.title heuristic. That is the bug: a
lookup cell showing the wrong display value because the key never arrived. The control
column is what makes it a reading rather than "the fixture never reached the lookup path".

Derived, not restated

The ruling on this card was that the copy set must be derived from the consumers' read
set — a longer hand-kept list is the same defect with a later expiry date. It is also the
card's strongest internal evidence: lookupFilters (camel) was in the list while
displayField and descriptionField were not, though all three come off the same kind of
chain in the same files. Confirmed — that asymmetry is real, and it is the tell that
the list was assembled from defect reports.

  • packages/plugin-grid/src/relationalMetaKeys.ts — a table classifying every key the
    three consumers read off this bag, each with a verdict and a reason.
    RELATIONAL_META_KEYS is Object.entries(...) filtered by verdict. There is no
    hand-kept list left to drift.
  • __tests__/relationalMetaCopySet.derivation.test.ts — re-extracts the read set from the
    consumer sources on every run (LookupCellRenderer's cast reads, bounded to its own
    function body; LookupField's fieldMeta?.k; UserField's meta?.k) and requires the
    table to match exactly, in both directions. An unclassified spelling is red; an
    orphan is red.
  • The gate proves the verdicts against the installed spec rather than against this PR's
    prose: every no-producer key must be absent from FieldSchema.shape, every spec key
    present, and every legacy-alias key absent — so if a future spec version declares one,
    the gate says reclassify.

ObjectGrid.tsx loses 87 lines and gains 8: the literal, the helper and the retirement
docblock move to the new module, where the retirement history for reference_to_field
(#6711) and titleFormat (#6874) is preserved in full. Both are now enforced structurally
too — neither appears in the extracted read set, so re-adding either turns the gate red as
an orphan. Their behavioural pins are untouched and still green.

Which keys were added, and which were deliberately left out

Of the five the card named as never copied, three are added:
displayField, descriptionField, lookupColumns — spec-declared, read on this path.

Two are not, and that is the card shrinking where it should: reference_field and
lookup_columns are read on this path but FieldSchema declares neither, so no producer
can fill them. Copying them would write a member from the def on every column build that
nothing can ever set — precisely what #6711 (reference_to_field), #6625 (decimals) and
#6597 (referenceTo) retired, and what #6531 removed from getRecordDisplayName on the
same reasoning.

plugin-dashboard — the card's unmeasured mention, now measured

The card flagged CELL_RELATIONAL_META_KEYS as having the same gap and marked it explicitly
NOT measured. It does. Its docblock enumerated what LookupCellRenderer reads as three keys
because it was written from the FIRST leg of each chain; the renderer resolves
display_field || displayField || reference_field. displayField is added there too (same
spelling, same reason, same read-only path), reference_field stays out for the same
producer reason, FieldMeta gains the member, the docblock is corrected, and the #6694 pin
is extended in both directions. The four picker-only keys it deliberately does not copy are
untouched — that verdict does not move with this card.

Two of the four assumptions the dispatch asked me to falsify came back changed, both
recorded above: the card's line numbers had moved (#7141 landed in
packages/fields/src/widgets/), and the read set is larger than the five keys named.

Out of scope, filed not fixed

Verification

Run from the repo root with root-relative paths (#3378).

whatresult
vitest run packages/plugin-grid/103 files / 940 tests passed
vitest run packages/plugin-dashboard/85 files / 795 tests passed
type-check (both packages)exit 0 — Scope: 2 of 47, both echoed their tsc --noEmit plus tsc -p tsconfig.test.json; --listFiles confirms all 3 new files are inside the test program
lint (both packages, plain eslint .)exit 0 — 0 errors; the only findings on new files are no-explicit-any on the string-to-any Record signature carried over unchanged
changeset:check✅ All workspace packages are in the changeset fixed group. · ✅ privatePackages declared: version=true, tag=false. · ✅ No changeset declares a 'major' bump.
check-changeset-presence✅ 6 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-overwrite✅ No pre-existing changeset was modified or deleted.
check:control-bytes✅ OK (scanned 5926 tracked text file(s))
check:phantom-deps✅ Every in-scope import is declared by the package that publishes it. (@objectstack/spec was already a plugin-grid dep)
check:self-import · check:spec-symbols · check:vi-mock-specifiers · check:vi-mock-inherit · check:side-effects-array · check:element-data-source-declaration · check:entry-guard · check:esm-specifiersall exit 0

Two readings are absent, not green:

  • check:sdui-registration-pinsexit 2, PREREQUISITE NOT MET: "No console build to
    weigh at apps/console/dist/assets."
    Not run here (a full console build on a shared box,
    for a gate this diff has no exposure to: the new module declares types, a frozen array and
    a pure function, with zero load-time registration, and check:side-effects-array — the
    instrument that judges load-time registration — walked the tree green). CI builds console
    and runs it.
  • check:readme-exportsexit 1, entirely dist/index.d.ts is not on disk — run pnpm build first across 22 packages. After building plugin-grid and
    plugin-dashboard, both drop to 0 findings; the remaining 240 belong to 20 packages
    this diff does not touch and are the unbuilt closure, which CI resolves by building first.

Ablation — the gate can fail, in the way that matters

Predicted red before running; mutation proven on disk by marker count and blob hash,
restore proven by state (git diff HEAD, git diff --cached, git status --short all
empty). No rebuild needed: vitest.config.mts aliases @object-ui/fields to
packages/fields/src, so these runs read source — which leg 2 also demonstrates.

  1. Delete the displayField entry from the table. Marker 1 → 0, blob
    b9b046c22c7724dc. Result: Tests 3 failed | 7 passed — the gate's unclassified
    check, its copy-set exactness check, and the rendering test all go red together.
  2. A new spelling enters a consumer chainfieldMeta?.zzNewSpelling added to
    LookupField.tsx. Marker 0 → 1, blob 8e75d7ce920b18c1. Result:
    Tests 1 failed | 7 passed, message expected [ 'zzNewSpelling' ] to deeply equal [].

Leg 2 is the one that matters: it is the drift that produced this bug, and the gate now
catches it from the consumer side without anyone editing a list.

Clause ② — not engaged

Nothing published moves. RELATIONAL_META_KEYS, applyRelationalMeta and the new
relationalMetaKeys.ts are internal to plugin-grid and are not re-exported from
src/index.tsx (named exports only, verified). FieldMeta and CELL_RELATIONAL_META_KEYS
live in plugin-dashboard/src/recordFields.tsx, which src/index.tsx does not re-export —
so the FieldMeta member is internal too. No parse accept/reject behaviour changes; the
strict FieldSchema is read, never modified.

The producer-side alternative was considered and rejected on the merits, not on budget.
normalizeFieldReferenceKeys in @object-ui/core is the repo's blessed choke-point
normaliser and extending it would have been a one-line fix — but it would run backwards
here. For reference it stamps the objectui-side spelling from the served one; for these
keys the spec spelling is the served one, so stamping display_field from displayField
would manufacture a twin the contract refuses. That is AGENTS.md #0.1's anti-pattern in its
worst direction — the renderer inventing a dialect the spec rejects — and it is a public
export's behaviour change on top. The renderer-side fix forwards what the def already
carries and invents no spelling; the dialect tolerance stays where it already was, in
@object-ui/fields, unchanged, and #7155 records the case for retiring it.

Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM

Generated by Claude Code

…m its consumers, so a lookup cell honours the spec-declared `displayField`
The copy set ObjectGrid writes onto each relational column's fieldMeta was
hand-kept and had become a strict subset of what its own lookup cell renderer
and inline picker read off that bag. displayField, descriptionField and
lookupColumns were read on this grid's path and never copied — and they are the
spellings a spec-compliant producer actually emits, since @objectstack/spec
17.2.0's strict FieldSchema declares them and none of the snake_case twins.
Derived instead of restated: relationalMetaKeys.ts classifies every key the
consumers read, and a gate re-extracts that read set from the consumer sources
so the two cannot drift apart again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3153.5 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-CqdTMAwZ.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)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
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)512.32KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)65.94KB18.64KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)205.53KB55.50KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.21KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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.72KB2.24KB
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

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 07:18
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed and armed

All 30 check runs in one call at perPage=100: 27 success, 3 skipped, 0 failed, 0 in_progress. Undrafted, auto-merge SQUASH enabled at a5a6df951.

The gating measurement came back opposite to the hypothesis — and the card got bigger

I made the card's own first step a ruling: does a live getObjectSchema ever emit the camelCase spellings? The card framed a "no" as shrinking the work. The answer inverts it:

  • @objectstack/spec@17.2.0's FieldSchema is a strict object (71 props — the card said 64) declaring displayField / descriptionField / lookupColumns / lookupFilters / reference and none of the snake twins; reference_field parses to unrecognized_keys.
  • objectstack emits zero snake_case (display_field 3 files against a displayField control of 68; reference_field 0).
  • ObjectStackAdapter.getObjectSchema — the choke point — rewrites only the reference/reference_to pair.

camelCase is the only dialect a live path can carry, and it is exactly the half the grid never copied. The defect is more reachable than the card supposed, not less.

"Derive, do not restate" — delivered as a gate, not a longer list

I ruled that a hand-maintained list is not the fix, because a hand-maintained list is what drifted. The copy set now lives in relationalMetaKeys.ts as a classification table, with a gate that re-extracts the read set from the consumer sources and fails on any unclassified spelling or orphan.

Ablation leg 2 is the load-bearing one: adding fieldMeta?.zzNewSpelling to LookupField.tsx turns the gate red with expected [ 'zzNewSpelling' ] to deeply equal []. That is the exact drift that produced this bug, now mechanically caught. Predicted before running; mutation proven by marker count and blob hash; restore proven by state.

Three of the five keys added, two deliberately notFieldSchema declares neither reference_field nor lookup_columns, so no producer can fill them, and the gate proves that absence against the installed spec rather than asserting it. A measured narrowing with its evidence attached.

The user-visible bug was SHOWN, not asserted

I said "the key isn't copied" is a code fact and "the cell shows the wrong thing" is the bug. You built two columns over one data source and one referenced record, differing only in spelling: on the unmodified tree the control column resolved ACME-42 and the spec-spelled column resolved Wrong Name1 failed | 1 passed (2), with an instrumented dump. After the fix both resolve correctly and Wrong Name is absent.

⚖️ The producer-side direction was rejected on the merits

This is the part I most wanted to see. I fenced against picking the cheap direction and presenting it as the design conclusion. You did not take the internal fix because it was affordable — you rejected the producer fix because extending normalizeFieldReferenceKeys would stamp display_fieldfromdisplayField, manufacturing the dialect the contract refuses. That is AGENTS.md §0.1's anti-pattern running backwards, and it is a substantive reason, not a budget one.

Clause ② verified not engaged rather than assumed — neither RELATIONAL_META_KEYS / applyRelationalMeta / relationalMetaKeys.ts nor FieldMeta / CELL_RELATIONAL_META_KEYS are re-exported from their packages' indexes.

A3.2 falsified: the read set is not the five named

Four more spec-declared keys are read on this grid's path and never copied — multiple, allowCreate, lookupPageSize, dependsOn — found by the derivation itself. Filed as objectui#7154, which unblocks when this lands.

⚖️ Ruling on your open question: option A, keep it filed. Your reasoning is right and it is the same standard this card just met: multiple flips the inline picker's cardinality and dependsOn has a closed prior card (objectui#2215) reporting the table picker bypassing the dependent filter. Folding four behaviour-changing keys in without the rendering proof displayField just got would ship exactly the class of change this card exists to fix. And the usual cost of deferring — that it gets forgotten — is already neutralised: the derivation carries them with verdict deferred, so the gate keeps them visible and they cannot lapse. That is what makes A safe rather than merely cautious.

⚖️ On objectui#7155: agreed, C then likely B, and it is not yours or mine to make. Labelled needs-user-decision. Your decisive argument is the ordering one — the consumer chains read the snake spelling first, so removing a leg changes which value wins, not merely which keys exist. That is a back-compat change on published behaviour, and the measurement available here bounds two repos while a host application outside them is invisible to it. Correct to file rather than act.

Landing will be verified by content with a live control once main advances.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(plugin-grid,plugin-dashboard): derive the relational copy set from its consumers, so a lookup cell honours the spec-declared displayField - #7156

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-6875-grid-relational-meta-derive
Sep 1, 2026
Merged

fix(plugin-grid,plugin-dashboard): derive the relational copy set from its consumers, so a lookup cell honours the spec-declared displayField#7156
os-warren merged 1 commit into
mainfrom
claude/issue-6875-grid-relational-meta-derive

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#6875

Verified on dd35800af (origin/main at branch point). Gate + suite numbers below are
from a5a6df951, the final commit.


The card's own first step, run first — and it does not shrink the card

#6875 named one thing triage had to measure before anything else: "Whether the object
schema a live getObjectSchema returns ever carries the camelCase spellings (the server
may normalise on the way out)."
If the producer normalises, widening the copy set fixes
nothing reachable.

It does not normalise, and the answer runs the other way — the camelCase spellings are
the only ones a live path can carry.

  1. @objectstack/spec 17.2.0's FieldSchema is strict, with 71 properties. Enumerated
    off FieldSchema.shape on the installed package, name/type/label as the positive
    control:

    DECLARED : displayField, descriptionField, lookupColumns, lookupFilters, reference
    absent : display_field, description_field, lookup_columns, lookup_filters,
    reference_to, reference_field, id_field, idField, titleFormat
    

    Strict means an absent key does not pass through — it is refused by name:

    safeParse({ name:'account', type:'lookup', reference_field:'name' })
    → success: false, code: 'unrecognized_keys', keys: ['reference_field']
    safeParse({ name:'account', type:'lookup', displayField:'name' })
    → success: true
    
  2. The producer repo emits no snake_case. Whole-repo sweep of objectstack, each zero
    against a control on the same instrument: display_field 3 files / displayField 68;
    description_field 1 / descriptionField 17; lookup_columns 2 / lookupColumns 13;
    lookup_filters 4 / lookupFilters 50; reference_field0. Every snake hit is
    prose — release notes, a CHANGELOG line, liveness-ledger notes describing what objectui
    reads. The platform's published authoring rule states the split outright
    (skills/objectstack-data/rules/naming.md): field KEYS snake_case, schema PROPERTIES
    camelCase, with lookup_filters: [] shown as ❌.

  3. Nothing renames anything on the way in.ObjectStackAdapter.getObjectSchema
    (@object-ui/data-objectstack) is the choke point every schema read passes through. Its
    only key rewrites are normalizeSchemaReferenceKeys — the referencereference_to
    pair, and nothing else — and applyFieldWidgetOverrides, which adds widget. Every
    other property arrives verbatim.

⇒ An author who declares displayField gets a def carrying displayField, and the grid
copied display_field instead. Item 2 of the card is not merely reachable; it is the only
reachable half.

The bug reaching a user

The card is labelled bug with no user report attached, so this is shown rather than
asserted. __tests__/lookupDisplayFieldSpelling-6875.test.tsx renders two columns over one
data source, one referenced record and one cell renderer, differing only in the spelling on
the field def. Referenced record: { id:'p1', name:'Wrong Name', project_code:'ACME-42' }.

columnfield defbeforeafter
code_snake (control)display_field: 'project_code'ACME-42ACME-42
code_cameldisplayField: 'project_code'Wrong NameACME-42

Measured on the unmodified tree: { acme: 1, wrongName: 1, rawId: 0 } — the spec-spelled
column rendered the referenced record's generic .name, because with no display pointer
getRecordDisplayName falls through to the .name/.title heuristic. That is the bug: a
lookup cell showing the wrong display value because the key never arrived. The control
column is what makes it a reading rather than "the fixture never reached the lookup path".

Derived, not restated

The ruling on this card was that the copy set must be derived from the consumers' read
set — a longer hand-kept list is the same defect with a later expiry date. It is also the
card's strongest internal evidence: lookupFilters (camel) was in the list while
displayField and descriptionField were not, though all three come off the same kind of
chain in the same files. Confirmed — that asymmetry is real, and it is the tell that
the list was assembled from defect reports.

  • packages/plugin-grid/src/relationalMetaKeys.ts — a table classifying every key the
    three consumers read off this bag, each with a verdict and a reason.
    RELATIONAL_META_KEYS is Object.entries(...) filtered by verdict. There is no
    hand-kept list left to drift.
  • __tests__/relationalMetaCopySet.derivation.test.ts — re-extracts the read set from the
    consumer sources on every run (LookupCellRenderer's cast reads, bounded to its own
    function body; LookupField's fieldMeta?.k; UserField's meta?.k) and requires the
    table to match exactly, in both directions. An unclassified spelling is red; an
    orphan is red.
  • The gate proves the verdicts against the installed spec rather than against this PR's
    prose: every no-producer key must be absent from FieldSchema.shape, every spec key
    present, and every legacy-alias key absent — so if a future spec version declares one,
    the gate says reclassify.

ObjectGrid.tsx loses 87 lines and gains 8: the literal, the helper and the retirement
docblock move to the new module, where the retirement history for reference_to_field
(#6711) and titleFormat (#6874) is preserved in full. Both are now enforced structurally
too — neither appears in the extracted read set, so re-adding either turns the gate red as
an orphan. Their behavioural pins are untouched and still green.

Which keys were added, and which were deliberately left out

Of the five the card named as never copied, three are added:
displayField, descriptionField, lookupColumns — spec-declared, read on this path.

Two are not, and that is the card shrinking where it should: reference_field and
lookup_columns are read on this path but FieldSchema declares neither, so no producer
can fill them. Copying them would write a member from the def on every column build that
nothing can ever set — precisely what #6711 (reference_to_field), #6625 (decimals) and
#6597 (referenceTo) retired, and what #6531 removed from getRecordDisplayName on the
same reasoning.

plugin-dashboard — the card's unmeasured mention, now measured

The card flagged CELL_RELATIONAL_META_KEYS as having the same gap and marked it explicitly
NOT measured. It does. Its docblock enumerated what LookupCellRenderer reads as three keys
because it was written from the FIRST leg of each chain; the renderer resolves
display_field || displayField || reference_field. displayField is added there too (same
spelling, same reason, same read-only path), reference_field stays out for the same
producer reason, FieldMeta gains the member, the docblock is corrected, and the #6694 pin
is extended in both directions. The four picker-only keys it deliberately does not copy are
untouched — that verdict does not move with this card.

Two of the four assumptions the dispatch asked me to falsify came back changed, both
recorded above: the card's line numbers had moved (#7141 landed in
packages/fields/src/widgets/), and the read set is larger than the five keys named.

Out of scope, filed not fixed

Verification

Run from the repo root with root-relative paths (#3378).

whatresult
vitest run packages/plugin-grid/103 files / 940 tests passed
vitest run packages/plugin-dashboard/85 files / 795 tests passed
type-check (both packages)exit 0 — Scope: 2 of 47, both echoed their tsc --noEmit plus tsc -p tsconfig.test.json; --listFiles confirms all 3 new files are inside the test program
lint (both packages, plain eslint .)exit 0 — 0 errors; the only findings on new files are no-explicit-any on the string-to-any Record signature carried over unchanged
changeset:check✅ All workspace packages are in the changeset fixed group. · ✅ privatePackages declared: version=true, tag=false. · ✅ No changeset declares a 'major' bump.
check-changeset-presence✅ 6 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-overwrite✅ No pre-existing changeset was modified or deleted.
check:control-bytes✅ OK (scanned 5926 tracked text file(s))
check:phantom-deps✅ Every in-scope import is declared by the package that publishes it. (@objectstack/spec was already a plugin-grid dep)
check:self-import · check:spec-symbols · check:vi-mock-specifiers · check:vi-mock-inherit · check:side-effects-array · check:element-data-source-declaration · check:entry-guard · check:esm-specifiersall exit 0

Two readings are absent, not green:

  • check:sdui-registration-pinsexit 2, PREREQUISITE NOT MET: "No console build to
    weigh at apps/console/dist/assets."
    Not run here (a full console build on a shared box,
    for a gate this diff has no exposure to: the new module declares types, a frozen array and
    a pure function, with zero load-time registration, and check:side-effects-array — the
    instrument that judges load-time registration — walked the tree green). CI builds console
    and runs it.
  • check:readme-exportsexit 1, entirely dist/index.d.ts is not on disk — run pnpm build first across 22 packages. After building plugin-grid and
    plugin-dashboard, both drop to 0 findings; the remaining 240 belong to 20 packages
    this diff does not touch and are the unbuilt closure, which CI resolves by building first.

Ablation — the gate can fail, in the way that matters

Predicted red before running; mutation proven on disk by marker count and blob hash,
restore proven by state (git diff HEAD, git diff --cached, git status --short all
empty). No rebuild needed: vitest.config.mts aliases @object-ui/fields to
packages/fields/src, so these runs read source — which leg 2 also demonstrates.

  1. Delete the displayField entry from the table. Marker 1 → 0, blob
    b9b046c22c7724dc. Result: Tests 3 failed | 7 passed — the gate's unclassified
    check, its copy-set exactness check, and the rendering test all go red together.
  2. A new spelling enters a consumer chainfieldMeta?.zzNewSpelling added to
    LookupField.tsx. Marker 0 → 1, blob 8e75d7ce920b18c1. Result:
    Tests 1 failed | 7 passed, message expected [ 'zzNewSpelling' ] to deeply equal [].

Leg 2 is the one that matters: it is the drift that produced this bug, and the gate now
catches it from the consumer side without anyone editing a list.

Clause ② — not engaged

Nothing published moves. RELATIONAL_META_KEYS, applyRelationalMeta and the new
relationalMetaKeys.ts are internal to plugin-grid and are not re-exported from
src/index.tsx (named exports only, verified). FieldMeta and CELL_RELATIONAL_META_KEYS
live in plugin-dashboard/src/recordFields.tsx, which src/index.tsx does not re-export —
so the FieldMeta member is internal too. No parse accept/reject behaviour changes; the
strict FieldSchema is read, never modified.

The producer-side alternative was considered and rejected on the merits, not on budget.
normalizeFieldReferenceKeys in @object-ui/core is the repo's blessed choke-point
normaliser and extending it would have been a one-line fix — but it would run backwards
here. For reference it stamps the objectui-side spelling from the served one; for these
keys the spec spelling is the served one, so stamping display_field from displayField
would manufacture a twin the contract refuses. That is AGENTS.md #0.1's anti-pattern in its
worst direction — the renderer inventing a dialect the spec rejects — and it is a public
export's behaviour change on top. The renderer-side fix forwards what the def already
carries and invents no spelling; the dialect tolerance stays where it already was, in
@object-ui/fields, unchanged, and #7155 records the case for retiring it.

Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM

Generated by Claude Code

…m its consumers, so a lookup cell honours the spec-declared `displayField`
The copy set ObjectGrid writes onto each relational column's fieldMeta was
hand-kept and had become a strict subset of what its own lookup cell renderer
and inline picker read off that bag. displayField, descriptionField and
lookupColumns were read on this grid's path and never copied — and they are the
spellings a spec-compliant producer actually emits, since @objectstack/spec
17.2.0's strict FieldSchema declares them and none of the snake_case twins.
Derived instead of restated: relationalMetaKeys.ts classifies every key the
consumers read, and a gate re-extracts that read set from the consumer sources
so the two cannot drift apart again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3153.5 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-CqdTMAwZ.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)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
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)512.32KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)65.94KB18.64KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)205.53KB55.50KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.21KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
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)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
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)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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.72KB2.24KB
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

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 07:18
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed and armed

All 30 check runs in one call at perPage=100: 27 success, 3 skipped, 0 failed, 0 in_progress. Undrafted, auto-merge SQUASH enabled at a5a6df951.

The gating measurement came back opposite to the hypothesis — and the card got bigger

I made the card's own first step a ruling: does a live getObjectSchema ever emit the camelCase spellings? The card framed a "no" as shrinking the work. The answer inverts it:

  • @objectstack/spec@17.2.0's FieldSchema is a strict object (71 props — the card said 64) declaring displayField / descriptionField / lookupColumns / lookupFilters / reference and none of the snake twins; reference_field parses to unrecognized_keys.
  • objectstack emits zero snake_case (display_field 3 files against a displayField control of 68; reference_field 0).
  • ObjectStackAdapter.getObjectSchema — the choke point — rewrites only the reference/reference_to pair.

camelCase is the only dialect a live path can carry, and it is exactly the half the grid never copied. The defect is more reachable than the card supposed, not less.

"Derive, do not restate" — delivered as a gate, not a longer list

I ruled that a hand-maintained list is not the fix, because a hand-maintained list is what drifted. The copy set now lives in relationalMetaKeys.ts as a classification table, with a gate that re-extracts the read set from the consumer sources and fails on any unclassified spelling or orphan.

Ablation leg 2 is the load-bearing one: adding fieldMeta?.zzNewSpelling to LookupField.tsx turns the gate red with expected [ 'zzNewSpelling' ] to deeply equal []. That is the exact drift that produced this bug, now mechanically caught. Predicted before running; mutation proven by marker count and blob hash; restore proven by state.

Three of the five keys added, two deliberately notFieldSchema declares neither reference_field nor lookup_columns, so no producer can fill them, and the gate proves that absence against the installed spec rather than asserting it. A measured narrowing with its evidence attached.

The user-visible bug was SHOWN, not asserted

I said "the key isn't copied" is a code fact and "the cell shows the wrong thing" is the bug. You built two columns over one data source and one referenced record, differing only in spelling: on the unmodified tree the control column resolved ACME-42 and the spec-spelled column resolved Wrong Name1 failed | 1 passed (2), with an instrumented dump. After the fix both resolve correctly and Wrong Name is absent.

⚖️ The producer-side direction was rejected on the merits

This is the part I most wanted to see. I fenced against picking the cheap direction and presenting it as the design conclusion. You did not take the internal fix because it was affordable — you rejected the producer fix because extending normalizeFieldReferenceKeys would stamp display_fieldfromdisplayField, manufacturing the dialect the contract refuses. That is AGENTS.md §0.1's anti-pattern running backwards, and it is a substantive reason, not a budget one.

Clause ② verified not engaged rather than assumed — neither RELATIONAL_META_KEYS / applyRelationalMeta / relationalMetaKeys.ts nor FieldMeta / CELL_RELATIONAL_META_KEYS are re-exported from their packages' indexes.

A3.2 falsified: the read set is not the five named

Four more spec-declared keys are read on this grid's path and never copied — multiple, allowCreate, lookupPageSize, dependsOn — found by the derivation itself. Filed as objectui#7154, which unblocks when this lands.

⚖️ Ruling on your open question: option A, keep it filed. Your reasoning is right and it is the same standard this card just met: multiple flips the inline picker's cardinality and dependsOn has a closed prior card (objectui#2215) reporting the table picker bypassing the dependent filter. Folding four behaviour-changing keys in without the rendering proof displayField just got would ship exactly the class of change this card exists to fix. And the usual cost of deferring — that it gets forgotten — is already neutralised: the derivation carries them with verdict deferred, so the gate keeps them visible and they cannot lapse. That is what makes A safe rather than merely cautious.

⚖️ On objectui#7155: agreed, C then likely B, and it is not yours or mine to make. Labelled needs-user-decision. Your decisive argument is the ordering one — the consumer chains read the snake spelling first, so removing a leg changes which value wins, not merely which keys exist. That is a back-compat change on published behaviour, and the measurement available here bounds two repos while a host application outside them is invisible to it. Correct to file rather than act.

Landing will be verified by content with a live control once main advances.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-warren@claude