Skip to content

Commit 348860c

Browse files
os-steveclaude
andauthored
feat(spec): refuse authored radio + multiple:true at the schema layer (ruled Option C) (#11560)
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). Claude-Session: https://claude.ai/code/session_01T9cDbY2NBiVJWYx3BpWfH2 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 67ceb9a commit 348860c

3 files changed

Lines changed: 164 additions & 0 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): refuse an authored `radio` + `multiple: true` at the schema layer (#11437, maintainer ruling 2026-08-22 on objectui#4015, Option C)
6+
7+
**BREAKING** accept-set narrowing, shipped as `minor` under the repo's
8+
launch-window convention for breaking changes.
9+
10+
An author could declare `{ type: 'radio', multiple: true }` and the producer
11+
honoured it everywhere the widget could not: the data layer stored an array,
12+
validated it as multi, split it on import and inferred multi arity for action
13+
params, while the one renderer `radio` has draws a single-value radio group
14+
with zero diagnostics. Declared multi, rendered single — the contradiction sat
15+
inside `packages/spec` itself, where `SINGLE_OPTION_TYPES` calls `radio`
16+
single-choice on one line and `MULTI_CAPABLE_TYPES` carries it on another
17+
because it "shares the select branch".
18+
19+
Per the maintainer ruling recorded 2026-08-22 on objectui#4015 (Option C,
20+
「接受所有」), `FieldSchema` now refuses the authored combination at parse
21+
time — the seam every publish crosses, both for a standalone `field` document
22+
and for fields embedded in an `ObjectSchema` — with a diagnostic that names
23+
the field, names the illegal pair, and prescribes the correctly-named
24+
multi-choice types: `checkboxes` (all options visible, radio-like layout),
25+
`multiselect` (dropdown) and `tags` (free-form values).
26+
27+
**What stays accepted, byte-identically:** `radio` without `multiple`
28+
(including its materialized `multiple: false`), `radio` with an authored
29+
`multiple: false`, `select`/`lookup`/`user`/`file`/`image` with
30+
`multiple: true`, and the inherently-multi types with or without the redundant
31+
flag. Because `multiple` materializes `.default(false)`, the refusal can only
32+
ever fire on an authored `true` — a defaulted value never trips it, and
33+
`parse(parse(x))` stays stable (pinned).
34+
35+
**What is deliberately untouched, per the same ruling:** `MULTI_CAPABLE_TYPES`
36+
and `isMultiValueField` in `field-value.zod.ts` keep `radio`, so data at rest
37+
that was written under the old contract keeps its read path and no
38+
stored-shape migration is paid — that is the whole reason Option C was
39+
preferred over narrowing the sets themselves. A test pins `radio`'s membership
40+
so a future cleanup trips loudly. `packages/objectql`'s record-validator
41+
select/radio branch likewise stays, as a data-safety fallback for stock.
42+
43+
Measured before landing (both repos, examples/docs/fixtures/tests included):
44+
21 `type: 'radio'` declarations, none carrying `multiple: true` — the refused
45+
combination has zero occurrences, so no existing metadata is invalidated. The
46+
ruling attaches an explicit flip condition: if deployed tenant metadata
47+
carrying the combination with stored data is ever found, entrance rejection
48+
alone would strand it and the set-narrowing option becomes required.
49+
50+
<!-- adr-0087: not-required (no-migration-prescription) The ruling explicitly declines a migration: at-rest data keeps its read path via the untouched MULTI_CAPABLE_TYPES / isMultiValueField, and re-measurement at the merge base found zero occurrences of the refused combination in either repo, so there is no existing author to migrate and nothing objectstack migrate meta is authorized to rewrite — a conversion rewriting the pair would itself be the stored-shape migration Option C was chosen to avoid. Guidance for future authors lives in the refusal diagnostic itself. -->

‎packages/spec/src/data/field.test.ts‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
type CurrencyValue,
1313
} from './field.zod';
1414
import { ObjectSchema } from './object.zod';
15+
import { MULTI_CAPABLE_TYPES, isMultiValueField } from './field-value.zod';
1516

1617
describe('FieldType', () => {
1718
it('should accept valid field types', () => {
@@ -1397,6 +1398,92 @@ describe('ADR-0113 — required is a write contract; storage.notNull is the colu
13971398
expect(() => FieldSchema.parse({ type: 'text', storage: { collation: 'C' } })).toThrow();
13981399
});
13991400
});
1401+
1402+
describe('FieldSchema — authored `radio` + `multiple: true` is REFUSED (#11437, maintainer ruling 2026-08-22 on objectui#4015, Option C)', () => {
1403+
// Option C rejects the contradiction at the entrance: the data layer
1404+
// honoured the flag while the widget rendered a single-value radio group —
1405+
// declared multi, rendered single, zero diagnostics. The other half of the
1406+
// same ruling is that the field-value.zod.ts sets stay UNTOUCHED (at-rest
1407+
// data keeps its read path); the last pin below trips a future "cleanup".
1408+
1409+
it('REJECTS radio + multiple: true, naming the field and the illegal pair on the `multiple` path', () => {
1410+
const r = FieldSchema.safeParse({ name: 'severity', type: 'radio', multiple: true });
1411+
expect(r.success).toBe(false);
1412+
if (!r.success) {
1413+
const issue = r.error.issues.find((i) => i.path.join('.') === 'multiple');
1414+
expect(issue).toBeDefined();
1415+
expect(issue!.message).toMatch(/"severity"/);
1416+
expect(issue!.message).toMatch(/'radio'/);
1417+
expect(issue!.message).toMatch(/multiple: true/);
1418+
}
1419+
});
1420+
1421+
it('the rejection carries the prescription — all three correctly-named multi-choice types', () => {
1422+
const r = FieldSchema.safeParse({ name: 'labels', type: 'radio', multiple: true });
1423+
expect(r.success).toBe(false);
1424+
if (!r.success) {
1425+
const message = r.error.issues.map((i) => i.message).join('\n');
1426+
expect(message).toMatch(/`checkboxes`/);
1427+
expect(message).toMatch(/`multiselect`/);
1428+
expect(message).toMatch(/`tags`/);
1429+
}
1430+
});
1431+
1432+
it('fires through ObjectSchema too — the publish path an object document crosses', () => {
1433+
const r = ObjectSchema.safeParse({
1434+
name: 'crm_lead',
1435+
label: 'Lead',
1436+
fields: {
1437+
severity: { type: 'radio', multiple: true, options: [{ label: 'Low', value: 'low' }, { label: 'High', value: 'high' }] },
1438+
},
1439+
});
1440+
expect(r.success).toBe(false);
1441+
if (!r.success) {
1442+
const messages = r.error.issues.map((i) => i.message).join('\n');
1443+
expect(messages).toMatch(/`checkboxes`/);
1444+
}
1445+
});
1446+
1447+
it('radio WITHOUT `multiple` stays accepted — and the key materializes its usual `false`', () => {
1448+
const f = FieldSchema.parse({ name: 'severity', type: 'radio', options: [{ label: 'Low', value: 'low' }, { label: 'High', value: 'high' }] });
1449+
expect(f.multiple).toBe(false);
1450+
});
1451+
1452+
it('radio + authored `multiple: false` stays accepted — the refusal reads only the authored `true`', () => {
1453+
const f = FieldSchema.parse({ name: 'severity', type: 'radio', multiple: false });
1454+
expect(f.multiple).toBe(false);
1455+
});
1456+
1457+
it('parse(parse(x)) is stable — the materialized `multiple: false` re-parses cleanly (#9689 class)', () => {
1458+
const once = FieldSchema.parse({ name: 'severity', label: 'Severity', type: 'radio', options: [{ label: 'Low', value: 'low' }, { label: 'High', value: 'high' }] });
1459+
const twice = FieldSchema.parse(once);
1460+
expect(twice).toEqual(once);
1461+
});
1462+
1463+
it('the prescribed multi-choice types all still parse — with and without the redundant flag', () => {
1464+
for (const type of ['checkboxes', 'multiselect', 'tags']) {
1465+
expect(() => FieldSchema.parse({ name: 'labels', type, options: [{ label: 'Alpha', value: 'alpha' }, { label: 'Beta', value: 'beta' }] })).not.toThrow();
1466+
// Inherently-multi types tolerated a redundant `multiple: true` before
1467+
// this refusal and still do — the refusal is radio-scoped.
1468+
expect(() => FieldSchema.parse({ name: 'labels', type, options: [{ label: 'Alpha', value: 'alpha' }, { label: 'Beta', value: 'beta' }], multiple: true })).not.toThrow();
1469+
}
1470+
});
1471+
1472+
it('`select` + multiple: true — the multi-capable sibling on the same parse branch — stays accepted', () => {
1473+
const f = FieldSchema.parse({ name: 'labels', type: 'select', options: [{ label: 'Alpha', value: 'alpha' }, { label: 'Beta', value: 'beta' }], multiple: true });
1474+
expect(f.multiple).toBe(true);
1475+
});
1476+
1477+
it('UNTOUCHED-HALF PIN — `radio` stays in MULTI_CAPABLE_TYPES and isMultiValueField still promotes it', () => {
1478+
// The ruling's other half: at-rest data written under the old contract
1479+
// keeps its read path. A "cleanup" that drops `radio` from the set is
1480+
// Option B (spec-set narrowing + stored-shape migration), explicitly NOT
1481+
// taken — this pin makes that cleanup trip a test instead of landing
1482+
// silently.
1483+
expect(MULTI_CAPABLE_TYPES.has('radio')).toBe(true);
1484+
expect(isMultiValueField({ type: 'radio', multiple: true })).toBe(true);
1485+
});
1486+
});
14001487
describe('FieldSchema — `placeholder` is a DECLARED key (#9019, ruled Option C on objectui#4676)', () => {
14011488
// The reverse of the pre-#9019 posture: `placeholder` used to be refused by
14021489
// name via FIELD_KEY_GUIDANCE ("never a FieldSchema key. Author hint text

‎packages/spec/src/data/field.zod.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1510,6 +1510,33 @@ export const FieldSchema = lazySchema(() => strictObject({
15101510
});
15111511
}
15121512

1513+
// [#11437] (maintainer ruling 2026-08-22 on objectui#4015, Option C —
1514+
// reject at the entrance): an authored `radio` + `multiple: true` is
1515+
// refused at the schema/publish seam. The data layer honoured the flag
1516+
// (stored an array, validated as multi, split on import, inferred action
1517+
// -param arity) while the widget rendered a single-value radio group with
1518+
// zero diagnostics — declared multi, rendered single. Per the same ruling,
1519+
// `MULTI_CAPABLE_TYPES` / `isMultiValueField` (field-value.zod.ts) stay
1520+
// UNTOUCHED so at-rest data keeps its read path and no stored-shape
1521+
// migration is paid (why C beat B), and objectql record-validator's
1522+
// select/radio branch stays as a data-safety fallback. `multiple`
1523+
// materializes `.default(false)` above, so `true` here is always AUTHORED —
1524+
// this check can never fire on a defaulted value, and `parse(parse(x))`
1525+
// stays stable (#9689 class; pinned in field.test.ts).
1526+
if (field.type === 'radio' && field.multiple === true) {
1527+
ctx.addIssue({
1528+
code: 'custom',
1529+
path: ['multiple'],
1530+
message:
1531+
`Field "${field.name ?? '<unnamed>'}": \`type: 'radio'\` cannot be combined with \`multiple: true\` — ` +
1532+
'a radio group is single-choice by definition (its widget renders exactly one selected option ' +
1533+
'and has no multi arity), so the declaration would validate and store arrays no radio input can ' +
1534+
'ever produce: declared multi, rendered single. Use a multi-choice type instead: `checkboxes` ' +
1535+
'(all options visible, radio-like layout), `multiselect` (dropdown), or `tags` (free-form ' +
1536+
'values). For a single-choice field, drop `multiple`.',
1537+
});
1538+
}
1539+
15131540
// #7918 (maintainer ruling 2026-08-12, Option A): the FIELD-level
15141541
// `precision` key doubles as the currency display width — objectui's
15151542
// CurrencyField reads it, and objectui#4361 pinned authored-precision-wins

0 commit comments

Comments
 (0)