Skip to content

Add the ObjectViewSchema drift guard, and fold the four per-view-type config aliases - #5760

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-2890-objectview-drift-guard
Aug 23, 2026
Merged

Add the ObjectViewSchema drift guard, and fold the four per-view-type config aliases#5760
os-sam merged 2 commits into
mainfrom
claude/issue-2890-objectview-drift-guard

Conversation

@claude

@claudeclaudeBot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Part of #2890 — the two items the 2026-08-06 triage put in pm:queue, and only those. The card body describes a much larger project; the triage narrowed it, and that narrowing is what this PR implements. All verification below ran at f108410.

1. The ObjectViewSchema drift guard

packages/types/src/__tests__/object-view-spec-parity.test.ts — the sibling of list-view-spec-parity.test.ts for the node that never had one. Premise re-verified on origin/main3ddc8c2: object-view-spec-parity / objectViewSpecParity return zero hits across packages, controlled against list-view-spec-parity, which hits three files under the same scan.

The audit's step 1 has been ruled the other way since July, and the guard is shaped around that. The 2026-07 audit asked first for "make the declaration match the reads (the 28 undeclared keys)". Maintainer ruling of 2026-08-18 on objectui#5097 (verbatim 「同意」) settled it the opposite way: those keys are host-composition surface, deliberately not declared on ObjectViewSchema, with OBJECT_VIEW_HOST_COMPOSITION_KEYS as their single home and plugin-view/src/__tests__/objectViewHostSurface.test.tsx pinning them by name from the side that can read the source. So the reads half is already guarded, and guarded where it belongs.

What had no guard at all is the declared surface. That is what this file covers:

  1. zod ↔ TS interface, ratcheted — the gap may shrink, never grow.
  2. Every declared key with a spec counterpart still has a LIVE one.
  3. Every declared key is triaged — mapped upstream, sanctioned local, or a recorded TS-only gap.

Re-measured, since the audit is from July

measurementaudit (2026-07)this PR (3ddc8c2)
keys read off the node, declared nowhere2827, now ruled-exempt host surface with a named home and a by-name pin
declared in zod1313 (11 non-envelope + type/description, which the node narrows)
declared in the TS interface, own surface2522 (the audit's 25 includes type/description/className, which the envelope owns)
TS-declared but absent from zod1211

The 11 are pinned in TS_ONLY_BACKLOG. Each is a key an author can write in TypeScript that the CLI validator and the VS Code extension silently ignore, because those parse through zod.

Two mechanisms are load-bearing, and both were verified by ablation

ADR-0087 tombstone filtering. A D2 retirement does not delete the key — it replaces the member with z.never(), so Object.keys(shape) still reports it and any check spelled "is this key in the spec shape" passes on a retired key. This is not hypothetical here: the spec pin objectui resolves (@objectstack/spec@17.1.0) carries five live tombstones on ListViewSchemastriped, bordered, virtualScroll, responsive, performance — so the filter is exercised by real data rather than by a synthetic fixture. Ablated by replacing the predicate with the naive spelling: 2 tests redden.

The compiler-checked interface key list. The obvious keyof spelling is a guard-shaped no-op here, and this was caught by ablation rather than reasoning. BaseSchema ends with [key: string]: any, so keyof collapses to string | number, Exclude<keyof ObjectViewInterface, keyof BaseInterface> becomes never, Record<never, true> is {} — and the exhaustiveness check accepts any object. Measured: with that spelling, deleting an entry left type-checkgreen. A key-remapping filter that drops index members restores it — deleting an entry now gives TS2741, adding an undeclared one gives TS2353. That same index signature is the deeper reason this node's TS half could drift unnoticed for so long.

2. The four phase-3 per-view aliases folded into normalizeListViewSchema

kanban.groupFieldgroupByField, kanban.cardFieldscolumns, gallery.imageFieldcoverField, timeline.dateFieldstartDateField. Same one-directional shape as the A1–A5 folds: the canonical key wins when both are present, and the legacy key is removed so a missed read-site fails loudly instead of quietly taking the legacy path.

One rendering behaviour changes, and it is a correction.ListView's kanban adapter resolves the card field list as cardFields || columns — legacy over canonical, the same inverted precedence A2 fixed for densityMode. A kanban config carrying both therefore rendered the legacy value and silently ignored the spec-canonical columns. After the fold the authored columns reaches it. Every other reader of these four was already canonical-first, so nothing else moves; each is pinned in the new tests.

calendar.defaultView is deliberately not folded — it aliases nothing and has no spec counterpart, so it wants promotion upstream rather than a rename.

The fold is scoped to the declared per-view path. It does not reach into the legacy options.* twin, which is a passthrough bag ListView merges underschema.kanban; that boundary is stated as a test rather than left implicit.

Verification

All at f108410, each read from the gate's own verdict line (never a bare $? behind a pipe):

gateresult
pnpm exec vitest run over core, types, plugin-list, plugin-view, plugin-kanbanTest Files 221 passed (221) · Tests 3446 passed (3446)
pnpm lint (full repo)Tasks: 47 successful, 47 total0 errors (warnings pre-existing; the workflow sets no --max-warnings)
pnpm type-check (full repo)Tasks: 81 successful, 81 total
pnpm check:spec-symbols✅ spec symbol derivation: 1297 files scanned against 4966 spec export names
pnpm check:control-bytes✅ check-control-bytes: OK (scanned 4801 tracked text file(s))

Both lint and type-check were run at full repo scope, so no narrowing argument is claimed.

Ablations. Every leg confirmed the mutation on disk with an anchored grep count before reading any result, and every script carried a trap … EXIT INT TERM restore; each restore was verified byte-exact with diff. These suites resolve the code under test through relative source imports (not a package exports hop into dist/), so no rebuild sits between the mutation and the reading — stated because a dist-resolved ablation that skips the rebuild stays green and is invisible to CI.

ablationmutation confirmed on diskresult
delete the per-view fold's application bodydelete nextCfg[legacy]; 1 → 0, marker injected 16 of the new tests redden
replace the tombstone predicate with the naive spellingpredicate 1 → 0, marker injected 12 tests redden
drop one entry from the compiler-checked key recordviewActions: true, 1 → 0type-check exit 2, TS2741
add a key the interface does not declaremarker injected 1type-check exit 2, TS2353

Out of scope, deliberately untouched

Both were named by the triage as rider risks, and neither is an objectui decision:

  • A6 objectNamedata.provider:'object', and viewType. Blocked upstream — @objectstack/spec's react-blocks.ts declares objectName a sanctioned React-tier prop and packages/lint/validate-react-page-props.ts enforces it. A packages/spec face, owned by the objectstack domain:spec seat via cross-seat transfer. objectName is recorded in the guard's SANCTIONED_LOCAL with that reason rather than quietly triaged as local-forever.
  • conditionalFormatting / exportOptions. objectui's shapes are supersets carrying capability the spec cannot express; they want upstream promotion, not a rename.

No @objectstack/spec declaration was changed, so clause ② is not tripped: no contract accept/reject behaviour moves and no public surface widens.

Build Docs is inherited-red on main (#5668, fs reaching the browser bundle via pg-connection-string); unrelated to this diff.


Generated by Claude Code

…istViewSchema
Phase-3 carry-over from #2890: `kanban.groupField`, `kanban.cardFields`,
`gallery.imageField` and `timeline.dateField` are the pre-#2231 objectui
spellings kept declared alongside the spec keys so stored view metadata keeps
validating. They now fold at the same boundary as the A1-A5 folds, in the same
one-directional shape: the canonical key wins when both are present, and the
legacy key is removed so a missed read-site fails loudly.
One read-site changes behaviour, and it is a correction of the same inverted
precedence A2 fixed for `densityMode`: ListView's kanban adapter resolves
`cardFields || columns`, i.e. legacy over canonical, so a config carrying both
rendered the legacy value. Every other reader of these four was already
canonical-first.
`calendar.defaultView` is deliberately not folded — it aliases nothing and has
no spec counterpart, so it wants promotion upstream rather than a rename.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E7snar5mwF7qoXJazqKhys
…sked for
`ObjectViewSchema` is the node #2231 never touched and the only view schema with
no parity guard: `object-view-spec-parity` returned zero hits repo-wide, against
three files for its `list-view` sibling. The 2026-07 audit
(docs/audits/2026-07-objectview-detailview-schema.md) opened with exactly this
gap, and asked for the guard to land BEFORE any restructuring so the restructure
has a baseline.
Scope note: the audit's step 1 -- "make the declaration match the reads (the 28
undeclared keys)" -- has since been ruled the other way (maintainer, 2026-08-18
on objectui#5097). Those keys are host-composition surface, deliberately
undeclared, already pinned by name in objectViewHostSurface.test.tsx. This guard
therefore covers the DECLARED surface, which had no guard at all:
- zod shape vs TS interface, ratcheted so the gap can shrink but never grow;
- every declared key that has a spec counterpart still has a LIVE one;
- every declared key is triaged: mapped upstream, sanctioned local, or a
recorded TS-only gap.
Two mechanisms are load-bearing and both were verified by ablation rather than
assumed:
- ADR-0087 tombstone filtering. A D2 retirement replaces a member with
`z.never()` instead of deleting the key, so `Object.keys(shape)` still
reports it and a naive "is this key in the shape" check passes on a retired
key. The spec pin objectui resolves carries five such tombstones on
ListViewSchema (striped, bordered, virtualScroll, responsive, performance),
so the filter is exercised by real data.
- The compiler-checked interface key list. The plain `keyof` spelling does not
work here: BaseSchema ends with `[key: string]: any`, so
`Exclude<keyof ObjectViewInterface, keyof BaseInterface>` collapses to
`never` and the exhaustiveness check accepts any object. Measured -- with
that spelling, deleting an entry left type-check green. A key-remapping
filter that drops index members restores the error.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E7snar5mwF7qoXJazqKhys
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3917.9 KB3990.2 KB
Main entry chunk (gzip)152.5 KB350 KB
Entry fileindex-DvWNTmBO.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)10.04KB3.72KB
app-shell (runtime-config.js)12.80KB4.47KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)16.66KB6.35KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)33.99KB8.57KB
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)510.39KB114.67KB
core (index.js)4.92KB1.97KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)164.55KB45.67KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
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.62KB12.83KB
plugin-charts (index.js)64.65KB18.32KB
plugin-chatbot (index.js)181.41KB43.22KB
plugin-dashboard (index.js)128.41KB32.95KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)242.34KB60.98KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.80KB27.20KB
plugin-map (index.js)20.06KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.61KB20.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)3.77KB1.33KB
react (SchemaRenderer.js)44.39KB14.99KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
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)6.92KB2.40KB
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)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.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 (index.js)3.88KB1.85KB
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)3.40KB1.68KB
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-sam@claude