Skip to content

fix(types): BaseSchema.visible accepts the predicate string the renderer evaluates (#4581) - #4593

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4580-one-schemanode
Aug 13, 2026
Merged

yinlianghui merged 1 commit into
mainfrom
claude/issue-4580-one-schemanode

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Part of #4581.

⚠️ This PR is deliberately smaller than the card it was dispatched for. The seat was dispatched to reconcile SchemaNode (#4580) and to ride #4581 along with it. Measurement blocked both of those on one unruled contract question, so this PR ships only the half that measured clean, and the rest is escalated rather than guessed. #4580 is not touched here and stays open; #4581 is half done, hence Part of, not Fixes.

What landed

BaseSchema.visible is declared boolean | string.

It was boolean, but the renderer never read it as a boolean — it evaluates the key. Both call sites the ruling asked me to verify, cited:

  • visible — packages/react/src/SchemaRenderer.tsx:382:

    if (newSchema.visible !== undefined) {
      return !evaluator.evaluateCondition(newSchema.visible);
    }

    and evaluateCondition is declared (condition: string | boolean | undefined, context?) => boolean (packages/core/dist/evaluator/ExpressionEvaluator.d.ts:143). The sibling keys visibleWhen and the deprecated visibleOn are string for exactly this reason. The widening is boolean | string — what the evaluator accepts, no wider.

  • ariaLabel — packages/react/src/SchemaRenderer.tsx:111 calls resolveKeyedI18nLabel(schema.ariaLabel). This call site is what disproved the ruling's spelling for the ariaLabel half. See "Why ariaLabel did not land" below.

Two as unknown as BaseSchema casts in SchemaRenderer.expressions.test.tsx existed only for this gap and are dropped.

The census — the recorded "five" is not what is on disk

The ruling said five casts, "each carrying a comment naming this gap". Grepping as BaseSchema / as unknown as BaseSchema across packages and apps finds six, in three different classes, and only one carries such a comment:

Site Value Class
react/__tests__/SchemaRenderer.expressions.test.tsx:52 visible: '${data.role === "admin"}' visible gap — dropped here
react/__tests__/SchemaRenderer.expressions.test.tsx:61 visible: '${data.role === "admin"}' visible gap — dropped here
react/__tests__/SchemaRenderer.expressions.test.tsx:132 disabled: '${data.status === "locked"}' disabled gap — not ruled, left
react/__tests__/SchemaRenderer.expressions.test.tsx:141 disabled: '${data.status === "locked"}' disabled gap — not ruled, left
react/__tests__/SchemaRenderer.aria.test.tsx:59 ariaLabel: { key: …, defaultValue: … } ariaLabel gap — blocked, left
components/__tests__/html-anchor-links.test.tsx:33 { type: 'a', ...schema } unrelated — a Record< string, unknown > spread, no comment

So of the six, this card's ruled widenings close two. Three further notes:

  1. disabled is the same defect as visible, with the same evidence — SchemaRenderer.tsx:466 evaluates it through the same evaluateCondition, and a disabledOn?: string sibling exists for the same reason. finding(types): BaseSchema under-declares visible (predicate string) and ariaLabel (I18nLabel) — the renderer supports both, five test fixtures now cast past the gap #4581 named only visible and ariaLabel, so I did not widen it unruled. Two casts and one one-line widening are waiting on a word.
  2. html-anchor-links.test.tsx is the file finding(types): BaseSchema under-declares visible (predicate string) and ariaLabel (I18nLabel) — the renderer supports both, five test fixtures now cast past the gap #4581 named, but the cast in it is not this gap — it is a Record< string, unknown > spread being cast to BaseSchema.
  3. plugin-dashboard's two as BaseSchema are finding(react): SchemaRenderer carries the #4422 prop erasure in a spelling every sweep and both guards are blind to — Record< string, any > instead of [key: string]: any #4548's deliberate narrowing casts, untouched.

Red-first — predictions written into the test header before the run

packages/types/src/__tests__/base-schema-visible-predicate.test.ts pins the widening. Against origin/main (92250d648), tsc -p packages/types/tsconfig.test.json reported, verbatim:

src/__tests__/base-schema-visible-predicate.test.ts(67,3): error TS2344: Type 'false' does not satisfy the constraint 'true'.
src/__tests__/base-schema-visible-predicate.test.ts(75,3): error TS2322: Type 'string' is not assignable to type 'boolean | undefined'.
src/__tests__/base-schema-visible-predicate.test.ts(81,3): error TS2322: Type 'string' is not assignable to type 'boolean | undefined'.

Post-fix: clean, exit 0.

The equality assertion is invariant (Equal, not extends) on purpose, and the header says why: a satisfies-style or one-way extends check would be vacuous in both directions here — the narrow boolean is assignable to the wide boolean | string, so a widening that never happened and a widening that overshot to any would both stay green. BaseSchema's [key: string]: any index signature makes the overshoot a live risk, not a hypothetical: deleting the declared property altogether leaves visible typed any and every fixture still compiling. Pinning the exact union is the only assertion that can go red for the right reason.

must-not-change

  • Emitted JS is byte-identical. All 54 .js files in @object-ui/types' dist have equal sha256 against an origin/main compare worktree, built the same way. This is the finding(plugin-dashboard, plugin-list): the two packages #4422 left unswept still erase every declared prop — and the #4438 guard cannot see them #4528 bundle-sha256 bar, and a type-only card meets it exactly — ruling 4's "zero runtime behavior change" is satisfied at the strongest available standard rather than argued.
  • packages/types + packages/core suites: 119 files, 2200 tests, all passing.
  • Repo-wide type-check canary, PREFIX filter (--filter='...@object-ui/types' = the downstream consumers, 27 packages): zero new errors attributable to this widening. Measured in isolation, with the SchemaNode change reverted, precisely so the two changes could not launder each other.
  • eslint on the touched files vs the origin/main compare worktree: 21 warnings / 0 errors both sides — net zero; the new test file contributes 0.
  • All nine local gate scripts pass; control-byte and NBSP self-scan clean over every touched file including untracked.

Published .d.ts diff, and the grading analysis

Built both ways with dist/ and *.tsbuildinfo cleared between builds. The entire diff:

      /**
       * Controls whether the component is visible.
       * When false, component is not rendered (display: none).
+      *  … (evidence comment)
       * @default true
       */
-     visible?: boolean;
+     visible?: boolean | string;

Graded minor by position analysis, not by assumption: the diff adds a member to a union on an authored-input-dominant property, removes nothing, and touches no other declaration — the #4586/#4591 shape. Authors gain a spelling; nothing that type-checked before stops doing so. Readers of schema.visible were already coping with any through the index signature. Never major (major tracks @objectstack).

@object-ui/core gets no changeset entry: its declaration is untouched in this PR, so it has no source or declaration diff.

toRenderableSchema in packages/react STAYS

Stated explicitly so no future card "cleans it up": SchemaRenderer's component-level union deliberately excludes number / boolean (#4548 ruling, Q2), so the bridge still normalizes those onto their text form. It is a total function, not a cast. Nothing in this PR makes it an identity function, and nothing here is a reason to remove it.

Why SchemaNode (#4580) did not land — the measured blocker

The reconciliation itself works. Implemented as ruled (core's interface SchemaNode becomes export type { SchemaNode } from '@object-ui/types';), built clean, and the red-first collision pin went red pre-fix exactly as predicted and clean post-fix — the #4548 error class, verbatim, naming both dist identities:

SchemaNode.reconciliation.test.ts(64,34): error TS2344: Type 'false' does not satisfy the constraint 'true'.
SchemaNode.reconciliation.test.ts(77,14): error TS2322: Type 'import(".../packages/types/dist/base").SchemaNode' is not assignable to type 'import(".../packages/core/dist/types/index").SchemaNode'.
SchemaNode.reconciliation.test.ts(81,19): error TS2345: Argument of type 'import(".../packages/types/dist/base").SchemaNode' is not assignable to parameter of type 'import(".../packages/core/dist/types/index").SchemaNode'.

core/dist/index.d.ts was unchanged by it — the entry surface is preserved, as the ruling required.

What stopped it is the repo-wide canary. The reconciliation surfaces 274 errors in @object-ui/react:

  • 272 in test files (5 spec-bridge suites) — TS18049 ×134 and TS2339 ×131, all of the form Property 'sections' does not exist on type 'string | number | boolean | BaseSchema'. Mechanical narrowing, in scope, mine — but 272 of them is a cost worth a decision on its own, and it points at the bridges' declared return type as the real lever rather than 272 local narrowings.
  • 2 in runtime source, and these are the blocker — packages/react/src/spec-bridge/bridges/list-view.ts:180 and :224:
error TS2322: Type 'string | Record< string, string >' is not assignable to type 'string | undefined'.

from if (spec.label) node.label = spec.label; and the description twin. spec.label is the spec's I18nLabel; BaseSchema.label is string. Core's [key: string]: any had been absorbing that mismatch — remove the duplicate declaration and a real latent defect surfaces. Fixing it needs a cast (the lenient-consumer fallback the contract-first rule forbids) or a producer-side widening that nobody has ruled. Per ruling 4, runtime-source reshaping is a STOP, so I stopped.

Why ariaLabel did not land — the ruling's spelling is the wrong vocabulary

The ruling says widen ariaLabel to string | I18nLabel. Measured, that spelling declares a shape the renderer cannot resolve, and still rejects the shape it can. @object-ui/types re-exports I18nLabel from @objectstack/spec/ui, where it is the inline locale map string | Record< string, string >. But SchemaRenderer.tsx:111 resolves ariaLabel with resolveKeyedI18nLabel, whose declared input is the keyed form string | { key: string; defaultValue?: string; params?: Record< string, any > }. packages/react/src/utils/i18n.ts documents these as two vocabularies that "answer wrongly for the other's input, silently" (objectui#4167, PR #4169).

A throwaway probe measured all four consequences, and all four predictions held:

Probe Result
I18nLabel is string | Record< string, string > confirmed
the shipped fixture { key, defaultValue } under string | I18nLabel accepted — for the wrong reason, as a locale map whose "locales" are named key and defaultValue
the same keyed label carrying params: { name: 'Ada' } rejected: Type '{ name: string; }' is not assignable to type 'string'
the genuine inline map { en: 'Owner' } accepted by the type, and resolveKeyedI18nLabel returns undefined for it at runtime, rendering an empty aria-label

So the ruled widening would have turned the aria test green vacuously, invited a shape that renders empty, and still rejected keyed labels with params. That is the opposite of the ruling's own stated intent ("matching what the renderer actually supports"), so I did not write it.

The two escalations are one question

BaseSchema declares three sibling label slots — label?: string (:56), description?: string (:62), ariaLabel?: string (:173) — and all three under-declare, in two different i18n vocabularies: label/description receive the spec's inline I18nLabel from the bridges, while ariaLabel is resolved with the keyed resolver. Core's index signature was hiding all of it.

That is why #4580 is blocked on #4581 rather than the other way round: the ruling treated the ariaLabel widening as a rider on the reconciliation, but the reconciliation is what forces the label-vocabulary decision. One ruling on "which vocabulary does each BaseSchema label slot declare" unblocks the reconciliation, the ariaLabel widening, and the two list-view.ts defects together. Options and a recommendation are in the seat's report.


Generated by Claude Code

…rer evaluates (#4581)

`visible` was declared `boolean`, but the renderer never read it as one — it
evaluates the key. `SchemaRenderer.tsx:382` calls
`evaluator.evaluateCondition(schema.visible)`, and `evaluateCondition` is
declared `(condition: string | boolean | undefined, context?) => boolean`. The
sibling keys `visibleWhen` and the deprecated `visibleOn` are `string` for that
same reason; `visible` under-reported a capability it already had, and fixtures
exercising it had to cast past the declaration.

Widened to `boolean | string` — exactly what the evaluator accepts, no wider —
and the two `as unknown as BaseSchema` casts in the expressions suite that
existed only for this gap are dropped.

Type-only: all 54 emitted `.js` files in @object-ui/types are byte-identical to
origin/main.

Part of #4581. #4580 and the ariaLabel half are escalated rather than
implemented — see the PR body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 13, 2026 4:54pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-B54PSc01.js —
Status PASS —

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 25.13KB 5.40KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 38.46KB 10.17KB
auth (createAuthenticatedFetch.js) 6.34KB 2.43KB
auth (index.js) 2.35KB 1.07KB
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.02KB 0.88KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 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.65KB 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) 489.33KB 108.47KB
core (index.js) 3.79KB 1.52KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 163.56KB 44.83KB
fields (index.js) 230.37KB 57.17KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.84KB 1.45KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.98KB 10.85KB
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.74KB
mobile (index.js) 1.50KB 0.62KB
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.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.86KB 12.91KB
plugin-charts (index.js) 62.10KB 17.67KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 121.04KB 31.57KB
plugin-designer (index.js) 212.58KB 42.83KB
plugin-detail (index.js) 239.93KB 60.01KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.30KB 40.02KB
plugin-grid (index.js) 189.37KB 50.33KB
plugin-kanban (index.js) 52.74KB 14.53KB
plugin-list (index.js) 111.13KB 27.12KB
plugin-map (index.js) 18.16KB 5.81KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 41.38KB 11.09KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.09KB 20.56KB
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.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 27.64KB 9.44KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.26KB 0.67KB
react (schema-input.js) 1.45KB 0.83KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
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 (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
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

@yinlianghui
yinlianghui marked this pull request as ready for review August 13, 2026 17:14
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 13, 2026
Merged via the queue into main with commit 3d9769a Aug 13, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4580-one-schemanode branch August 13, 2026 17:15
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Aug 17, 2026
… the predicate string (objectstack-ai#4581) (objectstack-ai#4603)

* fix(types): ariaLabel declares the keyed vocabulary, disabled accepts the predicate string (objectstack-ai#4581)

Executes objectstack-ai#4580's label-vocabulary rulings Q2-B and Q3-A. PR objectstack-ai#4593 landed the
`visible` half and escalated the rest.

Q2-B: `BaseSchema.ariaLabel` widens from `string` to `string | KeyedI18nLabel`,
where `KeyedI18nLabel` is a new exported type in `@object-ui/types` naming the
keyed form `{ key, defaultValue?, params? }` that `SchemaRenderer.tsx:111`
actually resolves via `resolveKeyedI18nLabel`. Deliberately NOT the spec's
`I18nLabel` (the inline locale map) — PR objectstack-ai#4593 measured that spelling accepting
the keyed fixture only vacuously, rejecting `params`, and type-checking a
genuine locale map that renders an empty aria-label.

The three inline copies of the keyed shape were verified identical before the
name was minted; react/utils/i18n.ts and layout/NavigationRenderer.tsx now
import it. app-shell/utils/index.ts keeps its inline spelling because PR objectstack-ai#4208
has a pending change to that file — comment added naming the type and the wait.

Q3-A: `BaseSchema.disabled` widens from `boolean` to `boolean | string`, same
evidence as `visible` (SchemaRenderer.tsx:466 evaluates it through the same
`evaluateCondition`; `disabledOn?: string` sibling).

Three `as unknown as BaseSchema` casts drop (aria :59, expressions :132/:141),
closing the class the objectstack-ai#4581 census named.

Q1-B is NOT implemented: its premise measured false. The spec bridges have zero
production callers, `BridgeContext` has no locale field, the display-locale
channel is a React hook unusable from a plain class method, and nothing re-runs
the bridge on a locale change. `BaseSchema.label`/`.description` stay `string`
and are pinned that way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3

* fix(components): the toggle renderer resolves its keyed ariaLabel instead of forwarding it raw (objectstack-ai#4581)

Found by the downstream type-check sweep over consumers of @object-ui/types,
not by a test: with `BaseSchema.ariaLabel` honestly declared as
`string | KeyedI18nLabel`, `aria-label={schema.ariaLabel}` in
packages/components/src/renderers/form/toggle.tsx stops type-checking against
Toggle's `aria-label: string | undefined`.

The runtime harm is narrower than it first looks, and the test says so. Through
SchemaRenderer the bug is INVISIBLE: SchemaRenderer injects its own already
resolved `aria-label` via `...ariaProps` (SchemaRenderer.tsx:599/:625) and this
renderer spreads `{...props}` after its own attribute, so the resolved value
wins. Invoked directly the raw forward emits `aria-label="[object Object]"`.

My first prediction — that the harm would show through SchemaRenderer — was
wrong, and the test header records that rather than being rewritten to match:
a SchemaRenderer-driven case is green in both directions and would have shipped
looking like proof. The discriminating case invokes the registered renderer
directly via ComponentRegistry.get('toggle').

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 28, 2026
…red (objectui#9375) (objectstack-ai#9719)

Fixes objectstack-ai#9375

Clause-②: no

The vocabulary prose on `BaseSchema` said the two i18n label shapes
"each accept the other's shape vacuously". Measured against the tree,
that is false in both directions — and the `label` docblock one property
over was saying the same thing in its own words, so both are corrected
here. Comment-only: no declaration, accept set or requiredness moves,
per objectui#9092's read-only fence on `BaseSchema`.

## 1. The instrument first — does it discriminate?

The card rests on
`packages/types/src/__tests__/inline-locale-declared-face-9092.test.ts`
(added by objectui#9364). Taken on its own word, a green run proves
nothing: the file's `tsc` half is read only by `tsc -p
packages/types/tsconfig.test.json`, and an `@ts-expect-error` on a line
that the compiler would never flag is itself an error, so the baseline
zero had to be lit before it could be read.

Three ablations, each proved to have reached the disk (marker counts
before/after + `git hash-object` against the HEAD blob), each restored
with `git checkout HEAD --` and `git diff HEAD` proved empty, all under
a trapped script:

| direction | ablation | exit | what tsc printed |
|---|---|---|---|
| refusal is real (keyed slot) | drop the directive over `ariaLabel:
LOCALE_MAP` | 2 | `(172,49): error TS2322: Type 'Record...&{key?:
undefined; defaultValue?: undefined;}' is not assignable to type 'string
| KeyedI18nLabel | undefined'` |
| refusal is real (inline slot) | drop the directive over
`ObjectGridSchema.label: KEYED_REF` | 2 | `(159,83): error TS2322: Type
'{ key: string; defaultValue: string; }' is not assignable to ...` |
| it can also tell ACCEPT apart | add a directive over the line that
must compile (`ariaLabel: KEYED_REF`) | 2 | `(178,5): error TS2578:
Unused '@ts-expect-error' directive.` |

Baseline with all three restored: `tsc -p tsconfig.test.json` exit 0,
`vitest` 10/10. The third row is the one that matters most: the
instrument is not a slot that refuses every object — it distinguishes
the two vocabularies.

## 2. Then the same two crossings, measured on `BaseSchema` itself

The instrument asserts the inline crossings on inheritors
(`ObjectGridSchema`, `AppComponentSchema`, `PageNodeSchema.aria`) and
the keyed crossing on the flat slot. Since the docblock being corrected
is `BaseSchema`'s own, both crossings were re-measured directly on
`BaseSchema`, in a throwaway probe that was deleted afterwards (worktree
proved clean):

- `tsc`: `label: KEYED`, `description: KEYED` and `ariaLabel: MAP` each
fire an `@ts-expect-error`; `label/description: MAP` and `ariaLabel:
KEYED` compile as controls. Exit 0 with all five present.
- parse (`zod/base.zod.ts#BaseSchema`): `safeParse` is `false` for the
same three crossings and `true` for the same two controls.

Mechanism, read off the installed pin rather than assumed:
`InlineLocaleMapSchema` is typed `Record<string, string> & { key?:
never; defaultValue?: never }`, and the refusal message names the
pattern
`^(?!(?:key|defaultValue)$)(default|[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*)$`.

## 3. What a wrong slot actually costs — also measured, not quoted

Against the installed pin, with controls:

- `resolveI18nLabel({ key: 'a.b', defaultValue: 'D' }, 'en')` returns
`"a.b"` — the key renders. Control: `resolveI18nLabel({ en: 'Accounts',
'fr-FR': 'Comptes' }, 'en')` returns `"Accounts"`; `resolveI18nLabel(42,
'en')` returns `undefined`.
- `resolveKeyedI18nLabel({ en: 'Owner' })` returns `undefined` — the
aria-label renders empty. Control: `resolveKeyedI18nLabel({ key: 'a.b',
defaultValue: 'D' })` returns `"D"`.

So the residual hazard is a WRONG ANSWER, paid by metadata that reaches
a resolver without passing either face — the normal case for
server-driven JSON — and not the vacuous acceptance the prose claimed.

## 4. Sentences changed

**A. `BaseSchema.ariaLabel`, the sentence the card names.**

- before: "Two properties apart, both spelled `string | {object}`, and
each accepts the other's shape vacuously. Check which resolver owns a
slot before writing an object into it; the ruling accepted this cost
with exactly this naming + cross-referencing as the mitigation."
- after: "Two properties apart, both spelled `string | {object}` — but
NEITHER SLOT ADMITS THE OTHER'S SHAPE. The installed pin types the
inline arm with `key?: never; defaultValue?: never` and its
`INLINE_LOCALE_KEY` pattern excludes both names, so a keyed ref written
into `label` / `description`, and an inline map written into this slot,
are each REFUSED — at `tsc` on the declaration and at `safeParse` on the
zod mirror. The cross-vocabulary block of
`__tests__/inline-locale-declared-face-9092.test.ts` re-derives that on
every run, in both directions and with the accepting control beside each
refusal; read it rather than this sentence." Followed by a new paragraph
carrying the wrong-answer cost measured in section 3, and keeping the
original advice ("check which resolver owns a slot") and the ruling's
mitigation clause verbatim.

**B. `BaseSchema.label` — the other half of the same claim, which the
card did not name.** This is the correction that would have been missed
by fixing only the named sentence.

- before: "they are structurally confusable: a keyed ref typed into this
slot is accepted only *vacuously*, as a locale map whose "locales" are
named `key` and `defaultValue`."
- after: "they stay confusable to a READER — but NOT to the compiler or
the parser: a keyed ref written into this slot is REFUSED, because the
installed pin types the inline arm with `key?: never; defaultValue?:
never` and its `INLINE_LOCALE_KEY` pattern excludes both names." Plus a
pointer to `ariaLabel` for the full statement.

**C. The `NOT I18nLabel` paragraph on `ariaLabel` — marked as history
rather than rewritten.** PR objectstack-ai#4593's three measurements were correct
about the spelling the ruling withdrew, so they stay; what changed is
that they no longer read as the current reading.

- before: "...withdrew it (objectstack-ai#4580 Q2-B): `I18nLabel` is the spec's INLINE
LOCALE MAP (`string | Record<string, string>`), so the shipped keyed
fixture was accepted only *vacuously*..."
- after: "...withdrew it (objectstack-ai#4580 Q2-B). Read those three as HISTORY,
against the `I18nLabel` of that release — `string | Record<string,
string>`, a map with no name excluded from it: the shipped keyed fixture
was accepted *vacuously*, ... The pin has since closed the vacuous half
— see below. What survives is that the two vocabularies are structurally
confusable to a READER — objectui#4167's exact hazard."

**D. Two spellings of the inline arm, which were the premise the refuted
claim rested on.** The file-level `KeyedI18nLabel` vocabulary block and
the `label` docblock both spelled the spec's inline form `string |
Record<string, string>` flat — a spelling under which `{ key,
defaultValue }` IS a member, which is exactly what made "accepted
vacuously" readable as current. Both now carry the pin's exclusion
(`key?: never; defaultValue?: never`). No other wording in those
paragraphs moves.

**E. One line address removed.** The `ariaLabel` docblock cited
`packages/react/src/SchemaRenderer.tsx:111`; the call is at line 126 on
`origin/main`. The quoted code is unchanged and still correct, so the
citation now names the file and the symbol and drops the number — a
number in published prose is a claim with a shelf life, and
`check:new-line-citations` reports on exactly this shape.

## 5. Gates

Every exit code captured to disk before any pipe.

| gate | command | exit | printed verdict |
|---|---|---|---|
| package type-check | `pnpm --filter @object-ui/types type-check` | 0 |
`tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p
tsconfig.test.json`, silent |
| package tests | `pnpm --filter @object-ui/types test` | 0 | `Test
Files 202 passed (202) · Tests 4698 passed (4698)` |
| build (published bytes) | `pnpm --filter @object-ui/types build` | 0 |
`dist completeness: 1 package(s) complete (130 emitted files verified)`
|
| changeset presence | `node scripts/check-changeset-presence.mjs` | 0 |
`1 source file(s) of 1 released package(s) changed, and this change
declares 1 changeset(s)` |
| changeset claims | `node scripts/check-changeset-claims.mjs` | 0 | OK
|
| changeset fixed / no-major / pending literals | the three scripts | 0
/ 0 / 0 | all OK |
| line citations | `node
scripts/check-new-cross-file-line-citations.mjs` | 0 | `0 new
citation(s)` |
| control bytes | `node scripts/check-control-bytes.mjs` | 0 | `OK
(scanned 7818 tracked text file(s))` |
| installed pin claims | `node
scripts/check-installed-spec-pin-claims.mjs` | 0 | `OK` |
| governed surface | `node scripts/check-governed-queue-guard.mjs --test
...` | 0 | `NOT GOVERNED — 2 path(s) checked` |
| eslint | `npx eslint packages/types/src/base.ts .changeset/9375-*.md`
| 0 | 0 errors, 21 pre-existing `any` warnings on untouched lines |

Whole-repo `pnpm lint` and the rest of the gate farm are left to CI.

Published-bytes check, on the final commit: after building,
`dist/base.d.ts` carries the corrected sentence and no longer carries
the old one — which is why the changeset is a `patch` rather than an
empty declaration.

## 6. Pending-changeset re-read (the `changeset-claim-re-read` gate)

The gate listed 11 pending changesets whose bodies name
`packages/types/src/base.ts`. It is report-only and judges name
resolution, not meaning; the meaning question is answered here.
**Verdict: none of the 11 goes false, and one of them is independently
corroborated by this PR's measurements. No pending body is edited.**

**The decisive property, re-derived here on two instruments against
merge-base `78a9c6744`.**

- Line level: of the changed lines in `base.ts`, 0 are non-comment.
Every `+`/`-` line matches `^[+-] +\*`.
- Whole file: with comments stripped, both sides hash identically —
`sha256[:16] = 1d8d2a4a9d1ed76f`, 3202 bytes each. **Lit control:**
renaming one declared key (`ariaLabel` to `ariaLabelX`) in the head copy
and re-stripping gives `122f6229754cf290`, so the hash is capable of
telling a declaration change apart from a comment change.
- Block level: of the 77 JSDoc blocks in the file, exactly 3 changed
bytes, and they document `KeyedI18nLabel` (L22), `BaseSchema.label`
(L91, `label?: string | I18nLabel`) and `BaseSchema.ariaLabel` (L465).
Every other block is byte-identical, including `ComponentInput`,
`ComponentMeta`, `StyleProps`, `children`, `inputType`, `defaultValue`,
`description`, and the `label?: never` member of `ComponentInput` at
L733 — a different `label` from the one this PR edits.

⇒ A pending claim can only go false here if it asserts something about
the text of those three docblocks. Claims about members, retirement kits
(`?: never` + `retirementTombstone()`), mirrors, re-exports or parity
are untouched by construction, and the block-level reading is what makes
that a measurement rather than an argument.

**The one the gate's reader is right to single out:
`.changeset/7088-hidden-jsdoc-single-hide-path.md`.** It is a claim
about comment text in this very file, so the comment-only argument does
not dispose of it. Read in full, its subject is `BaseSchema.hidden`'s
JSDoc and the sibling `visible` comment (plus the schema-reference
`hidden` row and `SchemaRenderer.hiddenVisibleSynonymy.test.tsx`,
neither in this diff). Measured: the `hidden`, `visible`, `visibleWhen`,
`visibleOn` and `hiddenOn` JSDoc blocks are byte-identical between
merge-base and this branch, and no sentence of 7088's body names
`label`, `ariaLabel`, `KeyedI18nLabel`, `I18nLabel` or i18n at all (0
hits for that token set; `base.ts` resolves as the lit control). Its
general sentence — "the JSDoc is the authority a later docs correction
is measured against" — is not disputed by this change either: 7088
itself backs its JSDoc with a pin test, and this PR moves a JSDoc toward
what an instrument re-derives, which is the same direction. **Still
true.**

**`.changeset/zod-base-schema-mirror-parity-4605.md` — the one this PR
corroborates.** It says `label`/`description` take the spec's inline map
and `ariaLabel` the keyed reference, that "each slot admits only its own
vocabulary and both cross pairings are pinned as rejections", and that
`resolveKeyedI18nLabel` "returns `undefined` for a locale map and would
render an EMPTY aria-label". Both sentences were re-measured for this PR
(section 2 and section 3) and both hold. **Still true — and it is the
pending body that already said what the docblock is only now catching up
to.**

**The other nine.** Verified rather than inherited: each was scanned for
any token of the changed docblocks' subject, with `base.ts` as the lit
control for the scanner. Two produced a hit and were read:
`5928-classname-style-props-rename` uses "vocabulary" of `StyleProps`'
Tailwind scale (that block is byte-identical), and
`componentinput-reexport-4972` uses it of `ComponentInputControlType`
(likewise). The remaining seven —
`5905-componentinput-inputtype-tombstone`,
`5905-componentinput-retire-constraint-keys`,
`6067-component-meta-derive-from-canonical`,
`7493-componentinput-retire-label-defaultvalue-advanced`,
`component-meta-converge-and-deprecate-alias-5893`,
`default-children-retired-5051`, `plugin-component-input-deprecate-5674`
— are about declarations, retirement kits and re-exports, and name none
of the three changed blocks. **All still true.**

## Acceptance notes

Found and deliberately NOT done:

- `packages/types/src/objectql.ts` says "neither resolver **accepts**
the other's shape" — true of the two resolvers' signatures, misleading
about runtime for the reason measured in section 3. The card names this
sibling explicitly as a different claim; that file is also the surface
of open PR objectui#9709, so it is left untouched. Noted, not filed —
the card already records it.
- `packages/{components,core,plugin-dashboard,react,types}/CHANGELOG.md`
each carry the old sentence in a released entry. Release records are
history and are never rewritten. A whitespace-tolerant scan of the whole
tree (the prose wraps near eighty columns, so a line-anchored matcher is
blind to it) found no other LIVE copy: `packages/types/src/app.ts` and
the instrument's own header were already corrected by objectui#9364.
- `packages/types/src/base.ts` has two other cross-file line citations
of the same rotting shape (`.../bridges/list-view.ts:180` and `:224`, in
the `label` and `description` docblocks). Not verified and not touched —
outside the vocabulary claim this card is about. Noted, not filed; the
handler is whoever next edits those two docblocks, and
`check:new-line-citations` already reports the class.

---
🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01UanLVj6xvbS6puBCewLr8L


---
_Generated by [Claude Code](https://claude.ai/code)_

Co-authored-by: Claude <noreply@anthropic.com>
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.

2 participants