fix(cli): the one-definition-of-multi-valued invariant reaches os generate — the CLI half of #17469 - #18392
Conversation
…gration and type generators The maintainer ruling of 2026-09-13 (decision batch #128 item 5, option 1') gives "is this field multi-valued" ONE definition -- `isMultiValueField` in `packages/spec` -- and storage follows it. #17469 landed the driver-sql half; `packages/cli`'s generators were left reading `field.multiple` raw, and said so in three code comments. All five reads in `generate.ts` now go through one local seam, `declaredMultiValued`, which calls the spec predicate: - `fieldTypeToTs` call sites (os generate types, os generate client) - `declaredColumnDefault`'s multi-value short-circuit - `fieldTypeToSql` call site (os generate migration --format sql) - `generateMigrationTs`'s JSON short-circuit (--format typescript) The three comments that declared the losing authority are rewritten rather than left in place. Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
…rd the runtime site as excluded The #14829 pin held "one authored `multiple: true` field, three surfaces, ONE answer". #17469 split it on the retired shapes and the pin recorded the split in a header paragraph rather than papering over it. The split is closed, so: - the arm that asserted JSONB for a flagged `text` field is INVERTED, not deleted -- it is the only row where the flag and the one definition disagree, so it is the only row that can tell which one is being read; - the discriminating control moves from `text` to `lookup`, which still straddles the predicate; - the swept roster is split by the predicate itself rather than by a second hand-written list, with both arms pinned non-empty; - two new arms: the flag is redundant (not a second array level) on an inherently-multi option type, and all three CLI surfaces agree with `isMultiValueField` across the whole sweep. `packages/runtime/src/action-execution.ts` was measured against the same per-site premise and EXCLUDED: it inherits an author's declaration onto a descriptor, and the one definition is asked of that descriptor one frame down in `valueSchemaFor`. The reading is recorded at the site so the next grep does not align it. Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 41 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 eafe648039a4d2660b8ac1ff2eb67b37f53ffbe8 && git checkout eafe648039a4d2660b8ac1ff2eb67b37f53ffbe8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 37af6533be1af36671e52eefebfd7740e24d56f1 0ee83a64705f64da5258722a51a6cc3957389f62 && git checkout -B drift-repro 37af6533be1af36671e52eefebfd7740e24d56f1 && git merge --no-ff 0ee83a64705f64da5258722a51a6cc3957389f62
node scripts/docs-audit/affected-docs.mjs --json 37af6533be1af36671e52eefebfd7740e24d56f1
|
…ued, not the raw flag Two statements on `content/docs/protocol/objectql/types.mdx` were falsified by the surface this branch changes, in the #11430 shape the drift bot names: the page states the rule by the ObjectQL type names it maps FROM, so it shares no identifier with the emitter and was not listed. - "Any field flagged `multiple: true` becomes a `JSON` column regardless of its type" -- untrue since the maintainer ruling of 2026-09-13 gave "multi-valued" one definition and storage followed it. The column follows `isMultiValueField`, and the flag on a type outside the multi-capable and multi-option classes is refused at parse. - "set `multiple: true` on a scalar/`lookup` field" -- a scalar field is exactly where that declaration is now refused. Factual corrections only; no new narrative, and `content/docs/releases/` is untouched. Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
CI state on the CURRENT head — one red, and it needs a body edit, ⛔ not a push
|
…lti-valued-invariant-cli
Fixes #18199
Clause-②: no — this PR puts no new key on a published payload. It makes
os generatefollow the already-ruledisMultiValueFielddefinition (#17469, director batch #128 item 5, option 1′) — pulling a diverged emitter back to a declared contract, which narrows rather than widens. Declared by the dispatching seat in claim5693774731.The maintainer ruling of 2026-09-13 (director batch #128 item 5, option 1′) gives "is this field multi-valued" one definition —
isMultiValueFieldinpackages/spec— and storage follows it. #17469 landed thedriver-sqlhalf and stopped at the package boundary. This is the ruling card for thepackages/cli+packages/runtimehalf; thepackages/objectql(11 files) andpackages/drivers/driver-turso(1) sites are a separatedomain:enginechild card and are not touched here.Per-site verdict
The premise is the card's own, and it is falsifiable per site: does this site read
field.multipleto mean "is this value multi-valued"? It held for 18 of 21 sites indriver-sqland failed for 3. Here it holds for 5 and fails for 1.origin/main@8b81ab60)packages/cli/src/commands/generate.ts:2617if (fieldDef.multiple) { table.jsonb(...) }packages/cli/src/commands/generate.ts:2443fieldTypeToSql(fType, !!fieldDef.multiple, …)--format sqlpackages/cli/src/commands/generate.ts:1706declaredColumnDefault—if (declaring?.multiple) return { kind: 'none' }createColumn's multi-value short-circuit, which returns before the DEFAULT linepackages/cli/src/commands/generate.ts:757fieldTypeToTs(fType, !!fieldDef.multiple)—os generate typespackages/cli/src/commands/generate.ts:1203same call —os generate clientpackages/runtime/src/action-execution.ts:1279multiple: p?.multiple ?? field?.multipleWhy the two
fieldTypeToTssites are in, although the card's table names only threeThe card's table is illustrative — it names 2 of
objectql's 11 files too. The#14829pin in this package states the invariant as "one authoredmultiple: truefield, three surfaces, ONE answer": generated TS type,--format sql,--format typescript. Aligning the two migration surfaces and leavingfieldTypeToTson the raw flag would have given a flaggedtextfieldstring[]fromos generate typesandTEXTfrom the migration generated in the same run — #14829 re-created inside the CLI. Landing three of five sites was not available.Why
action-execution.ts:1279is excluded — the readingBoth triage comments flagged it as a candidate for the same exclusion that removed 3 of 18
driver-sqlsites, and the measurement agrees.resolveDeclaredActionParamscopiesmultiplefrom the referenced field onto aResolvedActionParam; the descriptor is then handed tovalidateActionParams, which builds the param's schema withvalueSchemaFor({ type, multiple, options })— whose last line isSo the runtime already follows the one definition at this seam, through the spec's own consumer, and the read here is the ADR-0104 D2 inheritance the function's docblock declares. Pre-computing the verdict into the field would also break that contract: the key would stop carrying what the author wrote, and the predicate would then be applied to its own output. The reading is recorded at the site, so the next grep does not align it.
The declared divergence is gone
Three comments in the tree declared the losing authority, and the card is class (b) because of them. Each is rewritten rather than left standing:
generate.ts:1332— "⛔ the DRIVER is the authority for which column exists, never the spec'sisMultiValueFieldVALUE predicate". The first clause is still true; the second stopped being true when the driver's own multi-value half became that predicate.generate.ts:2286— "WARNING: this is deliberately NOT the spec'sisMultiValueField… the column question belongs to the driver, and the driver's answer is the flag alone." The premise held and the conclusion inverted; the paragraph now records the reversal.generate.ts:2615-2616— "SeefieldTypeToSqlfor why the authority is the driver's flag rule and not the spec'sisMultiValueFieldvalue predicate."One more, in a test:
generate-field-type-vocabulary.pin.test.ts:90carried the same sentence.Measured —
origin/main@8b81ab60vs this branchOne config through all three generators, the same probe both times (the pre-fix leg was taken by checking
generate.tsout at the base commit and restoring it to a byte-identical blob,be774af8…):os generate types--format sql--format typescript{ type: 'text', multiple: true }string[]→stringJSONB→TEXTtable.jsonb→table.text{ type: 'text', multiple: true, defaultValue: 'x' }TEXTwith no DEFAULT →TEXT DEFAULT 'x'.defaultTo("x"){ type: 'multiselect', multiple: true }string[][]→string[]JSONB(unchanged)table.jsonb(unchanged){ type: 'lookup', multiple: true }string[]JSONBtable.jsonb— all unchangedThe last two rows are the same raw read reaching two shapes the card did not name.
multiple: trueis accepted redundantly on the inherently-multi option types, soFIELD_TYPE_MAP's ownstring[]entry was being wrapped a second time; and the short-circuit was withholding a DEFAULT that the driver emits. Both close with the same alignment.generate-multiple-json-column.pin.test.ts— re-targeted deliberatelyIt is #14829's pin, it went red when #17469 landed, and it pins the behaviour this PR changes. It was not skipped, disabled or quarantined. Before the re-target it failed exactly three arms, all on
text, everyMULTI_CAPABLE_TYPESmember still green:That red is the narrowness measurement: the only declaration whose answer moves is the one where the flag and the one definition disagree. What changed in the file:
textis inverted, not deleted — it is the only row that can tell which predicategenerate.tsreads, so it now asserts the scalar answer and says so in its own failure text;single_textvsmulti_text(now the same column, which is the fix) tosingle_lookupvsmulti_lookup, which still straddles the predicate;FLAGGED_TYPES.filter(t => isMultiValueField({ type: t, multiple: true }))) rather than by a second hand-written list, with a control pinning both arms non-empty —SINGLE_VALUED_WHEN_FLAGGEDmust be exactly['text'];isMultiValueFieldacross the whole sweep, with both outcomes required to occur so "they agree" is not "they are all the same answer".The two source-read pins against
driver-sqlare untouched and still green.Verification
All legs re-run on the final head after merging
origin/main—git rev-parse --short HEAD=0ee83a647. The merge broughtpackages/lintand.github/workflows/lint.yml, neither of which this diff touches; re-deriving the gate families on the merged tree added none.pnpm --filter '@objectstack/cli^...' build --concurrency=2VERDICT command-exit 0pnpm --filter @objectstack/cli buildVERDICT command-exit 0pnpm --filter @objectstack/cli typecheckcheck:test-typecheck: OKpnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2208 passed (208)/2976 passed (2976)node scripts/pm/dispatch-gates.mjsderivation, reconciled with--ranpnpm lint(whole repo, not narrowed)packages/cli'sintegrationtier is declared to CI: the diff touches no integration-layer file, nobin/entry and notest/helpers/serve-process.ts, so no local run of it is owed.Not measured, with the reason — three gates refused their own prerequisite on this checkout, which is neither a red nor a pass:
pnpm check:dual-build-cjs-loads— exit 3,PREREQUISITE NOT MET, 12 packages have nodist. Its own text: "⛔ This is NOT a pass: nothing was measured."pnpm check:i18n-coverage— exit 3,COULD NOT MEASURE: 1 of 13 configs failed to lint against an unbuilt@objectstack/connector-mcp.pnpm --filter @objectstack/spec check:skill-examples— exit 1 onpackages/client-react/dist holds no .d.ts declarations, the same class.All three are covered by CI, which builds first;
Lint & Repo Gatesis green on this branch.Acceptance notes
pnpm check:cross-package-test-inputsexits 1 on this branch, and it is not caused by this diff — it reproduces onorigin/main@8b81ab60d. Control taken in a comparison worktree at that commit: withpackages/specunbuilt it exits 0; afterpnpm --filter @objectstack/spec build, with onepackages/clipath modified so the@objectstack/cliscan is pulled in, it exits 1 with the identical finding —packages/cli/test/init-created-files-summary.e2e.test.tsdescendspackages/spec/dist/, which no declared glob reaches. So it is latent onmainand surfaces only on a built checkout, which is themainis red for every PR that touchespackages/spec: #7769 gavesys_api_keyupdatewithoutbulk, and the conformance scan that catches it lives in a package #7769 never touched #7802 blind spot the gate's own text describes ("red onmainwhile every PR reports green"). Reported for filing rather than fixed here: it is a different defect class from this card's, in a file this diff does not touch.packages/cli/src/commands/migrate/{meta,summary-nulls,multi-value-columns,files-to-references,value-shapes}.tsall containmultiple: true, and every one of them is an oclifFlags.string({ multiple: true })— a repeatable CLI flag, a different key with a different subject. Not covered, noted so the next grep does not have to re-derive it.packages/cli/src/commands/explain.ts:70describes themultiplefield prop in a metadata-key listing. It echoes an author's vocabulary and decides nothing — the same shape as the 3 interpolation sites driver-sqlisJsonFieldand specisMultiValueFielddisagree formaster_detail/tree/text+multiple: true, so a consumer using the spec predicate to shape a query gets a 400 #17469 excluded indriver-sql.os migrate multi-value-columnsreadsdriver-sql's ownmanual_column_type_changedrift report and never asks the question itself, so it inherits the one definition for free.Generated by Claude Code