fix(verify): derive a multi-valued select as a list compared as a set, by the spec's isMultiValueField - #21526
Conversation
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
… isMultiValueField A select declared multiple: true was written as one scalar option code and compared equal, then read back as a list, so os verify reported a fidelity gap the engine does not have (examples/app-todo todo_task.tags). The option arm and the relational ref now ask the spec's one predicate through a single local seam. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 3 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 fe7676603f4723d9ac205b8ccfc6be7aec943e05 && git checkout fe7676603f4723d9ac205b8ccfc6be7aec943e05
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 88fb5e85a02009344e9e2cf1abc929ae694c051f 2ea61a6178ec4f34d56673852e76862c76384a8f && git checkout -B drift-repro 88fb5e85a02009344e9e2cf1abc929ae694c051f && git merge --no-ff 2ea61a6178ec4f34d56673852e76862c76384a8f
node scripts/docs-audit/affected-docs.mjs --json 88fb5e85a02009344e9e2cf1abc929ae694c051f |
Fixes #21509
Clause-②: no
What changed
packages/verify/src/derive.tsnow answers "is this field multi-valued?" in one place,declaredMultiValued(type, f). That seam calls@objectstack/spec'sisMultiValueField, the predicate the engine stores by (objectqlengine.tsdeclaredMultiValued;driver-sqlandos generate migrationask it too). Two sites in the file go through it:synth.select,radio,multiselectandcheckboxesare now one arm. It writes one declared option code: as a list compared as asetwhen the predicate says multi-valued, and otherwise as the scalar code comparedequal. Before,select/radioalways wrote a scalar comparedequal. Aselectdeclaredmultiple: trueis stored and served as a list, so it read back[code]and was reported as a fidelity gap.RelationalRef.multiple, whichfillRelationalRefsreads). Before, this was a raw!!f.multiple. See the bounded in-place fix below.The comparison itself (
verify.ts,setEqual/deepEqualkeyed onkind) is unchanged, because the kind is decided inderive.ts. No export, type or accept-set change.The enumeration: every type the spec lets carry
multipleSource:
packages/spec/src/data/field-value.zod.tsandfield.zod.tsat88fb5e85a0, read only.field.zod.tsmultiDeclarableTypeSet) isMULTI_CAPABLE_TYPES∪MULTI_OPTION_TYPES= {select,radio,lookup,user,file,image} ∪ {multiselect,checkboxes,tags}.isMultiValueField: true for anyMULTI_OPTION_TYPESmember, and for aMULTI_CAPABLE_TYPESmember whenmultiple === true.selectequal, flag ignoredsetwhenmultiple: true, else scalar +equalradioequal, flag ignoredselect. The spec refusesradio+multipleat parse, but the value contract still promotes it, and storage follows the contract.multiselect,checkboxessettagssetlookupmultipleuserno-synthfile,imageunsynthesizable-optionalSo the types written as a scalar while carrying
multiplewere exactlyselectandradio, and both now read the flag in the same place. Every type whosemultiple: truethe predicate honours is stored and served as a list, so no type needed a decision.Bounded in-place fix, named: the relational ref
RelationalRef.multiplewas!!f.multiple, a second answer to the same question in the same file. It differs from the predicate only on types the predicate stores as a scalar:master_detailortreecarryingmultiple: true, plus the non-spec spellingsmaster-detail/masterdetail. The spec refusesmultipleon those at parse, so only an unparsed config reaches this. There it used to thread[id]into a single-valued reference column. All four conditions hold:Evidence: ablation leg B below goes red on exactly
master_detail multiple=true.Public door:
os verify --jsoninexamples/app-todohardFailurestodo_task13195e2fec(derive.tsis BASE bytes)fidelity-gaps:tags(select) wrote"important", read["important"]283d003998verified, 16 fields checkedThe CLI read
@objectstack/verifyfrom its rebuiltdist(declaredMultiValuedappears 4 times in bothindex.jsandindex.cjs).Control at the same door, after: deriving the real
examples/app-todoconfig through the built package givesstatus,priority,categoryandrecurrence_type(single-valuedselect) a scalar sample comparedequal, andtagsgets["important"]compared as aset.CI's
Dogfood Verify CLIjob verifiesapp-crmandapp-showcase, notapp-todo. Neither of those apps has an object field whose derived shape this diff changes. Everymultiple: truethere is on alookup(where the predicate agrees) or auser(never written). So their CI verify output is unchanged by construction.Pins (
packages/verify/src/derive.test.ts)All of these are pure functions with no stack boot:
selectwithmultiple: true, built with the spec's ownField.selectthe way app-todo authorstags, gives['important']compared as aset.multipleomitted orfalse, it gives'important'comparedequal.FieldType.optionsvalue, withmultipletrue and false, whatever the derivation writes (a body value or a relational ref) is a list exactly whenisMultiValueFieldsays so. The loop is driven by the spec's enum, so a type added there is judged without an edit.select,radio,multiselect,checkboxes,tagsandlookupmust all have been judged.select multiple=false,text multiple=trueandmaster_detail multiple=true."
examples/app-todopassesos verify" is held as the measured before/after above, not as a permanent test. Apackages/verifytest that readsexamples/app-todowould need aCROSS_PACKAGE_TEST_INPUTSdeclaration and aturbo.jsontask, both outside this card's file surface, andexamples/app-todois not edited.Reverse verification (expected direction: red)
Both legs used
scripts/ablation-replace.mjsin wrap mode at283d003998. The subject is imported by relative path (./derive.js), so the source is what was measured. No dist was involved.type === 'multiselect' || type === 'checkboxes').ba277d29a305to9b5489cb8b68.select+multiplepin ("expected 'important' to deeply equal [ 'important' ]") and the invariant atselect multiple=true. Both controls stayed green.git diff HEADis empty.!!(f as any)?.multiple.ba277d29a305to8ceb535a34f5.master_detail multiple=true("expected true to be false").git diff HEADis empty.Local verification
pnpm --filter @objectstack/verify test: 17 files and 131 tests pass, exit 0, at283d003998. The final commit2ea61a6178adds only the changeset.pnpm --filter @objectstack/verify typecheck: exit 0 (tsc --noEmitpluscheck:test-typecheck).tsc -p tsconfig.test.json --listFilesputs all 17 of 17src/*.test.tsfiles in the program,derive.test.tsincluded.packages/qa/dogfood/test/derive-topology.test.ts(the other caller ofderiveCrudCases, throughdist): 10 of 10 pass.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackat2ea61a6178: 61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN, with an exit code recorded per command.check:dual-build-cjs-loadsfirst answered PREREQUISITE NOT MET because seven packages outside the build closure had nodist. After building them (41 of 41 cache hits) it ran green: 106 require entry points across 66 packages load.2ea61a6178, with all three pieces of evidence:ESLint.isPathIgnored/calculateConfigForFile):derive.tsandderive.test.tsare linted, and the changeset.mdis ignored.--format jsonoutput ofeslint --no-inline-config(thepnpm lintform) counts 2 files, 0 errors and 0 warnings.eslint.config.mjsenables no type-aware linting (noparserOptions.project, noprojectService), and its inline plugins read only two baseline JSONs at config load. No rule reads another source file, so this diff cannot move any untouched file's verdict.Acceptance notes
userhas no sample arm. It is multi-capable in the spec, but the derivation neither synthesizes it nor treats it as relational. An optionaluserfield (app-showcase'sf_user,f_users,f_ownerandteam_members) is skipped asno-synth, and a required one would block its object. It produces no false gap and no failure. Not filed, carrier: none.CrudCase.skippedFieldsnever reaches theos verifyreport, becauseObjectVerifyResulthas no field-level skip list. A field the derivation skips shows only as a lowercheckedcount. Not filed, carrier: none.app-todohas no CI leg. CI's verify job never runsos verifyonexamples/app-todo, which is why this gap could sit onmain. Adding it is a CI change outside this card. Not filed, carrier: none.derive.tslowercasestypebefore asking the predicate, while the engine's seam passes it as authored. The two differ only for a non-lowercase type string, which the spec'sFieldTypeenum refuses at parse.Generated by Claude Code