From 72b3d9c8865cc80a76757878e12e4e48a8012280 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 02:47:09 +0000 Subject: [PATCH] feat(spec): refuse authored radio + multiple:true at the schema layer (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 Claude-Session: https://claude.ai/code/session_01T9cDbY2NBiVJWYx3BpWfH2 --- .../radio-multiple-refused-at-schema.md | 50 +++++++++++ packages/spec/src/data/field.test.ts | 87 +++++++++++++++++++ packages/spec/src/data/field.zod.ts | 27 ++++++ 3 files changed, 164 insertions(+) create mode 100644 .changeset/radio-multiple-refused-at-schema.md diff --git a/.changeset/radio-multiple-refused-at-schema.md b/.changeset/radio-multiple-refused-at-schema.md new file mode 100644 index 0000000000..7e3bb24227 --- /dev/null +++ b/.changeset/radio-multiple-refused-at-schema.md @@ -0,0 +1,50 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): refuse an authored `radio` + `multiple: true` at the schema layer (#11437, maintainer ruling 2026-08-22 on objectui#4015, Option C) + +**BREAKING** accept-set narrowing, shipped as `minor` under the repo's +launch-window convention for breaking changes. + +An author could declare `{ type: 'radio', multiple: true }` and the producer +honoured it everywhere the widget could not: the data layer stored an array, +validated it as multi, split it on import and inferred multi arity for action +params, while the one renderer `radio` has draws a single-value radio group +with zero diagnostics. Declared multi, rendered single — the contradiction sat +inside `packages/spec` itself, where `SINGLE_OPTION_TYPES` calls `radio` +single-choice on one line and `MULTI_CAPABLE_TYPES` carries it on another +because it "shares the select branch". + +Per the maintainer ruling recorded 2026-08-22 on objectui#4015 (Option C, +「接受所有」), `FieldSchema` now refuses the authored combination at parse +time — the seam every publish crosses, both for a standalone `field` document +and for fields embedded in an `ObjectSchema` — with a diagnostic that names +the field, names the illegal pair, and prescribes the correctly-named +multi-choice types: `checkboxes` (all options visible, radio-like layout), +`multiselect` (dropdown) and `tags` (free-form values). + +**What stays accepted, byte-identically:** `radio` without `multiple` +(including its materialized `multiple: false`), `radio` with an authored +`multiple: false`, `select`/`lookup`/`user`/`file`/`image` with +`multiple: true`, and the inherently-multi types with or without the redundant +flag. Because `multiple` materializes `.default(false)`, the refusal can only +ever fire on an authored `true` — a defaulted value never trips it, and +`parse(parse(x))` stays stable (pinned). + +**What is deliberately untouched, per the same ruling:** `MULTI_CAPABLE_TYPES` +and `isMultiValueField` in `field-value.zod.ts` keep `radio`, so data at rest +that was written under the old contract keeps its read path and no +stored-shape migration is paid — that is the whole reason Option C was +preferred over narrowing the sets themselves. A test pins `radio`'s membership +so a future cleanup trips loudly. `packages/objectql`'s record-validator +select/radio branch likewise stays, as a data-safety fallback for stock. + +Measured before landing (both repos, examples/docs/fixtures/tests included): +21 `type: 'radio'` declarations, none carrying `multiple: true` — the refused +combination has zero occurrences, so no existing metadata is invalidated. The +ruling attaches an explicit flip condition: if deployed tenant metadata +carrying the combination with stored data is ever found, entrance rejection +alone would strand it and the set-narrowing option becomes required. + + diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts index 38a153cfe2..82f6d1751c 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -12,6 +12,7 @@ import { type CurrencyValue, } from './field.zod'; import { ObjectSchema } from './object.zod'; +import { MULTI_CAPABLE_TYPES, isMultiValueField } from './field-value.zod'; describe('FieldType', () => { it('should accept valid field types', () => { @@ -1397,6 +1398,92 @@ describe('ADR-0113 — required is a write contract; storage.notNull is the colu expect(() => FieldSchema.parse({ type: 'text', storage: { collation: 'C' } })).toThrow(); }); }); + +describe('FieldSchema — authored `radio` + `multiple: true` is REFUSED (#11437, maintainer ruling 2026-08-22 on objectui#4015, Option C)', () => { + // Option C rejects the contradiction at the entrance: the data layer + // honoured the flag while the widget rendered a single-value radio group — + // declared multi, rendered single, zero diagnostics. The other half of the + // same ruling is that the field-value.zod.ts sets stay UNTOUCHED (at-rest + // data keeps its read path); the last pin below trips a future "cleanup". + + it('REJECTS radio + multiple: true, naming the field and the illegal pair on the `multiple` path', () => { + const r = FieldSchema.safeParse({ name: 'severity', type: 'radio', multiple: true }); + expect(r.success).toBe(false); + if (!r.success) { + const issue = r.error.issues.find((i) => i.path.join('.') === 'multiple'); + expect(issue).toBeDefined(); + expect(issue!.message).toMatch(/"severity"/); + expect(issue!.message).toMatch(/'radio'/); + expect(issue!.message).toMatch(/multiple: true/); + } + }); + + it('the rejection carries the prescription — all three correctly-named multi-choice types', () => { + const r = FieldSchema.safeParse({ name: 'labels', type: 'radio', multiple: true }); + expect(r.success).toBe(false); + if (!r.success) { + const message = r.error.issues.map((i) => i.message).join('\n'); + expect(message).toMatch(/`checkboxes`/); + expect(message).toMatch(/`multiselect`/); + expect(message).toMatch(/`tags`/); + } + }); + + it('fires through ObjectSchema too — the publish path an object document crosses', () => { + const r = ObjectSchema.safeParse({ + name: 'crm_lead', + label: 'Lead', + fields: { + severity: { type: 'radio', multiple: true, options: [{ label: 'Low', value: 'low' }, { label: 'High', value: 'high' }] }, + }, + }); + expect(r.success).toBe(false); + if (!r.success) { + const messages = r.error.issues.map((i) => i.message).join('\n'); + expect(messages).toMatch(/`checkboxes`/); + } + }); + + it('radio WITHOUT `multiple` stays accepted — and the key materializes its usual `false`', () => { + const f = FieldSchema.parse({ name: 'severity', type: 'radio', options: [{ label: 'Low', value: 'low' }, { label: 'High', value: 'high' }] }); + expect(f.multiple).toBe(false); + }); + + it('radio + authored `multiple: false` stays accepted — the refusal reads only the authored `true`', () => { + const f = FieldSchema.parse({ name: 'severity', type: 'radio', multiple: false }); + expect(f.multiple).toBe(false); + }); + + it('parse(parse(x)) is stable — the materialized `multiple: false` re-parses cleanly (#9689 class)', () => { + const once = FieldSchema.parse({ name: 'severity', label: 'Severity', type: 'radio', options: [{ label: 'Low', value: 'low' }, { label: 'High', value: 'high' }] }); + const twice = FieldSchema.parse(once); + expect(twice).toEqual(once); + }); + + it('the prescribed multi-choice types all still parse — with and without the redundant flag', () => { + for (const type of ['checkboxes', 'multiselect', 'tags']) { + expect(() => FieldSchema.parse({ name: 'labels', type, options: [{ label: 'Alpha', value: 'alpha' }, { label: 'Beta', value: 'beta' }] })).not.toThrow(); + // Inherently-multi types tolerated a redundant `multiple: true` before + // this refusal and still do — the refusal is radio-scoped. + expect(() => FieldSchema.parse({ name: 'labels', type, options: [{ label: 'Alpha', value: 'alpha' }, { label: 'Beta', value: 'beta' }], multiple: true })).not.toThrow(); + } + }); + + it('`select` + multiple: true — the multi-capable sibling on the same parse branch — stays accepted', () => { + const f = FieldSchema.parse({ name: 'labels', type: 'select', options: [{ label: 'Alpha', value: 'alpha' }, { label: 'Beta', value: 'beta' }], multiple: true }); + expect(f.multiple).toBe(true); + }); + + it('UNTOUCHED-HALF PIN — `radio` stays in MULTI_CAPABLE_TYPES and isMultiValueField still promotes it', () => { + // The ruling's other half: at-rest data written under the old contract + // keeps its read path. A "cleanup" that drops `radio` from the set is + // Option B (spec-set narrowing + stored-shape migration), explicitly NOT + // taken — this pin makes that cleanup trip a test instead of landing + // silently. + expect(MULTI_CAPABLE_TYPES.has('radio')).toBe(true); + expect(isMultiValueField({ type: 'radio', multiple: true })).toBe(true); + }); +}); describe('FieldSchema — `placeholder` is a DECLARED key (#9019, ruled Option C on objectui#4676)', () => { // The reverse of the pre-#9019 posture: `placeholder` used to be refused by // name via FIELD_KEY_GUIDANCE ("never a FieldSchema key. Author hint text diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 1757b6a224..e2fef5bf26 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -1510,6 +1510,33 @@ export const FieldSchema = lazySchema(() => strictObject({ }); } + // [#11437] (maintainer ruling 2026-08-22 on objectui#4015, Option C — + // reject at the entrance): an authored `radio` + `multiple: true` is + // refused at the schema/publish seam. The data layer honoured the flag + // (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. Per the same ruling, + // `MULTI_CAPABLE_TYPES` / `isMultiValueField` (field-value.zod.ts) stay + // UNTOUCHED so at-rest data keeps its read path and no stored-shape + // migration is paid (why C beat B), and objectql record-validator's + // select/radio branch stays as a data-safety fallback. `multiple` + // materializes `.default(false)` above, so `true` here is always AUTHORED — + // this check can never fire on a defaulted value, and `parse(parse(x))` + // stays stable (#9689 class; pinned in field.test.ts). + if (field.type === 'radio' && field.multiple === true) { + ctx.addIssue({ + code: 'custom', + path: ['multiple'], + message: + `Field "${field.name ?? ''}": \`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`.', + }); + } + // #7918 (maintainer ruling 2026-08-12, Option A): the FIELD-level // `precision` key doubles as the currency display width — objectui's // CurrencyField reads it, and objectui#4361 pinned authored-precision-wins