Skip to content

fix(types): the legacy widget component envelope judges a metric-card by its arm alone (objectui#11709) - #11715

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-11709-envelope-metric-card-arm
Oct 6, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-11709-envelope-metric-card-arm

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #11709

Clause-②: no (narrowing)

What changes

The dashboard widget's legacy component slot (DashboardWidgetSchema.component in @object-ui/types/zod) was a plain z.union of the slot's component arm (DashboardWidgetSlotComponentSchema) and passthrough BaseSchema. A union takes the first arm that parses, and BaseSchema admits any node. So every metric-card the arm refuses parsed through BaseSchema on the tolerant face (safeValidateSchema, which objectui validate runs).

A module-private constructor, WidgetComponentSlotUnion, now builds the slot. It holds the same two arms and adds one routing step before the union runs. A node whose type the arm declares goes to the arm alone. The routing reads the arm's own propValues for type, which is DASHBOARD_COMPONENT_WIDGET_TYPES. Every other node goes to the unchanged two-arm union. There is no new key, no copy of the arm's rules and no new export. The comment above the slot keeps its reasoning for the passthrough that every other node (a custom widget's) gets.

The shape, measured against the alternatives (same documents, from source)

Shape label through the envelope What objectui validate prints
D: z.discriminatedUnion('type', [arm, BaseSchema]) not reachable zod refuses the option at construction (Invalid discriminated union option at index "1"), with and without unionFallback: BaseSchema's open type declares no value
A: a BaseSchema arm that refuses type: 'metric-card' refused two arms: [arm 1/2] with the arm's message, and [arm 2/2] with a second, routing-only message from the fallback
B: route by type before the union (shipped) refused the arm's issue alone, at widgets → 0 → component → label. That is the shape the same card already has directly in widgets[]

Three faces, before (da35453) and after

The envelope is { type: 'dashboard', widgets: [{ id: 'w', component: NODE }] }. The tolerant column is the exit code of the built CLI, node packages/cli/dist/cli.js validate. The tsc column is a probe program compiled against the built @object-ui/types declarations.

NODE (a metric-card plus) objectui validate before after strict face before / after tsc before / after
label 0 1 refused / refused refused / refused
body 1, both arms' messages 1, the arm's message only refused / refused refused / refused
children 0 1 refused / refused refused / refused
trend: 'sideways' 0 1 refused / refused refused / refused
no value (title only) 0 1 refused / refused refused / refused
title control 0 0 accepted / accepted accepted / accepted
README kpi_custom envelope 0 0 accepted / accepted not probed
a custom widget's text node with an undeclared key 0 0 refused / refused (the undeclared key, by design) not probed

The strict face narrows in one corner. It was measured in a base worktree against the head: a card with no value that carries only BaseSchema keys ({ type: 'metric-card' }, or that plus id / className) used to parse through the strict BaseSchema arm. It is refused now for its missing value. tsc already refused both literals, and the changeset states the corner.

Pins

packages/types/src/__tests__/dashboard-widget-slot-component-arm-7952.test.ts:

  • One enumeration pin, derived from the arm. For each member of the arm except type, the probe authors the first value from a fixed list of JSON values that the member itself refuses. Each required member also gets an omission probe. The pin then asserts, through safeValidateSchema:

    • the document is refused;
    • the member's own first issue appears at the member's own path, with the member's own message;
    • there is no invalid_union at the slot (nothing but the arm judged the card);
    • the strict face refuses it too.

    At 32dfdf0 this derives 26 probes, label, body, children, trend and an omitted value among them. A lit control requires those five to be derived, so a derivation that reads nothing cannot pass. A refusal added to the arm later (a tombstone, an enum or a required input) is enumerated with no edit. The bound is stated in the pin: a refusal that only a value outside the list trips (a .refine on a member) is not derived.

  • Controls. A well-formed card and the same card with title parse on both faces, with every key kept. A custom widget's component still keeps an undeclared key. The slot's first arm IS the arm widgets[] holds a card by.

  • Both "MEASURED LIMIT" blocks are replaced. The valueless TypeScript-face case now also asserts the tolerant refusal.

Ablation (fix reverted, pins kept)

The ablation leg used objectstack's scripts/ablation-replace.mjs. It swapped the routed constructor in complex.zod.ts back to z.union([DashboardWidgetSlotComponentSchema, BaseSchema]):

  • Mutation. Anchor x1 → x0, blob 41e1da22941826e88ad8df9031b0d101e700a73e → 1f39e187437f7bfaebbc8523abf7c93ff3ad73d8.
  • Prediction. Every derived probe red, plus the valueless assertion; the identity pin, the lit control and both controls green.
  • Observed. 53 tests: 27 failed (the 26 probes and the valueless assertion), 26 passed (the identity pin, the lit control and both controls among them).
  • Restore. Blob after restore == HEAD blob 41e1da2294, and git diff HEAD is empty.

The test imports the schema by relative path, so it resolves to source and no build was in the loop. The leg ran at 0683a7f. The commits after it change only the changeset and the census test; complex.zod.ts and the pin file are byte-identical at 32dfdf0.

One test outside the claimed file surface: objectui#11073's census

terminal-unknown-key-refusal-11073.test.ts measures the terminal unknown-key refusal. It compares each plain union against an open twin rebuilt from the union's def. It built every twin with a plain z.ZodUnion, which drops the slot's routing. On the slot's strict twin, { type: 'metric-card' } therefore read closed=false open=true, and the census reported it as an accept move of the terminal refusal.

Measured directly, the routed slot refuses that card, a plain z.ZodUnion twin accepts it, and an own-constructor twin refuses it. The one-line change builds the twin with the union's own constructor (u._zod.constr), for the same reason the file already keeps def.checks on the twin. This file is not on the claim's file surface, and the report asks the seat to amend it.

Producers

I counted the tracked files at the base for a metric-card inside a component object, with the README as the lit positive control. Only two places hold one: packages/plugin-dashboard/README.md's TypeScript Support block (kpi_custom, which is well-formed and still parses, pinned) and test fixtures inside packages/types. No producer exists under examples/**, content/docs/** or apps/**. The README is untouched.

Clause-② evidence

I compared the built @object-ui/types declarations, base against head:

  • the same 74 .d.ts files, and 1182 export heads on each side, with an identical md5;
  • the slot's declaration now names a module-private alias, WidgetComponentSlot, instead of printing the union inline. The alias is declared without export, and the module ends export {};
  • the other order-insensitive differences are union-member reorderings inside lines of zod/index.zod.d.ts and zod/layout.zod.d.ts.

Nothing widens.

Local verification, head 32dfdf0

  • pnpm exec vitest run packages/types/: 359 files, 9566 tests passed.
  • pnpm --filter @object-ui/types type-check: exit 0, over three programs. tsconfig.test.json lists both edited test files.
  • pnpm --filter @object-ui/types lint (eslint .): 0 errors over a population of 440 files, read from --format json. Warnings on the three touched files are unchanged, base against head (0 / 6 / 2). Type-aware linting is not enabled (parserOptions is empty), so this diff cannot move a verdict on an untouched file.
  • Downstream:
    • pnpm exec vitest run packages/plugin-dashboard/: 172 files, 1699 passed, 6 skipped.
    • examples/schema-catalog/: 41 files, 2263 passed.
    • packages/cli/: 25 files, 369 passed.
    • pnpm --filter @object-ui/plugin-dashboard type-check: exit 0. It needed fields, permissions, providers and plugin-charts built first.
  • Builds:
    • @object-ui/cli and its closure at the base;
    • @object-ui/types and @object-ui/cli at 1b08f95. From there to 32dfdf0 only the changeset and one test file move, so the built source is identical.
  • Gates, derived by hand from this repo's package.json and workflows, all exit 0: check:new-line-citations (0 new), check:control-bytes, check-changeset-presence, check-changeset-fixed, check-changeset-no-major, check:changeset-claims, check:pending-changeset-literals, check-governed-queue-guard --test over the four paths (NOT GOVERNED), check:spec-symbols, check:test-path-roots, check:vi-mock-override-shape, check:esm-specifiers, check:unreferenced-sources, check:doc-types.
  • NOT MEASURED:
    • check:doc-snippets and check:doc-examples: exit 2, PRECONDITION NOT MET, because the packages they resolve against are unbuilt;
    • check:readme-exports: exit 1, "the population COLLAPSED -- this run proves nothing", for the same reason;
    • no doc is touched, so none of the three is owed here;
    • the examples/** type-check: no example authors a legacy envelope.

Acceptance notes

  • objectui#4425's pending changeset. It says the tolerant face still accepts label inside the envelope through the BaseSchema fallback. This change makes that false. This PR's changeset supersedes that line in the same release, the shape objectui#11608's note used for objectui#8347's. Editing the objectui#4425 changeset directly would need the claim's surface amended.
  • body was already refused at the base. It was refused through the envelope by both arms, so the loss there was the message, not the verdict. For label, children, an out-of-enum trend and a valueless card, the verdict was lost.
  • A stale sentence in the arm's docblock. It says objectui validate prints the arm's refusal "as one arm of two". Measured, the CLI's arm selection prints the component arm's issue alone for a refused card directly in widgets[]. I did not edit it, because it is outside the routing the claim covers. Carrier: none.
  • Not merged with main. The three commits that landed since da35453 touch no path in the packages this diff touches.

Generated by Claude Code

claude added 4 commits October 6, 2026 11:26
… to its arm alone

The widget's legacy `component` slot was a plain `z.union` of the slot's
component arm and passthrough `BaseSchema`, so every `metric-card` the arm
refuses (`label`, `children`, an out-of-enum `trend`, a missing `value`)
parsed through the second arm on the tolerant face that `objectui validate`
runs. The slot now routes a node whose `type` the arm declares to the arm
alone; every other node goes to the same union as before.

Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju
Co-authored-by: Claude <noreply@anthropic.com>
…rm carries, through the legacy envelope

Derives each probe from the arm's own members (a tombstone, an enum, a typed
member, a required input) and asserts the tolerant face refuses it inside a
widget's `component` envelope with the member's own message and no union
issue at the slot. Replaces the two MEASURED LIMIT notes that recorded the
`BaseSchema` fallback, and keeps the title and custom-widget passthrough
controls.

Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju
Co-authored-by: Claude <noreply@anthropic.com>
…d narrowing (objectui#11709)

Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju
Co-authored-by: Claude <noreply@anthropic.com>
…ith the union's own constructor

The census compares each closed union with an open twin rebuilt from the same
def. It rebuilt every twin as a plain `z.ZodUnion`, which drops the widget
`component` slot's routing (objectui#11709) and read the routing as an
accept-set move of the terminal refusal. The twin now keeps the union's
constructor, as it already keeps `def.checks`. The changeset names the one
strict-face corner the routing narrows.

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

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 330 chunks) 3323.2 KB 3330.4 KB
Main entry chunk (gzip) 153.6 KB 350 KB
Entry file index-CUoecvf-.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) 17.60KB 6.51KB
app-shell (runtime-config.js) 22.52KB 7.86KB
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.15KB 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.11KB 7.97KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.28KB 2.60KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.50KB 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) 574.72KB 137.94KB
core (index.js) 10.00KB 3.96KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 233.72KB 64.83KB
fields (index.js) 262.75KB 66.62KB
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.52KB 2.39KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.35KB 12.88KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 35.66KB 9.49KB
i18n (useSafeTranslation.js) 7.14KB 2.92KB
layout (index.js) 41.18KB 11.71KB
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.04KB 3.92KB
plugin-calendar (index.js) 53.39KB 15.52KB
plugin-charts (index.js) 84.26KB 23.05KB
plugin-chatbot (index.js) 198.81KB 47.14KB
plugin-dashboard (index.js) 143.75KB 38.87KB
plugin-designer (index.js) 231.46KB 48.87KB
plugin-detail (index.js) 247.23KB 65.04KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 177.09KB 45.89KB
plugin-gantt (index.js) 179.16KB 45.06KB
plugin-grid (index.js) 238.48KB 65.51KB
plugin-kanban (index.js) 52.17KB 16.37KB
plugin-list (index.js) 116.72KB 29.10KB
plugin-map (index.js) 25.60KB 8.62KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.12KB 12.29KB
plugin-timeline (index.js) 38.94KB 11.75KB
plugin-tree (index.js) 14.51KB 5.15KB
plugin-view (index.js) 90.23KB 22.73KB
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) 120.63KB 39.56KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.31KB 2.07KB
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) 7.30KB 3.12KB
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) 23.87KB 7.83KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (authoring-nodes.js) 0.20KB 0.19KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (cloud.js) 0.20KB 0.18KB
types (complex.js) 4.44KB 2.07KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (dashboard-widget-layout.js) 2.06KB 0.96KB
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) 1.13KB 0.65KB
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) 5.78KB 2.70KB
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 (node-slots.js) 7.18KB 2.34KB
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) 4.99KB 1.96KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.93KB 7.25KB
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
objectstack-fleet Bot marked this pull request as ready for review October 6, 2026 12:49
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 6, 2026 12:50
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 6, 2026
Merged via the queue into main with commit 89cc738 Oct 6, 2026
45 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-11709-envelope-metric-card-arm branch October 6, 2026 13:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants