fix(spec)!: refuse multiple: true outside the multi-capable types, and derive driver-sql's storage from the spec predicate - #18187
Conversation
… driver-sql derives JSON storage from the spec predicate Maintainer ruling 2026-09-13 (decision batch #128 item 5, option 1'): one definition of "multi-valued". `FieldSchema` refuses an authored `multiple: true` on any type outside MULTI_CAPABLE_TYPES union MULTI_OPTION_TYPES, and driver-sql's `isJsonField` asks `isMultiValueField` instead of reading `field.multiple` raw. `MULTI_CAPABLE_TYPES` and `isMultiValueField` are untouched. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
…17469 entry Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
…nit cycle `field-value.zod` reaches back into `field.zod` through `shared/strict-object` -> `shared/suggestions.zod`, so spreading the two sets at module top level threw `MULTI_CAPABLE_TYPES is not iterable` on the import orders that enter `field-value.zod` first (six spec suites). Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
…pec predicate, and retriage the fixtures it moves Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
… the ADR-0087 entry Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
…ltiple-non-capable-refused
multiple: true outside the multi-capable types, and derive driver-sql's storage from the spec predicate
📓 Docs Drift CheckThis PR changes 2 package(s): 19 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 2 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c5550294433c007ba970db550a3f41df7b09ab51 && git checkout c5550294433c007ba970db550a3f41df7b09ab51
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f0b2db30dabf08af0b543fee6642f315449bd5a5 3b5b8498f2c9f764cc5d9d39ed2ae89cd4034eb3 && git checkout -B drift-repro f0b2db30dabf08af0b543fee6642f315449bd5a5 && git merge --no-ff 3b5b8498f2c9f764cc5d9d39ed2ae89cd4034eb3
node scripts/docs-audit/affected-docs.mjs --json f0b2db30dabf08af0b543fee6642f315449bd5a5
|
…ue narrowing moves, restore the PG named-divergence pin, and correct five shipping docblocks Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
…ltiple-non-capable-refused
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".
FieldSchemarefusesmultiple: trueat the authoring entrance on every type outsideMULTI_CAPABLE_TYPES∪MULTI_OPTION_TYPES, and@objectstack/driver-sqlderives its storage decisions fromisMultiValueFieldrather than reading the flag raw.MULTI_CAPABLE_TYPESandisMultiValueFieldare 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 samesuperRefineand immediately after the #11437radiocheck it generalises. The message names the field, its type, the declarable set and a type-aware remedy (lookupfor a reference type,file/imagefor a media type, the option types otherwise).The declarable set is derived from
MULTI_CAPABLE_TYPES∪MULTI_OPTION_TYPESrather than re-listed (#12017 two-copies shape), and the remedy list filtersradioout 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.zodreaches back intofield.zodthroughshared/strict-object→shared/suggestions.zod, and spreading the sets at module scope threwTypeError: MULTI_CAPABLE_TYPES is not iterablein six spec suites — measured, then fixed.radiois inside the set, so the two refusals never double-fire; a pin asserts exactly one issue on themultiplepath forradio.2 — Storage alignment (
packages/drivers/driver-sql) — seventeen expressions, not one⭐ The ruling named
isJsonFieldas 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':
sql-driver.tscrossFieldComparisonClassif (decl.multiple) return null;multiple: trueand theJSON_COLUMN_TYPESclasses — element-wise semantics SQL comparison operators do not have)"createColumnif (field.multiple) { jsonColumn; return; }JSON_COLUMN_TYPES' header calls itself the single source for "both the DDL column-type switch andisJsonFieldso the two can't drift"isJsonFieldlimb 1if (!field.multiple && FILE_REFERENCE_TYPES.has(type))multipleis the multi/single discriminatorisJsonFieldlimb 2JSON_COLUMN_TYPES.has(type) || !!field.multiplevarcharColumnCharsif (field?.multiple) return null;multipleis decided before the type switch increateColumn— a JSON column, whatever the element type would have been".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 livecolumnInfo(), i.e. againstcreateColumnregisterObjectMetadatafill!field.multipleonmediaCols/booleanCols/numericCols/numericValueColsmediaFieldsis the SINGLE-VALUE media registry; the boolean line's own comment says "SCALAR only … a multi-valued (JSON) column collapses the parsed array totrue";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 excludedregisterExternalObjectfilllegacyMysqlTimestampColumns/legacyMysqlTimeColumns&& !field?.multipleTIMESTAMP/TIMEcolumn. 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 questionschema-drift.tsfieldHasColumnif (field?.multiple) return true;SqlDriver.createColumnexactly … everything else — includingmultiple(a JSON column) — gets one"schema-drift.tsdeclaresJsonColumn|| field.multiple === trueschema-drift.json-column-parity.test.tspins the two equal over the whole vocabulary ×multipleschema-drift.tsdeclaresArrayfield.multiple === true || MULTI_OPTION_TYPES.has(declaredType)isMultiValueField's own definitionAll of them now go through one module-local helper,
isMultiValuedColumn(type, field), which isisMultiValueField({ type, multiple: field?.multiple === true }). It takes the resolved type because every caller had already applied its ownfield.type || 'string'default, and two spellings of that default is how this drift started.⛔ Three
field.multiplereads deliberately NOT alignedsql-driver.tslines ~15565 / ~15572 / ~15630 interpolate', multiple'into anuncompilableFieldReferenceErrormessage. 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 fromcrossFieldComparisonClass(site 1), which is aligned. Premise tested per site, and it fails for exactly these three.Consequence, stated plainly
multiple: trueonboolean/toggle/number/currency/percent/date/datetime/timeceases 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/audiosplit fromfile/imagefor the same reason: only the latter two areMULTI_CAPABLE_TYPESmembers, so only they keep a deployment-independent JSON column undermultiple: 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 intomigrations/registry.tsbygen: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:minoron both packages under the launch-window convention,BREAKINGbanner,FROM → TOmapping, andadr-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: trueis INERT on a type outsideMULTI_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".
packages/spec/src/data/field.test.tsmultiplepath; the three remedy shapes; the remedy list derived from the sets and never offeringradio; every declarable type still accepted;multiple: false/ absent still accepted;parse(parse(x))stable; fires throughObjectSchema;radiokeeps exactly one issue;MULTI_CAPABLE_TYPES/isMultiValueFielduntouchedvalue-roundtrip-conformance.tsv_multi: { type: 'string', multiple: true }{ type: 'lookup', multiple: true }lookupis not a JSON-class type, so it still reaches JSON storage by themultipleroute alone.stringis a driver alias, not aFieldType: the old spelling reached that route by a door the protocol had closed. All five driver cells re-run greenschema-drift.base-type-mismatch.test.ts×4{ type: 'string'/'integer', multiple: true }{ type: 'lookup', multiple: true }schema-drift.json-column-parity.test.tsmultiple: truemedia is JSON on both arms for all 5file/imageon both arms andavatar/video/audiomoving with the arm, with the two halves derived fromMULTI_CAPABLE_TYPESand pinned by name (['file','image']/['audio','avatar','video'])differReports({type:'string',multiple:true}) === true{type:'lookup',multiple:true} === trueplus{type:'string',multiple:true} === falseschema-drift.unbounded-text-column.test.ts{ type: 'signature', multiple: true }{ type: 'image', multiple: true }imageis multi-capable so it still is onesql-driver-11223-updatemany-write-coercion.test.tstags: { type: 'string', multiple: true }{ type: 'select', multiple: true }sql-driver-target-field-provenance.test.ts×10{ type: 'text', multiple: true }{ type: 'select', multiple: true }sql-driver-17590-json-column-membership.test.tsnums: { type:'number', multiple:true }; population sweep overstring/boolean/datetime+multiplenums: { type:'select', multiple:true }(option values are numbers, so the members are still JSON numbers — the property the row is named for); sweep overselect/lookup/user+multiplesql-driver-15683-temporal-text-operator-gate.test.ts$contains" over{type:'datetime',multiple:true}select+multiple), plus a new assertion that{type:'datetime',multiple:true}now compiles1 = 0like the scalar beside itsql-driver-17343-…test.ts(rewritten)$containsoverboolean/toggle/number+multipleanswers by member; sweep asserting the gate never fires on a multi-valued non-text classselect/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 assertingFieldSchemarefuses all ofNON_TEXT_STORED_VALUE_TYPES+multiple, with a negative control that the multi-capable ones still parseboolean+multiple: trueis authorable —FieldSchema.multiplerefuses 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)[false]must not present astrue;booleanFieldsmust exclude multi-valued columns; four readersfor (const f of jsonRegistry()) expect(booleanRegistry()).not.toContain(f)) with non-vacuity on both sides; the same four readers; plus two new pins —FieldSchemarefusesboolean/toggle+multiple, and the retired declaration reads back as the scalar boolean it now is, inbooleanFieldsand not injsonFieldssql-driver-json-column-operator-refusal.test.ts{type:'datetime',multiple:true}on an external object)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 namesVerification
pnpm --filter @objectstack/spec testpnpm --filter @objectstack/spec typecheckcheck:scripts-typecheck,check:test-typecheck)pnpm --filter @objectstack/spec check:generatedpnpm --filter @objectstack/driver-sql testpnpm --filter @objectstack/driver-sql typechecktesttestnode 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 METon an unbuilt package; afterpnpm build(73 tasks) all seven are 0Population re-measure, on
origin/main689d606fand again after the change: 0 fields carryingmultiple: trueon a non-capable type, by TypeScript-AST sweep over all tracked non-test.ts(2744 files), pairing every object literal that declaresmultiple: truewith thetype:in the same literal or the enclosingField.TYPE()builder. Positive control fired both times (4file, 4select, 3user, 2→3lookup), so the zero is a reading. The one pre-change non-capable hit wasvalue-roundtrip-conformance.ts's driver-alias fixture, re-spelled above.Acceptance notes
multiplekey's.describe()now states the enforced set.packages/spec'sfiles[]shipssrc/**/*.zod.tsliterally, so that sentence goes to npm verbatim — it namesFieldSchemaparse as the door, which is the door the tests exercise.packages/lintmoved onmainwhile this branch was open; the merge touched nopackages/specorpackages/driversfile, so the generated spec artifacts are unaffected by it.sql-driver.tsdocblocks that quoted the oldisJsonFieldbody verbatim are updated in place (thejsonFieldsregistry note and theJSON_COLUMN_TYPESmedia 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.tsPASSED in that same job (✓ … (10 tests) 421ms), and the InnoDBos11565_narrowrecord-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.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 declaredtoggles: toggle,flags: boolean,nums: number+multiple: trueand 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'sdistinct()assertion is ever reachedTest Core (5/6)cli/generate-multiple-json-column.pin.test.ts› "driver-sqlfieldHasColumnstill answers the flag before the type"schema-drift.tsmatching the literalif (field?.multiple) return true;Test Core (6/6)cli/generate-field-type-vocabulary.pin.test.ts› theformularowexport function fieldHasColumn(; the newisMultiValueField({ … })line is ~3× the width of the read it replaced and pushed!== 'formula'outside that windowTest Core (6/6)cli/generate-declared-column-default.pin.test.ts×2c_multiple: { type: 'text', multiple: true, defaultValue: 'x' }is no longer a JSON column, socreateColumnreaches the default question and emitsDEFAULT 'x'packages/cliwas never in the local scope. All three are fixed below.Patch-round test re-targeting — before → after
sql-driver-17639-distinct-fault-envelope.test.tstoggle/boolean/number+multiple; loop over['toggles','flags','nums','tags_']['picks','refs','people','tags_']— multi-valuedselect/lookup/userplustags— plus a newretired_flagscontrol asserting that aboolean+multiple: truecolumn ANSWERSdistinct()because it is a scalar column nowjsonSTORAGE, 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 revertedcli/generate-multiple-json-column.pin.test.ts/if \(field\.multiple\)/oncreateColumn; source pin/if \(field\?\.multiple\) return true;/onfieldHasColumnin a 300-char window/if \(isMultiValuedColumn\(/and/if \(isMultiValueField\(/in a 600-char window, plus a new assertion that!== 'formula'is still inside that windowcli/generate-field-type-vocabulary.pin.test.tsslice(at, at + 200)slice(at, at + 600)toContain("!== 'formula'")) is byte-identical; only the read window was resized to still contain the thing it assertscli/generate-declared-column-default.pin.test.tsc_multiple: { type: 'text', multiple: true, … }{ type: 'lookup', multiple: true, … }lookupis multi-capable so it still does, andc_multiplestays inUNDEFAULTEDunchangedsql-driver-17586-…test.tsdistinct()over a JSON column →DATABASE_ERROR/500/cause42883) was deleted rather than re-targeted, and theelsebranch 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 onlypicks/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 thedistinctExecutesbranch entirely, since PG is precisely the cell each of them is aboutFive shipping docblocks corrected
driver-sqlbuilds withdeclaration: true, so this JSDoc reachesdist/index.d.ts. Corrected:mediaFields("already covered byjsonFieldsthrough!!field.multiple"),booleanFields("isJsonFieldreduces to!!field.multiplefor these two types"),isNonTextColumn's numeric-limb quote, its claim of a pin insql-driver-17586-…that no longer exists, andschema-drift.ts'sJSON_COLUMN_FIELD_TYPESheader still quoting… || !!field.multipleas the writer's predicate.Patch-round verification
driver-sqlagainst a live PostgreSQL 16.13 — servertimezone=Asia/Shanghai, processTZ=America/New_York, the shape CI uses@objectstack/clivitest run --project unit@objectstack/spectestspec/driver-sql/clitypecheckcheck: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 0scripts/pm/check-clause2-carriers.mjs --pair 18187field.zod.ts; the import is spelled on one line and it now exits 0⛔ Deliberately NOT done in this PR
field.multipleis still read raw outsidedriver-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, andpackages/cliis another lane.os generate migrationand the driver now disagree for the retired shapes — the generator emitsJSONBfor atextfield flaggedmultiple: truewhere 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 — session
session_01KB5PFtxuy1x3dcR5gxudx6.Generated by Claude Code