diff --git a/.changeset/20827-choice-door-select-radio-needs-options.md b/.changeset/20827-choice-door-select-radio-needs-options.md new file mode 100644 index 00000000000..7b3fefd0d23 --- /dev/null +++ b/.changeset/20827-choice-door-select-radio-needs-options.md @@ -0,0 +1,50 @@ +--- +"@objectstack/spec": minor +--- + +fix(spec)!: `FieldSchema` refuses a `select` / `radio` field with neither `options` nor `picklist` (#20827) + +Clause-②: yes + + + +**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. diff --git a/content/docs/data-modeling/field-types.mdx b/content/docs/data-modeling/field-types.mdx index 5074fd98c81..5a795546a62 100644 --- a/content/docs/data-modeling/field-types.mdx +++ b/content/docs/data-modeling/field-types.mdx @@ -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:** diff --git a/content/docs/data-modeling/formulas.mdx b/content/docs/data-modeling/formulas.mdx index 88bc48cfa9a..2a7ad45b195 100644 --- a/content/docs/data-modeling/formulas.mdx +++ b/content/docs/data-modeling/formulas.mdx @@ -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'`, }, }, diff --git a/content/docs/data-modeling/validation-rules.mdx b/content/docs/data-modeling/validation-rules.mdx index 4b401348e17..68cb0a7ee30 100644 --- a/content/docs/data-modeling/validation-rules.mdx +++ b/content/docs/data-modeling/validation-rules.mdx @@ -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. @@ -224,7 +224,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[]` | — | 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. @@ -232,7 +232,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** — 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. @@ -240,7 +240,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[]` | — | 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. diff --git a/content/docs/deployment/troubleshooting.mdx b/content/docs/deployment/troubleshooting.mdx index bccc8251857..0bfac4957ba 100644 --- a/content/docs/deployment/troubleshooting.mdx +++ b/content/docs/deployment/troubleshooting.mdx @@ -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 @@ -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" diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index d104f41f55b..17cfbf52eb8 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -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 | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 2ecf3e65e77..9d06ef8a500 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -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 | @@ -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 | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 029cf89bd4a..7f3f105d69e 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -75,8 +75,8 @@ Add a new field to an existing object | **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 | @@ -497,8 +497,8 @@ Add a new field to an existing object | **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 | diff --git a/packages/metadata-protocol/src/protocol.stored-conversions.test.ts b/packages/metadata-protocol/src/protocol.stored-conversions.test.ts index 54e12b563bf..e1ea195ea27 100644 --- a/packages/metadata-protocol/src/protocol.stored-conversions.test.ts +++ b/packages/metadata-protocol/src/protocol.stored-conversions.test.ts @@ -99,7 +99,7 @@ const legacyObjectRow = { name: 'crm_invoice', label: 'Invoice', fields: { - status: { type: 'select', label: 'Status' }, + status: { type: 'select', label: 'Status', options: [{ label: 'Sent', value: 'sent' }] }, amount: { type: 'currency', label: 'Amount', conditionalRequired: "record.status == 'sent'" }, }, }, @@ -257,6 +257,42 @@ describe('loadMetaFromDb — boot hydration converts, diagnoses, never drops (#3 }); }); +// ── the choice door's disposition for a STORED row ───────────────────────────── +// +// `FieldSchema` refuses a `select` / `radio` with neither `options` nor +// `picklist` (ruling record 5910124148). No migration can invent the options an +// author meant, so a row stored before the door keeps its bytes and is READ AND +// NAMED: served with `_diagnostics` locating the field, counted `invalid` at +// boot and still registered. These pins hold that half of the disposition. +const optionlessChoiceRow = { + type: 'object', + name: 'crm_ticket', + metadata: { + name: 'crm_ticket', + label: 'Ticket', + fields: { status: { type: 'select', label: 'Status' } }, + }, +}; + +describe('a stored select with no option source — read and named, never dropped', () => { + it('getMetaItem serves the row with _diagnostics naming fields.status.options', async () => { + const { engine } = makeStubEngine([optionlessChoiceRow]); + const protocol = new ObjectStackProtocolImplementation(engine); + const res: any = await protocol.getMetaItem({ type: 'object', name: 'crm_ticket' }); + expect(res.item.fields.status.type).toBe('select'); + expect(res.item._diagnostics?.valid).toBe(false); + expect(res.item._diagnostics.errors.map((e: { path?: string }) => e.path)).toContain('fields.status.options'); + }); + + it('boot hydration counts it invalid and STILL registers it', async () => { + const { engine, registered } = makeStubEngine([optionlessChoiceRow]); + const protocol = new ObjectStackProtocolImplementation(engine); + const res = await protocol.loadMetaFromDb(); + expect(res).toEqual({ loaded: 1, errors: 0, invalid: 1, storeUnavailable: false }); + expect(registered.some((r) => r.kind === 'object' && r.body?.name === 'crm_ticket')).toBe(true); + }); +}); + // ── [#20101] a declared DEFAULT is part of the canonical served shape ───────── // // `PageSchema` declares `type: PageTypeSchema.default('record')`: a page diff --git a/packages/metadata-protocol/src/protocol.stored-migration.test.ts b/packages/metadata-protocol/src/protocol.stored-migration.test.ts index a8795409c49..c1f0aed627d 100644 --- a/packages/metadata-protocol/src/protocol.stored-migration.test.ts +++ b/packages/metadata-protocol/src/protocol.stored-migration.test.ts @@ -140,7 +140,7 @@ const legacyObjectRow = { name: 'crm_invoice', label: 'Invoice', fields: { - status: { type: 'select', label: 'Status' }, + status: { type: 'select', label: 'Status', options: [{ label: 'Sent', value: 'sent' }] }, amount: { type: 'currency', label: 'Amount', conditionalRequired: "record.status == 'sent'" }, }, }, @@ -154,7 +154,7 @@ const canonicalObjectRow = { name: 'crm_quote', label: 'Quote', fields: { - status: { type: 'select', label: 'Status' }, + status: { type: 'select', label: 'Status', options: [{ label: 'Sent', value: 'sent' }] }, amount: { type: 'currency', label: 'Amount', requiredWhen: "record.status == 'sent'" }, }, }, @@ -579,6 +579,32 @@ describe('migrateStoredMetadata — what it declines to touch, loudly (#4327)', expect(JSON.parse(metaRows(tables)[0]!.metadata).fields.amount.conditionalRequired).toBe('x'); }); + it('marks a legacy row whose select has no option source `failed` and leaves its bytes alone', async () => { + // The choice door (ruling record 5910124148): the conversion chain + // lowers the retired alias, but no entry can invent the options an + // author meant, so the rewritten body still fails `FieldSchema`. The + // pass records the refusal; the row keeps reading through the chain + // and is named by its diagnostics until an option or a picklist is added. + const { engine, tables } = makeStubEngine([{ + ...legacyObjectRow, + metadata: { + ...legacyObjectRow.metadata, + fields: { ...legacyObjectRow.metadata.fields, status: { type: 'select', label: 'Status' } }, + }, + }]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata({ apply: true }); + + expect(report.failed).toBe(1); + expect(report.rewritten).toBe(0); + expect(report.rows[0]!.reason).toMatch(/failed spec validation/); + expect(historyRows(tables)).toHaveLength(0); + const stored = JSON.parse(metaRows(tables)[0]!.metadata); + expect(stored.fields.status).toEqual({ type: 'select', label: 'Status' }); + expect(stored.fields.amount.conditionalRequired).toBe("record.status == 'sent'"); + }); + it('does not clobber a row a concurrent writer moved — the optimistic lock is real', async () => { const { engine, tables } = makeStubEngine([legacyObjectRow]); // Someone else saved between the pass's scan and its write: the scan is diff --git a/packages/spec/src/data/field-autonumber-default-unique.test.ts b/packages/spec/src/data/field-autonumber-default-unique.test.ts index 90d0757fc0d..d4ea0226126 100644 --- a/packages/spec/src/data/field-autonumber-default-unique.test.ts +++ b/packages/spec/src/data/field-autonumber-default-unique.test.ts @@ -41,10 +41,11 @@ import { } from './field.zod'; import { ObjectSchema } from './object.zod'; -/** Minimal valid authored input per field type (relationship types need a target). */ +/** Minimal valid authored input per field type (relationship types need a target, single-choice types an option). */ function minimalField(type: string): Record { const input: Record = { type }; if (type === 'lookup' || type === 'master_detail' || type === 'tree') input.reference = 'account'; + if (type === 'select' || type === 'radio') input.options = [{ label: 'Open', value: 'open' }]; if (type === 'summary') input.summaryOperations = { object: 'line', field: 'amount', function: 'sum' }; if (type === 'formula') input.expression = '1 + 1'; return input; diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts index c802ff7629f..10a23fce4b7 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -2153,7 +2153,7 @@ describe('FieldSchema — authored `radio` + `multiple: true` is REFUSED (#11437 }); it('radio + authored `multiple: false` stays accepted — the refusal reads only the authored `true`', () => { - const f = FieldSchema.parse({ name: 'severity', type: 'radio', multiple: false }); + const f = FieldSchema.parse({ name: 'severity', type: 'radio', multiple: false, options: [{ label: 'Low', value: 'low' }] }); expect(f.multiple).toBe(false); }); @@ -2709,3 +2709,79 @@ describe('Relationship target — `reference` required on lookup/master_detail ( expect(viaMasterDetail.success).toBe(true); }); }); + +describe('Choice source — a `select` / `radio` needs `options` or `picklist` (ADR-0078 rule at the door)', () => { + // The `reference` precedent applied to the single-choice types: a choice with + // no option source parses nowhere. A missing key and `options: []` are the + // SAME hole (the completeness predicate the door applies reads "no entries"), + // so each gets its own pin per type; a `picklist` reference IS a source. + const ONE_OPTION = [{ label: 'Open', value: 'open' }]; + const choiceIssue = (input: Record) => { + const result = FieldSchema.safeParse(input); + expect(result.success).toBe(false); + return result.error!.issues.find((i) => i.path.join('.') === 'options'); + }; + + it.each(['select', 'radio'] as const)( + 'refuses a %s with neither key, prescribing both on the `options` path', + (type) => { + const issue = choiceIssue({ name: 'status', label: 'Status', type }); + expect(issue).toBeDefined(); + expect(issue!.code).toBe('custom'); + expect(issue!.message).toContain(`\`${type}\``); + expect(issue!.message).toMatch(/non-empty `options` list/); + expect(issue!.message).toMatch(/`picklist: ''`/); + }, + ); + + it.each(['select', 'radio'] as const)( + 'refuses a %s with an empty `options: []` — a spelled-out missing list, same issue and message', + (type) => { + const issue = choiceIssue({ name: 'status', label: 'Status', type, options: [] }); + expect(issue).toBeDefined(); + expect(issue!.code).toBe('custom'); + expect(issue!.message).toMatch(/non-empty `options` list/); + }, + ); + + it.each(['select', 'radio'] as const)('accepts a %s with one option (positive control)', (type) => { + const result = FieldSchema.safeParse({ name: 'status', label: 'Status', type, options: ONE_OPTION }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.options).toEqual(ONE_OPTION); + }); + + it.each(['select', 'radio'] as const)('accepts a %s with a `picklist` and no `options` (positive control)', (type) => { + const result = FieldSchema.safeParse({ name: 'industry', label: 'Industry', type, picklist: 'industry' }); + expect(result.success).toBe(true); + }); + + it.each(['multiselect', 'checkboxes', 'tags'] as const)( + 'leaves the multi-option type %s parsing with neither key — free-form or a warning, never this refusal', + (type) => { + expect(FieldSchema.safeParse({ name: 'labels', label: 'Labels', type }).success).toBe(true); + expect(FieldSchema.safeParse({ name: 'labels', label: 'Labels', type, options: [] }).success).toBe(true); + }, + ); + + it('refuses at the DOCUMENT level too, located at the field', () => { + const result = ObjectSchema.safeParse({ + name: 'crm_ticket', + label: 'Ticket', + fields: { status: { name: 'status', label: 'Status', type: 'select' } }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'fields.status.options'); + expect(issue).toBeDefined(); + expect(issue!.code).toBe('custom'); + }); + + it('helper builders that carry options pass; `Field.select([])` emits `options: []` and reaches the refusal', () => { + expect(FieldSchema.safeParse({ name: 'status', ...Field.select({ options: ONE_OPTION, label: 'Status' }) }).success) + .toBe(true); + expect(FieldSchema.safeParse({ name: 'status', ...Field.select(['Open', 'Closed'], { label: 'Status' }) }).success) + .toBe(true); + const empty = Field.select([], { label: 'Status' }); + expect(empty.options).toEqual([]); + expect(choiceIssue({ name: 'status', ...empty })?.code).toBe('custom'); + }); +}); diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 657a3433672..ea9313c7687 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -10,6 +10,11 @@ import type { KeySetGuidance } from '../shared/suggestions.zod'; // `shared/visibility.ts`, which imports nothing at runtime. import { SELECT_OPTION_EDITABILITY_GUIDANCE } from '../shared/editability-boundary'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; +// The ADR-0078 completeness predicate the choice door below applies, read +// rather than restated: one notion of "a choice with no option source" for +// the author-time finding and the parse refusal. No cycle: that module +// imports nothing at runtime. +import { checkFieldCompleteness, FIELD_CHOICE_WITHOUT_OPTIONS } from '../kernel/functional-completeness'; import { SnakeCaseIdentifierSchema, SystemIdentifierSchema } from '../shared/identifiers.zod'; import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; import { FilterConditionSchema } from './filter.zod'; @@ -1341,8 +1346,20 @@ export const FieldSchema = lazySchema(() => { + 'the stored file size, not just checked in the browser.', ), - /** Selection Options */ - options: z.array(SelectOptionSchema).optional().describe('Static options for select/multiselect'), + /** + * Selection Options — the field's own inline option list. + * + * A `select` / `radio` field needs an option source: a non-empty `options` + * list here, or a `picklist` reference below. With neither (an empty + * `options: []` included) it is refused at parse — the form control would + * offer nothing and server-side value validation would be off. The + * free-form option types (`multiselect`, `tags`) and `checkboxes` may omit + * both. + */ + options: z.array(SelectOptionSchema).optional().describe( + '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.', + ), /** * Reference to a shared option list — a `picklist` item, by name — in place @@ -1351,7 +1368,8 @@ export const FieldSchema = lazySchema(() => { * Mutually exclusive with `options`, refused at this door when both are * written: the field's options come from exactly one source. Valid only on * the option types (`select`, `radio`, `multiselect`, `checkboxes`, - * `tags`), the types whose value is an option code. + * `tags`), the types whose value is an option code. A `select` / `radio` + * must declare one of the two: with neither it is refused at parse. * * `PicklistServedFieldSchema` declares the served form. * Option labels translate under `picklists..options.`, which @@ -1359,7 +1377,8 @@ export const FieldSchema = lazySchema(() => { */ picklist: SnakeCaseIdentifierSchema.optional().describe( 'Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. ' - + 'Option types only (select, radio, multiselect, checkboxes, tags).', + + '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.', ), /** @@ -2127,11 +2146,53 @@ export const FieldSchema = lazySchema(() => { }); } + // A single-choice field (`select` / `radio`) with NO option source — neither + // a non-empty `options` list nor a `picklist` reference — is a choice with + // nothing to choose: the form control offers nothing, and server-side value + // validation is off (`record-validator.ts` checks membership only against a + // non-empty allowed list), so any value writes through the API. ADR-0078's + // author-time gate has always graded this an error + // (`field/choice-without-options`), and the registry warns on it at boot; + // this door is one more gate beside those two, not a replacement for either, + // on the `reference` precedent above (ruling record 5910124148): the + // publish seam was the one door that let the hole through, exactly where + // AI-authored metadata that omits the list would otherwise parse cleanly. + // + // The predicate is the completeness module's own, applied rather than + // restated (`checkFieldCompleteness`, its error-severity finding), so the + // two gates cannot drift apart. That fixes three facts: `options: []` is + // the same hole spelled out, as `''` is for `reference`; a `picklist` + // reference IS a source; and the types are exactly `select` / `radio` — + // `checkboxes` is a warning there (it degrades to free-form) and + // `multiselect` / `tags` are free-form by design (the module's NON-rule), so + // all three keep parsing with neither key. + // + // A stored row carrying the hole is not rewritten by anything — no migration + // can invent the options an author meant. It is still read and named + // (`_diagnostics.valid: false`, `/meta/diagnostics`, the boot log's + // `field/choice-without-options` line), and a later save of its object is + // refused here until a source is added. + if ( + checkFieldCompleteness(field).some( + (finding) => finding.rule === FIELD_CHOICE_WITHOUT_OPTIONS && finding.severity === 'error', + ) + ) { + ctx.addIssue({ + code: 'custom', + path: ['options'], + message: + `A \`${field.type}\` field needs its choices: declare a non-empty \`options\` list ` + + "(e.g. `options: [{ label: 'Open', value: 'open' }]`), or `picklist: ''` to offer a shared " + + 'list. Without either it is a choice with nothing to choose: the form control is empty and ' + + 'server-side value validation is off, so any value writes through. An empty `options: []` is the ' + + 'same gap. Declare `options` or `picklist`, or use a `text` field if any value is meant to be allowed.', + }); + } + // A field's options come from exactly ONE source: inline `options`, or the // shared list `picklist` names (`data/picklist.zod.ts`). Both is refused — - // two sources, one of them silently ignored. (Neither, on a single-choice - // type, stays the error-severity `FIELD_CHOICE_WITHOUT_OPTIONS` finding of - // `kernel/functional-completeness.ts`, whose prescription names both.) + // two sources, one of them silently ignored. Neither, on a single-choice + // type, is refused by the check directly above. if (field.picklist !== undefined) { if (!SINGLE_OPTION_TYPES.has(field.type) && !MULTI_OPTION_TYPES.has(field.type)) { ctx.addIssue({ diff --git a/packages/spec/src/data/filter-number-comparand-declared-type.ts b/packages/spec/src/data/filter-number-comparand-declared-type.ts index 05b5e58c4d1..def484ea18c 100644 --- a/packages/spec/src/data/filter-number-comparand-declared-type.ts +++ b/packages/spec/src/data/filter-number-comparand-declared-type.ts @@ -677,6 +677,8 @@ export interface NumberComparandDoorFixtureField { readonly returnType?: 'number' | 'text' | 'boolean' | 'date'; /** `summary` — a roll-up declaration, present so the field is a legal declaration. */ readonly summaryOperations?: { readonly object: string; readonly field: string; readonly function: 'count' }; + /** Single-choice types (`select` / `radio`) — one option, present so the field is a legal declaration. */ + readonly options?: readonly { readonly label: string; readonly value: string }[]; } /** The fixture object's name. */ @@ -710,6 +712,7 @@ const fixtureFieldFor = (type: string): NumberComparandDoorFixtureField => { if (type === 'summary') { return { name, type, summaryOperations: { object: NUMBER_COMPARAND_DOOR_FIXTURE_OBJECT, field: 'id', function: 'count' } }; } + if (SINGLE_OPTION_TYPES.has(type)) return { name, type, options: [{ label: 'Open', value: 'open' }] }; return { name, type }; }; diff --git a/packages/spec/src/data/filter-text-operator-declared-type.ts b/packages/spec/src/data/filter-text-operator-declared-type.ts index 046dfb77533..1482278a3f9 100644 --- a/packages/spec/src/data/filter-text-operator-declared-type.ts +++ b/packages/spec/src/data/filter-text-operator-declared-type.ts @@ -409,6 +409,8 @@ export interface TextOperatorDoorFixtureField { readonly returnType?: 'number' | 'text' | 'boolean' | 'date'; /** `summary` — a roll-up declaration, present so the field is a legal declaration. */ readonly summaryOperations?: { readonly object: string; readonly field: string; readonly function: 'count' }; + /** Single-choice types (`select` / `radio`) — one option, present so the field is a legal declaration. */ + readonly options?: readonly { readonly label: string; readonly value: string }[]; } /** The fixture object's name. */ @@ -420,6 +422,7 @@ const fixtureFieldFor = (type: string): TextOperatorDoorFixtureField => { if (type === 'summary') { return { name, type, summaryOperations: { object: TEXT_OPERATOR_DOOR_FIXTURE_OBJECT, field: 'id', function: 'count' } }; } + if (SINGLE_OPTION_TYPES.has(type)) return { name, type, options: [{ label: 'Open', value: 'open' }] }; return { name, type }; }; diff --git a/packages/spec/src/data/picklist.test.ts b/packages/spec/src/data/picklist.test.ts index 1591c01f75c..9e5f4c333f5 100644 --- a/packages/spec/src/data/picklist.test.ts +++ b/packages/spec/src/data/picklist.test.ts @@ -126,15 +126,23 @@ describe('Field `picklist` — mutually exclusive with `options` at the schema d expect(FieldSchema.safeParse(def).success).toBe(false); }); - it.each(['select', 'radio'] as const)('neither on a %s is the completeness gate\'s error, not a parse refusal', (type) => { - // The schema door refuses only the pair. An optionless single-choice field - // keeps the verdict it had before the kind existed: parse-legal, and an - // error-severity `field/choice-without-options` finding at the author-time - // and registry gates — which a `picklist` reference now satisfies. - expect(FieldSchema.safeParse({ name: 'f', label: 'F', type }).success).toBe(true); + it.each(['select', 'radio'] as const)('neither on a %s is refused at `options` — and stays the completeness gate\'s error', (type) => { + // The schema door refuses the pair AND the hole: a single-choice field with + // no option source is refused at parse on the `options` path, with a + // prescription naming both keys. The author-time rule is unchanged — the + // door is one more gate beside it, not a replacement: the same input is + // still an error-severity `field/choice-without-options` finding, which a + // `picklist` reference satisfies. + const issues = issuesByPath(FieldSchema.safeParse({ name: 'f', label: 'F', type })); + const neither = issues.get('options'); + expect(neither?.code).toBe('custom'); + expect(neither?.message).toContain(`\`${type}\``); + expect(neither?.message).toContain('`options`'); + expect(neither?.message).toContain('`picklist'); expect(checkFieldCompleteness({ type }).map((f) => [f.rule, f.severity])) .toEqual([['field/choice-without-options', 'error']]); expect(checkFieldCompleteness({ type, picklist: 'industry' })).toEqual([]); + expect(FieldSchema.safeParse({ name: 'f', label: 'F', type, picklist: 'industry' }).success).toBe(true); }); it.each(['select', 'radio', 'multiselect', 'checkboxes', 'tags'] as const)('`picklist` is accepted on the option type %s', (type) => {