Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .changeset/radio-multiple-refused-at-schema.md
Original file line numberDiff line numberDiff line change
@@ -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.

<!-- 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. -->
87 changes: 87 additions & 0 deletions packages/spec/src/data/field.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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', () => {
Expand DownExpand Up@@ -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
Expand Down
27 changes: 27 additions & 0 deletions packages/spec/src/data/field.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 ?? '<unnamed>'}": \`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
Expand Down
Loading