Skip to content

feat(spec): refuse authored radio + multiple:true at the schema layer (ruled Option C, objectui#4015) - #11560

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-11437-radio-multiple-refusal
Aug 24, 2026
Merged

feat(spec): refuse authored radio + multiple:true at the schema layer (ruled Option C, objectui#4015)#11560
os-steve merged 1 commit into
mainfrom
claude/issue-11437-radio-multiple-refusal

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

What this PR does

Implements the maintainer ruling recorded 2026-08-22 on objectui#4015 (decision-inbox digest, batch accepted verbatim: 「接受所有」):

Ruled: Option C — reject at the entrance: refuse radio + multiple: true at the
schema/publish layer with a diagnostic prescribing the correctly-named multi-choice
types (checkboxes/multiselect/tags), while leaving MULTI_CAPABLE_TYPES and
isMultiValueField untouched so at-rest data keeps its read path and no stored-shape
migration is paid. Option A (rendering the combination as checkboxes) is explicitly not
taken. […] The implementation lands on the spec side (objectstack, domain:spec lane).

objectui#4015 remains open as the decision record — nothing in this PR closes it.

The defect being ruled on

An author could declare { type: 'radio', multiple: true }. The data layer honoured it (stored an array, validated as multi, split on import, inferred action-param arity) while the widget rendered a single-value radio group with zero diagnostics — declared multi, rendered single. The producer contradicted itself about radio (re-verified at this branch's merge base d10e214626, matching the card's measurement at 5d163792c):

SiteSays
packages/spec/src/data/field-value.zod.ts:79-81SINGLE_OPTION_TYPES = ['select','radio'] — "Single-choice option types."
packages/spec/src/data/field-value.zod.ts:204-206MULTI_CAPABLE_TYPES includes radio, its own comment conceding it "shares the select branch"
packages/spec/src/data/field-value.zod.ts:224-226isMultiValueField promotes any MULTI_CAPABLE_TYPES member on multiple: true
packages/spec/src/data/field.zod.ts:818multiple's published description: "Applicable for select, lookup, file, image" — radio absent
packages/spec/src/ui/action.zod.ts:453param arity inferred from MULTI_CAPABLE_TYPES

Consumer side (objectui, read-only, verified at the local checkout): RadioField.tsx has 0 occurrences of multiple (control probe 23 for RadioGroup|value), and paramValueShape.ts:142 pins radio as cardinality: 'scalar'.

Occurrence re-measurement at the merge base: both repos scanned; 18 type: 'radio' declarations found by this branch's scanner, 0 carrying multiple: true in a ±6-line window. The single window hit is the known select + multiple line adjacent to a radio entry in objectui paramValueShape.test.ts:93 — the same false positive the card documents. The flip-to-B condition (deployed tenant metadata with stored data) has not been triggered by anything measurable from here.

Where the refusal homes, and the diagnostic

FieldSchema's .superRefine in packages/spec/src/data/field.zod.ts — the same seam that carries the module's sibling authored-combination refusals (referenceVia on a non-text type, referenceVia + reference, storage.notNull + requiredWhen, the #7918 currency-precision contradiction). This is the parse path every publish crosses, measured rather than assumed: kernel/metadata-type-schemas.ts:91 registers field: FieldSchema for the standalone metadata type, and ObjectSchema's fields record embeds FieldSchema directly (object.zod.ts:1894), so both publish shapes hit the refinement. No .extend/.omit/.pick derivative of FieldSchema exists that could strip it.

The diagnostic (issue path: ['multiple'], verified verbatim against the built dist):

Field "severity": `type: 'radio'` cannot be combined with `multiple: true` — a radio group is single-choice by definition (its widget renders exactly one selected option and has no multi arity), so the declaration would validate and store arrays no radio input can ever produce: declared multi, rendered single. Use a multi-choice type instead: `checkboxes` (all options visible, radio-like layout), `multiselect` (dropdown), or `tags` (free-form values). For a single-choice field, drop `multiple`.

It names the field, names the illegal pair, and prescribes all three correctly-named multi-choice types.

Parse-idempotency (#9689 class — mandated check)

multiple is declared .default(false) (field.zod.ts:818), i.e. the default materializes at parse — but it materializes false, never true. The .superRefine runs over the parsed object, so a multiple: true it observes can only ever be authored; a defaulted value can never trip the refusal. Pinned by test: parse(parse(validRadioField)) is deep-equal stable, and the materialized multiple: false re-parses cleanly as an authored false. No mainline parse path materializes true, so the refusal required no authored-vs-defaulted machinery (unlike the #7918.overwrite() relocation one block below it).

Untouched halves (binding constraints of the ruling)

  • MULTI_CAPABLE_TYPES and isMultiValueField (field-value.zod.ts) — untouched. At-rest data keeps its read path; no stored-shape migration is paid. A new test pins MULTI_CAPABLE_TYPES.has('radio') and isMultiValueField({ type: 'radio', multiple: true }) === true so a future "cleanup" trips loudly instead of landing as Option B by accident.
  • packages/objectql/src/validation/record-validator.ts's (t === 'select' || t === 'radio') branch (line 656) — untouched, stays as a data-safety fallback for stock.
  • objectui — untouched (Option A explicitly not taken). The objectui field-type-alias.ts:28-37 prose correction the ruling assigns to the landing fork is deliberately not in this PR: it is objectui-side, and the PM relays it at ACCEPT per the dispatch brief.
  • multiple's published .describe() ("Applicable for select, lookup, file, image") — untouched: radio was already absent from it, consistent with the refusal; nothing in it is falsified by this change (confirmed by check:docs green with no regeneration needed).

Changeset reasoning

.changeset/radio-multiple-refused-at-schema.md@objectstack/spec: minor with an explicit BREAKING body, per the launch-window convention (mirroring the #11124/#11492 spelling). ADR-0087 disposition: not-required (no-migration-prescription) — the ruling explicitly declines a migration (that is why C beat B), re-measurement found zero occurrences of the refused combination in either repo, and a conversion rewriting the pair would itself be the stored-shape migration Option C was chosen to avoid. node scripts/check-adr-0087-registration.mjs accepts the disposition (exit 0). This follows the closest sibling precedent (#7918: a ruled contradiction-refusal in the same superRefine, registered nothing) rather than #8321 (which registered a conversion because it had stored rows to keep loading — the ruling here forbids exactly that move).

Verification (all at commit 72b3d9c886, via the shared verify lock)

  • pnpm --filter @objectstack/spec build — VERDICT command-exit 0.
  • pnpm --filter @objectstack/spec test420 files / 11213 tests passed, VERDICT command-exit 0, run after the final commit.
  • pnpm --filter @objectstack/spec typecheck — green (tsc + scripts + test programs; the check:test-typecheck self-test line printed its pass).
  • pnpm --filter @objectstack/spec check:generated — "All 14 generated artifacts are up to date" (no artifact moved: the refusal adds no describe, no export, no authorable key).
  • Reverse verification (fix committed first; source-only revert to origin/main proven on disk by marker grep 2 → 0): exactly the 3 refusal tests red pre-patch, the 6 accept-side controls green on both sides; restored byte-identically (git checkout HEAD, porcelain clean, marker 2, empty diff vs HEAD), re-run 9/9 green.
  • Acceptance probe on the built dist via @objectstack/spec/data package exports from a consumer package: radio+multiple red with the verbatim prescriptive message on path ['multiple']; radio-without-multiple green with multiple: false materialized; untouched-half probes true.
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (derivation attested at 72b3d9c886, --repo assertion held): 26 derived/convention gates run, 25 to verdict green — including check:adr-0087-registration, check:changeset-no-major, check:empty-changeset, check:cross-package-test-inputs, check:engine-double-contract, check:where-matcher, check:test-source-alias, check:nul-bytes, and check:doc-formula-expressions (after building @objectstack/formula, whose missing dist was worktree build state, not this diff).
  • Declared narrowings (2), both workspace-build-state preconditions no spec-only diff can move, both CI-covered on a fully built tree: check:dev-prereqs (wants all 67 package dists on disk; spec's own dist is built and content-fresh — its spec-freshness half is the only half this diff touches) and check:type-check-debt --re-measure (needs the full workspace closure built; the slice this diff can move — spec's own programs including the new test code — is proven green by spec's typecheck).

Review status

Draft, held for the PM's contract review (clause-②) — this PR changes accept/reject behaviour on the contract surface and does not land until that review; please do not flip it ready or queue it.

Fixes#11437


Generated by Claude Code

… (ruled Option C)
FieldSchema's superRefine now rejects the authored combination with a
diagnostic naming the field, the illegal pair, and the three correctly-named
multi-choice types (checkboxes / multiselect / tags). MULTI_CAPABLE_TYPES and
isMultiValueField stay untouched per the same ruling (at-rest data keeps its
read path; a pin trips a future cleanup), and the objectql record-validator
select/radio branch stays as a data-safety fallback. multiple materializes
.default(false), so the refusal can only fire on an authored true and
parse(parse(x)) stays stable (pinned).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9cDbY2NBiVJWYx3BpWfH2
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 1 documentable anchor(s).

10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-driven.mdx(via FieldSchema (symbol))
  • content/docs/data-modeling/external-datasources.mdx(via FieldSchema (symbol))
  • content/docs/data-modeling/field-types.mdx(via FieldSchema (symbol))
  • content/docs/data-modeling/validation-rules.mdx(via FieldSchema (symbol))
  • content/docs/deployment/troubleshooting.mdx(via FieldSchema (symbol))
  • content/docs/deployment/validating-metadata.mdx(via FieldSchema (symbol))
  • content/docs/getting-started/quick-reference.mdx(via FieldSchema (symbol))
  • content/docs/kernel/contracts/data-engine.mdx(via FieldSchema (symbol))
  • content/docs/protocol/backward-compatibility.mdx(via FieldSchema (symbol))
  • content/docs/protocol/objectql/types.mdx(via FieldSchema (symbol))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via FieldSchema (symbol))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 126 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 033a34c7c42cdae90bc4d18ca0bdcbd725df631bpackageMentionDocs.

Which tree this was computed on

This run read content/docs from d1b9cd46f235fc28d96e9fc0478b72c77ef6846f — the merge of head 72b3d9c8865cc80a76757878e12e4e48a8012280 into base 033a34c7c42cdae90bc4d18ca0bdcbd725df631b, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d1b9cd46f235fc28d96e9fc0478b72c77ef6846f && git checkout d1b9cd46f235fc28d96e9fc0478b72c77ef6846f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 033a34c7c42cdae90bc4d18ca0bdcbd725df631b 72b3d9c8865cc80a76757878e12e4e48a8012280 && git checkout -B drift-repro 033a34c7c42cdae90bc4d18ca0bdcbd725df631b && git merge --no-ff 72b3d9c8865cc80a76757878e12e4e48a8012280
node scripts/docs-audit/affected-docs.mjs --json 033a34c7c42cdae90bc4d18ca0bdcbd725df631b

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 033a34c7c42cdae90bc4d18ca0bdcbd725df631b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation protocol:data tests tooling labels Aug 24, 2026
@os-steve
os-steve marked this pull request as ready for review August 24, 2026 03:28
@os-steve
os-steve added this pull request to the merge queueAug 24, 2026
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Aug 24, 2026
@os-steve
os-steve added this pull request to the merge queueAug 24, 2026
Merged via the queue into main with commit 348860cAug 24, 2026
33 checks passed
@os-steve
os-steve deleted the claude/issue-11437-radio-multiple-refusal branch August 24, 2026 06:31
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec: refuse an authored radio + multiple: true at the schema/publish layer (maintainer ruling 2026-08-22, Option C)

2 participants

@os-steve@claude