Skip to content

console: read the converged FormFieldSpec instead of a third copy of it - #5597

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-5542-console-formfieldspec-drift
Aug 21, 2026
Merged

console: read the converged FormFieldSpec instead of a third copy of it#5597
os-sales merged 1 commit into
mainfrom
claude/issue-5542-console-formfieldspec-drift

Conversation

@os-sales

@os-salesos-sales commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Fixes#5542

The comparison, key by key — the route is a measurement

Triage ruled that both outcomes close the class and that which one applies is a
measurement, not a preference. Here is the measurement.

The console's declaration (FormPage.tsx:278 on cad512fe1) had 9 keys. The
converged app-shell declaration (form-spec.ts:26) has 26. Every one of the
console's 9 is present in the shared type with an identical type, and the console
had zero keys of its own:

keyconsoleapp-shell form-spec.tsverdict
fieldstring (required)string (required)identical
labelstring?string?identical
placeholderstring?string?identical
helpTextstring?string?identical
requiredboolean?boolean?identical
readonlyboolean?boolean?identical
hiddenboolean?boolean?identical
colSpan1 | 2 | 3 | 4 optional1 | 2 | 3 | 4 optionalidentical
widgetstring?string?identical
type, options, reference, dependsOn, maxLength, minLength, min, max, precision, scale, multiple, immutable, disclosure, language, visibleWhen, visibleOn, fieldsabsentdeclared17 keys the console did not admit

Zero disagreements, zero console-only keys, strict 9-of-26 subset.

Which route the comparison supports, and why

Same contract — import it. A subset relation alone does not settle it; a narrower
authoring layer would also look like a subset. Three further facts do settle it:

  1. The position describes an authored document, not something the console authors.
    The type sits on FormSectionSpec.fields, and sec.fields is read straight off the
    /meta/view/:name payload (resolveInternalForm casts the response into
    FormViewSpec). That is the same spec FormView metadata-admin renders — app-shell's
    own container says so in as many words, "Lightweight shape of the spec FormView we
    consume". Both files even spell the same character-identical six-member type union
    and both call the element type FormFieldSpec.
  2. The narrower renderer type already exists, under a different name.FormPage.tsx
    declares RenderableField — the normalized row buildSections emits, which really is
    "what this renderer honours". The narrowing role was already filled, so the local
    FormFieldSpec was not filling it.
  3. So the console's copy was not a narrower layer, it was a wrong description. Legal
    metadata the spec accepts and the runtime passes through — visibleWhen, dependsOn,
    type, options, immutable, the recursive fields, 17 keys in all — was
    undeclared here. That is objectui#5040's own symptom, "the type rejects the
    configuration the runtime accepts", which no runtime test can see.

A rename would have declared a narrowing that the code does not perform, so it was not
the honest route even though it is the smaller diff.

The change

  • @object-ui/app-shell re-exports FormFieldSpec from its package root. Type-only
    — erased at build, nothing added to the bundle (the eager-closure gate confirms it
    below). Reachability is the load-bearing half of the fix: the type was previously
    unnameable outside views/metadata-admin/, and a type that cannot be imported is a
    type that gets retyped. FormPage.tsx already documents this exact unreachability for
    a sibling case (urlParams), which is how the third copy came to exist.
  • FormPage.tsx imports it and deletes the local declaration.
  • FormPage.fieldSpec.test.ts is the pin.
  • One changeset.

⚠️Scope note for review. The card fenced
packages/app-shell/src/views/metadata-admin/form-spec.ts as a read-only reuse target
and it is untouched — confirmed, it is not in this diff. But reuse needed
reachability, so this PR does add two additive export type lines to app-shell's
barrels (views/metadata-admin/index.ts and src/index.ts). That is beyond the letter
of the declared file surface. Flagging rather than burying it: if the reviewer would
rather not widen app-shell's public type surface, the alternative is a subpath export or
moving the type to a shared package, both larger than this card.

The pin, and proof that it bites

The pin does not name the console's type — it reads it back out of the exported
buildSections signature
, walking spec → sections → fields → the non-string arm. So it
follows whatever FormPage.tsx actually uses in that position and cannot be satisfied by
a decoy:

typeConsoleFormViewSpec=Parameters(typeofbuildSections)[0];typeConsoleSectionSpec=NonNullable(ConsoleFormViewSpec['sections'])[number];typeConsoleFieldSpec=Exclude(ConsoleSectionSpec['fields'][number],string);constoneContract: Assert(Equal(ConsoleFieldSpec,FormFieldSpec))=true;

(Written with parens above because the write path eats angle brackets; the file uses real
generics.)

Liveness probe — a type assertion no project compiles is a phantom check. Rather than
flip the assertion to a trivially false thing, I simulated the actual regression: dropped
the shared import and re-inlined the nine-key local interface FormFieldSpec, exactly as
a future agent would. FormPage.tsx still compiles on its own, so the redness is the pin
and nothing else:

BEFORE import-line count : 1 local-decl count : 0
MUTATE
AFTER import-line count : 0 local-decl count : 1
apps/console/src/components/FormPage.tsx | 13 ++++++++++++-
1 file changed, 12 insertions(+), 1 deletion(-)
src/components/FormPage.fieldSpec.test.ts(81,29): error TS2344: Type 'false' does not satisfy the constraint 'true'.
src/components/FormPage.fieldSpec.test.ts(81,9): error TS2322: Type 'true' is not assignable to type 'false'.
src/components/FormPage.fieldSpec.test.ts(109,5): error TS2353: Object literal may only specify known properties, and 'type' does not exist in type 'FormFieldSpec'.
src/components/FormPage.fieldSpec.test.ts(165,17): error TS2353: Object literal may only specify known properties, and 'type' does not exist in type 'FormFieldSpec'.
Exit status 2

Line 81 is the pin. Note the TS2353 pair: a re-inlined copy immediately re-creates
#5040's exact symptom, and the pin reports it in #5040's own words. Restored via
trap ... EXIT INT TERM; git status --porcelain empty afterwards and both anchored
counts back to 1 / 0.

Three further controls stop the pin being vacuous, and they compile on every run
rather than being a one-time ritual:

  • the removed nine-key shape is pinned not equal to the shared type, so the Equal
    helper is proven to still discriminate;
  • RenderableField is pinned not equal to it either, so the honoured-row and
    authored-document types cannot be quietly collapsed again;
  • an undeclared key is still rejected (@ts-expect-error), so the import did not smuggle
    in an index signature or any — the failure mode a widening reaches for first.

check-type-check-coverage independently confirms a project really compiles this file:
41/41 packages compile their tests, 0 with a narrow type-assertion project.

Gate verdicts — exit codes captured before any pipe, each gate's own verdict line quoted

All run at 5536bd387, the final commit; the probe restored the tree to that exact sha
before this list was taken.

gateverdict lineexit
@object-ui/consoletype-checkos-verify-lock: VERDICT command-exit 0 · held the lock 21s0
@object-ui/app-shelltype-check(tsc --noEmit then tsc -p tsconfig.test.json, no output)0
@object-ui/app-shellbuild(tsc, no output)0
@object-ui/consolelint✖ 200 problems (0 errors, 200 warnings)0
@object-ui/app-shelllint✖ 2509 problems (0 errors, 2509 warnings)0
vitest run (repo root cwd)Test Files 202 passed (202) / Tests 2191 passed | 1 skipped (2192)0
vitest — the new pin file, verboseTest Files 1 passed (1) / Tests 2 passed (2)0
check-control-bytes✅ check-control-bytes: OK (scanned 4647 tracked text file(s); skipped 85 binary).0
check-changeset-presence✅ 3 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)0
check-changeset-no-major✅ No changeset declares a 'major' bump.0
check-changeset-fixed✅ All workspace packages are in the changeset fixed group.0
check-type-check-coverage✅ type-check coverage: 45/46 via 'type-check' … 41/41 packages compile their tests0
check-lint-coverage✅ lint coverage: 46/46 packages linted, 0 with outstanding errors (0 total).0
@object-ui/consolebuild(tsc, vite build, tsc -p tsconfig.plugin.json)0
check-eager-closure-budget✅ Console eager closure is 3784.9 KB gzipped across 52 of 508 chunks (budget: 3867.2 KB, headroom: 82.3 KB).0

Two notes on how that list was built rather than taken on trust:

  • The dispatched gate list was treated as a clue, not a spec. I re-derived it from
    the CI workflows against my actual diff, which added four gates the dispatch did not
    name: check-type-check-coverage, check-lint-coverage, check-eager-closure-budget,
    and the console build the last of those needs. check-eager-closure-budget was red
    before the build
    No eager-closure report … This is a broken gauge, not a passing budget — so rather than argue that a type-only import must be erased, I built the
    console and read the real number.
  • The root vitest reporter names only failing files, so a green run is not by
    itself evidence that the new pin file ran at all — the same shape as a --filter that
    matches nothing. Re-run alone with --reporter=verbose, which names it under the
    @object-ui/console project with both tests passing.

Declared narrowing: vitest was scoped to apps/console/src/components/ and
packages/app-shell/src/views/metadata-admin/ rather than the whole repo. Evidence it
cannot hide a failure: the app-shell half of this diff is two export type lines, which
emit no JavaScript at all, so no module outside those two paths can observe a runtime
difference; the console half is an import type plus a deleted interface, both fully
erased. CI runs the full farm regardless.

Out of scope, filed separately

Three findings from the same read, all unassigned, none fixed here:

…it (#5542)
objectui#5040 was not a missing key — it was that two hand-written descriptions
of one contract drifted, and nothing could notice, because each was only ever
checked against itself. PR #5537 converged the two app-shell descriptions into
views/metadata-admin/form-spec.ts. A third survived: apps/console FormPage.tsx
declared its own nine-key `interface FormFieldSpec`, under the same name, in a
different package, leaving that failure mode fully available.
Measured key by key before picking a route. The console's copy was a strict
subset — 9 of the shared type's 26 keys, every one identical in type, none
console-only — sitting in a position that describes an AUTHORED DOCUMENT:
`FormSectionSpec.fields`, read straight off the /meta/view/:name payload, the
same spec FormView metadata-admin renders (both files spell the same six-member
`type` union and call the element type `FormFieldSpec`). The narrow,
renderer-honoured shape is a different type that already exists in that file,
`RenderableField`. So this was one contract described twice, and the console's
description was wrong about the document: `visibleWhen`, `dependsOn`, `type`,
`options`, `immutable`, the recursive `fields` and ten more legal keys were
undeclared there — #5040's own symptom, "the type rejects the configuration the
runtime accepts".
Route: import, not rename. @object-ui/app-shell re-exports FormFieldSpec from
its package root (type-only, erased at build), FormPage.tsx imports it and
deletes the local declaration. Reachability is load-bearing: a type that cannot
be imported is a type that gets retyped. form-spec.ts itself is untouched.
FormPage.fieldSpec.test.ts is the pin. It reads the field-spec type back out of
the exported buildSections signature rather than naming it, so re-inlining a
local copy fails type-check even if the copy agrees on every key the day it is
written. Liveness controls keep it from being a phantom check: the removed
nine-key shape is pinned NOT equal to the shared type, RenderableField is pinned
not equal either, and an undeclared key is still rejected.
Fixes#5542
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012u2pRjcqAYtoEjgr3wwhnK
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3785.0 KB3867.2 KB
Main entry chunk (gzip)151.2 KB350 KB
Entry fileindex-CDxss6tw.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 (index.js)10.04KB3.72KB
app-shell (runtime-config.js)8.91KB2.99KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)29.34KB7.05KB
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)6.35KB2.43KB
auth (index.js)2.77KB1.22KB
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.02KB0.89KB
auth (useIsWorkspaceAdmin.js)3.04KB1.45KB
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)506.99KB113.73KB
core (index.js)4.51KB1.80KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.80KB44.33KB
fields (index.js)237.61KB59.63KB
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.22KB3.08KB
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.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
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.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.72KB18.35KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)128.53KB32.97KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)242.15KB60.89KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.07KB30.43KB
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.70KB27.17KB
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.50KB20.68KB
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)43.66KB14.77KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)1.45KB0.83KB
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)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
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-retry.js)4.32KB2.02KB
types (index.js)3.08KB1.53KB
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 (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
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-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review — ACCEPT (card #5542)

Gates. 22 named check runs read individually for completed + success: 19 success, 3 skippedTest (coverage), the unexpanded Test (coverage shard …/4) matrix placeholder, and dependabot. Those three are always-skipped no-ops in this repo's set, not missing coverage. All four real shards, Type Check, Lint, Build & E2E, Build Docs, Bundle Analysis, Doc Snippet Type Check, Doc Component Type Check, both Changeset checks and Changeset Fixed Group Check are green on the head commit.

Merits.

  • The third FormFieldSpec at apps/console/src/components/FormPage.tsx:278 carries exactly 9 keys (field, label, placeholder, helpText, required, readonly, hidden, colSpan, widget) against the shared form-spec.ts:26 declaration's 26. The dev's count is correct; my own earlier count of 10 was wrong — my read window overran the interface block into the declaration that follows and picked up its name. Correcting that here so the record is right.
  • RenderableField immediately follows and is the genuine narrower renderer type, so the duplication being removed is the redundant one, not the intentional narrowing.

PM fence error, disclosed. I fenced form-spec.ts as a read-only reuse target. FormFieldSpec there is reachable from neither views/metadata-admin/index.ts nor src/index.ts, so the target was not in fact reusable and the fence was unsatisfiable as written. The two additive export type lines in this diff are a consequence of my fence, not scope creep by the implementer.

Clause ② — not tripped. The two added lines are type-only: no contract accept/reject behaviour changes, no runtime surface widens, and the barrel already re-exports from views/metadata-admin/index.js at src/index.ts:301,311. This does not require the contract-review tier.

Flagged to the maintainer. Exporting these two types from the app barrel is a boundary call I made, not one the card asked for. It is cheaply reversible later — either a subpath export, or moving the type into a shared package — if you would rather the console barrel stay closed.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review August 21, 2026 16:47
@os-sales
os-sales added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit cebdfe7Aug 21, 2026
23 checks passed
@os-sales
os-sales deleted the claude/issue-5542-console-formfieldspec-drift branch August 21, 2026 16:48
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-sales@claude