Skip to content

fix(app-shell): the generic metadata editor renders a view's served schema (a top-level anyOf), and a scalar-arm repeater draws scalar rows (objectui#11251, objectui#10239) - #11277

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-11251-schemaform-top-level-union
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-11251-schemaform-top-level-union

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #11251
Fixes #10239
Clause-②: no

What was wrong

Studio → object → Related → Views → a row opens MetadataDetailDrawer, which mounts MetadataResourceEditPage embedded; with no canvas there, the page renders the generic SchemaForm over the served view schema. That schema is the server's derivation of ViewMetadataSchema: a top-level anyOf over the stored shapes (ViewItem record, defineView container, list overlay, form overlay) with no top-level properties. SchemaFormBody read only effectiveSchema.properties ?? {}, so every stored view opened an editor with zero controls.

The fix

  1. SchemaFormBody resolves a top-level anyOf / oneOf against the value with the existing resolveUnionBranch (the resolver repeaterItemSchema and resolveFieldFace already use). No second resolver. A schema that declares its own properties beside a union is left alone.
  2. Two refinements inside resolveUnionBranch, because the obvious call lands the stored views on the wrong member. Measured on the installed spec 17.5.0 with the unextended scorer: a ViewItem resolved to the list overlay, a flattened list overlay carrying type: 'grid' resolved to the FORM overlay, and a container resolved to the list overlay. The ViewItem case is the harmful one: its form would show the columns under config as an empty top-level columns, and ViewMetadataSchema.safeParse of that ViewItem plus a top-level columns / type edit answers success: true with branch viewItem and those keys stripped, so the edit would be dropped silently. The refinements:
    • unionMembers: a member that is itself a bare union (no type, no properties) is scored member by member. The ViewItem arm is { oneOf: [list ViewItem, form ViewItem] } and scored zero as one member.
    • valueContradictions: among members that fit the value's kind, the one the value contradicts least wins: a key a closed member does not declare, a primitive outside a const / enum, a required key the value lacks. An empty value contradicts nothing (create mode still lands on the first member). Where contradictions tie, ranking is the old score in the old order.
  3. objectui#10239's scalar-arm guard, carried as triage bound it. RepeaterField draws one scalar control per row when its items resolve to a scalar arm and the value has rows; Add appends a scalar. Before, a string-array columns row got the form's 14 object sub-fields, the card summary read String.prototype.link, and editing wrote {"0":"n","1":"a","2":"m","3":"e","field":"title"}. With no rows the first arm wins by order alone, so the empty value keeps its object-row first Add.

The curated designer (VIEW_CURATED_FIELDS, FORM_FIELDS_OWNED_ELSEWHERE, ViewVariantInspector.tsx) is untouched.

Measured through the real page

jsdom, the real MetadataDetailDrawer → MetadataResourceEditPage (embedded), the mocked client serving z.toJSONSchema(ViewMetadataSchema, { unrepresentable: 'any' }) + METADATA_FORM_REGISTRY.view. Not run in a browser. Before = base 54a78308a, after = 4a9380847, same probe, form controls counted by scoped mdf-drawer-metadata. id after clicking Edit:

stored shape before after
ViewItem 0 2 (name, label)
list overlay 0 11, including 2 scalar columns rows
container 0 2 (name, label)
control: ListViewSchema served 9 ids, 2 column rows the same 9 ids, 2 column rows

Pins — SchemaForm.topLevelUnion-11251.test.tsx

  • Served view schema through the real drawer: each of the three stored shapes renders controls, and not the wrong member's (ViewItem: no top-level type / columns / searchableFields; list overlay: searchableFields and the columns group, which the form overlay lacks; container: none of the list overlay's).
  • Control: ListViewSchema served renders its list form with two object column rows.
  • objectui#10239, through SchemaForm over ListViewSchema (top-level properties, so independent of the union step): a string-array columns stays strings ListViewSchema accepts, the row control is named by the host label plus its ordinal, Add appends ''; object rows keep their declared fields; the empty value's first Add gives an object row.

Ablations (committed HEAD 2082cb00b, mutations through scripts/ablation-replace.mjs with anchor hit and blob change proven, each restore proven blob == HEAD and git diff HEAD empty; the test imports ./SchemaForm from source, so no build leg)

leg mutation red
A drop the top-level resolution exactly the three view rows (3 failed / 5 passed)
B drop the scalar-row guard exactly the string-array row (1 / 7)
C1 drop the contradiction ranking the list overlay row (1 / 7)
C2 drop the bare-union flattening the ViewItem row (1 / 7)

Gates

  • pnpm exec vitest run over all 368 test files under packages/app-shell/src/views/metadata-admin/, in 7 chunks at 4a9380847: 368 files passed, 4346 tests passed, 1 skipped. SchemaForm consumers outside it (studio-design panels, console FormPage.sectionGroup): 6 files, 20 tests passed.
  • At 2082cb00b (it differs from 4a9380847 by comment text, one test name and changeset prose): the pin file 8/8; the 43 SchemaForm.* / ResourceEditPage.* / EmbeddedItemEditor.* files, 413 tests passed.
  • pnpm --filter @object-ui/app-shell type-check (closure built first): exit 0 at 4a9380847; tsconfig.test.json --listFilesOnly lists the new pin file.
  • At 2082cb00b: pnpm exec eslint on both changed files 0 errors (16 warnings, all on unchanged lines); check-changeset-presence ✅; check-control-bytes ✅; check:new-line-citations 0 new.

Acceptance notes

  • The served viewForm describes the flattened list shape only. A ViewItem or a container therefore renders its identity controls (name, label) plus the form's "declared in form layout but missing from schema" notice for the rest (8 each in the drawer). The ViewItem's list config under config is not editable through this door. Observation; the form registry is objectstack's. Carrier: none.
  • In the served JSON, the overlays' z.undefined() refusals of config and the container slots derive to {} (accepts anything), so the served document cannot tell those refusals from free keys. The resolver separates container from overlay through the overlays' required object + viewKind pair instead. Observation only.
  • Resolver reach beyond view: across the installed spec's served derivations (34 derived documents, both io modes), bare nested unions occur only at the view top level and at the visible / disabled predicate slots of action and object actions (input derivation), which render through the registered condition widget. No union member lacks type while carrying required. One-time scan; nothing re-derives it.
  • A mixed columns array whose FIRST row is an object still draws object rows for every row. No valid stored view carries one: both arms refuse a mixed array at save.

Implemented by the domain:ui seat 2 dev run, session https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec.


Generated by Claude Code

…el union, and a scalar-arm repeater draws scalar rows

SchemaFormBody read only top-level `properties`, so the served `view`
schema (a union over the stored shapes) rendered zero controls in the
MetadataDetailDrawer editor. It now resolves a top-level anyOf/oneOf
against the value with resolveUnionBranch. The resolver scores a bare
nested union member by member and ranks members the value contradicts
below those it does not, so each stored view lands on its own member.

RepeaterField draws one scalar control per row when its items resolve
to a scalar arm and rows exist, so a string-array columns stays strings.

Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec
Co-authored-by: Claude <noreply@anthropic.com>
… what they measure

The ranking comment marks its one-time scan as not re-derived, the
changeset states the tie rule exactly, and the container pin is named
for what it observes (controls render, none of the list overlay's).

Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

changeset-claim-re-read

⚠️ 3 pending changeset(s) describe a file this change touches

Their bodies publish verbatim into the CHANGELOG at the next release, so this is a request to re-read them against your diff — addressed here because you are the one seat that can answer it without re-deriving anything.

⛔ Nothing here blocks, and nothing here is a verdict on your change. This gate exits 0, is not a required context, and judges name resolution, never meaning: it asked whether a pending body names a file you touched. "Is this sentence still true?" is the one question it will not answer, and the one you are being asked to answer.

.changeset/6247-option-visiblewhen-metadata-admin.md

  • names SchemaForm.tsx → packages/app-shell/src/views/metadata-admin/SchemaForm.tsx — edited by this change

    Three consuming controls now filter, and each has a pin asserting an option absent on a false predicate: the builtin Select in SchemaForm.tsx, MultiSelectWidget, and colorPaletteOptions → ColorSwatchGroupWidget. Asserting absence is the whole of the test design — this evaluator fails OPEN, so "the option is shown" is equally the outcome of predicate-true, predicate-never-arrived and predicate-faulted, and a positive-only pin passes against the unfixed renderer.

.changeset/console-form-container-specs-one-declaration-5596.md

  • names SchemaForm.tsx → packages/app-shell/src/views/metadata-admin/SchemaForm.tsx — edited by this change

    objectui#5542 converged the LEAF of this contract — the field spec — and left the two containers above it untouched, because converging them was a bigger call than a mechanical import. FormSectionSpec and FormViewSpec were each hand-declared twice under the same names, once in packages/app-shell's SchemaForm.tsx and once in apps/console's FormPage.tsx. Unlike the leaf — whose console copy was a clean subset — these two had already drifted, in both directions, so neither copy was a subset of the other and there were two live answers to "what may an author write":

.changeset/formfieldspec-dependson-5040.md

  • names SchemaForm.tsx → packages/app-shell/src/views/metadata-admin/SchemaForm.tsx — edited by this change

    No runtime behaviour changes: MetadataField already handed dependsOn through and both widgets already read it. What changes is the type face — it now admits what the runtime has always accepted. The two descriptions are one declaration, extracted to a leaf module (views/metadata-admin/form-spec.ts) that both halves import, because SchemaForm.tsx imports ./widgets.js and a back-edge would close a cycle. SchemaForm.tsx re-exports FormFieldSpec and VisibilityPredicate, so every existing importer is unaffected.

Read the paragraph, not the line: both false halves of the objectui#8617 claim sat in one paragraph, and correcting either alone would have left it asserting the same wrong thing.

If a claim did go false, correct the body. That is precedented and prose-only, frontmatter untouched; check-changeset-overwrite.mjs will report the correction as its own case 2 ("correcting a declaration on purpose … legitimate"), which is the intended shape — one gate asks for the read, the other records the write.

Not covered, stated so nobody reads this as more: a born-false claim that spells no line address at all (objectui#9495 coordinated one by ORDINAL — "a grep finds that member first" — and deciding that means reading what the sentence means), a claim spelled as a symbol or a package rather than a backticked file name, and a file named ambiguously.

Compared the checked-out tree with f16c01e90 (merge-base with origin/main): 2 file(s) changed outside .changeset/, read against 1849 pending declaration(s) that publish a body (2461 pending in total). · run

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 330 chunks) 3584.3 KB 3607.4 KB
Main entry chunk (gzip) 150.0 KB 350 KB
Entry file index-BU_5hMwa.js —
Status PASS —

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.88KB 6.25KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.17KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.70KB 10.94KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.13KB 7.95KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 570.09KB 136.40KB
core (index.js) 10.00KB 3.96KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 228.86KB 63.53KB
fields (index.js) 261.11KB 66.26KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.24KB 2.27KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.40KB 12.91KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 34.35KB 9.18KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 41.23KB 11.53KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.86KB 5.00KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.52KB 2.26KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.01KB 3.93KB
plugin-calendar (index.js) 52.17KB 15.06KB
plugin-charts (index.js) 84.09KB 22.93KB
plugin-chatbot (index.js) 198.22KB 46.97KB
plugin-dashboard (index.js) 138.72KB 37.05KB
plugin-designer (index.js) 216.32KB 44.56KB
plugin-detail (index.js) 244.72KB 64.45KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 173.61KB 44.59KB
plugin-gantt (index.js) 173.03KB 43.07KB
plugin-grid (index.js) 231.28KB 63.53KB
plugin-kanban (index.js) 48.62KB 15.21KB
plugin-list (index.js) 116.42KB 28.89KB
plugin-map (index.js) 23.50KB 7.82KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.04KB 12.21KB
plugin-timeline (index.js) 33.05KB 9.67KB
plugin-tree (index.js) 11.20KB 3.89KB
plugin-view (index.js) 90.32KB 22.76KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.81KB 3.58KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 119.55KB 39.23KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.50KB 1.99KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 6.06KB 2.68KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 21.42KB 7.05KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 3.19KB 1.62KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.26KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 21.59KB 7.71KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2082cb00b2ba51e6adfdb5018c000f4ada2f9f27
Local-runs: none

① Derived judgments

Inputs and gate verdicts. Card #11251 (body; triage 5917836441, claim 5919281525, dev report 5920910481); coupled card #10239 (body; all six comments, including its dev report 5916952580 and seat 1's release 5917056769); PR #11277 (body, the 3-file list, the diff against main at the head, the PR's own comments 5920570625 and 5920591799); the head's check-runs with filter=latest: 43 runs, all completed — 40 success, 3 skipped (Test (coverage), Test (coverage shard), dependabot: conditional jobs), 0 in progress. Type Check, Lint, Test shards 1–8, Test, Test (dist pins), Build & E2E, Changeset Declaration / Bump Policy / Fixed Group Check / Overwrite Report / Claim Re-read, Line Citation Gate, Control Byte Scan, Governed Surface Queue Guard: success. Context read through contents?ref= only: SchemaForm.tsx, ResourceEditPage.tsx, MetadataDetailDrawer.tsx, src/index.ts, SchemaForm.repeaterUnion.test.tsx, SchemaForm.nestedAnyOfPredicate-9830.test.tsx and the three pending changesets at the head; objectstack packages/spec/src/ui/view.zod.ts and view.form.ts at 0f6dcac5e9 (the 17.5.0 tag commit); compare/4a9380847...2082cb00b.

The declared deviation (dev report deviation 1) — judged first. Right, with the moves named.

  • One function, resolveUnionBranch (SchemaForm.tsx:344), and its call sites are the old ones plus one: repeaterItemSchema (:428, :435), resolveFieldFace (:848, :854), the new SchemaFormBody call (:1199). Not a second resolver: unionMembers is a pre-pass on the branch list and valueContradictions is one more key in the same ranking loop. Triage's ⛔ against a second resolver holds.
  • Ranking now: kind (a type that fits the value), then fewest contradictions, then rest (the items-type tiebreak plus required keys present), then declaration order. Before: the single sum kind + rest, declaration order on ties. Where contradictions tie, the lexicographic (kind, rest) equals the old sum unless a branch with no fitting type carries at least six more present required keys than a typed sibling; the head's comment (:332–:335) names that shape as absent from every served derivation. valueContradictions is 0 for every non-object value, an empty object, and any branch without properties, so scalar, array and empty-value picks are the old ones.
  • Values whose branch MOVES for an existing caller, each judged right:
    1. A field-level object union on a partial draft whose named discriminator the old tie ignored. ViewDataSchema (view.zod.ts:162, four strict arms discriminated on provider): data: { provider: 'api' } (likewise 'value', 'schema') resolved before to the FIRST arm, object (every arm scored 1 for provider; tie, declaration order), and resolves now to the api arm (0 contradictions against the object arm's 2: const mismatch, missing object). The old pick rendered the wrong provider's form; the move is the correction. A complete value ({ provider: 'object', object: 'x' }) and a provider-less draft ({ object: 'x' }) pick as before.
    2. A bare nested union at a field. The dev's one-time scan places these only at the view top level and at the visible / disabled predicate slots of action and object actions (input derivation) — the shape SchemaForm.nestedAnyOfPredicate-9830.test.tsx fixes (anyOf: [boolean, anyOf: [string, envelope]]). With a string value the resolved effective was the boolean arm (nothing fit, first branch) and is now the string arm. No face moves: those keys render through the registered condition widget, which resolveFieldFace returns before it reads effective (:863–:866), and FieldControl hands a registered widget the raw schema (:2165). The 9830 pin is green on the head.
    3. Generic class, no served instance found by this review and none claimed by the dev: two object-typed branches where a partial draft of the richer one (one required key still missing) lands on the leaner one until that key is filled — a properties-bearing arm beside a z.record arm would do it. Bounded by the 368-file metadata-admin suite (4346 green at 4a9380847) and by CI on the head; not a pin gap of this card. Named so the seat has it.
  • Ablations C1 / C2 against the diff and the spec: C2 (no unionMembers) scores the ViewItem wrapper { oneOf: [list arm, form arm] } as one typeless member (kind 0), so a stored ViewItem falls to the list overlay (kind 5) and the ViewItem pin's null assertions on type / columns-label / searchableFields go red — matches. C1 (no contradictions) leaves the flattened list overlay value to rest: the list overlay member requires the identity pair only (listOverlayPatchFields, view.zod.ts:5974 — columns optional, type without its default), the form overlay member requires the pair plus type (FormViewSchema.type .default('simple'), :4191, hence required in the output derivation) and the value's type: 'grid' counts for it: 3 beats 2, the form overlay wins, searchableFields goes null — the list-overlay row goes red, matches. Necessity is measured for both, as claimed.
  • The container pin (renamed in 2082cb00b, rightly): under C1 the container value would land on the list ViewItem arm (rest 2: name, object), not on the list overlay, and through this door the two members render the same name / label controls, so that pin cannot tell the container member from the ViewItem arm. The container's own member is what valueContradictions reaches (container 0; ViewItem arms 3; overlays at least 1 for the missing viewKind) — judged from the spec, not from a red leg. A limit of what the pin can observe through the served viewForm, not a defect.

Each stored shape lands on the member that keeps it. Right. Against ViewMetadataSchema at 0f6dcac5e9 (VIEW_METADATA_BRANCHES order: viewItem, container, listOverlay, formOverlay; :6175):

  • ViewItem { name, object, viewKind: 'list', label, config }: list ViewItem arm 0 contradictions, rest 4; list overlay 0 contradictions (its config guard derives to {}, so no undeclared-key hit), rest 2 — the ViewItem arm wins. Its properties carry no type, columns or searchableFields, so the served viewForm renders those as missing-from-schema notices, never as controls: no top-level columns / type edit exists to be stripped on save. The pin asserts exactly that (byId null for all three).
  • List overlay: list overlay 0 against the form overlay's at least 2 (viewKind enum, type enum) — searchableFields, type and the columns group are asserted present.
  • Container { name, object, label, list }: container (ViewSchema, strict, every key optional) 0; ViewItem arms 3 (viewKind and config missing, list undeclared on a closed arm); overlays at least 1 (viewKind required) — the container wins.
  • Residual, named for the seat: the resolution runs on every render against the live draft, as every field-level caller of this resolver already does. On a stored ViewItem the one single-key edit that moves it is blanking name (rendered and not locked on an existing item; ResourceEditPage.tsx:2828–:2864 hands the draft to SchemaForm unchanged): the form then transiently shows the list overlay's controls, and a top-level columns / type edit made inside that window survives the name being retyped (the closed ViewItem arm keeps 1 contradiction for that key against the overlay's 0) and is stripped by member 1 on save. Reach needs a deliberately blanked identity key plus an overlay-only edit inside that window; the stored-shape edit the card measures never reaches it. Not a breach of the direction, which was resolution against the value with this resolver.

Top-level resolution only when the schema has no properties of its own. Right: SchemaFormBody :1195–:1200 gates on effectiveSchema.properties === undefined and an anyOf / oneOf array, after inlineSchemaRefs. The control (ListViewSchema, top-level properties) is untouched: 9 ids and 2 object rows before and after.

Scalar-row guard. Right. RepeaterField receives schema = { ...schema, items: face.itemSchema } (:2117, :2209), where face.itemSchema is repeaterItemSchema's pick — for ['name', 'status'] the array-of-strings arm (rest +3 on the first item), so items.type === 'string'. With rows present (:2691) each row is one FieldControl over the row value (id mdf-columns.N, named by the host label plus its own ordinal), replace keeps a cleared string row as '', and Add appends ''. Empty value: repeaterItemSchema still picks the first arm (strings) but rows.length === 0 skips the guard, so add() pushes the object row as before, after which the first row is an object and the object arm resolves. Object rows keep their declared sub-fields (mdf-columns.1.field / .width / .label). A stray object row in a scalar list gets schema={} (its JSON), never coercion. The three objectui#10239 pins assert each of these through SchemaForm over ListViewSchema — top-level properties, so independent of the union step. Ablation B (guard dropped) reds exactly the string-array row — matches.

Pins and the four legs. 8 tests in SchemaForm.topLevelUnion-11251.test.tsx: 1 fixture admissibility (the three bodies parse under ViewMetadataSchema; the served JSON has anyOf and no properties), 3 stored shapes through the real MetadataDetailDrawer mounting MetadataResourceEditPage embedded (mocked useMetadata serving z.toJSONSchema(ViewMetadataSchema) plus METADATA_FORM_REGISTRY.view, Edit clicked, controls counted by mdf-drawer-metadata. id), 1 control, 3 for objectui#10239. Leg A (top-level call dropped) reds exactly the three view rows — the control and the 10239 rows do not depend on it — matches. All four legs are consistent with the diff, each red set as predicted.

Public surface and accept set. Nothing moves. resolveUnionBranch was module-private at the base and stays so (no export at :344; the diff's context line is unchanged). resolveFieldFace is a module export but not a package export: packages/app-shell/src/index.ts exports SchemaForm and MetadataResourceEditPage from metadata-admin (:377, :382) and neither resolver. No type, prop, schema or registry entry changes; the curated designer (VIEW_CURATED_FIELDS, FORM_FIELDS_OWNED_ELSEWHERE, ViewVariantInspector.tsx) is not in the 3-file list. objectui#10239's guard and its three pins ride this PR, as the coupling binds.

Declared narrowing. Verified: compare/4a9380847...2082cb00b is one commit, three files — SchemaForm.tsx +8/−4 inside the resolver's JSDoc only (:329–:336), the pin file +3/−1 (one it title and a two-line comment), the changeset +2/−2 prose. No code moved, so the suite, the consumers and the type-check at 4a9380847 cover the head; the pin file, the 43-file subset, eslint and the check scripts were re-run at the head per the report, and CI is green on the head.

② Semver level

.changeset/11251-schemaform-top-level-union.md: '@object-ui/app-shell': patch. Right. What the diff publishes is a behaviour fix inside one component — the served top-level union renders, and a scalar-arm repeater writes scalars. No exported type, prop, name or accepted set changes (① above); the two behaviour moves named in ① are corrections of picks the old resolver made by fallback. Changeset Declaration, Bump Policy, Fixed Group Check and Overwrite Report are success on the head.

Clause-②: no — present in the PR body and in the claim (5919281525), and right: the generic form renders the schema it already receives; nothing the package accepts or publishes moves.

Changeset-claim-re-read (5920570625: 3 pending bodies name SchemaForm.tsx), each read against the diff at the head:

  • 6247-option-visiblewhen-metadata-admin.md — the builtin Select in SchemaForm.tsx filters per-option visibleWhen, and resolveFieldFace's hasOptions reads the raw list. The diff's three hunks (:239–:406, :1185–:1200, :2674–:2741) touch neither the Select nor the options path. Still true.
  • console-form-container-specs-one-declaration-5596.md — a historical claim about two hand-declared copies, plus the re-export of the container types: SchemaForm.tsx:81 still re-exports FormSectionSpec / FormViewSpec from ./form-spec.js. Still true.
  • formfieldspec-dependson-5040.md — "SchemaForm.tsx imports ./widgets.js" (:70) and "re-exports FormFieldSpec and VisibilityPredicate" (:81). Still true.
    No correction needed; nothing for check-changeset-overwrite to record.

③ Boundary flags

Dev report 5920910481: open_questions: []. Six deviations, each answered:

  1. The resolver refinements — judged in ①: the existing resolver, extended; the moving values named; necessity measured by C1 / C2.
  2. The declared narrowing — verified in ① from the compare.
  3. Probe files never committed — the PR's file list is the three files and nothing else.
  4. The worktree removed and re-created for the root scripts/__tests__ run — process; no bearing on the head.
  5. Lock queue timeouts retried on the same slot — process.
  6. Labels tests / package: app-shell from the labeler — they match the PR; the run wrote none.

Out-of-scope notes, against the filing classes:

  • The served viewForm covers the flattened list shape only, so a ViewItem or a container renders name / label plus 8 missing-from-schema notices, and a ViewItem's config is not editable through this door. Measured through the real page (the 8 notices); named site METADATA_FORM_REGISTRY.view / view.form.ts in objectstack. Not an objectui defect: the form renders what the served form declares, the notices are the designed signal for a layout/schema mismatch, and the curated designer is the view's editor. It is a spec-side form-registry gap with measured reach, so it is escalated to the seat as an objectstack domain:spec candidate — not filed here, not blocking.
  • The overlays' z.undefined guards derive to {} — an observation the resolver already routes around (the overlays are separated by their required object + viewKind, the container by carrying no viewKind); the members refuse loudly at save; no silent drop measured. Not a filing class.
  • A mixed columns array whose first row is an object draws object rows — both arms refuse a mixed array at save and no valid stored view carries one. Not a filing class.

PR body at the head: Fixes #11251 and Fixes #10239 are its first two lines, Clause-②: no the third. Every sentence checked holds at the head: the call sites named exist; the gate condition and both refinements read as described; the pins are as listed; the measured table matches the report; the narrowing sentence is exact per the compare; the 34-document scan is the dev's declared one-time measurement, with its predicate-slot half corroborated by the 9830 pin; the curated designer is untouched. One precision note, not a falsity: "where contradictions tie, ranking is the old score in the old order" is exact given the body's own acceptance note that no served member lacks type while carrying required, and the head's code comment states that exception. The PR is a draft; flipping it is the seat's act, not a finding.

Implemented-by: claude/issue-11251-schemaform-top-level-union
Reviewed-by: session_011p7ikEivgXefNDaE5S5Uec

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 30, 2026 22:56
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit 1b30c0f Sep 30, 2026
45 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-11251-schemaform-top-level-union branch September 30, 2026 23:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment