Skip to content

fix(types): refuse inputType by name on the email / password shorthands (objectui#8762) - #9051

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-8762-shorthand-inputtype-refusal
Sep 10, 2026
Merged

fix(types): refuse inputType by name on the email / password shorthands (objectui#8762)#9051
os-warren merged 1 commit into
mainfrom
claude/issue-8762-shorthand-inputtype-refusal

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #8762

inputType authored on the email / password node shorthands parsed green and was then
overwritten by the registration wrapper, which pins inputType itself and spreads it last.
So { "type": "password", "inputType": "text" } validated, rendered a masked field anyway,
and no diagnostic said so — a validated key the runtime discards. This refuses the key by
name, with guidance
, on those shorthand arms.

This is a narrowing of the published accept set (Clause-②: yes), and the diff moves that
set in one direction only. Nothing else rides.

The measurements, re-derived on this base (681d3f10e)

Every reading below was taken here, not inherited from the card.

The trap and its firing control, on one instrument

Before the change:

document schema runtime (DOM type attribute)
{ "type": "password", "inputType": "text" } ACCEPT — and inputType: "text" survived into safeParse's output rendered passwordDISCARDED
{ "type": "email", "inputType": "text" } ACCEPT rendered emailDISCARDED
{ "type": "input", "inputType": "text" } ACCEPT (the control) rendered textHONOURED

After the change: the first two are REFUSED at path: ['inputType'] with guidance naming
the key and pointing at { "type": "input", "inputType": "email" }; the third still parses and
still renders text, at every value tested. Both halves are pinned —
packages/types/src/__tests__/shorthand-input-type-refusal-8762.test.ts (17 tests) and
packages/components/src/renderers/form/__tests__/shorthand-input-type-discarded-8762.test.tsx
(13 tests).

BaseSchema is still passthrough — so omission really was not refusal

Confirmed by content (packages/types/src/zod/base.zod.ts, BaseSchemaCore ends
}).passthrough();) and by measurement: an undeclared key still rides through on the very
same document after this change, which is what makes this a declared refusal rather than
strictness. The premise has not moved; pinned as its own leg.

Census — who stops validating

Nobody in reach. Four independent readings:

  1. Every tracked JSON document — 632 files, 546 parsed (the 86 excluded are tsconfig*.json,
    which are JSONC). Six nodes carry a shorthand type; none authors inputType.
    ⚠️ Five of those six sit at a fields[] position, which is FormFieldSchema, not this arm.
  2. Every fenced JSON block in all 1691 tracked .md / .mdx files — zero shorthand nodes,
    and zero blocks containing both inputType and a shorthand type literal.
  3. A text scan of all 81 files carrying inputType anywhere — 13 co-occurrence windows, every
    one of them prose, a docblock, a CHANGELOG entry, or a form field. Zero component-node
    authorings.
  4. The repo's own gates agree, independently: pnpm check (the CLI over 629 files) rc=0,
    check:doc-types rc=0, check:doc-snippets rc=0 (639 blocks compiled against the built
    narrowed types), check:doc-examples rc=0.

⚠️ The hotcrm and objectstack corpora are outside this repository and were NOT measured.
They are out of reach from here; this PR makes no claim about them.

The card's figure of "23 files carry inputType" did not reproduce under any spelling I could
construct: repo-wide it is 81, tracked JSON 17, catalog schemas 15, content/** 5.
The card's conclusion — none on a shorthand root — does reproduce, and this PR strengthens it to
none at any depth, in any parsed document.

One rule, not an enumeration — and what happens to the next shorthand

InputShorthandSchema is a single arm over a type enum, so the refusal is one member
covering both literals and any literal later added to that enum. Enumerating per literal would
have meant splitting the arm in two.

What happens to a third shorthand:

  • Added to the enum — it inherits the refusal with no second edit. Covered by construction.
  • Registered but not added to the enumAnyComponentSchema refuses the literal whole,
    because no arm claims it (measured: tel, url, search, number all REFUSE today). That is
    the loud direction, not a silent accept, so the class-(c) trap cannot recur through this path.
  • Given its own new arm that omits inputType — the only way to recreate the defect, so the
    new types pin compares the enum against the registration site: it reads input.tsx, collects
    every ComponentRegistry.register call whose wrapper pins a matching inputType, and asserts
    set equality, with a non-vacuity guard and a fail-closed control. Ablation leg C below fires it.

Changeset level, from this repo's own precedent

minor. The closest sibling — .changeset/7694-chart-series-chart-type-alias-refusal.md, also a
named refusal narrowing a published accept surface — says it in the same words: "⚠️ Shipped as
minor, not patch, because this is a NARROWING of a published accept surface". major is not
available at all: AGENTS.md §版本号策略 keeps this repository's major aligned with @objectstack
and states that objectui's own breaking changes ship as minor with the breaking semantics in the
body — mechanically enforced by scripts/check-changeset-no-major.mjs (rc=0).

⚠️ What a reader should not misread

A form FIELD is a different position with the opposite precedence, and this refusal does not
reach it.
renderers/form/form.tsx resolves
type={inputType || NATIVE_INPUT_FIELD_TYPES[declaredType] || 'text'}, so inside fields: [ … ]
an authored inputType still wins:
{ "name": "contact", "type": "email", "inputType": "text" } renders a text box, before and
after this PR. The same two spellings therefore mean two different things depending on position.
The refusal's guidance string says so explicitly, and that sentence is pinned on both sides so
it cannot rot into false prose. Five of the six shorthand nodes in the catalog sit at exactly this
position and are untouched.

⛔ The cheaper-looking route, deliberately not taken

Flipping the wrapper's precedence so the author's value wins would render
{ "type": "password", "inputType": "text" } as an unmasked field under a password key — a
secret in clear text, worse than refusing the key. The wrapper is unchanged, and the components
pin is the thing that goes red if anyone flips it (see ablation leg B).

Mechanism — the established one, not a new one

packages/types/src/zod/tombstone.zod.ts already owns named refusal. Of its four helpers,
retirementTombstone is the fit, decided by content:

  • not aliasKeyRefusal — its lead composes "Did you mean `alias` → `canonical`?", and the
    remedy here is a different type, not a sibling spelling of a key on the same object. Its lead
    would read "Did you mean `inputType` → `inputType`?".
  • not handlerKeyRefusal — that is for function-valued keys, and its z.custom primitive is
    not JSON-Schema representable.
  • not retiredNodeType — that claims a type literal on the union, not a member.
  • ⚠️ and not an ADR-0049 retirement of inputType itself. The key is alive and honoured on
    InputSchema; it is this position that cannot author it. That reading has precedent:
    aliasKeyRefusal's own docblock records MenuItemSchema.type as a retirementTombstone for a
    never-declared spelling pointed at its canonical form. The new docblock states this so the arm
    is not later misread as a withdrawal of the key.

One string feeds both the parse-time message and .describe(), so the error an author reads and
the text generated docs publish cannot drift apart. Pinned by comparing them.

Both faces move together

The zod arm refuses by name; the TS twin (packages/types/src/form.ts) declares
inputType?: never, so tsc refuses it at the authoring site. Reverse-verified against the
built dist/form.d.ts (published as the @object-ui/types/form subpath), two legs in one
file:

  • trap present → error TS2322: Type 'string' is not assignable to type 'undefined' at the
    inputType position, while the InputSchema control in the same file compiled;
  • trap removed → rc=0, clean.

That proves the reading came from the rebuilt .d.ts and not a cache.
⚠️ Honest limitation: the TS message names the position, not the key — the guidance lives in the
member's JSDoc (which editors surface on hover). The zod face is the one that names the key.

Ablation — each pin proven able to fail

Each leg: mutate → prove the mutation reached disk by blob hash → run → restore → prove the
restore by hash and an empty git diff HEAD. Committed before ablating; every leg restored
clean.

leg mutation predicted observed
A remove the refusal member from the arm red 9 failed / 8 passed (17)
B flip the wrapper's precedence in input.tsx red 4 failed / 9 passed (13) — exactly the four precedence assertions
C register a third shorthand without arming it red 2 failed / 15 passed (17) — the comparison instrument and its fail-closed control

Verification

All rc values captured to a file before any pipe.

  • pnpm exec vitest run packages/types/170 files, 3369 tests, rc=0. Includes
    zod-mirror-parity.test.ts (held by fix(types): collapse the redundant UserActionsSchema extension, correct its docblock (objectui#8992) #9017, not edited) — the z.never member and the
    ?: never twin agree, so no ledger row is owed.
  • pnpm exec vitest run packages/components/src/renderers/form/60 files, 416 tests, rc=0;
    plus form-renderers.test.tsx + text-input.test.tsx — 42 tests, rc=0.
  • pnpm --filter @object-ui/types type-check rc=0 (all three tsconfigs);
    pnpm --filter @object-ui/components type-check rc=0.
    ⚠️ The first components run reported 9 errors that were entirely the unbuilt-dist artefact;
    after pnpm --filter '@object-ui/components^...' build it is 0. The pre-build reading is not
    reported as a result.
  • Gates, each rc captured: check:control-bytes 0, check:new-line-citations 0,
    check:handler-key-reads 0, check:sdui-registration-pins 0 (after building the console —
    it reports PREREQUISITE NOT MET otherwise, which is not a red), check:unreferenced-sources 0,
    check:published-tsconfig-exclude 0, check:lint-rule-coverage 0, check:self-import 0,
    check:esm-specifiers 0, check:doc-types 0, check:doc-fences 0, check:doc-snippets 0,
    check:doc-examples 0, check:doc-example-ids 0, check:skill-examples 0,
    check:changeset-presence 0, check:changeset-no-major 0, pnpm check 0.
  • Lint, counts read from --format json, not from grepped text: the four changed files lint
    0 errors. The 10 warnings are all @typescript-eslint/no-explicit-any in form.ts at lines
    883–1233 — above my edit point (line 1640+), so their line numbers are unshifted and they are
    demonstrably pre-existing.
  • Declared narrowing: the full packages/components/ suite did not fit the foreground
    10-minute cap (killed at 560s having produced no test output — NOT MEASURED, neither green
    nor red). It was narrowed to the form-renderer directory, which is where the diff and the new
    test live, and declared here. CI runs the full farm.
  • check:governed-queue-guard --test on all five paths: NOT GOVERNED.
  • No dependency bump. pnpm-lock.yaml and packages/core/package.json are untouched
    (held by fix(core): raise declared @objectstack/spec floor to ^17.3.0 #9032); the diff is 2 modified + 3 added files, all under
    packages/types/src/, packages/components/src/ and .changeset/.
  • File-surface holds respected. Nothing in the held list was edited —
    zod-mirror-parity.test.ts in particular is read and run, never written.

Premise

Valid. Every load-bearing claim on the card reproduced: the wrapper pins inputType last, the
key parses green, the value is discarded at runtime, BaseSchema is still passthrough, and the
honoured neighbour is { "type": "input", "inputType": "…" }. The one thing that did not reproduce
is the card's "23 files" figure, corrected above.


🤖 Generated with Claude Code

https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w


Generated by Claude Code

…orthands (objectui#8762)

`packages/components/src/renderers/form/input.tsx` registers both shorthands by
wrapping the `input` renderer and spreading its own `inputType` LAST, so an
authored value is overwritten before the renderer reads it. `BaseSchema` is
`.passthrough()`, so `{ "type": "password", "inputType": "text" }` parsed green,
kept the key in `safeParse`'s output, and rendered a MASKED field anyway — a
validated key the runtime discards, with no diagnostic.

objectui#8499 gave this arm its declared face with the key omitted and recorded
that omission is not refusal. This closes it: the key stays DECLARED and
unwritable on both faces, refused by name via `retirementTombstone`, with one
guidance string feeding both the parse-time message and `.describe()` and
pointing at `{ "type": "input", "inputType": "email" }` — the spelling that is
read.

One rule, not an enumeration: a single member on the one arm whose `type` is an
enum, so a literal added later inherits the refusal. The new types pin compares
that enum against the registration site, so a third shorthand cannot diverge in
silence.

The wrapper's precedence is deliberately NOT flipped: letting the author win
would render `{ "type": "password", "inputType": "text" }` as an UNMASKED field
under a `password` key.

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

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3490.0 KB 3512.7 KB
Main entry chunk (gzip) 144.2 KB 350 KB
Entry file index-CLq_W6mx.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) 16.69KB 6.21KB
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) 11.08KB 4.58KB
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) 500.20KB 114.67KB
core (index.js) 8.28KB 3.31KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 207.56KB 57.44KB
fields (index.js) 247.01KB 62.29KB
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) 13.52KB 4.88KB
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) 8.39KB 3.10KB
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) 14.81KB 3.63KB
plugin-calendar (index.js) 49.03KB 13.93KB
plugin-charts (index.js) 71.50KB 19.97KB
plugin-chatbot (index.js) 195.32KB 46.51KB
plugin-dashboard (index.js) 131.09KB 34.58KB
plugin-designer (index.js) 215.68KB 44.27KB
plugin-detail (index.js) 251.44KB 65.17KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.79KB 34.19KB
plugin-gantt (index.js) 166.65KB 40.91KB
plugin-grid (index.js) 211.56KB 57.50KB
plugin-kanban (index.js) 46.03KB 14.30KB
plugin-list (index.js) 112.52KB 27.64KB
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.55KB 3.32KB
plugin-view (index.js) 84.42KB 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) 83.34KB 27.61KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
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.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
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) 14.82KB 4.99KB
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.93KB 1.49KB
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 (strict-authoring-face.js) 14.27KB 5.47KB
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

Copy link
Copy Markdown
Collaborator Author

Contract review

PR: #9051 · Card: #8762 · Round: 1
Head reviewed: 691aa71ba09d8fa24d39bec4d9df41f8cf6764f8 — re-read from the PR object immediately before this post; unchanged since the review began.
Charter: .claude/skills/pm-dispatch/references/contract-review.md + landing-operations.md + true-green.md + platform-readings.md + SKILL.md §复核 / §入队与落地, read in full from objectstack origin/main at 9165d5cd4c990bd66f5f393a95bfa6f968d07b7f (fresh sparse clone; no local copy used).
Tier: CONTRACT_REVIEW_TIER = 'claude-fable-5-1' (objectstack origin/main scripts/pm/dispatch-gates.mjs:10507). Serving tier of this review subagent read from its own transcript — located by grepping a unique marker string, not by filename — harness-stamped per-message model field: 82/82 = claude-fable-5-1, zero deviations at the moment of composing.

Method

Fed the card (body + all 3 comments: triage ruling, Claim:, os-dev-report) and the PR (body, full diff, the one commit, the one comment, all check runs); not fed the dispatching seat's conclusions. Every claim below was re-measured on a fresh clone of the head in an isolated scratch directory with its own install; no shared checkout was touched. Ablation mutations were made only in that scratch clone, proven on disk by blob hash and git diff --stat, and restored to the pinned head commit with an empty git diff HEAD after each leg.

① Derived judgments — every accept-set and public-surface change, one by one

  1. zod face: InputShorthandSchema.inputType becomes retirementTombstone(SHORTHAND_INPUT_TYPE_REFUSAL) (packages/types/src/zod/form.zod.ts). RIGHT. One probe run at head and, by path-restoring form.zod.ts to the pinned base blob ec2a40a7, at base: {type:'password',inputType:'text'} and {type:'email',inputType:'text'} go from ACCEPT (key kept in parsed data) to REFUSE with a single issue, code invalid_type, path inputType, message naming the key and the honoured spelling. Controls on the same instrument: {type:'input',inputType:'text'} ACCEPT at both; {type:'password'} / {type:'email'} ACCEPT at both; {type:'password',required:'yes'} REFUSED at both (the arm still judges); {type:'password',zzzUndeclared:1} ACCEPT at both (BaseSchemaCore still ends .passthrough() — a declared refusal, not strictness). Shape: by name, at the key's own path, with guidance, through the retirementTombstone primitive (z.never({error}).optional().describe()), the same primitive aliasKeyRefusal (finding(types): series chartType is undeclared and SILENTLY STRIPPED - the normalizer reads it first, measured not live, alias-refusal owed #7694) shares; handlerKeyRefusal's z.custom and retiredNodeType are correctly ruled out on content. Observation, not a defect: at a NESTED position the top-level issue is invalid_union at children; the named guidance is present inside that issue's nested errors tree (measured) but is not what a path-only consumer sees — the pre-existing reporting shape for every nested refusal through the node union, not introduced here.

  2. TS face: InputShorthandSchema.inputType?: never (packages/types/src/form.ts). RIGHT. Built @object-ui/types from the head; dist/form.d.ts:1672 carries inputType?: never with the JSDoc guidance. tsc 6.0.3 against that dist: the trap { type: 'password', inputType: 'text' } reports TS2322: Type 'string' is not assignable to type 'undefined' at the inputType position while the InputSchema control and a plain { type: 'email' } in the same file compile; the clean leg (trap removed) is rc=0. Both faces move together; zod-mirror-parity.test.ts (held by fix(types): collapse the redundant UserActionsSchema extension, correct its docblock (objectui#8992) #9017, not edited) registers the pair at lines 1150/1315 and CI's four test shards are green on the head, so no ledger row is owed.

  3. Direction and reach. RIGHT. Narrowing only; nothing else on the arm moved (.omit({type,inputType}).extend({type: enum, inputType: tombstone, wrapperClass})). Diff is 2 modified + 3 added files; none is in the Claim's held list; pnpm-lock.yaml and packages/core/package.json are untouched.

  4. Protocol reading — the arbiter for a narrowing. RIGHT: the narrowing is not narrower than the protocol on any protocol surface. Read from objectstack origin/main packages/spec/src/ui/component.zod.ts: the element registry (line 3202) has element:text_input with inputType: z.enum(['text','email','number','tel','url','password']) under strictObject, and NO email / password element type exists in the spec (grep of the registry keys and of literal('email'|'password'): zero). The email / password node shorthands are objectui registration-level aliases outside protocol vocabulary; the refusal's guidance points at { "type": "input", "inputType": "email" } — the one-element-with-inputType shape that IS the protocol's model — and refusing an unrecognised key by name with guidance is the posture the protocol's strictObject already takes. No protocol change is needed and no spec card is owed.

  5. Wrapper precedence deliberately NOT flipped (input.tsx unchanged). CONFIRMED by reading: lines 95 and 112 spread { ...props.schema, inputType: 'email' | 'password' } with the wrapper's value last, and the file's blob is identical between base and head. Leg B below shows what flipping does.

  6. "One rule, not an enumeration" — all three branches verified. (a) A literal added to the type enum inherits the refusal: by construction, the member sits on the single arm whose type is z.enum(['email','password']). (b) A shorthand registered but not added to the enum is refused WHOLE: measured at both base and head, {type:'tel'}, {type:'url'}, {type:'search'}, {type:'number'} and {type:'secret'} each REFUSE with invalid_union at type — the loud direction. (c) A same-name wrapper added to input.tsx without arming: leg C turns 2 tests red. Hostile leg C-prime (mine): a DIFFERENTLY named wrapper (register('secret', … inputType: 'password')) is not seen by the same-name comparison filter, but the registration-count guard (allRegistered.length must be 3) still turns 1 test red — loud, fail-closed. Boundary, recorded not graded: the pin reads only input.tsx; a wrapper registered in another file with its own arm in another module is outside any local pin's reach.

  7. Censuses ("ZERO in reach"). HOLD under an independent instrument with positive controls. Population asserted before use: 632 tracked JSON files, 546 parse, 86 fail and every failure is a tsconfig*.json (JSONC) — the dev's figures reproduce exactly. Walker over all 546: 6 nodes with type in {email, password} (5 at a fields[] position, 1 at children[1] in components-form-label/required-label.json), 0 carry inputType. Controls: a synthetic {type:'password',inputType:'text'} fed to the same walker is flagged; the same walker sees the 23 real {type:'input', inputType:…} nodes in the corpus — the instrument can hit. Markdown: 1692 tracked .md/.mdx (dev: 1691; immaterial), 2130 fenced blocks, 257 parse as JSON, 4 shorthand nodes (all fields[], README + schema-reference), 0 carry inputType, 0 blocks textually co-occur. Text: 84 files carry inputType at head = the dev's 81 at base + the PR's own 3 new files; every co-occurrence window is prose, a test, form.tsx, or the changeset. The card's "23 files carry inputType" reproduces as 23 NODES (type:'input' nodes carrying inputType, across 15 files) in the parsed JSON corpus — a node count mislabelled as a file count; the dev's 81 / 17 / 15 / 5 file figures also reproduce. The conclusion — no inputType on any shorthand node — survives under either figure.

  8. Ablation legs. Baseline: types pin 17/17, components pin 13/13. Each mutation proven on disk (blob hash changed, git diff --stat non-empty) and each restore proven clean (hash equal to the head blob, git diff HEAD empty, nothing untracked). Leg A, remove the refusal member: 9 failed / 8 passed (17). Leg B, flip both wrappers to { inputType, ...props.schema }: 4 failed / 9 passed (13), exactly the four precedence assertions. Leg C, register tel pinned tel: 2 failed / 15 passed (17), the comparison instrument and its fail-closed control. The three counts are pairwise distinct and match the PR body.

② Semver grading

.changeset/8762-shorthand-input-type-refusal.md declares '@object-ui/types': minor. Consistent with (i) the repo's precedent .changeset/7694-chart-series-chart-type-alias-refusal.md — a named refusal narrowing a published accept surface, shipped as minor with the same rationale; (ii) AGENTS.md §版本号策略 — major pinned to @objectstack, objectui's own breaking changes ship as minor with the break spelled out, mechanically enforced by scripts/check-changeset-no-major.mjs; (iii) the head's Changeset Bump Policy, Changeset Declaration and Changeset Fixed Group Check runs, all success. The components-side change is test-only and the package sits in the same fixed group, so no second entry is owed. Grade: minor is right, and the body names the break and who it can hit.

③ Boundary flags — each answered or escalated

  • open_questions[0] — needs:contract-review dual carrier, PR side missing. ANSWERED on the PR as it stands now: the PR timeline shows needs:contract-review labeled on fix(types): refuse inputType by name on the email / password shorthands (objectui#8762) #9051 by os-warren at 2026-09-10T21:12:20Z (after the report at 21:10:41Z); the PR's labels include it and card finding(types,components): inputType authored on { type: 'email' } / { type: 'password' } parses green and is OVERWRITTEN by the registration wrapper — a validated key the runtime discards #8762 carries it too. Both carriers are present on the head reviewed. Option A is what happened; reporting rather than writing under a label prohibition was the conservative act. Option C — the dispatch prohibition colliding with the charter's rule that the PR creator hangs the label in the same stroke — is a dispatch-template matter: ESCALATED to the seat as process, not a contract question, not blocking.
  • open_questions[1] — TS face refuses without naming the key. ANSWERED: accept (option A). Measured above; TS2322 lands at the inputType position and the JSDoc guidance is in the built .d.ts; this is the ?: never twin every tombstone in the family uses, the finding(types): series chartType is undeclared and SILENTLY STRIPPED - the normalizer reads it first, measured not live, alias-refusal owed #7694 alias twin included. No card owed.
  • out_of_scope_findings[0] — z.toJSONSchema throws under bare options. CONFIRMED pre-existing: throws Custom types cannot be represented in JSON Schema on both InputSchema and InputShorthandSchema at base AND at head; with unrepresentable: 'any' it does not throw; all 6 toJSONSchema( call sites under app-shell/src/views/metadata-admin pass a TO_JSON_OPTS carrying unrepresentable: 'any'. Not a regression; noting-not-filing is right.
  • out_of_scope_findings[1] — 3 catalog documents fail pnpm check, covered by 24 catalog entries fail safeValidateSchema because the Zod mirror names a key its renderer never reads (8 components) #6939. 24 catalog entries fail safeValidateSchema because the Zod mirror names a key its renderer never reads (8 components) #6939 read: open, pm:blocked, priority:p2, domain:spec, and its title is that class. Not this PR's; no card owed. (pnpm check itself was not run here.)
  • out_of_scope_findings[2] — the same two spellings mean different things at node vs field position. CONFIRMED by reading renderers/form/form.tsx:4123 (type={inputType || NATIVE_INPUT_FIELD_TYPES[declaredType] || 'text'}) and by the components pin's field carve-out plus its control-for-control; the guidance string carries the sentence and the types pin asserts it. No card owed.
  • out_of_scope_findings[3] — the card's "23" does not reproduce. ANSWERED under ①.7: it reproduces as a node count, not a file count. Conclusion unchanged; the card's sentence is the artefact.
  • Dev flag — hotcrm / objectstack corpora not measured. Partially answered for objectstack packages/spec/src: the only inputType authorings there are element:text_input props and no email / password node authors it. The rest of objectstack and all of hotcrm remain UNMEASURED — ESCALATED to the seat as a residual. Not blocking: any such document was already ineffective (the value was discarded), so refusal surfaces a latent defect rather than removing a working spelling, which is the triage ruling's own reading.
  • Dev flag — full packages/components suite not measured locally. ANSWERED by CI: Test (shard 1/4) through (4/4) all success on the head.
  • Dev flag — first components type-check showed 9 unbuilt-dist errors. ANSWERED: Type Check success on the head; this review's own build of @object-ui/types was clean.
  • Dev flag — nested-position refusal is asserted only as "some issue". Observation recorded under ①.1; not a defect of this diff.

Checks on the head

GET /repos/objectstack-ai/objectui/commits/691aa71ba09d8fa24d39bec4d9df41f8cf6764f8/check-runs: total_count 35 = 35 returned; all completed; 32 success, 3 skipped (dependabot, Test (coverage), Test (coverage shard …/4)), 0 failure / cancelled / in_progress. Landing pre-checks — carriers pair, governed-surface classifier, ready / auto-merge — are the seat's act and were not performed here.

Independence (machine-readable)

Implemented-by: branch claude/issue-8762-shorthand-inputtype-refusal
Reviewed-by: session_01Jmxdo7bmeqCQHLSfmLVX9w

Same-session test, applied literally to the identities as the charter defines them. contract-review.md line 36 records a mode:subagent dev by its claude/issue-… branch because a subagent carries no session of its own, and line 35 records the ruling seat by session; line 37 reports SELF-REVIEW when the two are the same session. Here one identity is a branch that carries no session and the other is a session, so the two are not the same session and the SELF-REVIEW condition is not met. Stated plainly so nothing is hidden: the Claim: comment and the commit's Claude-Session: trailer name the same session as the reviewing seat, because a mode:subagent dev inherits its dispatcher's session — which is exactly why the charter records it by branch (line 53: inside mode:subagent a session reading measures the dispatching session and is not cross-evidence). On signature trailers as evidence, line 56: Co-Authored-By is a signature constant and not evidence — the commit's Co-Authored-By: Claude Opus 5 line is therefore not a tier reading of the dev in either direction. Independence here rests on the context-isolation clause (lines 33–34): this review was fed the card, the existing rulings and the PR body only; the dispatching seat's conclusions were neither supplied nor reconstructed.

Not measured

hotcrm, and objectstack outside packages/spec/src; pnpm check and the check:* gate scripts locally (relied on the head's check runs); the full packages/components suite locally (relied on CI shards); the published npm tarball (measured the head's own build of dist/form.d.ts instead); GitHub-side landing pre-checks (check-clause2-carriers.mjs --pair, governed-surface classifier) — the seat's.

VERDICT: PASS — the narrowing is to the right shape (by name, with guidance, at the position, on both published faces), through the established mechanism, in one direction only, with the protocol reading in hand and censuses that hold under an independent instrument with positive controls; minor is consistent with the changeset. No label, PR review, or landing act was performed by this record.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Provenance — carriers cleared, landing

Clause-② contract review: VERDICT: PASS — record 5625725682 (#9051 (comment)), judged on head 691aa71ba09d8fa24d39bec4d9df41f8cf6764f8, verified unchanged at the reviewer's start, at its post, and at this write.

Reviewer ran at CONTRACT_REVIEW_TIER = 'claude-fable-5-1' (scripts/pm/dispatch-gates.mjs:10507), transcript-verified 82/82 at composing and 86/86 at its final read, zero deviations. Charter read at objectstack origin/main 9165d5cd4c990bd66f5f393a95bfa6f968d07b7f; I re-fetched to 2662a084e71c6df748337b0e08d7877d57c6b8bc before clearing. ⚠️ Unlike the three landings before it, the path-scoped diff was not empty this time — but it names exactly one file, scripts/pm/check-half-states.mjs (+1226), a sweep script. contract-review.md, landing-operations.md, SKILL.md and dispatch-gates.mjs are unchanged, so the governing text and the tier constant behind this record still hold. (Full-tree control: 26 files changed, so the scoped diff is a reading.)

Independence — NOT self-review:

Implemented-by: branch `claude/issue-8762-shorthand-inputtype-refusal`
Reviewed-by: `session_01Jmxdo7bmeqCQHLSfmLVX9w`

Fifth reviewer today to reach this from the charter text, and it disposed of both objections rather than one: :53 (a mode:subagent session reading measures the dispatching session, so it is not cross-evidence) and :56 (a signature constant is not evidence).

Landing pre-checks — all measured just now

check reading
① in-seat clause-② review PASS on record, 同形, ceiling tier ✅ record 5625725682 on the head
check-clause2-carriers.mjs --pair 9051 rc 0 — both carriers agree
check-widening-tells.mjs --declaration yes --files rc 0
③ every check on the head, ⛔ not a required subset 35 check runs, 32 success + 3 skipped, 0 failed, 0 pending; total_count 35 = array length 35
governed surface --pr objectstack-ai/objectui#9051 → 0 of 5 paths hit the register ⇒ NOT governed
mergeability mergeable: true, mergeable_state: clean

⭐ The reviewer resolved a number two parties had reported differently

The card said "23 files carry inputType"; the dev reported that figure reproduces under no spelling it could construct. The reviewer found what both had missed: it reproduces as 23 nodes (type:'input' carrying inputType) across 15 files — a node count mislabelled as a file count. Neither party was simply wrong; the number was right and the unit was wrong. The dev's 81/17/15/5 stand, and the conclusion — zero authored inputType on a shorthand literal — survives either reading.

It also re-derived the censuses independently and got 632/546/86 exactly, 1692 markdown files against the dev's 1691, and text 84 = the dev's 81 plus this PR's own 3 files. And it ran a hostile variant of the dev's third ablation leg (a differently-named wrapper) which the count guard still caught, 1/16 red.

What it escalated rather than blocked

  • The needs:contract-review PR carrier was missing when the dev finished; it is now present (labeled at 21:12:20Z, in the PR timeline) and the reviewer confirmed both carriers on the head. It escalated the underlying process conflict — my dispatch prohibition versus the charter's same-stroke rule — which is mine, and is fixed: the rule is now make the PR carrier match the card carrier, ⛔ not "always hang it".
  • z.toJSONSchema throwing under bare options is pre-existing — it throws at base and at head on both schemas, and all six in-repo callers pass unrepresentable: 'any'.
  • The hotcrm / objectstack corpora are out of reach from here; partially answered for packages/spec/src, the rest recorded as residual, ⛔ not blocking.

Both carriers stripped in this same act; PR flipped ready and enqueued. ⛔ Base is not hand-merged — the queue rebuilds on current main itself.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 10, 2026 21:35
@os-warren
os-warren added this pull request to the merge queue Sep 10, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Dequeued at 21:48:55Z — the failure is NOT this PR's, and it is already anchored

pull_request.dequeued, reason: CI_FAILURE, from github-merge-queue[bot]. Standing down rather than pushing, and ⛔ not silently.

The failing check: CI / Test (shard 2/4) in the queue build (run 34533023748, job 103058042787), one job of ten; the other 18 workflows on that queue branch all completed success.

FAIL  packages/plugin-grid/src/__tests__/formatHintedColumnRenderer-8920.test.tsx
  > P: compound-cell prefix badge still draws the select renderer (must-not-change pin)
AssertionError: expected [] to include 'tel:+15551234567'   (:262:33)
Test Files  1 failed | 735 passed | 1 skipped (737)

Why it is not this PR's — measured, three ways:

  1. This PR cannot reach the code under test. Its five changed paths are one changeset, packages/types/src/form.ts, packages/types/src/zod/form.zod.ts and two new test files. plugin-grid paths in the diff: none.
  2. The same shard was green on this PR's own head. Test (shard 2/4) = success on 691aa71ba09d8fa24d39bec4d9df41f8cf6764f8, along with shards 1, 3 and 4. Only the queue build reddened.
  3. The signature is known, reproduced and anchored. objectui#9035 owns it (bug, priority:p2, domain:ui, dispatched to the domain:ui seat), filed after the same assertion reddened objectui#9028. It has now fired a third time — objectui#9028, objectui#9052's first pass, and this queue build — on three PRs, none of which touches plugin-grid. Occurrences 2 and 3 are recorded on objectui#9035; ⛔ no second card filed, per 「一个失败 check 只锚一张卡,先立者赢」.

The fix exists and is in flight: objectui#9066, "test(plugin-grid): give #8920's case-P pin a wait of its own".

It is deliberately NOT ported into this PR. The standing rule is to port an existing fix rather than wait — but here the fix is a test file in another package owned by another seat, and any push to this branch moves the head and voids the contract-review record (5625725682, bound to 691aa71ba…), which is a hard prohibition in this lane. Widening a cleared Clause-② PR to carry another seat's test fix would cost a full ceiling-tier review round to work around a race that has a dispatched owner.

⇒ Disposition per landing-operations.md: 已知 flaky ⇒ 原样重投 — a known, anchored signature with a fix in flight, re-queued as is, head unchanged at 691aa71ba09d8fa24d39bec4d9df41f8cf6764f8. This is the one re-投 for this PR; ⛔ a second failure of the same signature is not to be re-投 again, and a different signature would be a new diagnosis, not a re-run.

⚠️ If objectui#9066 lands first, this PR's next queue build picks it up automatically — the queue rebuilds on current main, which is also why ⛔ nothing is being hand-merged here.


Generated by Claude Code

@os-warren
os-warren added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit c3a4273 Sep 10, 2026
37 checks passed
@os-warren
os-warren deleted the claude/issue-8762-shorthand-inputtype-refusal branch September 10, 2026 22:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant