Skip to content

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

Description

@claude

What was ruled

Maintainer ruling recorded 2026-08-22 on objectstack-ai/objectui#4015 (decision-inbox
digest with the skills seat; the 46-card batch was 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).

This card carries that implementation. It is filed unassigned, by an os-dev seat that was
dispatched on the objectui card and stopped rather than implementing a renderer-side
variant the ruling does not authorize.

The defect being ruled on

An author may declare { type: 'radio', multiple: true }. The data layer honours it
(stores an array, validates as multi, splits on import, infers action-param arity), while
the widget renders a single-value radio group with zero diagnostics — declared multi,
rendered single.

The producer contradicts itself about radio, all on origin/main 5d163792c:

Site Says
packages/spec/src/data/field-value.zod.ts:79-81 SINGLE_OPTION_TYPES = ['select','radio'] — "Single-choice option types."
packages/spec/src/data/field-value.zod.ts:198-206 MULTI_CAPABLE_TYPES includes radio, its own comment conceding radio is there because it "shares the select branch"
packages/spec/src/data/field-value.zod.ts:226 isMultiValueField = MULTI_CAPABLE_TYPES.has(type) && multiple === true (ADR-0104 D1)
packages/spec/src/data/field.zod.ts:818 multiple's published description: "Applicable for select, lookup, file, image" — radio absent
packages/spec/src/ui/action.zod.ts:453 param arity inferred from MULTI_CAPABLE_TYPES

Consumer side, objectui origin/main 515f171f3:

  • packages/fields/src/widgets/RadioField.tsx — 0 occurrences of multiple
    (control probe: 23 for RadioGroup|value, so the file really was read).
  • packages/app-shell/src/utils/paramValueShape.ts:142 —
    radio: { base: 'string', cardinality: 'scalar', note: 'Single option value string.' },
    where 'scalar' is defined as "always a single base value". This is a landed,
    drift-tested declaration (paramValueShape.test.ts:93 pins radio -> string) that
    radio does not promote on multiple — which is why Option A (render as checkboxes)
    would have contradicted a pinned contract in that repo, independently of the ruling.

Scope of the ruled change (Option C)

  1. Refuse radio + multiple: true at the schema/publish layer, with a diagnostic
    naming the field and prescribing checkboxes / multiselect / tags.
  2. ⛔ Do not touch MULTI_CAPABLE_TYPES or isMultiValueField — at-rest data keeps its
    read path, and no stored-shape migration is paid. That is the whole reason C was
    preferred over B.
  3. packages/objectql/src/validation/record-validator.ts's (t === 'select' || t === 'radio')
    branch stays as a data-safety fallback for stock, not as a capability.

Flip condition to B, attached by the ruling

If deployed tenant metadata carrying { type: 'radio', multiple: true } with stored
data
is found, entrance rejection alone strands those tenants and Option B (narrow the
spec sets themselves, with a migration path) becomes required rather than optional. The
ruling's own stated blind spot is that only the two code repositories were measured.

Occurrence measurement, re-measured 2026-08-23

Both repos at objectui 515f171f3 / objectstack 5d163792c: 10,977 files scanned, 21
type: 'radio' declarations found
(control probe — the scanner reads real declarations),
0 of them carrying multiple: true. Examples, showcase, dogfood matrix, docs, fixtures
and tests are all clean. The single window match was select + multiple on a line adjacent
to a radio entry in objectui paramValueShape.test.ts.

Owed by the landing fork, named by the ruling

objectui packages/fields/src/field-type-alias.ts:28-37 states that "every other member
renders both arities INSIDE one widget: LookupField, FileField and ImageField"
—
silently omitting radio, the one member with no renderer for its multi arity. That
sentence is what made this gap invisible; the ruling assigns its correction to whichever
fork lands.


Decision anchor: objectstack-ai/objectui#4015 — it stays open as the decision record
until this ships. ⛔ Do not put a closing keyword next to that number from here.

Generated by Claude Code


Generated by Claude Code

Activity

  1. added theissue type on Aug 24, 2026
  2. claude commented on Aug 24, 2026

    @claude
    ContributorAuthor

    Triage (daily round, session session_01Kktexqp6uVuFMztvvTMf3V, 2026-08-24): ruled card (maintainer 2026-08-22, Option C — refuse authored radio + multiple: true at schema/publish layer): pm:queue added on the existing domain:spec, Task. Clause-② yes — accept/reject behaviour changes ⇒ contract-review tier. Quote the ruling verbatim in the dispatch brief.


    Generated by Claude Code

  3. self-assigned this
    on Aug 24, 2026
  4. os-steve commented on Aug 24, 2026

    @os-steve
    Collaborator

    Claim: PM seat session_01T9cDbY2NBiVJWYx3BpWfH2 (/pm-dispatch, spec lane) — dispatching to an os-dev subagent under the recorded maintainer ruling (2026-08-22 on objectui#4015, 「接受所有」; verbatim Option C text on this card).
    Session: session_01T9cDbY2NBiVJWYx3BpWfH2
    Branch: claude/issue-11437-radio-multiple-refusal
    Worktree: ../objectstack-11437 (created by the dev agent, per-task)
    Domain: domain:spec
    File surface: packages/spec/src/data/field.zod.ts (or wherever the schema-layer refusal correctly homes — dev measures against the ruling's "schema/publish layer") + tests; ⛔ MULTI_CAPABLE_TYPES / isMultiValueField (field-value.zod.ts) untouched; objectql record-validator branch untouched (data-safety fallback stays).
    Container & model: os-dev subagent, claude-fable-5 (clause-② tier — new accept/reject refusal on a previously-parseable combination; fable serialization satisfied, sole fable dev).
    Clause-②: yes (triage concurs) — needs:contract-review hung on this card at claim; PR-side at creation; PR stays draft, landing decision is the review's.
    Parse-idempotency check mandated (#9689 class): verify whether multiple carries a materialized default anywhere radio fields parse — the refusal must reject only the AUTHORED true, never a materialized value, and parse(parse(x)) must stay stable.
    Cross-repo boundary: the objectui field-type-alias.ts:28-37 prose correction the ruling assigns to the landing fork is objectui-side — the dev does NOT touch objectui; this seat relays it at ACCEPT (objectui#4015 stays open as the decision record; ⛔ no closing keyword next to that number anywhere in the PR).
    Serial constraints: field.zod.ts hot-file check — #9784 is blocked-parked (not in flight) and PR #11406 is draft-parked on a different region (deleteBehavior); no live overlap, but the dev must verify merge cleanliness against origin/main at claim time.


    Generated by Claude Code

  5. os-steve commented on Aug 24, 2026

    @os-steve
    Collaborator

    os-dev-report

    {
      "issue": 11437,
      "status": "done",
      "branch": "claude/issue-11437-radio-multiple-refusal",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/11560",
      "premise_still_valid": true,
      "summary": "Implemented ruled Option C (maintainer 2026-08-22 on objectui#4015): FieldSchema's superRefine in packages/spec/src/data/field.zod.ts now refuses an authored radio + multiple:true with a diagnostic that names the field, names the illegal pair on path ['multiple'], and prescribes checkboxes / multiselect / tags — homed beside the sibling authored-combination refusals, firing on both publish shapes (the standalone 'field' metadata type via metadata-type-schemas.ts:91 and ObjectSchema-embedded fields via object.zod.ts:1894). MULTI_CAPABLE_TYPES / isMultiValueField and objectql record-validator's select/radio branch stay untouched per the ruling, with a new pin so a future cleanup of radio's set membership trips a test; objectui untouched. Premise re-verified at merge base d10e214626: all five contradiction-table sites hold, no prior landing of the refusal, occurrence re-measure found 18 type:'radio' declarations across both repos with 0 carrying multiple:true (the single window hit is the known select+multiple adjacency in objectui paramValueShape.test.ts:93), so the flip-to-B condition stays untriggered. Reminder owed by the landing fork, NOT in this PR: the objectui field-type-alias.ts:28-37 prose correction is the PM's to relay at ACCEPT. Draft PR held for clause-2 contract review; changeset ships @objectstack/spec minor with explicit BREAKING body and ADR-0087 disposition not-required (no-migration-prescription), accepted by check:adr-0087-registration.",
      "tests": "All at commit 72b3d9c886 via scripts/pm/os-verify-lock.sh. Build: pnpm --filter @objectstack/spec build, VERDICT command-exit 0. Full suite after the final commit: pnpm --filter @objectstack/spec test = 'Test Files 420 passed (420) / Tests 11213 passed (11213)', VERDICT command-exit 0 (one earlier attempt died at the 10-min foreground cap, exit 143, read-only, rerun clean). Typecheck green (tsc + scripts + test programs; check:test-typecheck self-test printed its pass line). check:generated: 'All 14 generated artifacts are up to date' after rebuild (an mtime-only false stale on check:api-surface from the reverse-verification revert/restore cleared by rebuilding identical content). Reverse verification, fix committed first: git restore --source=origin/main on field.zod.ts only; mutation confirmed on disk by marker grep 'declared multi, rendered single' 2 -> 0 with porcelain showing the lone M — no rebuild needed for this leg because field.test.ts imports ./field.zod relatively so vitest reads src, not dist (the dist ablation ledger is not in play); observed direction: exactly the 3 refusal tests red, the 6 accept-side controls green on both sides; restored via git checkout HEAD (porcelain clean, marker back to 2, empty diff vs HEAD), re-run 9/9 green. Dist acceptance probe through @objectstack/spec/data package exports from packages/objectql (dist built from the committed content, build VERDICT 0): bad.success=false with the verbatim prescriptive message on path ['multiple']; good.success=true with multiple:false materialized; MULTI_CAPABLE_TYPES.has('radio')=true and isMultiValueField({type:'radio',multiple:true})=true. dispatch-gates.mjs --repo objectstack-ai/objectstack (derivation attested at 72b3d9c886, --repo assertion held): 26 gates run, 25 to verdict green including check:adr-0087-registration, check:changeset-no-major, check:empty-changeset, check:changeset-gate-self-tests, check:cross-package-test-inputs, check:engine-double-contract, check:where-matcher, check:test-source-alias, check:nul-bytes, and check:doc-formula-expressions (green after building @objectstack/formula — its red was worktree build state, not this diff). Declared narrowings (2), both workspace-build-state preconditions a spec-only diff cannot move, both CI-covered on a fully built tree: check:dev-prereqs (wants all 67 dists; spec's own dist is built and content-fresh) and check:type-check-debt --re-measure (needs full closure built; the slice this diff can move — spec's programs including the new test code — proven green by spec typecheck). Parse-idempotency pinned: parse(parse(validRadioField)) deep-equal stable; multiple's .default(false) materializes false only, so the refusal can never fire on a defaulted value.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code


    Generated by Claude Code

  6. os-steve commented on Aug 24, 2026

    @os-steve
    Collaborator

    PM verdict: ACCEPT — PR #11560

    Contract review (clause ②): PASS. Fuse reading this stroke via get_session: external_metadata.last_served_model = claude-fable-5. Reviewed the actual diff at head 72b3d9c886.

    1. Ruling fidelity (Option C, exactly): the refusal homes in FieldSchema's superRefine beside the sibling authored-combination refusals, fires on both publish shapes (standalone field + ObjectSchema-embedded, each tested), and the diagnostic names the field, the illegal pair on path ['multiple'], and all three prescriptions plus the single-choice remedy. MULTI_CAPABLE_TYPES / isMultiValueField / objectql's fallback branch are untouched — and the untouched-half pin (MULTI_CAPABLE_TYPES.has('radio') + isMultiValueField promotion) means a future Option-B-by-stealth cleanup trips a test instead of landing silently. objectui untouched; no closing keyword near objectui#4015.
    2. Parse-idempotency (FieldSchema accepts deleteBehavior: 'set_null' on a master_detail, and the engine silently resolves it to cascade #9689 class) is safe by construction and by pin: the check runs after multiple materializes .default(false), so true at the refinement is always authored; parse(parse(validRadioField)) deep-equal pinned.
    3. Flip-to-B condition re-measured and untriggered: 0 occurrences of the pair at the merge base (fresh sweep: 18 type:'radio' declarations, none with multiple:true; the changeset quotes the card's earlier 21-declaration count — both measurements agree on the load-bearing zero; noted, not rework).
    4. Changeset discipline: minor + explicit BREAKING per the launch-window convention (the feat(service-datasource): external-datasource driver return contract fails compile-time on mis-shaped drivers (#11123 option C) #11492 precedent), ADR-0087 not-required (no-migration-prescription) with a sound rationale (a conversion rewriting the pair would itself be the stored-shape migration C exists to avoid), accepted mechanically by the gate. Reverse verification exact (3 refusal tests red pre-patch, 6 accept controls green both sides); dist acceptance probe verbatim; two declared workspace-state narrowings, both CI-covered.

    Cross-repo relay owed at this ACCEPT — executed in the same stroke: comment posted on objectui#4015 recording that the spec fork is in landing and that the field-type-alias.ts:28-37 prose correction (the sentence whose silent omission of radio made this gap invisible) routes to the objectui lane. objectui#4015 stays OPEN as the decision record.

    needs:contract-review cleared from both carriers in this same stroke. Landing: flip ready → every check green → merge queue. Fixes #11437 closes this card at merge; hygiene at the landing stroke.


    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

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions