Skip to content

spec(types): redirect the node recursion point from BaseSchemaCore to AnyComponentSchema — measured at 9 newly-refused documents, and it drops 118 phantom strict refusals #8344

Description

@claude

Priced by the domain:spec @ objectui seat on #7935 (the pricing card the #5250 ruling names as its step 2). ⛔ Filed unassigned, not claiming. Appetite is in this body and is binding: the maintainer approved the direction, not a blank cheque on scope (#5250, director comment 5534418546).

This is the FIRST card of the strict-authoring-face programme, and it is not the strict face. It is the structural precondition #7581's measurement identified: until the node recursion point resolves per-type, every document-level number the programme quotes is measuring the recursion point instead of the components.

What is true today

packages/types/src/zod/base.zod.ts:53:

export const SchemaNodeSchema = z.lazy(() =>
  z.union([BaseSchemaCore, z.string(), z.number(), z.boolean(), z.null(), z.undefined()])
);

(The export additionally carries an explicit z.ZodType annotation taking an any type argument — the recursion-breaking annotation objectui#7760 is in flight to replace. Elided above only because a type-argument bracket does not survive GitHub's body sanitizer; the union arms are the part this card is about.)

BaseSchemaCore is the ~21 base keys and nothing type-specific. Every child slot (body, children, and every per-component redeclaration of them) is z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]). ⇒ per-type enforcement is root-only, at every depth, for every component type. That is the finding already recorded as #7869, measured independently there (an off-spec size on a nested icon node is accepted; the same node alone is refused).

The measurement this card is priced from

Measured on origin/main 5505aec1a07c26976d160a39f152a48b088cfbb4, zod 4.4.3, face read from packages/types/dist/zod/index.zod.js, over the catalog + docs corpora — 553 node documents (431 catalog JSON files, 204 ```json fence openings). Four twins, one corpus, one commit:

reading recursion point strict? documents refused
R1 baseline — the face as shipped BaseSchemaCore no 45 / 553
R2 naive strict — what a .strict() flip really does today BaseSchemaCore yes 294 / 553
R3 this card — redirect only AnyComponentSchema no 54 / 553
R4 redirect + strict — where the programme is going AnyComponentSchema yes 176 / 553

Controls: R1 and R2 reproduce scripts/measure-strict-authoring-face.mjs at the same commit (46 / 555 and 296 / 555) — the 2-document and 1-refusal difference is corpus 3, the 2 authored documents this reading omits.

⭐ The two results that decide the ordering

  1. The redirect costs 9 documents. R3 minus R1 = 54 minus 45. Every one is enumerated below.
  2. The redirect removes 118 documents of phantom redness. R2 minus R4 = 294 minus 176. Those 118 are refused today only because the recursion point unrecognises every child's own declared props — no per-component declaration budget can ever close them. ⇒ pricing the strict face on 294 rather than 176 prices the wrong programme.

The 9 documents, and why each refuses — both classes are pre-existing debt

Class A — a child node whose type resolves in no arm of AnyComponentSchema (4 documents, 7 spellings):

  • examples/schema-catalog/src/schemas/components-form-calendar/form-integration.json — child ui:calendar
  • examples/schema-catalog/src/schemas/components-layout-semantic/complete-layout.json — children header, nav, main, footer
  • content/docs/utilities/runner.mdx#L269 — child my-component
  • content/docs/utilities/runner.mdx#L323 — child h1

Class B — a child node already red under its OWN schema today, shielded until now by the recursion point (5 documents):

  • examples/schema-catalog/src/schemas/components-complex-filter-builder/search-interface.jsonfilter-builder: field invalid_type, operator / logic invalid_value
  • examples/schema-catalog/src/schemas/components-feedback-toaster/with-toast-trigger.jsonbutton: onClick custom
  • content/docs/api/schema-reference.md#L173badge: variant invalid_value
  • content/docs/api/schema-reference.md#L1267dashboard: type invalid_value + unrecognized_keys
  • content/docs/guide/schema-playground.md#L98stack: direction invalid_value, gap invalid_type

⇒ The redirect creates no new defect class. It surfaces two existing ones, at a size that fits in one card.

The cost driver that is NOT in the document count — the module cycle

SchemaNodeSchema is declared in base.zod.ts, which today imports only tombstone.zod.js and expression.zod.js. AnyComponentSchema is built in index.zod.ts from all 13 category modules, and 14 modules import base.zod.js. ⇒ pointing the recursion point at the union makes base.zod.ts depend on index.zod.ts, which depends transitively back on base.zod.ts. z.lazy defers the evaluation, not the module graph, so this needs a deliberate break — a late-binding holder that index.zod.ts fills at load, or relocating the union. ⛔ Do not discover this at implementation time; it is the reason this card is not a one-line diff.

And one hard constraint, measured, that a naive implementation will get wrong

The recursion point must be recognised by identity on the exported wrapper, ⛔ never through .unwrap(). Measured at the same commit:

exported wrapper identity stable   : true    (two module reads return the same object)
CardSchema.body union contains S   : true    (identity survives through a declared slot)
S.unwrap() === S.unwrap()          : false
getter() === getter()              : false
_zod.innerType stable              : true    (zod 4.4.3 caches the resolved inner)

This is #7918's consequence ①, relocated by os-sam on #7935 (comment 5563938784): the public handle is unstable for eight of the ten lazy exports, and SchemaNodeSchema is one of them. Identity on the export works today and is what scripts/measure-strict-authoring-face.mjs already relies on.

Appetite — one dispatched card, one PR

In scope: the redirect, the module-cycle break, the 9 documents above, and a pin that a nested off-spec node is now refused (the #7869 reproduction, both directions).

⛔ Falls off the back rather than growing this card:

  • Any of the 7 Class-A spellings that turns out to need a new component arm — that is a public-surface widening, and it splits into its own card rather than riding this one.
  • Everything strict. This card does not add .strict() anywhere; R3, not R4, is its acceptance number.
  • The declaration-repair worklist (the 184-node / 2099 per-component reading). Not this card.

If the 9 do not fit, the card is wrong and comes back for re-pricing — ⛔ do not widen it in flight.

Grading notes

Refs: #5250 (the ruling) · #7935 (this pricing) · #7581 / PR #7916 (the measurement) · #7869 (the finding) · #7760 (in flight, same files) · #7918 (the unstable public handle) · #6318


Generated by Claude Code

Activity

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

Metadata

Metadata

Assignees

Labels

domain:specobjectui spec stream: fix lands on packages/types, schema corpus or spec pin coupling — spec lanepackage: typespm:dispatchedpriority:p2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions