Repository navigation
feat(spec): refuse authored radio + multiple:true at the schema layer (ruled Option C, objectui#4015) - #11560
Conversation
… (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
📓 Docs Drift CheckThis PR changes 1 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 126 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
What this PR does
Implements the maintainer ruling recorded 2026-08-22 on objectui#4015 (decision-inbox digest, batch accepted verbatim: 「接受所有」):
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 aboutradio(re-verified at this branch's merge based10e214626, matching the card's measurement at5d163792c):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_TYPESincludesradio, its own comment conceding it "shares the select branch"packages/spec/src/data/field-value.zod.ts:224-226isMultiValueFieldpromotes anyMULTI_CAPABLE_TYPESmember onmultiple: truepackages/spec/src/data/field.zod.ts:818multiple's published description: "Applicable for select, lookup, file, image" —radioabsentpackages/spec/src/ui/action.zod.ts:453MULTI_CAPABLE_TYPESConsumer side (objectui, read-only, verified at the local checkout):
RadioField.tsxhas 0 occurrences ofmultiple(control probe 23 forRadioGroup|value), andparamValueShape.ts:142pinsradioascardinality: 'scalar'.Occurrence re-measurement at the merge base: both repos scanned; 18
type: 'radio'declarations found by this branch's scanner, 0 carryingmultiple: truein a ±6-line window. The single window hit is the knownselect + multipleline adjacent to aradioentry in objectuiparamValueShape.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.superRefineinpackages/spec/src/data/field.zod.ts— the same seam that carries the module's sibling authored-combination refusals (referenceViaon 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:91registersfield: FieldSchemafor the standalone metadata type, andObjectSchema'sfieldsrecord embedsFieldSchemadirectly (object.zod.ts:1894), so both publish shapes hit the refinement. No.extend/.omit/.pickderivative ofFieldSchemaexists that could strip it.The diagnostic (issue
path: ['multiple'], verified verbatim against the built dist):It names the field, names the illegal pair, and prescribes all three correctly-named multi-choice types.
Parse-idempotency (#9689 class — mandated check)
multipleis declared.default(false)(field.zod.ts:818), i.e. the default materializes at parse — but it materializesfalse, nevertrue. The.superRefineruns over the parsed object, so amultiple: trueit 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 materializedmultiple: falsere-parses cleanly as an authoredfalse. No mainline parse path materializestrue, 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_TYPESandisMultiValueField(field-value.zod.ts) — untouched. At-rest data keeps its read path; no stored-shape migration is paid. A new test pinsMULTI_CAPABLE_TYPES.has('radio')andisMultiValueField({ type: 'radio', multiple: true }) === trueso 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.field-type-alias.ts:28-37prose 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:radiowas already absent from it, consistent with the refusal; nothing in it is falsified by this change (confirmed bycheck:docsgreen with no regeneration needed).Changeset reasoning
.changeset/radio-multiple-refused-at-schema.md—@objectstack/spec: minorwith 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.mjsaccepts 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 test— 420 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).git checkout HEAD, porcelain clean, marker 2, empty diff vs HEAD), re-run 9/9 green.@objectstack/spec/datapackage exports from a consumer package: radio+multiple red with the verbatim prescriptive message on path['multiple']; radio-without-multiple green withmultiple: falsematerialized; untouched-half probes true.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(derivation attested at72b3d9c886,--repoassertion held): 26 derived/convention gates run, 25 to verdict green — includingcheck: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, andcheck:doc-formula-expressions(after building@objectstack/formula, whose missing dist was worktree build state, not this diff).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) andcheck: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'stypecheck).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