Skip to content

fix(console): make invalid_type decisive in refusesKind, so union-typed members are judged by what the contract refused (objectui#8204) - #8210

Merged
os-sam merged 1 commit into
mainfrom
claude/fix-refuseskind-union-recursion-8204
Sep 7, 2026
Merged

os-sam merged 1 commit into
mainfrom
claude/fix-refuseskind-union-recursion-8204

Conversation

@claude

@claude claude Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #8204

Test-only. One logic line added and one removed, both inside refusesKind; everything
else in the diff is prose and a changeset.

What was actually wrong — the first suspected mechanism is FALSE

objectui#8204 suspected that refusesKind does not recurse into z.union. Measured on
disk: it does. The invalid_union arm at the bottom of the every runs, and it is
what carries branch verdicts up. The defect was one level in — in how a single branch's
issues were read.

refusesKind asked "is EVERY complaint at this node a kind complaint". Zod keeps
running a schema's checks after its type check has already failed, so one node can carry
both kinds of complaint at once. For z.string().min(1) the probe [] comes back as
invalid_type (expected string) and too_small ([].length is 0). Read by every,
that pair is a CONTENT refusal — and the value it describes is an array the contract
refused for being an array.

The cleanest evidence is two probes on one key, differing only by the artefact issue.
@objectstack/spec 17.3.0 declares
RecordActivityProps.types = z.array(z.union([FeedItemType, z.string().min(1)])):

probe []           branch 2 -> [invalid_type, too_small]  -> every() false -> refuses-content
probe ['Account']  branch 2 -> [invalid_type]             -> every() true  -> refuses-kind

Same key, same coarse kind, opposite verdicts. [] is the first array probe and the
accepted-kind walk is a some, so that one probe alone put array into the key's
accepted set.

The fix

invalid_type at a node is now decisive, ahead of the every. This is a statement about
the contract, not about Zod: invalid_type says the value is not of that node's type, and
a value cannot be simultaneously the right kind and the wrong one, so whatever else the
node says about a value it has already refused for its kind is downstream of that refusal
rather than a second opinion competing with it. too_small on a non-string is exactly
such an artefact — "too short" is not a claim anyone would make about [] against a
string contract. The change also makes the invalid_type arm of the every unreachable,
so it is gone.

The second suspected mechanism is INTENDED, and is now documented

acceptedMemberKinds counting a kind when the verdict is accepts or
refuses-content is deliberate, is the same fold judgeMembers and the arm direction
already make, and is load-bearing. Measured on 17.2.0, where record:activity.types is a
bare z.enum([...]) of strings: every string probe is refused as invalid_value,
'Account' among them, so an accepts-only reading answers {} for a key whose
of: 'string' was derived from that very enum, and the one-kind assertion would red on a
declaration that is right. The fold was never the leak; it was the amplifier. A comment at
the helper now says so, as objectui#8204 asked.

Measurements

Verdicts are safeParse against the installed @objectstack/spec artifact, and the
kind sets are the gate's own, from its own failure message.

The CI list is not derived — three of its four names are clean. CI on PR #7685 head
f1e02a88f named element:record_picker.sort, object-grid.searchableFields and
page:accordion.items alongside record:activity.types. They appear because that
assertion's message prints every judged row; the received array held exactly one
element. Measured on 17.3.0:

member accepted kinds, before after
record:activity.types {string,array} {string}
element:record_picker.sort {object} {object}
object-grid.searchableFields {string} {string}
page:accordion.items {object} {object}

Positive control — a genuine multi-kind member stays multi-kind.
record:highlights.fields is a real member union (a field NAME or an inline field object)
and is the one key MULTI_KIND_MEMBER_CONTRACTS pins. It reads {string,object} before
and after, on 17.2.0 and on 17.3.0. So the fix did not collapse members to one kind.
Across all sixteen keys checked on both spec versions, exactly one row moves:
record:activity.types on 17.3.0.

The gate itself, run against a real 17.3.0 artifact (the installed 17.2.0 package
swapped for a registry copy of 17.3.0 under the repository's own zod 4.4.3, then restored
and the restore proven by sha256 of dist/ui/index.mjs):

before  Tests  7 failed | 174 passed (181)
after   Tests  6 failed | 175 passed (181)

The failure that disappears is member declarations are derived from single-kind member contracts — this card's. No new failure appears.

The remaining six are not this card's, and one of them is red on purpose.
the member judge reds on the drift that started this fails at
expect(specMemberVerdict('record:activity', 'types', 'array', 'Account')).toBe('refuses-content')
— accepts received, because 17.3.0's open vocabulary genuinely accepts it. That is
failure (1) of objectui#8137, it sits on the manual floor, it is undecided, and it is
untouched here. The other five are 17.3.0 spec-drift ledger failures around
object-grid.defaultSort, which belong to objectui#7122 / PR #7685.

Gates

All at 65b2e6451; git status is empty at that commit, so every figure below was
measured on that tree.

  • pnpm exec vitest run apps/console/src/__tests__/registry-inputs-spec-parity.test.ts on the
    installed 17.2.0 — Test Files 1 passed (1), Tests 181 passed (181), unchanged from
    the pre-fix baseline.
  • pnpm exec vitest run apps/console/ — Test Files 89 passed (89), Tests 1052 passed (1052).
  • pnpm --filter @object-ui/console run lint — 0 errors (208 pre-existing warnings, none
    in this file; eslint --format json on the changed file reports 0/0).
  • pnpm --filter @object-ui/console run type-check — exit 0, after
    pnpm --filter '@object-ui/console^...' build. tsc --listFiles confirms the changed
    file is in the program, so the green is a measurement of it and not an exclude.
  • node scripts/check-changeset-presence.mjs — OK, empty-frontmatter declaration.
  • node scripts/check-control-bytes.mjs — OK.

Coordination with objectui#8176

Per the file/region granularity ruling (maintainer, 2026-09-05), the regions touched are:

  • refusesKind and its doc comment — the shared classifier in the ARM direction
    helper block (objectui#4971), lines ~1124-1180.
  • One comment block above acceptedMemberKinds inside the MEMBER direction
    (objectui#8067) test member declarations are derived from single-kind member contracts,
    line ~2705.

objectui#8176 works the REVERSE direction (objectui#3808, eager-registration judging),
whose block begins at line 2189 and whose helpers sit above line 700. No overlap: nothing
in this diff falls inside the reverse-direction block, and no reverse-direction assertion
or helper is read or written here. There is no collision to merge around.

Scope bans, honoured

  • No MULTI_KIND_MEMBER_CONTRACTS entry added.
  • No expectation literal, calibration control or it(...) assertion edited. The diff
    contains no expect(, toBe(, toEqual(, or probe-table line on either side.
  • A green suite was not the criterion, and the suite is not green on 17.3.0. It should
    not be.

Generated by Claude Code

…-typed members are judged by what the contract refused (objectui#8204)

`registry-inputs-spec-parity`'s kind classifier asked "is EVERY complaint at this
node a kind complaint". Zod keeps running a schema's checks after its type check
has already failed, so a node can carry both at once: for `z.string().min(1)` the
probe `[]` comes back as `invalid_type` AND `too_small`. Read by `every`, that
pair is a CONTENT refusal — and the value it describes is an array the contract
refused for being an array.

Measured on the installed `@objectstack/spec` 17.3.0 artifact, whose
`RecordActivityProps.types` is `z.array(z.union([FeedItemType, z.string().min(1)]))`:
the `[]` probe alone put `array` into that key's accepted kind set, and the
derivation gate reported `record:activity.types → contract accepts {string,array}`
for a key whose array members `safeParse` measurably rejects.

`invalid_type` at a node is now decisive, ahead of the `every`: a value cannot be
simultaneously the right kind and the wrong one, so whatever else the node says
about a value it has already refused for its kind is downstream of that refusal,
not a second opinion. That also makes the `invalid_type` arm of the `every`
unreachable, so it is gone.

The card's first suspected mechanism — "`refusesKind` does not recurse into
`z.union`" — was measured and is FALSE: the recursion runs and is what carries
branch verdicts up. The per-branch reading underneath it was the defect. The
second — `acceptedMemberKinds` folding `refuses-content` into "accepted" — is
INTENDED, is the same fold `judgeMembers` and the arm direction already make, and
is now documented as such at the helper with the 17.2.0 measurement that shows
dropping it would break the enum case the coarse-kind ceiling exists for.

Test-only. No expectation literal, calibration control or `it(...)` assertion is
edited; the two logic lines are inside `refusesKind`, the rest is prose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114Ytxr5sM1vdW19Y9WAx6E
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3189.7 KB 3191.4 KB
Main entry chunk (gzip) 143.9 KB 350 KB
Entry file index-hEe9Dmno.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) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
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) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
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) 498.00KB 113.91KB
core (index.js) 6.96KB 2.79KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 187.85KB 52.13KB
fields (index.js) 243.04KB 61.36KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
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) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
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) 15.16KB 3.68KB
plugin-calendar (index.js) 47.67KB 13.25KB
plugin-charts (index.js) 70.62KB 19.71KB
plugin-chatbot (index.js) 193.54KB 46.04KB
plugin-dashboard (index.js) 131.41KB 34.43KB
plugin-designer (index.js) 211.51KB 43.01KB
plugin-detail (index.js) 247.68KB 63.49KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 131.01KB 32.32KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.58KB 56.63KB
plugin-kanban (index.js) 52.83KB 14.63KB
plugin-list (index.js) 113.35KB 27.73KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.33KB 3.25KB
plugin-view (index.js) 84.46KB 20.80KB
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.66KB 3.50KB
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) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
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.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
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 (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

Merged via the queue into main with commit 580b0fd Sep 7, 2026
33 of 34 checks passed
Sign up for free to 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.

1 participant