Skip to content

fix(plugin-dashboard): type ObjectDataTable's column emit against the TableColumn[] slot it fills - #6427

Merged
os-support-ai merged 2 commits into
mainfrom
claude/issue-6373-datatable-enrich-typed
Aug 26, 2026
Merged

fix(plugin-dashboard): type ObjectDataTable's column emit against the TableColumn[] slot it fills#6427
os-support-ai merged 2 commits into
mainfrom
claude/issue-6373-datatable-enrich-typed

Conversation

@claude

@claudeclaudeBot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Fixes#6373

ObjectDataTable.enrich() returned NormalizedColumn, whose [key: string]: any accepts anything, so nothing checked what this producer wrote into DataTableSchema.columns: TableColumn[]. { ...col, ...fieldMeta } wrote seven keys TableColumn does not declare. Six retire; the seventh is #5120's and is unchanged.

⭐ The rule, so #6004 gets the same answer on a different key set

This is #6004's remedy applied to the second producer, and #6004's own key set (headerIcon, options, pinned, wrap, essential) is different. So the criterion is stated as a rule rather than as seven verdicts:

A producer may write into a TableColumn[] slot only keys the CONSUMER of that slot reads, and the consumer's read set is measured from the consumer's source, never assumed. Then:

  1. the consumer reads it andTableColumn declares it → write it (nothing to do);
  2. the consumer reads it andTableColumn does not → hold as alias only where a ruling already holds it, and declare the hold at the seam; otherwise it is the consumer-side twin defect and gets filed, not silently written;
  3. the consumer does not read it → RETIRE from the emit. ⛔ Never declare it. Declaring a key nothing reads is the same declared != enforced defect facing the other way — that is exactly [finding] ObjectGrid forwards a per-column wrap into the DataTable column object, but nothing in data-table.tsx ever reads it #5453's forwarded wrap.

Before retiring, prove the value still reaches its consumer by another road. That check is part of the rule, not an aside: here the road is the FieldMeta the cell closure captures, which is where this widget's type-aware rendering has always read those values. A key with no second road is not inert, and retiring it would change behaviour — that case is rule 1 or 2, not rule 3.

The measured read set of data-table.tsx (comments stripped, every col.<key>): accessorKey 28, width 6, align 5, header 4, className 4, sortable 3, editable 3, cell 2, name 2, headerIcon 2, fitContent 4, cellClassName / resizable / type 1 each. Not one of the six retired keys appears at any count.

Verdicts

keyverdictreason
labelretireconsumer read-count 0. header is the adapter's spelling, and #5351 already retired label's alias on the consumer side.
optionsretireread-count 0. Select-option labels reach the badge through fieldMeta; pinned below by rendering Technology with options gone from the column.
referenceToretireread-count 0. The $expand whitelist that does care reads schema.columns (the AUTHORED list) in computeLookupExpand, never the enriched output.
formatretireread-count 0. Formatting happens inside the cell closure.
currencyretireread-count 0. Same closure; the tenant-default backstop is applied there.
decimalsretireread-count 0. Same closure.
typenot adjudicated here#5853 settled it at this exact seam; its normalizeTableColumnType fold stands unchanged, and the value is now written out explicitly instead of arriving in the spread.
nameheld, not mine#5120's, still open. data-table reads col.accessorKey || col.name and holds that alias while two published skill guides teach a { name, label } column. The producer keeps writing it, byte for byte what the spread wrote; the hold is now DECLARED at the seam instead of arriving anonymously.

⚠️ The annotation the card suggested is blind to this defect — measured

Annotating the return TableColumn and leaving the spread in place raises no error at all. TypeScript's excess-property check is a freshness check on the properties an object literal writes OUT; properties arriving through a spread are exempt. Probed on this program before the fix was written:

e1 ({ ...col, ...fieldMeta, type, align }) : TableColumn → NO ERROR
e2 ({ ...col, label: fieldMeta.label }) : TableColumn → error TS2353
e3 ({ ...col, ...fieldMeta, … }) : Tombstoned → error TS2322 'label': string is not assignable to undefined
e4 ({ ...col, type, align }) : Tombstoned → NO ERROR (the index signature on `col` does not trip it)

So the emit type carries ADR-0049 ?: nevertombstones — this repo's convention for a key that is refused rather than absent (StaticTableColumn, crud.tsconfirm) — which bite by assignability instead of freshness. The member is derived (Exclude<keyof FieldMeta, keyof TableColumn | 'name'>), never hand-listed, so a future FieldMeta member is tombstoned by default and has to be adjudicated to escape.

Reverse verification

Both ablations state their predicted direction first, prove the mutation reached disk by grepping the injected AND removed text (a zero-hit perl -i exits 0), and restore by comparing the file's hash against the HEAD blob, with git diff HEAD proving the restore. No build/dist step is involved: the test imports ../ObjectDataTable relatively, so the mutation is in the module under test.

Ablation A — put { ...fieldMeta } back. Predicted: the runtime census red, and the source type-check red too. Observed both; the third failure was not predicted and is explained below.

src/ObjectDataTable.tsx(563,43): error TS2322: … is not assignable to type 'EnrichedColumn'.
… not assignable to type '{ label?: undefined; options?: undefined; … }'.
Types of property 'label' are incompatible.
Type 'string' is not assignable to type 'undefined'.
src/ObjectDataTable.tsx(567,7): error TS2322: … (the second return site)
× auto-derived columns carry no undeclared key but the held alias
AssertionError: column name wrote undeclared keys: expected [ 'label', 'options', …(4) ] to deeply equal []
× names the six keys that retired, and keeps the one that is held
AssertionError: name.label: expected [ 'header', 'accessorKey', …(10) ] to not include 'label'
× does not strip keys the AUTHOR spelled — retirement is about what the producer writes
Tests 3 failed | 6 passed (9)

I predicted two red census tests and got three: the authored-column case also censuses the keys the producer ADDS beyond the author's, so restoring the spread reddens it too. Same direction, one more instrument than expected.

Ablation B — delete the tombstones only, keep everything else. This one falsified my first prediction and changed the test.

Predicted: the source type-check stays green (proving a bare annotation enforces nothing) and the test project goes red with two unused-directive errors. Observed: both green. Diagnosis: the full-FieldMeta spread pin is refused for two independent reasons — the tombstones, and FieldMeta's type?: string not fitting the TableColumnType union #5853 narrowed this key to. So that directive stays used without the tombstones: on its own it pinned "the spread is refused" without pinning why, and would have gone on passing after the enforcement was deleted. That is the "passes for the wrong reason" trap this repo's own headers warn about, caught only because the ablation was run.

The pin was split (second commit). Re-run of ablation B against the corrected instrument, prediction stated first and met exactly:

ABL_B_SRC_TSC_EXIT=0 ← a bare TableColumn-shaped annotation enforces nothing. This IS the measurement.
ABL_B_TEST_TSC_EXIT=2
src/__tests__/ObjectDataTable.emitBoundary-6373.test.tsx(283,5): error TS2578: Unused '@ts-expect-error' directive.

Exactly one directive — the isolating one (Omit<FieldMeta, 'name' | 'type'>, i.e. precisely the six retired members). The hand-written-key pin is refused by excess-property checking alone and is documented as such, so the three type pins now say which machinery covers which case.

What is pinned, and why it is not a blind instrument

packages/plugin-dashboard/src/__tests__/ObjectDataTable.emitBoundary-6373.test.tsx (10 tests). A test that merely renders the table would stay green with the annotation removed, so nothing here is a render smoke test:

  • A runtime key census. The declared set is read from TableColumnSchema.shape (@object-ui/types/zod), which zod-mirror-parity.test.ts keeps in step with the interface — so there is no key list in the file to drift. It censuses the objects the widget actually hands to data-table, which catches a future spread, a computed write or an as any detour by what LANDED. The census runs on the auto-derive path, where every key on the object was written by enrich, so it is exact rather than "the author's keys plus ours".
  • The instrument's own premises are asserted — a .shape read that silently answered {} would make "none of the retired keys is declared" pass vacuously.
  • The second road is pinned: with options gone from the column, the select cell still renders Technology (not tech), and the lookup cell still renders Ada (not u1).
  • The authored path is pinned in the other direction: a column the author spelled { format: '$0,0', currency: 'EUR', field: 'amount' } keeps all three. Retirement is about what the producer writes, and normalizeColumns' documented rule that authored spellings survive is unchanged.
  • Three @ts-expect-error pins with a positive control, checked by tsc -p tsconfig.test.json — chained from this package's type-check script and run by CI's Type Check job. Coverage was measured, not assumed: tsc -p tsconfig.test.json --listFiles includes this file, and check-type-check-coverage.mjs reports 41/41 packages compile their tests, 0 declared debt. (A test header in this package still claims the opposite; filed as finding(plugin-dashboard): a test header still says this package's tests are compiled by nothing — that debt was paid #6426.)

Local gates — run at c2fcae536, the final commit

Union re-run after the last commit, on a clean tree:

gateverdict line
vitest run packages/plugin-dashboard/Test Files 81 passed (81) / Tests 764 passed (764)
the new pin fileTest Files 1 passed (1) / Tests 10 passed (10)
neighbouring contract tests (*)Test Files 6 passed (6) / Tests 265 passed (265)
tsc --noEmit (plugin-dashboard)exit 0, no diagnostics
tsc -p tsconfig.test.jsonexit 0, no diagnostics
pnpm --filter @object-ui/plugin-dashboard lint✖ 393 problems (0 errors, 393 warnings), exit 0
check-control-bytes✅ OK (scanned 5315 tracked text file(s); skipped 85 binary)
check-changeset-presence✅ 2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-no-major✅ No changeset declares a major bump.
check-changeset-fixed✅ All workspace packages are in the changeset fixed group.
check-type-check-coverage✅ test type-check coverage: 41/41 packages compile their tests, 0 declared debt
check:vi-mock-specifiers✅ OK (3795 tracked source file(s), 2098 test-named; 447 carry a mock …)
check:phantom-deps✅ Every in-scope import is declared by the package that publishes it.
check:self-importexit 0

(*) skill-guide-data-table-binding, widget-dom-leak-sweep, table-declared-equals-enforced, table-column-type-read-set, zod-mirror-parity, table-column-type-canonical.

Declared narrowing. Repo-wide pnpm lint (turbo run lint, every package) was narrowed to this one package's eslint .. Three pieces of evidence, not an assumption: the population came from eslint's own config resolution, not from my guess about which files count; --format json reports 111 files linted in this package; and eslint.config.js uses tseslint.configs.recommended with languageOptions: { ecmaVersion, globals } and noparserOptions.project / projectService, i.e. type-aware linting is off — so a type-only edit here cannot move a verdict on any file it did not touch, and no other package contains a changed file. CI runs the full farm regardless.

One caveat recorded rather than buried: an exploratory eslint . --no-inline-config run exited 1 on MetricCard.tsx / MetricWidget.tsx, files this PR does not touch — the flag disables their existing eslint-disable-next-line comments. That flag is not what this repo's lint script runs (eslint ., no flag), so it was a self-inflicted red, not a finding.

Out of scope, filed

⛔ Out of scope for this PR: #6004 remains open (different package, different keys, independent adjudication), #5120 remains open (name stays held), and #5453 remains open. None of those three is addressed here — the wording is deliberate, so that no closing keyword sits next to a card number this PR must not close.

Behaviour

None changes. Rendering is driven by the cell closure over fieldMeta, untouched. Authored keys still pass through. The only observable difference is that the objects handed to data-table no longer carry six keys that nothing read — keys that, because buildFieldMeta always returns all eight members, were present on every emitted column even when the schema said nothing about them.


Generated by Claude Code


Generated by Claude Code

… slot it fills
`enrich()` returned `NormalizedColumn` (`[key: string]: any`), so nothing checked
what this producer wrote into `DataTableSchema.columns: TableColumn[]`.
`{ ...col, ...fieldMeta }` wrote six keys `TableColumn` does not declare —
`label`, `options`, `referenceTo`, `format`, `currency`, `decimals`.
All six retire from the emit: the consumer's measured read set contains none of
them, and declaring a key nothing reads is the same `declared != enforced`
defect facing the other way. Rendering is unchanged — every one of those values
still reaches the cell through the `FieldMeta` the `cell` closure captures.
`type` is objectui#5853's and unchanged. `name` is objectui#5120's, still held,
still written, now declared at the seam instead of arriving inside a spread.
The emit type carries ADR-0049 `?: never` tombstones rather than being a bare
`TableColumn` annotation: measured on this program, a bare annotation raises no
error here at all, because excess-property checking exempts spread properties.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
…orce
The full-`FieldMeta` spread pin is refused for two independent reasons — the
tombstones AND `FieldMeta.type?: string` not fitting the union objectui#5853
narrowed `TableColumn.type` to. Measured by removing the tombstones: that
directive stays used, so on its own it pinned "the spread is refused" without
pinning why, and would have survived the enforcement being deleted.
Adds the isolating pin (`Omit<FieldMeta, 'name' | 'type'>` — exactly the six
retired members) and states which machinery refuses which case.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3233.6 KB3266.6 KB
Main entry chunk (gzip)157.4 KB350 KB
Entry fileindex-D6ZiQBS1.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)11.30KB4.28KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)505.86KB114.58KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.18KB47.97KB
fields (index.js)238.89KB60.02KB
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.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.66KB12.84KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.60KB44.82KB
plugin-dashboard (index.js)133.48KB34.49KB
plugin-designer (index.js)211.90KB42.74KB
plugin-detail (index.js)245.10KB62.31KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)131.78KB32.19KB
plugin-gantt (index.js)164.14KB39.87KB
plugin-grid (index.js)201.79KB54.60KB
plugin-kanban (index.js)52.87KB14.57KB
plugin-list (index.js)112.63KB27.45KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.70KB7.69KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.55KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)54.84KB18.43KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)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

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(plugin-dashboard): ObjectDataTable's enrich() spreads a FieldMeta into the TableColumn[] slot, writing seven keys TableColumn does not declare

2 participants

@os-support-ai@claude