Skip to content
50 changes: 50 additions & 0 deletions .changeset/20827-choice-door-select-radio-needs-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
"@objectstack/spec": minor
---

fix(spec)!: `FieldSchema` refuses a `select` / `radio` field with neither `options` nor `picklist` (#20827)

Clause-②: yes

<!-- adr-0087: not-required (no-migration-prescription) A validity narrowing over two existing keys: `options` and `picklist` are not removed, renamed or re-shaped, so there is no tombstone and nothing mechanical for `objectstack migrate meta` to rewrite. Which choices a `select` / `radio` with no option source was meant to offer is authoring intent that no conversion entry can invent. Stored rows follow the ruling's arm for rows that exist (production `sys_metadata` is not measurable from this repository, so the disposition assumes some do): new writes are refused at the parse site with the remedy, and a stored row keeps its bytes, is still served with `_diagnostics.valid: false`, is listed by `/meta/diagnostics`, and is counted invalid and named by the `field/choice-without-options` boot line until an option or a picklist is added (pinned in `@objectstack/metadata-protocol`). The in-tree authored population is zero (examples and platform objects); the fixture census is on the PR. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers the choice-source rule and this diff adds none (not registered / already-registered); and the change narrows a metadata schema's accept set, not a runtime interface or a type surface alone (not runtime-interface-only / type-surface-only). -->

**BREAKING** accept-set narrowing on `FieldSchema`, shipped as `minor` under the
repo's launch-window convention for breaking changes — the grade the `reference`
precedent shipped with (a `lookup` / `master_detail` without `reference`, refused
at parse as a `minor` with the **BREAKING** header).

**What was accepted before.** A `select` or `radio` field with no `options` key,
with `options: []`, and with no `picklist` parsed cleanly. It is a choice with
nothing to choose: the form control offers nothing, and server-side value
validation is off (the record validator checks membership only against a
non-empty allowed list), so any value writes through the API. The author-time
completeness gate (ADR-0078, `field/choice-without-options`, used by `os build`,
`os validate` and `os lint`) already graded it an error, and registration warns on
it; a runtime-API or Studio save was the one door that let it through.

**What is refused now.** At parse, on the `options` path, with a `custom` issue
that names the field type and both remedies: a `select` / `radio` whose `options`
is absent or empty and whose `picklist` is absent. The predicate is the
completeness gate's own, so the two cannot disagree.

**The fix.** Declare `options: [{ label, value }]` with at least one entry, or
`picklist: 'industry'` (the name of any shared list) to offer a shared list —
never both (that pair stays refused as before). If any value is meant to be allowed, use a `text` field instead.

**Unchanged.** `multiselect` and `tags` keep parsing without options (free-form
by design), and `checkboxes` keeps parsing with a completeness warning. A
`select` / `radio` with at least one option, or with a `picklist`, parses as
before. The ADR-0078 author-time rule and the registration warning are
unchanged — this door is one more gate, not a replacement. `Field.select()`
called with an empty list emits `options: []`, which is now refused at parse.

**Stored rows.** No conversion can supply the missing options, so a row saved
before this release is not rewritten. It is still served — with
`_diagnostics.valid: false` naming `fields.FIELD.options` — and still
registered at boot (counted invalid); a later save of its object is refused
until an option or a `picklist` is added. To find such rows, read
`GET /api/v1/meta/diagnostics`, or the boot log's `field/choice-without-options`
lines. The `os migrate meta --stored` preview does not validate bodies, so it
does not find them: it counts such a row canonical, or — when the row also
carries an older spelling to lower — pending, and the apply then reports that
row failed and leaves its bytes as they were.
2 changes: 1 addition & 1 deletion content/docs/data-modeling/field-types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ Single-choice dropdown.

| Property | Type | Default | Description |
|:---|:---|:---|:---|
| `options` | `SelectOption[]` | **required** | List of available options |
| `options` | `SelectOption[]` | **required** (or `picklist`) | List of available options — non-empty. Name a shared list with `picklist: 'NAME'` instead, never both. With neither, a `select` (or `radio`) is refused at parse. |

**SelectOption properties:**

Expand Down
1 change: 1 addition & 0 deletions content/docs/data-modeling/formulas.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ ObjectSchema.create({
// Raw (non-factory) spelling — the map key is the field name.
rating: {
type: 'select',
options: [{ label: 'Hot', value: 'hot' }, { label: 'Cold', value: 'cold' }],
visibleWhen: P`record.status == 'qualified'`,
},
},
Expand Down
8 changes: 4 additions & 4 deletions content/docs/data-modeling/validation-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ These properties apply to **all** field types and are validated by the base `Fie

| Property | Type | Default | Validation Behavior |
|:---|:---|:---|:---|
| `options` | `SelectOption[]` | — | **Required.** Static option list |
| `options` | `SelectOption[]` | — | **Required** — a non-empty static option list, or a `picklist` naming a shared list instead. With neither (or `options: []`) the field is refused at parse. |
| `defaultValue` | `string` | — | Must match an option `value` |

**Option validation:** Each option `value` must be a lowercase system identifier — starts with a letter, then letters/digits/underscores/dots (`^[a-z][a-z0-9_.]*$`), minimum 2 characters.
Expand All @@ -224,23 +224,23 @@ These properties apply to **all** field types and are validated by the base `Fie

| Property | Type | Default | Validation Behavior |
|:---|:---|:---|:---|
| `options` | `SelectOption[]` | — | **Required.** Static option list |
| `options` | `SelectOption[]` | — | Static option list. Optional: without options the field takes free-form values (tags mode). |

**Default constraints:** Stores array of selected option values. Each value validated against options.

### `radio`

| Property | Type | Default | Validation Behavior |
|:---|:---|:---|:---|
| `options` | `SelectOption[]` | — | **Required.** Static option list |
| `options` | `SelectOption[]` | — | **Required** — as for `select`: a non-empty list or a `picklist`, refused at parse with neither. |

**Default constraints:** Single-value selection. Same validation as `select` with radio button UI.

### `checkboxes`

| Property | Type | Default | Validation Behavior |
|:---|:---|:---|:---|
| `options` | `SelectOption[]` | — | **Required.** Static option list |
| `options` | `SelectOption[]` | — | Static option list. Parses without one, but draws a completeness warning: a checkbox group with no boxes renders nothing. |

**Default constraints:** Multi-value selection. Same validation as `multiselect` with checkbox group UI.

Expand Down
13 changes: 9 additions & 4 deletions content/docs/deployment/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,15 @@ The custom error map provides "Did you mean?" suggestions for common typos.

---

### "Required property missing: options"
### "A `select` field needs its choices"

**Symptom:** A `select`, `multiselect`, `radio`, or `checkboxes` field fails validation.
**Symptom:** A `select` or `radio` field is refused at parse (on save, publish, `os validate` or `os build`), with the issue on its `options` path.

**Cause:** Selection-type fields require an `options` array.
**Cause:** A single-choice field needs an option source: a non-empty `options` array, or a `picklist` naming a shared list. With neither — and `options: []` counts as neither — the form control would offer nothing and server-side value validation would be off, so any value would write through. `multiselect`, `tags` and `checkboxes` may omit both (the first two are free-form without options; `checkboxes` draws a completeness warning).

**Fix:**
```typescript
// ❌ Missing options
// ❌ No option source
{ name: 'status', type: 'select' }

// ✅ With options
Expand All @@ -70,8 +70,13 @@ The custom error map provides "Did you mean?" suggestions for common typos.
{ label: 'Closed', value: 'closed' }
]
}

// ✅ Or with a shared list
{ name: 'industry', type: 'select', picklist: 'industry' }
```

A row stored before this check keeps its bytes: it is still served, with `_diagnostics` naming the field, listed by `/meta/diagnostics`, and named at boot by the `field/choice-without-options` log line. A later save of its object is refused until an option or a `picklist` is added.

---

### "Required property missing: reference"
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/data/field.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ const result = CurrencyConfigSchema.parse(data);
| **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. |
| **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. |
| **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. |
| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for select/multiselect |
| **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). |
| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. |
| **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). A select / radio declares this or a non-empty `options`; with neither it is refused at parse. |
| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target. |
| **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. |
| **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted |
Expand Down
8 changes: 4 additions & 4 deletions content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -238,8 +238,8 @@ const result = ApiMethod.parse(data);
| **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. |
| **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. |
| **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. |
| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for select/multiselect |
| **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). |
| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. |
| **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). A select / radio declares this or a non-empty `options`; with neither it is refused at parse. |
| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target. |
| **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. |
| **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted |
Expand Down Expand Up @@ -572,8 +572,8 @@ const result = ApiMethod.parse(data);
| **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. |
| **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. |
| **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. |
| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for select/multiselect |
| **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). |
| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. |
| **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). A select / radio declares this or a non-empty `options`; with neither it is refused at parse. |
| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target. |
| **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. |
| **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted |
Expand Down
Loading
Loading