Skip to content
Merged
111 changes: 111 additions & 0 deletions .changeset/17469-multiple-non-capable-type-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
---
"@objectstack/spec": minor
"@objectstack/driver-sql": minor
---

fix(spec)!: `multiple: true` is refused on every type outside the multi-capable set, and driver-sql derives JSON-column storage from the spec predicate (#17469)

<!-- adr-0087: registered field-multiple-non-capable-type-refused -->

**BREAKING** in the accept-set sense, landing in the launch window as `minor`
(the lockstep convention: `major` is refused by `check-changeset-no-major`, and
breaking-ness is carried by this banner plus the ADR-0087 disposition).

Two definitions of "multi-valued" disagreed, and the user saw the disagreement as
a `400`.

- `FieldSchema` accepted `multiple: true` on **any** type.
- `@objectstack/driver-sql`'s `isJsonField` read the flag raw —
`JSON_COLUMN_TYPES.has(type) || !!field.multiple` — and built a **JSON array
column** for it.
- `isMultiValueField` — the published spec predicate consumers shape queries from
— answered **"not multi-value"** for that same field, because `master_detail` /
`tree` / `text` are outside `MULTI_CAPABLE_TYPES`.

So a related list composed `=` against a JSON array column, and the driver refused
the equality family there with a `400`.

In business terms: `multiple` means "this cell holds several values at once", and
that has meaning only on multi-select, multi-record / multi-user and multi-file
fields — exactly what the spec already declares. A child record with several
masters, a tree node with several parents, or a text box holding several texts has
no meaning on any mainstream platform. The declaration was accepted silently, the
UI rendered a single value, the database built a JSON array column, and the
related list answered the user a 400.

FROM → TO, for metadata that used to parse and now fails:

```ts
// FROM — parsed, stored a JSON array, rendered single, answered `=` with 400
{ type: 'text', label: 'Aliases', multiple: true }
{ type: 'master_detail', label: 'Parents', reference: 'account', multiple: true }
{ type: 'tree', label: 'Parents', reference: 'category', multiple: true }

// TO — pick the type that actually holds several values…
{ type: 'tags', label: 'Aliases' } // several free-form strings
{ type: 'lookup', label: 'Parents', reference: 'account', multiple: true } // several related records

// …or drop the key, if the cell really holds one value.
{ type: 'text', label: 'Alias' }
{ type: 'master_detail', label: 'Parent', reference: 'account' }
```

The refusal names the field, its type and the alternative, on the `multiple` path.
`radio` keeps its own narrower 2026-08-22 message (#11437); the two never
double-fire.

**`MULTI_CAPABLE_TYPES` and `isMultiValueField` are untouched**, deliberately: a
field that was already multi-valued by that predicate keeps its declaration, its
storage and its read path byte-identically. What moved is which declarations can
be newly authored, plus the storage decision for the shapes that are now refused.

**Storage change (`@objectstack/driver-sql`)**: every site that asked
`field.multiple` the question "is this value multi-valued" now asks
`isMultiValueField` — **eighteen expressions across two files**, not one. The
file's own header already called `JSON_COLUMN_TYPES` membership "owned by
`@objectstack/spec`"; that sentence is now true for the `multiple` half too.

- `sql-driver.ts` — the DDL writer (`createColumn`'s multi-value short-circuit),
the read-side deserializer (`isJsonField`, both limbs), the `varchar` width
mirror (`varcharColumnChars`), the cross-field comparison class
(`crossFieldComparisonClass`), the four scalar registries filled by BOTH
`registerObjectMetadata` and `registerExternalObject` (`mediaFields`,
`booleanFields`, `numericFields`, `numericValueFields`), and the two MySQL
temporal-widening candidate sets.
- `schema-drift.ts` — the differ's `fieldHasColumn`, its `declaresJsonColumn`
disjunct and its `declaresArray` test, which #15771 bound to the writer's
predicate and which a pin test holds equal to it.

Only one of those was named in the ruling; aligning it and leaving seventeen
would have re-opened #11535 in reverse — the DDL writing a JSON column that the
read-side deserializer no longer recognises. A column whose field is multi-valued
by the spec predicate behaves exactly as before; the shapes that change are the
ones the schema now refuses at the entrance.

⛔ Three `field.multiple` reads are deliberately NOT aligned: the three that
interpolate `', multiple'` into an `uncompilableFieldReferenceError` message.
They echo what the author DECLARED back to them; they do not ask whether the
value is multi-valued (the verdict there comes from `crossFieldComparisonClass`,
which is aligned).

⚠️ **Two consequences worth reading before you upgrade.**

1. A **stored** field carrying `multiple: true` on a non-capable type has no
lossless conversion — its column was physically built as a JSON array. The
ADR-0087 semantic entry `field-multiple-non-capable-type-refused` emits the
structured TODO naming the object, field and type; migrating the data is the
author's judgment call, and the entry states how to prove it.
2. `isMultiValueField` reads the **authorable** `FieldType` vocabulary. A driver
-internal column-type alias (`string` / `integer` / `int` / `float` — the
introspected-column spellings) is not a `FieldType`, so a hand-declared
external object that puts `multiple: true` on one of those no longer gets a
JSON column. Declare such a column as `object` or `array` (both are
`JSON_COLUMN_TYPES` members and unchanged), or as the authorable type it
really is.
3. `multiple: true` on `boolean` / `toggle` / `number` / `currency` / `percent` /
`date` / `datetime` / `time` **ceases to be a supported shape end to end**, as
a consequence of the entrance refusal above. Such a column is no longer a JSON
column, so it is no longer excluded from the scalar read-coercion registries
and the declared-type text-operator gate (`isNonTextColumn`) applies to it: a
`$contains` against one answers the declared no-match rather than a JSON
membership test. Stored data in that shape is the ADR-0087 entry's subject.
2 changes: 1 addition & 1 deletion content/docs/references/data/field.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const result = CurrencyConfigSchema.parse(data);
| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. |
| **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. |
| **searchable** | `boolean` | optional (default: `false`) | Is searchable |
| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). |
| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); `multiple: true` on any other type is REFUSED at parse (maintainer ruling 2026-09-13), and on `radio` by the narrower 2026-08-22 ruling. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). |
| **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (an auto-number is a business identifier, so the platform makes it unique per organization by default — the same tenant-composite shape an explicit `unique: true` produces). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. |
| **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. |
| **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused. |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ const result = ApiMethod.parse(data);
| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. |
| **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. |
| **searchable** | `boolean` | optional (default: `false`) | Is searchable |
| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). |
| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); `multiple: true` on any other type is REFUSED at parse (maintainer ruling 2026-09-13), and on `radio` by the narrower 2026-08-22 ruling. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). |
| **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (an auto-number is a business identifier, so the platform makes it unique per organization by default — the same tenant-composite shape an explicit `unique: true` produces). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. |
| **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. |
| **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused. |
Expand Down Expand Up @@ -555,7 +555,7 @@ const result = ApiMethod.parse(data);
| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. |
| **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. |
| **searchable** | `boolean` | optional (default: `false`) | Is searchable |
| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). |
| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); `multiple: true` on any other type is REFUSED at parse (maintainer ruling 2026-09-13), and on `radio` by the narrower 2026-08-22 ruling. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). |
| **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (an auto-number is a business identifier, so the platform makes it unique per organization by default — the same tenant-composite shape an explicit `unique: true` produces). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. |
| **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. |
| **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused. |
Expand Down
Loading
Loading