Skip to content

fix(app-shell): audit history reads the relationship target as reference, a bare string - #6836

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-6719-audit-field-def-narrow
Aug 30, 2026
Merged

fix(app-shell): audit history reads the relationship target as reference, a bare string#6836
os-sam merged 1 commit into
mainfrom
claude/issue-6719-audit-field-def-narrow

Conversation

@claude

@claudeclaudeBot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Fixes#6719

packages/app-shell/src/utils/auditHistoryDisplay.ts was a third reader of a
relationship field's target object, missed by objectui#6528 (which narrowed two
resolvers to the spec spelling reference) and objectui#6648 (which narrowed the
same two to the string carrier). Both narrowings now reach it:

exportinterfaceAuditFieldDef{reference?: string;// was: string | string[] (and a `reference_to` twin)}functionlookupTarget(def: AuditFieldDef|undefined): string|null{consttarget=def?.reference;// was: def?.reference_to ?? def?.referencereturntypeoftarget==='string'&&target.length>0 ? target : null;}

Observation-class, not a bug. The runtime typeof narrowing already resolved a
non-string carrier to null, so nothing produced a wrong answer. What is removed is a
declared surface wider than anything that can reach it, plus a spelling preference
inverted relative to the contract.

The card's one open question, settled by measurement

Drop the reference_to half of the chain, or state why this reader legitimately
sees ObjectUI's own view/field contract — the census did not establish which.

It is fed object metadata documents. The arm is dropped.lookupTarget has exactly
one caller chain: RecordDetailView's History effect
(packages/app-shell/src/views/RecordDetailView.tsx), which passes objectDef.fields,
where objectDef is an entry of useMetadata().objects. That list has one writer —
MetadataProvider's type cache for metadata type object (TYPE_BY_STATE_KEY.objects).
The two passes that decorate it on the way out, mergeViewsIntoObjects and
attachInlineSubforms, write listViews / formViews / form, never fields; and the
session cache that could bypass the fetch path is registered for type app only. So the
fields record reaching this helper is an object metadata document on every path.

ObjectUI's own view/field contract does carry reference_toDetailViewFieldSchema in
@object-ui/typesviews.zod.ts — but plugin-detailtranslates INTO it from
reference (RecordDetailDrawer, RecordMetaFooter), and none of that flows back into
objectDef.fields. Note that even there the carrier is z.string(), never a list.

Dropping the arm loses nothing even for a def that arrives spelling only the legacy key:
normalizeSchemaReferenceKeys (@object-ui/core) runs over every object item at the
ingestion choke point and stamps both snake_case keys from whichever spelling arrived
(pinned in packages/core/src/utils/__tests__/reference-keys.test.ts). Its own docs give
the reason this reader must not keep a second copy of that tolerance — the choke point
exists "so per-consumer dual-key fallbacks can't drift" (AGENTS.md #0.1).

Re-derived census (against origin/main, not the card's numbers)

Structure-walked, not text-matched: JSON/YAML parsed and walked, TS/TSX/JS through the
TypeScript compiler API, recording each hit's ancestor property chain, its enclosing
object's sibling keys, and the carrier its value takes — so a field def is separated from
the other tiers that also spell reference. Trees: this repo at 26896c689, framework
at 7404925. 2209 candidate files, 903 hits, 815 at the field-def key position.

Spec probe (ObjectSchema.safeParse, @objectstack/spec 17.2.0). FieldSchema.reference
introspects as optional -> string.

input at fields.planverdict
reference: 'crm_account'ACCEPTED — the positive control
reference: 'crm_account', multiple: true (the fixture shape)ACCEPTED
reference: ['crm_account']REFUSED invalid_type: expected string, received array
reference: ['a','b','c']REFUSED, same
reference: { object: 'crm_account' }REFUSED expected string, received object
reference_to: 'crm_account'REFUSED unrecognized_keys — "Did you mean reference_to to reference?"
referenceTo: 'crm_account'REFUSED unrecognized_keys

Carrier census at the field-def key position — every zero measured against a control
in the same query that hits:

carriercountcontrol?
bare string on reference599 (143 here + 456 framework)this is the control, and it hits
array on reference0
{ object } on reference0

The detector is not blind to the shapes it hunted: it did report 8 object carriers
and 3 array carriers, and every one is another tier — a JSON-Schema property descriptor
({ type: 'string', title: 'Reference' }), a form field literally namedreference,
four generated i18n translation entries ({ label: "Reference", helpText: ... }), two
packages/spec/liveness/field.json ledger rows, and a memory-driver $not test document
whose own data column is called reference. Zero of them is a producer of a relationship
target.

Type-declaration censusAuditFieldDef held the onlystring | string[]
declaration of either key in either tree:

declared typecount
reference / reference_to: string | string[]1 — this file, both members
reference / reference_to / referenceTo: string43
the same keys as unknown (structural pass-throughs)10
referenceTo: never (a retirement tombstone)1

Out of structural scope, stated rather than assumed:.md / .mdx were swept
textually only. Seven hits, all documentation prose of the form
"Spec reference: Kernel". None is a carrier.

Pins and ablations

Both axes are pinned in the shape objectui#6528 and objectui#6648 established. Ablations
were run from the committed implementation, each mutation confirmed on disk by counting
the injected and the removed text (not by an editor's exit code), each restored with
git checkout HEAD -- on absolute paths under an EXIT INT TERM trap and verified by an
empty git diff HEAD. No dist is involved: the suite imports the source relatively
inside its own package, so no rebuild gates these readings.

Baseline 24 passed (24); post-restore re-measurement 24 passed (24).

ablationmutationresult
A — spellingrestore def?.reference_to ?? def?.referenceRED: 3 failed / 21 passedrefuses the legacy spelling reference_to, prefers reference on a partially-migrated def carrying both, does not resolve a display label through a refused spelling
B — carrier, runtimedelete the typeof target === 'string' narrowingRED: 4 failed / 20 passed — all three carrier cases plus the runtime half of the type pin
C — carrier, compile-timere-widen the member to string | string[]RED:auditHistoryDisplay.test.ts(176,5): error TS2578: Unused '@ts-expect-error' directive, tsc exit=2

Ablation C is the only thing that can measure a type-only narrowing, and it doubles as
proof the file is genuinely in the tsconfig.test.json program rather than silently
excluded. referenceTo and reference_to_object are pinned alongside reference_to
to match the sibling pins, but they stay green under ablation A — they were never read
here, so they document the refusal rather than carry it.

What moved

One existing fixture. predecessors spelled its target reference_to; that is not merely
non-canonical on this surface, it is refused by name by ObjectSchema.safeParse, so
the fixture was never a document this reader could legally be handed. Re-spelled to
reference, which the same parse accepts with multiple: true alongside it — a fixture
triage decision, not a search-and-replace. No other test in either tree reads these
helpers (collectAuditChanges / collectLookupIds / formatAuditValue appear in exactly
three files, all in app-shell), and no test drives RecordDetailView's history diff.

Verification

All runs from the repo root, on the final commit 634719ffa:

  • pnpm exec vitest run packages/app-shell/src/utils/__tests__/auditHistoryDisplay.test.ts
    Test Files 1 passed (1) / Tests 24 passed (24), executed file echoed as
    packages/app-shell/src/utils/__tests__/auditHistoryDisplay.test.ts.
  • pnpm --filter @object-ui/app-shell run type-checktsc --noEmit && tsc -p tsconfig.test.json,
    both green. The second is what measures the @ts-expect-error pin, and what the CI
    Type Check job runs.
  • pnpm --filter '@object-ui/app-shell^...' build (the dependency closure first, so nothing
    below reads a stale dist) — green.
  • Changeset gates, each quoting its own verdict line: check-changeset-presence "2 source
    file(s) of 1 released package(s) changed, and this change declares 1 changeset(s) ...
    Every one of them has an EMPTY frontmatter — declared as releasing nothing, which is the
    explicit exemption and a complete answer to this gate"; check-changeset-no-major "No
    changeset declares a major bump"; check-changeset-fixed "All workspace packages are in
    the changeset fixed group"; check-changeset-overwrite "No pre-existing changeset was
    modified or deleted".
  • check-control-bytes — "OK (scanned 5647 tracked text file(s); skipped 85 binary)", plus
    a direct control-character sweep of the three changed files (no hits).

Lint was narrowed, and here is the measurement that says the narrowing excluded nothing.
Ran eslint . inside packages/app-shell (the repo-root flat config resolves upward)
rather than turbo run lint across all 39 packages. (1) The receiving population is
eslint's own selection from its own config, not my guess about which files count.
(2) --format json reports 1016 files linted, 0 errors (2790 pre-existing warnings,
which eslint . does not gate on), and both changed files appear in that file list.
(3) Invariance for untouched files: eslint.config.js extends tseslint.configs.recommended,
notrecommendedTypeChecked, and its languageOptions declares only ecmaVersion and
globals — no parserOptions.project, no projectService. Type-aware linting is off, so
every rule is single-file and AST-local; a diff that touches no config file cannot move the
verdict of any file it does not contain.

No changeset content, deliberately

Empty frontmatter, which the gate names as a complete answer. AuditFieldDef and
auditHistoryDisplay are package-internal: neither is re-exported from
packages/app-shell/src/index.ts, and the package's exports map exposes only . and
./styles.css, so no consumer can deep-import them. Runtime behaviour is unchanged on
every document that can reach the helper, for the normalizer reason above.

No packages/spec change of any kind, and no content/docs/releases/ edit.

Generated by Claude Code


Generated by Claude Code

…ence`, a bare string
`AuditFieldDef` was the third reader of a relationship field's target object,
missed by objectui#6528 (which narrowed two resolvers to the spec spelling) and
objectui#6648 (which narrowed the same two to the string carrier). It declared
`reference_to?: string | string[]` / `reference?: string | string[]` and read
`def?.reference_to ?? def?.reference` — the legacy spelling first.
Both widenings are unfounded on the documents this reader is handed. Its one
caller chain is `RecordDetailView`'s History effect, which passes
`objectDef.fields` from `useMetadata().objects` — object metadata documents,
never ObjectUI's own view/field contract. `ObjectSchema.safeParse` (spec 17.2.0)
accepts `reference: '<name>'`, refuses `reference_to` by name, and refuses both
the array and `{ object }` carriers; a structure-walking census of both trees
found zero producers of either carrier at the field-def key position against 599
bare-string carriers, and `AuditFieldDef` held the only `string | string[]`
declaration of either key in either tree against 43 declaring `string`.
Observation-class, not a live defect: the runtime `typeof` narrowing already
resolved a non-string carrier to `null`, and `normalizeSchemaReferenceKeys`
stamps both snake_case spellings at the ingestion choke point — which is also
why dropping the `reference_to` arm loses nothing. That choke point exists so
per-consumer dual-key fallbacks cannot drift; this was one of them.
Both axes are pinned in the shape objectui#6528 / objectui#6648 established,
plus a `@ts-expect-error` pin for the carrier, which only `tsconfig.test.json`
can measure. The `predecessors` fixture moved from `reference_to` to
`reference`: the old spelling is refused by name, so it was never a document
this reader could legally be handed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3174.0 KB3222.7 KB
Main entry chunk (gzip)148.2 KB350 KB
Entry fileindex-DajZeVpy.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.89KB4.50KB
app-shell (runtime-config.js)20.61KB7.35KB
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)511.50KB116.32KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)240.93KB60.76KB
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.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.33KB45.10KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)245.46KB62.46KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.03KB32.64KB
plugin-gantt (index.js)165.23KB40.37KB
plugin-grid (index.js)201.57KB54.55KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
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)28.95KB8.33KB
plugin-tree (index.js)9.00KB3.08KB
plugin-view (index.js)85.87KB21.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)76.75KB25.49KB
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-sam
os-sam marked this pull request as ready for review August 30, 2026 03:35
@os-sam
os-sam added this pull request to the merge queueAug 30, 2026
Merged via the queue into main with commit 4fae8e6Aug 30, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6719-audit-field-def-narrow branch August 30, 2026 04:00
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(app-shell): AuditFieldDef is a third relationship-target reader — widened carrier type + #6528's legacy spelling chain

2 participants

@os-sam@os-sales