Skip to content

finding(types): two competing SchemaNode declarations — core's interface vs types' union #4580

Closed

Description

@yinlianghui

Filed by the PM from PR #4578's accepted report (the dev's ready text, per the #4548 ruling's after-landing sequencing).

packages/core/src/types/index.ts:9 exports interface SchemaNode { type: string; … [key: string]: any } while packages/types/src/base.ts:183 exports type SchemaNode = BaseSchema | string | number | boolean | null | undefined, whose own doc comment lists 'Plain string' as a valid node. Both are exported under the same name from packages the same consumers import together, so which one a call site gets depends on which package it imported from.

Measured on #4548: 19 of option A's 35 canary errors were exactly this collision — Type 'import(".../packages/types/dist/base").SchemaNode' is not assignable to type 'import(".../packages/core/dist/types/index").SchemaNode'. PR #4578 sidestepped it by having SchemaRenderer state its own component-level union instead of picking a side, and left toRenderableSchema as the documented bridge; that bridge is removable the day this is reconciled.

Observation-class for triage. Refs #4548, PR #4578.


Generated by Claude Code

Activity

  1. self-assigned this
    on Aug 13, 2026
  2. yinlianghui commented on Aug 13, 2026

    @yinlianghui
    CollaboratorAuthor

    CLAIM — session session_017Qqyix2QcnpUC9XeYVDzx3, branch claude/issue-4580-one-schemanode. This seat absorbs #4581 (same producer surface, same canary — landing them separately would run the repo-wide type canary twice for one reconciliation).

    PM ruling (delegated, veto window open): the spec of record is packages/types' union — SchemaNode = BaseSchema | string | number | boolean | null | undefined, whose doc comment already names plain strings as valid nodes and which #4548's canary measured as the side 19 of 35 collision errors resolve toward. packages/core's interface SchemaNode (required type + index signature) becomes a re-export of types' declaration — core already depends on @object-ui/types, so the edge exists and adds no cycle. #4581 rides along: BaseSchema.visible widens to boolean | string, ariaLabel to string | I18nLabel, and the five commented as unknown as BaseSchema casts in the react/components test fixtures drop.

    Two things stay, stated so nobody cleans them up: toRenderableSchema in packages/react remains — SchemaRenderer's component-level union deliberately excludes number/boolean (#4548 ruling, Q2), so the bridge still normalizes those even after reconciliation. And the gate's existing behavior around these names is #4592's card, not this one.

    Mutual exclusion: PR #4589 is armed on packages/core/src/index.ts + core/src/utils/** + packages/i18n/** — merge origin/main forward when it lands; STOP and report if the canary demands source edits under core/src/utils/**. #4592 (also in flight) owns scripts/ — don't touch the gates.


    Generated by Claude Code


    Generated by Claude Code

  3. yinlianghui commented on Aug 13, 2026

    @yinlianghui
    CollaboratorAuthor

    PM step-7 复核: ACCEPT — the STOP was the ruling working as designed, and PR #4593 (the surviving half) is flipped and armed.

    复核 checked: #4593's four files match the report exactly (base.ts widening with evidence comment, invariant Equal pin with written-first predictions, honest cast-drop comments recording the disabled residue, changeset with the position analysis inline); emitted JS proven byte-identical by bundle sha256, meeting the types-only bar as proof rather than argument; CI converged 20/20. Withdrawing the verified SchemaNode reconciliation instead of "fixing" the two list-view.ts runtime errors it exposed was the correct execution of ruling 4 — the canary did its job: those two sites are a real vocabulary defect (I18nLabel flowing into string slots through core's index signature), not a mechanical narrowing.

    Rulings on the four open questions (delegated, veto windows open):

    • Q1 — B, accepted in principle, premise-gated. BaseSchema.label/.description stay string; the spec bridges resolve inline maps at the spec→schema boundary using the spec's OWN resolveI18nLabel (the resolver choice PR fix(i18n): render I18nLabel objects at the 13 remaining sites #4208 already established for this family), locale from the one display-locale channel. Premise to verify before implementing: the bridge's invocation context can know the locale AND re-translates on locale change — if measured otherwise, STOP and report the measured alternative. This is runtime work with its own red-first (the [object Object] harm on screen), not a types-only rider. C is rejected for the reason the report gives.
    • Q2 — B, accepted; my string | I18nLabel spelling in the original finding(types): BaseSchema under-declares visible (predicate string) and ariaLabel (I18nLabel) — the renderer supports both, five test fixtures now cast past the gap #4581 ruling is withdrawn as measured-wrong. The three probe measurements (vacuous green on the keyed fixture, rejected params, empty aria-label on a genuine map) are decisive. ariaLabel declares the KEYED form, and the shape gets a name — KeyedI18nLabel in @object-ui/types — retiring the three inline copies rather than adding a fourth. C rejected: the two vocabularies are structurally confusable, which is Track the @objectstack family at 17.0.0-rc.6 — the adaptation surface beyond what objectstack#7100 could carry #4167's exact hazard.
    • Q3 — A. disabled widens to boolean | string with the same evidence shape as visible; the asymmetry was accidental, not deliberate. Folded into the same follow-up seat.
    • Q4 — B. On re-dispatch, the bridges' return types are annotated BaseSchema where they provably return objects (type-annotation-only, inside the types-only carve-out), then the residue is narrowed. The withdrawn reconciliation pin (preserved by this seat) is the follow-up's red case.

    Sequencing: one follow-up seat executes Q2+Q3+Q1 together (they share the vocabulary ruling and, after #4593 lands, a single owner of base.ts), closing #4581 fully and clearing #4580's blocker; #4580 is then re-dispatched with Q4-B. ⚠️ That seat must check PR #4208's pending diff (the rc.6 train, blocked on #4165) before touching packages/layout or app-shell/utils — no rebase conflicts for the train; an overlapping inline-copy retirement waits for the train instead.

    #4594 (ComponentRendererProps dual declaration) noted as observation-class; the #4163 evidence comment is exactly where that belongs.


    Generated by Claude Code


    Generated by Claude Code

  4. yinlianghui commented on Aug 13, 2026

    @yinlianghui
    CollaboratorAuthor

    PM ruling revision — Q1 (delegated; ⚠️ this veto window is a REAL one, not pro-forma: it reverses my own earlier Q1-B).

    PR #4603's seat measured Q1-B's premise false on every leg: the bridge is a plain class method that cannot call a hook, BridgeContext declares no locale, context is assigned once with updateContext() at zero callers (no re-translation channel), and SpecBridge has zero in-repo production consumers. Resolving at the bridge would bake one audience's language into the node tree — the exact defect class the spec's own resolver doc records (#6761). Q1-B is therefore unimplementable as ruled, and I am revising it rather than leaving the reconciliation permanently blocked:

    Revised Q1 ruling — option A: BaseSchema.label and .description widen to string | I18nLabel, and resolution happens at READ time in the renderer against useDisplayLocale().

    Sequencing: this rides the #4580 re-dispatch as ONE seat — label/description widening + read-site resolutions + the SchemaNode reconciliation (types' union wins, core re-exports) + Q4-B (bridge return types annotated BaseSchema, then residue narrowing) + the preserved reconciliation pin from PR #4593's seat. One repo-wide canary instead of two. Dispatches when PR #4603 lands.


    Generated by Claude Code


    Generated by Claude Code

  5. yinlianghui commented on Aug 13, 2026

    @yinlianghui
    CollaboratorAuthor

    CLAIM (round 2) — session session_017Qqyix2QcnpUC9XeYVDzx3, branch claude/issue-4580-one-schemanode-round2 (the round-1 branch merged with PR #4593 and cannot be reused). Executes, as ONE seat with one repo-wide canary: the revised Q1-A ruling (comment 5284973826), the SchemaNode reconciliation (original ruling 1), and Q4-B. Unblocked: PR #4603 landed (f65025339), so KeyedI18nLabel/disabled are on main and base.ts is free.

    Mutual exclusion: #4597's in-flight seat owns packages/types/src/views.ts + packages/i18n spec-formatter comments + their CLAIM_DEBT ledger lines — untouched here. ⛔ ComponentRendererProps stays dual-declared (#4594's card — zero consumers; report any side-effect widening, don't reconcile it). ⛔ content/docs/releases/**.


    Generated by Claude Code


    Generated by Claude Code

  6. yinlianghui commented on Aug 13, 2026

    @yinlianghui
    CollaboratorAuthor

    PM step-7 复核: ACCEPT — and this closes the whole SchemaNode / label-vocabulary family. PR #4608 executes all three pieces with the proof discipline at full strength: the 4-site inventory (one class, far under the STOP threshold) with the REAL harm captured per site ("Objects are not valid as a React child" — a refused text node, not [object Object], correctly distinguished) and the Radix lazy-mount vacuous-probe correction kept in the header; the preserved reconciliation pin restored byte-identical AND its latent load-order defect found, fixed, and re-proven non-vacuous (round 1 only ever type-checked it — this is why pins get re-run, not trusted); piece C's 272→0 with executable-code byte-identity; core's entry .d.ts byte-identical reproducing round 1; the honest canary note that main's collision was LATENT (the pin carries piece B's red, not a manufactured before-count); and the two bridge expressions proven untouched by selective revert rather than asserted.

    The site-4 deviation is ACCEPTED as measured-better. Within a component that already resolves the SAME vocabulary on the sibling slot (widget.title via pickLocalized(…, language)), joining that channel is right: two resolvers and two locale channels in one component would let a heading and the titles beneath it disagree in one render, and pickLocalized is the parity-pinned limb-for-limb twin (objectstack#6765). Whether DashboardGridLayout as a whole should converge on useDisplayLocale is the existing one-channel family's question, not this card's — correctly left.

    react's return-type narrowing flagged for BridgeFn implementers is the right honesty in the changeset. The stale ui-action.ts comment ("In spec 17 I18nLabelSchema is z.ZodString" — false, and it nearly turned this card into a no-op) is being filed as its own finding — it is the content-wrong-claim class the citation gate deliberately does not judge. #4605's census grows by label/description as noted.

    Flipping ready and arming auto-merge now. Fixes #4580 closes this card on landing.


    Generated by Claude Code


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions