Skip to content

Commit d285bf0

Browse files
os-warrenclaude
andauthored
fix(spec)!: refuse multiple: true outside the multi-capable types, and derive driver-sql's storage from the spec predicate (#18187)
Fixes #17469 Maintainer ruling of 2026-09-13 (director seat decision batch #128 item 5, option **1′**), implemented in full: **one definition of "multi-valued"**. `FieldSchema` refuses `multiple: true` at the authoring entrance on every type outside `MULTI_CAPABLE_TYPES` ∪ `MULTI_OPTION_TYPES`, and `@objectstack/driver-sql` derives its storage decisions from `isMultiValueField` rather than reading the flag raw. `MULTI_CAPABLE_TYPES` and `isMultiValueField` are untouched, exactly as the ruling requires. One PR across two domains, on the cross-domain exception path — ⛔ deliberately not split into a driver PR that could land without the refusal. ## 1 — Entrance refusal (`packages/spec`) `packages/spec/src/data/field.zod.ts`, in the same `superRefine` and immediately after the #11437 `radio` check it generalises. The message names the field, its type, the declarable set and a type-aware remedy (`lookup` for a reference type, `file`/`image` for a media type, the option types otherwise). The declarable set is **derived** from `MULTI_CAPABLE_TYPES` ∪ `MULTI_OPTION_TYPES` rather than re-listed (#12017 two-copies shape), and the remedy list filters `radio` out mechanically so the author is never sent from one refusal into another. Both are computed on first use rather than at module top level: `field-value.zod` reaches back into `field.zod` through `shared/strict-object` → `shared/suggestions.zod`, and spreading the sets at module scope threw `TypeError: MULTI_CAPABLE_TYPES is not iterable` in six spec suites — measured, then fixed. `radio` is *inside* the set, so the two refusals never double-fire; a pin asserts exactly one issue on the `multiple` path for `radio`. ## 2 — Storage alignment (`packages/drivers/driver-sql`) — seventeen expressions, not one ⭐ **The ruling named `isJsonField` as the instance it had measured, not as an exhaustive list.** Measurement found sixteen more expressions asking the same question, pinned equal to one another by the package's own tests. Aligning only the named one re-opens #11535 **in reverse**: the DDL writer builds a JSON column that the read-side deserializer no longer recognises. Every site below was read individually against the premise *"this expression asks whether the value is multi-valued"*. The evidence column is the site's own contract text or its neighbours': | # | site | expression (before) | why it means "is this multi-valued" | |---|---|---|---| | 1 | `sql-driver.ts` `crossFieldComparisonClass` | `if (decl.multiple) return null;` | its own docblock: refuses "every JSON-stored shape (`multiple: true` and the `JSON_COLUMN_TYPES` classes — element-wise semantics SQL comparison operators do not have)" | | 2 | `createColumn` | `if (field.multiple) { jsonColumn; return; }` | the multi-value short-circuit above the type switch; `JSON_COLUMN_TYPES`' header calls itself the single source for "both the DDL column-type switch and `isJsonField` so the two can't drift" | | 3 | `isJsonField` limb 1 | `if (!field.multiple && FILE_REFERENCE_TYPES.has(type))` | selects the SINGLE-VALUE media arm; `multiple` is the multi/single discriminator | | 4 | `isJsonField` limb 2 | `JSON_COLUMN_TYPES.has(type) \|\| !!field.multiple` | the ruled site | | 5 | `varcharColumnChars` | `if (field?.multiple) return null;` | its own comment: "`multiple` is decided before the type switch in `createColumn` — a JSON column, whatever the element type would have been". ⚠️ It is a *width* function, so it was read twice: it does not compute a width **from** `multiple`, it asks "is this a JSON column" and returns "no varchar width" as the consequence. Its pin (`sql-driver-11565-row-byte-budget.test.ts`) compares it against live `columnInfo()`, i.e. against `createColumn` | | 6–9 | `registerObjectMetadata` fill | `!field.multiple` on `mediaCols` / `booleanCols` / `numericCols` / `numericValueCols` | `mediaFields` is the SINGLE-VALUE media registry; the boolean line's own comment says "SCALAR only … a multi-valued (JSON) column collapses the parsed array to `true`"; `isNonTextColumn`'s docblock quotes the numeric one as "`NUMERIC_SCALAR_TYPES.has(type) && !field.multiple`, so the condition never reaches this predicate" — the condition being *a multi-valued column is excluded* | | 10–13 | `registerExternalObject` fill | the same four | same registries, the other fill; #17586 measured that a repair to one leaves the other live | | 14–15 | `legacyMysqlTimestampColumns` / `legacyMysqlTimeColumns` | `&& !field?.multiple` | candidate sets for widening a legacy MySQL `TIMESTAMP`/`TIME` column. A multi-valued temporal field is a JSON column, not a temporal column, so it is not a candidate — the exclusion is the multi/single question | | 16 | `schema-drift.ts` `fieldHasColumn` | `if (field?.multiple) return true;` | its docblock: "Mirrors `SqlDriver.createColumn` exactly … everything else — including `multiple` (a JSON column) — gets one" | | 17 | `schema-drift.ts` `declaresJsonColumn` | `\|\| field.multiple === true` | #15771 bound the differ's JSON-class predicate to the writer's; `schema-drift.json-column-parity.test.ts` pins the two equal over the whole vocabulary × `multiple` | | 18 | `schema-drift.ts` `declaresArray` | `field.multiple === true \|\| MULTI_OPTION_TYPES.has(declaredType)` | "Is the declared VALUE an array?" — character for character `isMultiValueField`'s own definition | All of them now go through one module-local helper, `isMultiValuedColumn(type, field)`, which is `isMultiValueField({ type, multiple: field?.multiple === true })`. It takes the *resolved* type because every caller had already applied its own `field.type || 'string'` default, and two spellings of that default is how this drift started. ### ⛔ Three `field.multiple` reads deliberately NOT aligned `sql-driver.ts` lines ~15565 / ~15572 / ~15630 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 on those paths comes from `crossFieldComparisonClass` (site 1), which *is* aligned. Premise tested per site, and it fails for exactly these three. ### Consequence, stated plainly `multiple: true` on `boolean` / `toggle` / `number` / `currency` / `percent` / `date` / `datetime` / `time` **ceases to be a supported shape end to end** — a consequence of ruled item 1, which refuses those declarations at the entrance, not something the driver half invents. 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 applies to it. `avatar` / `video` / `audio` split from `file` / `image` for the same reason: only the latter two are `MULTI_CAPABLE_TYPES` members, so only they keep a deployment-independent JSON column under `multiple: true`. The other three follow the ADR-0104 arm like any single-value media column. ## 3 — Migration + changeset `packages/spec/src/migrations/entries/semantic/18.field-multiple-non-capable-type-refused.ts`, registered into `migrations/registry.ts` by `gen:migration-registry` (⛔ the generated table was never hand-edited). No lossless conversion exists — the column was physically built as a JSON array — so the entry emits the structured TODO naming the surface, the replacement, the reason and how the author proves the hand-migration. It states the **full** narrowing, including that a stored field in the retired shape also leaves the JSON read path. `.changeset/17469-multiple-non-capable-type-refused.md`: `minor` on both packages under the launch-window convention, `BREAKING` banner, `FROM → TO` mapping, and `adr-0087: registered field-multiple-non-capable-type-refused`. `Clause-②: no` — the diff narrows the accept set and widens nothing. ## 4 — Consumers objectui#8886's pin ("`multiple: true` is INERT on a type outside `MULTI_CAPABLE_TYPES`") stays true and becomes enforced rather than tolerated. objectui#8937's "owed and not filed" is discharged. The objectui seat is to be notified when the spec version lands. ## Test re-targeting — per test, before → after, and why it is the post-ruling contract ⛔ No test was deleted to make anything pass; no assertion was relaxed to "any answer is fine". | file | before | after | why this is the contract, not a relaxation | |---|---|---|---| | `packages/spec/src/data/field.test.ts` | — | **+9 new tests**: refusal over 14 non-declarable types on the `multiple` path; the three remedy shapes; the remedy list derived from the sets and never offering `radio`; every declarable type still accepted; `multiple: false` / absent still accepted; `parse(parse(x))` stable; fires through `ObjectSchema`; `radio` keeps exactly one issue; `MULTI_CAPABLE_TYPES` / `isMultiValueField` untouched | pure addition | | `value-roundtrip-conformance.ts` | `v_multi: { type: 'string', multiple: true }` | `{ type: 'lookup', multiple: true }` | the route under test ("multi-value decides the column type **before** the type switch") is unchanged — `lookup` is not a JSON-class type, so it still reaches JSON storage by the `multiple` route alone. `string` is a driver alias, not a `FieldType`: the old spelling reached that route by a door the protocol had closed. All five driver cells re-run green | | `schema-drift.base-type-mismatch.test.ts` ×4 | `{ type: 'string'/'integer', multiple: true }` | `{ type: 'lookup', multiple: true }` | same assertions, byte for byte; only the type carrying the multi-value route changed, to the spelling the file already uses in its other 14 rows | | `schema-drift.json-column-parity.test.ts` | media loop asserted `multiple: true` media is JSON on **both** arms for all 5 | asserts `file`/`image` on both arms **and** `avatar`/`video`/`audio` moving **with** the arm, with the two halves derived from `MULTI_CAPABLE_TYPES` and pinned by name (`['file','image']` / `['audio','avatar','video']`) | strictly **more** assertions than before; the split is the ruled change made observable | | same file | non-vacuity control `differReports({type:'string',multiple:true}) === true` | `{type:'lookup',multiple:true} === true` **plus** `{type:'string',multiple:true} === false` | the old single control became a two-sided one: the positive moved to a shape that still exists, and the retired shape is now the negative control | | `schema-drift.unbounded-text-column.test.ts` | `{ type: 'signature', multiple: true }` | `{ type: 'image', multiple: true }` | the row asserts a multi-value field has no varchar bound to compare; `image` is multi-capable so it still is one | | `sql-driver-11223-updatemany-write-coercion.test.ts` | `tags: { type: 'string', multiple: true }` | `{ type: 'select', multiple: true }` | identical assertions; the column is still the JSON column the bulk door must stringify into | | `sql-driver-target-field-provenance.test.ts` ×10 | JSON column made by `{ type: 'text', multiple: true }` | `{ type: 'select', multiple: true }` | all ten #8197 refusal/disclosure assertions unchanged; only the declaration that produces the JSON column moved | | `sql-driver-17590-json-column-membership.test.ts` | `nums: { type:'number', multiple:true }`; population sweep over `string`/`boolean`/`datetime` + `multiple` | `nums: { type:'select', multiple:true }` (option values are numbers, so the members are still JSON **numbers** — the property the row is named for); sweep over `select`/`lookup`/`user` + `multiple` | same rows, same expected ids, same construct regexes. The sweep's population is the post-ruling one; pinning the old one would pin a branch the writer no longer has | | `sql-driver-15683-temporal-text-operator-gate.test.ts` | "a MULTI-VALUED temporal column keeps `$contains`" over `{type:'datetime',multiple:true}` | the carve-out asserted over a JSON column that still exists (`select` + `multiple`), **plus** a new assertion that `{type:'datetime',multiple:true}` now compiles `1 = 0` like the scalar beside it | one assertion **added**. The carve-out is still asserted; the ruled change is asserted next to it | | `sql-driver-17343-…test.ts` (rewritten) | `$contains` over `boolean`/`toggle`/`number` + `multiple` answers by member; sweep asserting the gate never fires on a multi-valued non-text class | same invariant over `select`/`lookup`/`tags`; the sweep now runs in **both** directions — the retired declarations are gated (they are scalars now) **and** every shape that still produces a JSON column is not; plus a new entrance-half block asserting `FieldSchema` refuses all of `NON_TEXT_STORED_VALUE_TYPES` + `multiple`, with a negative control that the multi-capable ones still parse | the file's own fourth reading was *"`boolean` + `multiple: true` is authorable — `FieldSchema.multiple` refuses exactly one type (`radio`)"*. That premise is what the ruling retires, so the file says so and pins both halves of the new contract. Test count went **up** (11 → 12 in the compiled block, + 2 entrance pins) | | `sql-driver-17586-…test.ts` (rewritten) | a stored `[false]` must not present as `true`; `booleanFields` must exclude multi-valued columns; four readers | the array-survives-the-read assertions over the JSON columns that still exist; the registry rule restated as a **partition** (`for (const f of jsonRegistry()) expect(booleanRegistry()).not.toContain(f)`) with non-vacuity on both sides; the same four readers; plus two new pins — `FieldSchema` refuses `boolean`/`toggle` + `multiple`, and the retired declaration reads back as the scalar boolean it now is, in `booleanFields` and not in `jsonFields` | the partition is **stronger** than the original per-column assertion: it quantifies over the whole JSON registry instead of naming two columns. The collapse the card is filed for has no reachable input any more, and the file states that rather than asserting it over a shape that cannot exist | | `sql-driver-json-column-operator-refusal.test.ts` | 9 rows refusing the #7398 operator family on "the normalised JSON column" (`{type:'datetime',multiple:true}` on an external object) | the premise row still asserts the column reaches the normalised lowering family; one new row asserts it is **no longer** a JSON column and that the operators are therefore compiled rather than refused | ⚠️ **This is a coverage loss and it is named, not hidden.** The normalised family is reached only through `needsLegacyDatetimeRepair` / `needsLegacyTimeRepair`, both of which require a declared temporal field — and no declared temporal field can be multi-valued any more. The intersection "JSON column × normalised lowering" is **empty**, so #7398's gate covers that family as defence only. Re-declaring the column as a multi-capable type would make it JSON again but drop it out of the normalised family, turning the block into a silent copy of the one above — the exact failure its own header names | ## Verification | run | result | |---|---| | `pnpm --filter @objectstack/spec test` | **477 files / 13606 tests passed**, 0 failed | | `pnpm --filter @objectstack/spec typecheck` | pass (incl. `check:scripts-typecheck`, `check:test-typecheck`) | | `pnpm --filter @objectstack/spec check:generated` | **All 15 generated artifacts up to date** | | `pnpm --filter @objectstack/driver-sql test` | **177 files passed / 11 skipped, 2618 tests passed**, 0 failed | | `pnpm --filter @objectstack/driver-sql typecheck` | pass | | driver-memory / driver-sqlite-wasm / driver-turso / driver-mongodb `test` | 1245 / 518 / 1249 / 562 passed, 0 failed — the four other consumers of the shared round-trip fixture | | objectql / rest / lint / metadata `test` | 4990 / 3196 / 3822 / 788 passed, 0 failed — the spec-contract consumers | | derived gate sweep | `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` → **114 families**, all 114 run, exit codes captured to disk before any output was read; reconciled with `--ran`: **114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN** (a derived zero — every entry carries its code). Seven first exited 3 / `PREREQUISITE NOT MET` on an unbuilt package; after `pnpm build` (73 tasks) all seven are 0 | **Population re-measure**, on `origin/main` `689d606f` and again after the change: **0 fields carrying `multiple: true` on a non-capable type**, by TypeScript-AST sweep over all tracked non-test `.ts` (2744 files), pairing every object literal that declares `multiple: true` with the `type:` in the *same* literal or the enclosing `Field.TYPE()` builder. Positive control fired both times (4 `file`, 4 `select`, 3 `user`, 2→3 `lookup`), so the zero is a reading. The one pre-change non-capable hit was `value-roundtrip-conformance.ts`'s driver-alias fixture, re-spelled above. ## Acceptance notes - The `multiple` key's `.describe()` now states the enforced set. `packages/spec`'s `files[]` ships `src/**/*.zod.ts` literally, so that sentence goes to npm verbatim — it names `FieldSchema` parse as the door, which is the door the tests exercise. - `packages/lint` moved on `main` while this branch was open; the merge touched no `packages/spec` or `packages/drivers` file, so the generated spec artifacts are unaffected by it. - noted, not filed: the `sql-driver.ts` docblocks that quoted the old `isJsonField` body verbatim are updated in place (the `jsonFields` registry note and the `JSON_COLUMN_TYPES` media note). Successor: this PR. ## Patch round — the four red CI jobs at `72a1f589`, root-caused and fixed ⭐ **The `varcharColumnChars` / row-byte-budget hypothesis is FALSIFIED.** `sql-driver-11565-row-byte-budget.test.ts` PASSED in that same job (`✓ … (10 tests) 421ms`), and the InnoDB `os11565_narrow` record-size line in the MySQL container log is that suite's OWN negative-path fixture — the table it builds so the driver's prescriptive refusal has something to refuse. It is not a symptom. | red job | real failing test | root cause, tied to a diff line | |---|---|---| | `Temporal Conformance (live PG + MySQL)` | `sql-driver-17639-distinct-fault-envelope.test.ts` › "the JSON-column refusal the card measured (live postgres)" — `insert … invalid input syntax for type boolean: "{"false"}"` | `createColumn`'s multi-value short-circuit (`if (field.multiple)` → `if (isMultiValuedColumn(…))`). The PG-only fixture declared `toggles: toggle`, `flags: boolean`, `nums: number` + `multiple: true` and wrote arrays into them. Those are the retired shapes: the DDL now emits real scalar columns, so the array write is refused before the card's `distinct()` assertion is ever reached | | `Test Core (5/6)` | `cli/generate-multiple-json-column.pin.test.ts` › "driver-sql `fieldHasColumn` still answers the flag before the type" | a SOURCE-TEXT pin on `schema-drift.ts` matching the literal `if (field?.multiple) return true;` | | `Test Core (6/6)` | `cli/generate-field-type-vocabulary.pin.test.ts` › the `formula` row | it slices 200 chars after `export function fieldHasColumn(`; the new `isMultiValueField({ … })` line is ~3× the width of the read it replaced and pushed `!== 'formula'` outside that window | | `Test Core (6/6)` | `cli/generate-declared-column-default.pin.test.ts` ×2 | `c_multiple: { type: 'text', multiple: true, defaultValue: 'x' }` is no longer a JSON column, so `createColumn` reaches the default question and emits `DEFAULT 'x'` | ⚠️ **Why nothing local caught them**, stated so the gap is closed and not just patched: (a) the population sweep excluded test files; (b) the live-PG/MySQL cells are SKIPPED without a server, and the local run reported 11 skipped files; (c) `packages/cli` was never in the local scope. All three are fixed below. ### Patch-round test re-targeting — before → after | file | before | after | why it is the contract, not a relaxation | |---|---|---|---| | `sql-driver-17639-distinct-fault-envelope.test.ts` | JSON columns built from `toggle`/`boolean`/`number` + `multiple`; loop over `['toggles','flags','nums','tags_']` | the same loop over `['picks','refs','people','tags_']` — multi-valued `select`/`lookup`/`user` plus `tags` — **plus a new `retired_flags` control** asserting that a `boolean` + `multiple: true` column ANSWERS `distinct()` because it is a scalar column now | the file's own comment already says the class is "the `json` STORAGE, not the type"; the members of that class moved. One assertion **added**, and it is the one that reddens if the storage half of the ruling is reverted | | `cli/generate-multiple-json-column.pin.test.ts` | source pin `/if \(field\.multiple\)/` on `createColumn`; source pin `/if \(field\?\.multiple\) return true;/` on `fieldHasColumn` in a 300-char window | `/if \(isMultiValuedColumn\(/` and `/if \(isMultiValueField\(/` in a 600-char window, **plus** a new assertion that `!== 'formula'` is still inside that window | the pins exist to catch the driver moving its rule; they now pin the rule it moved to. The window assertion is new coverage, not a loosening | | `cli/generate-field-type-vocabulary.pin.test.ts` | `slice(at, at + 200)` | `slice(at, at + 600)` | the assertion (`toContain("!== 'formula'")`) is byte-identical; only the read window was resized to still contain the thing it asserts | | `cli/generate-declared-column-default.pin.test.ts` | `c_multiple: { type: 'text', multiple: true, … }` | `{ type: 'lookup', multiple: true, … }` | the row's job is "the multi-value short-circuit returns before the default question"; `lookup` is multi-capable so it still does, and `c_multiple` stays in `UNDEFAULTED` unchanged | | `sql-driver-17586-…test.ts` | ⛔ **I had WEAKENED this file and did not disclose it.** The PG named-divergence pin (`distinct()` over a JSON column → `DATABASE_ERROR`/500/cause `42883`) was deleted rather than re-targeted, and the `else` branch went with it, so "the SCALAR boolean answers normally at the same door" and "reader 1 — the #11635 cast" ended up gated to non-PG cells only | the named-divergence pin is **restored**, re-aimed at `picks` / `tags_` (still JSON columns, still refused by PG's json-equality gap) and asserted on the CLASS so a one-column regression stops matching; the two controls are moved OUT of the `distinctExecutes` branch entirely, since PG is precisely the cell each of them is about | declared here rather than left in the diff. Restoring is the only acceptable outcome for a weakened gate | ### Five shipping docblocks corrected `driver-sql` builds with `declaration: true`, so this JSDoc reaches `dist/index.d.ts`. Corrected: `mediaFields` ("already covered by `jsonFields` through `!!field.multiple`"), `booleanFields` ("`isJsonField` reduces to `!!field.multiple` for these two types"), `isNonTextColumn`'s numeric-limb quote, its claim of a pin in `sql-driver-17586-…` that no longer exists, and `schema-drift.ts`'s `JSON_COLUMN_FIELD_TYPES` header still quoting `… || !!field.multiple` as the writer's predicate. ### Patch-round verification | run | result | |---|---| | `driver-sql` **against a live PostgreSQL 16.13** — server `timezone=Asia/Shanghai`, process `TZ=America/New_York`, the shape CI uses | **185 files passed / 3 skipped, 3299 tests passed**, 0 failed. This is the run that could see the 11 cells the earlier local run skipped | | `@objectstack/cli` `vitest run --project unit` | **206 files / 2958 tests passed**, 0 failed | | `@objectstack/spec` `test` | 477 files / 13606 tests passed | | `spec` / `driver-sql` / `cli` `typecheck` | all exit 0 | | derived gate sweep, re-derived at the patched head | **117 families** (3 more than before: `check:i18n`, `check:i18n-coverage`, `check:i18n-walk-parity`), all 117 run, exit codes to disk before any read, reconciled `--ran`: **117 derived, 117 run, 0 NOT-MEASURED, 0 UNRUN**, every one exit 0 | | `scripts/pm/check-clause2-carriers.mjs --pair 18187` | was exit 4 on a T2 false positive at the multi-line import specifier list in `field.zod.ts`; the import is spelled on one line and it now exits **0** | ### ⛔ Deliberately NOT done in this PR `field.multiple` is still read raw outside `driver-sql`: `packages/cli/src/commands/generate.ts` (`:2617`, `:2443`, `:1706`), `driver-turso/src/remote-transport.ts:2435`, `objectql/src/engine.ts:4741` / `:13584`, `runtime/src/action-execution.ts:1279`. The ruling's scope was the driver, and `packages/cli` is another lane. ⚠️ The consequence is recorded rather than hidden: **`os generate migration` and the driver now disagree for the retired shapes** — the generator emits `JSONB` for a `text` field flagged `multiple: true` where the driver emits a varchar (#14829 in reverse). It is **bounded**, because that declaration is refused at the authoring entrance and can only reach the generators through the unvalidated door they explicitly serve. The CLI pin file's header now says so in place of its old "the column authority is the driver's flag-first rule" thesis. --- _Authored in [Claude Code](https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6) — session `session_01KB5PFtxuy1x3dcR5gxudx6`._ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d4554d4 commit d285bf0

25 files changed

Lines changed: 1242 additions & 682 deletions
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/driver-sql": minor
4+
---
5+
6+
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)
7+
8+
<!-- adr-0087: registered field-multiple-non-capable-type-refused -->
9+
10+
**BREAKING** in the accept-set sense, landing in the launch window as `minor`
11+
(the lockstep convention: `major` is refused by `check-changeset-no-major`, and
12+
breaking-ness is carried by this banner plus the ADR-0087 disposition).
13+
14+
Two definitions of "multi-valued" disagreed, and the user saw the disagreement as
15+
a `400`.
16+
17+
- `FieldSchema` accepted `multiple: true` on **any** type.
18+
- `@objectstack/driver-sql`'s `isJsonField` read the flag raw —
19+
`JSON_COLUMN_TYPES.has(type) || !!field.multiple` — and built a **JSON array
20+
column** for it.
21+
- `isMultiValueField` — the published spec predicate consumers shape queries from
22+
— answered **"not multi-value"** for that same field, because `master_detail` /
23+
`tree` / `text` are outside `MULTI_CAPABLE_TYPES`.
24+
25+
So a related list composed `=` against a JSON array column, and the driver refused
26+
the equality family there with a `400`.
27+
28+
In business terms: `multiple` means "this cell holds several values at once", and
29+
that has meaning only on multi-select, multi-record / multi-user and multi-file
30+
fields — exactly what the spec already declares. A child record with several
31+
masters, a tree node with several parents, or a text box holding several texts has
32+
no meaning on any mainstream platform. The declaration was accepted silently, the
33+
UI rendered a single value, the database built a JSON array column, and the
34+
related list answered the user a 400.
35+
36+
FROM → TO, for metadata that used to parse and now fails:
37+
38+
```ts
39+
// FROM — parsed, stored a JSON array, rendered single, answered `=` with 400
40+
{ type: 'text', label: 'Aliases', multiple: true }
41+
{ type: 'master_detail', label: 'Parents', reference: 'account', multiple: true }
42+
{ type: 'tree', label: 'Parents', reference: 'category', multiple: true }
43+
44+
// TO — pick the type that actually holds several values…
45+
{ type: 'tags', label: 'Aliases' } // several free-form strings
46+
{ type: 'lookup', label: 'Parents', reference: 'account', multiple: true } // several related records
47+
48+
// …or drop the key, if the cell really holds one value.
49+
{ type: 'text', label: 'Alias' }
50+
{ type: 'master_detail', label: 'Parent', reference: 'account' }
51+
```
52+
53+
The refusal names the field, its type and the alternative, on the `multiple` path.
54+
`radio` keeps its own narrower 2026-08-22 message (#11437); the two never
55+
double-fire.
56+
57+
**`MULTI_CAPABLE_TYPES` and `isMultiValueField` are untouched**, deliberately: a
58+
field that was already multi-valued by that predicate keeps its declaration, its
59+
storage and its read path byte-identically. What moved is which declarations can
60+
be newly authored, plus the storage decision for the shapes that are now refused.
61+
62+
**Storage change (`@objectstack/driver-sql`)**: every site that asked
63+
`field.multiple` the question "is this value multi-valued" now asks
64+
`isMultiValueField` — **eighteen expressions across two files**, not one. The
65+
file's own header already called `JSON_COLUMN_TYPES` membership "owned by
66+
`@objectstack/spec`"; that sentence is now true for the `multiple` half too.
67+
68+
- `sql-driver.ts` — the DDL writer (`createColumn`'s multi-value short-circuit),
69+
the read-side deserializer (`isJsonField`, both limbs), the `varchar` width
70+
mirror (`varcharColumnChars`), the cross-field comparison class
71+
(`crossFieldComparisonClass`), the four scalar registries filled by BOTH
72+
`registerObjectMetadata` and `registerExternalObject` (`mediaFields`,
73+
`booleanFields`, `numericFields`, `numericValueFields`), and the two MySQL
74+
temporal-widening candidate sets.
75+
- `schema-drift.ts` — the differ's `fieldHasColumn`, its `declaresJsonColumn`
76+
disjunct and its `declaresArray` test, which #15771 bound to the writer's
77+
predicate and which a pin test holds equal to it.
78+
79+
Only one of those was named in the ruling; aligning it and leaving seventeen
80+
would have re-opened #11535 in reverse — the DDL writing a JSON column that the
81+
read-side deserializer no longer recognises. A column whose field is multi-valued
82+
by the spec predicate behaves exactly as before; the shapes that change are the
83+
ones the schema now refuses at the entrance.
84+
85+
⛔ Three `field.multiple` reads are deliberately NOT aligned: the three that
86+
interpolate `', multiple'` into an `uncompilableFieldReferenceError` message.
87+
They echo what the author DECLARED back to them; they do not ask whether the
88+
value is multi-valued (the verdict there comes from `crossFieldComparisonClass`,
89+
which is aligned).
90+
91+
⚠️ **Two consequences worth reading before you upgrade.**
92+
93+
1. A **stored** field carrying `multiple: true` on a non-capable type has no
94+
lossless conversion — its column was physically built as a JSON array. The
95+
ADR-0087 semantic entry `field-multiple-non-capable-type-refused` emits the
96+
structured TODO naming the object, field and type; migrating the data is the
97+
author's judgment call, and the entry states how to prove it.
98+
2. `isMultiValueField` reads the **authorable** `FieldType` vocabulary. A driver
99+
-internal column-type alias (`string` / `integer` / `int` / `float` — the
100+
introspected-column spellings) is not a `FieldType`, so a hand-declared
101+
external object that puts `multiple: true` on one of those no longer gets a
102+
JSON column. Declare such a column as `object` or `array` (both are
103+
`JSON_COLUMN_TYPES` members and unchanged), or as the authorable type it
104+
really is.
105+
3. `multiple: true` on `boolean` / `toggle` / `number` / `currency` / `percent` /
106+
`date` / `datetime` / `time` **ceases to be a supported shape end to end**, as
107+
a consequence of the entrance refusal above. Such a column is no longer a JSON
108+
column, so it is no longer excluded from the scalar read-coercion registries
109+
and the declared-type text-operator gate (`isNonTextColumn`) applies to it: a
110+
`$contains` against one answers the declared no-match rather than a JSON
111+
membership test. Stored data in that shape is the ADR-0087 entry's subject.

‎content/docs/references/data/field.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ const result = CurrencyConfigSchema.parse(data);
6060
| **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. |
6161
| **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. |
6262
| **searchable** | `boolean` | optional (default: `false`) | Is searchable |
63-
| **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). |
63+
| **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). |
6464
| **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. |
6565
| **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. |
6666
| **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. |

‎content/docs/references/data/object.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ const result = ApiMethod.parse(data);
222222
| **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. |
223223
| **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. |
224224
| **searchable** | `boolean` | optional (default: `false`) | Is searchable |
225-
| **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). |
225+
| **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). |
226226
| **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. |
227227
| **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. |
228228
| **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. |
@@ -555,7 +555,7 @@ const result = ApiMethod.parse(data);
555555
| **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. |
556556
| **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. |
557557
| **searchable** | `boolean` | optional (default: `false`) | Is searchable |
558-
| **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). |
558+
| **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). |
559559
| **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. |
560560
| **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. |
561561
| **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. |

0 commit comments

Comments
 (0)