Skip to content

fix(verify): derive a multi-valued select as a list compared as a set, by the spec's isMultiValueField - #21526

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21509-verify-select-multiple
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21509-verify-select-multiple

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21509

Clause-②: no

What changed

packages/verify/src/derive.ts now answers "is this field multi-valued?" in one place, declaredMultiValued(type, f). That seam calls @objectstack/spec's isMultiValueField, the predicate the engine stores by (objectql engine.ts declaredMultiValued; driver-sql and os generate migration ask it too). Two sites in the file go through it:

  • The option arm of synth. select, radio, multiselect and checkboxes are now one arm. It writes one declared option code: as a list compared as a set when the predicate says multi-valued, and otherwise as the scalar code compared equal. Before, select / radio always wrote a scalar compared equal. A select declared multiple: true is stored and served as a list, so it read back [code] and was reported as a fidelity gap.
  • The relational ref (RelationalRef.multiple, which fillRelationalRefs reads). Before, this was a raw !!f.multiple. See the bounded in-place fix below.

The comparison itself (verify.ts, setEqual / deepEqual keyed on kind) is unchanged, because the kind is decided in derive.ts. No export, type or accept-set change.

The enumeration: every type the spec lets carry multiple

Source: packages/spec/src/data/field-value.zod.ts and field.zod.ts at 88fb5e85a0, read only.

  • The declarable set (field.zod.ts multiDeclarableTypeSet) is MULTI_CAPABLE_TYPES ∪ MULTI_OPTION_TYPES = {select, radio, lookup, user, file, image} ∪ {multiselect, checkboxes, tags}.
  • The value contract is isMultiValueField: true for any MULTI_OPTION_TYPES member, and for a MULTI_CAPABLE_TYPES member when multiple === true.
type derivation path before after
select option arm scalar + equal, flag ignored list + set when multiple: true, else scalar + equal
radio option arm scalar + equal, flag ignored same as select. The spec refuses radio + multiple at parse, but the value contract still promotes it, and storage follows the contract.
multiselect, checkboxes option arm list + set unchanged
tags own arm list + set unchanged
lookup relational ref list of ids when multiple unchanged: the predicate agrees
user none (not relational, no sample arm) skipped, no-synth unchanged, never written
file, image media skipped, unsynthesizable-optional unchanged, never written

So the types written as a scalar while carrying multiple were exactly select and radio, and both now read the flag in the same place. Every type whose multiple: true the predicate honours is stored and served as a list, so no type needed a decision.

Bounded in-place fix, named: the relational ref

RelationalRef.multiple was !!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_detail or tree carrying multiple: true, plus the non-spec spellings master-detail / masterdetail. The spec refuses multiple on 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:

  • the same defect class: the derived shape is not the stored shape, in the other direction;
  • the fix is mechanical, with its shape fixed by the engine's own seam;
  • no other claim holds this file;
  • it stays in the same gate family.

Evidence: ablation leg B below goes red on exactly master_detail multiple=true.

Public door: os verify --json in examples/app-todo

reading commit exit hardFailures todo_task
before 13195e2fec (derive.ts is BASE bytes) 1 1 fidelity-gaps: tags (select) wrote "important", read ["important"]
after 283d003998 0 0 verified, 16 fields checked

The CLI read @objectstack/verify from its rebuilt dist (declaredMultiValued appears 4 times in both index.js and index.cjs).

Control at the same door, after: deriving the real examples/app-todo config through the built package gives status, priority, category and recurrence_type (single-valued select) a scalar sample compared equal, and tags gets ["important"] compared as a set.

CI's Dogfood Verify CLI job verifies app-crm and app-showcase, not app-todo. Neither of those apps has an object field whose derived shape this diff changes. Every multiple: true there is on a lookup (where the predicate agrees) or a user (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:

  • select with multiple: true, built with the spec's own Field.select the way app-todo authors tags, gives ['important'] compared as a set.
  • Control: with multiple omitted or false, it gives 'important' compared equal.
  • Over every FieldType.options value, with multiple true and false, whatever the derivation writes (a body value or a relational ref) is a list exactly when isMultiValueField says so. The loop is driven by the spec's enum, so a type added there is judged without an edit.
    • Non-vacuity floor: the multi-valued writers select, radio, multiselect, checkboxes, tags and lookup must all have been judged.
    • So must the single-valued controls select multiple=false, text multiple=true and master_detail multiple=true.

"examples/app-todo passes os verify" is held as the measured before/after above, not as a permanent test. A packages/verify test that reads examples/app-todo would need a CROSS_PACKAGE_TEST_INPUTS declaration and a turbo.json task, both outside this card's file surface, and examples/app-todo is not edited.

Reverse verification (expected direction: red)

Both legs used scripts/ablation-replace.mjs in wrap mode at 283d003998. The subject is imported by relative path (./derive.js), so the source is what was measured. No dist was involved.

  • Leg A, the option arm back to a per-type answer (type === 'multiselect' || type === 'checkboxes').
    • The anchor went 1 to 0 and the blob changed from ba277d29a305 to 9b5489cb8b68.
    • Result: 2 failed, 20 passed. The failures were the select + multiple pin ("expected 'important' to deeply equal [ 'important' ]") and the invariant at select multiple=true. Both controls stayed green.
    • Restored: the blob equals HEAD and git diff HEAD is empty.
  • Leg B, the relational ref back to !!(f as any)?.multiple.
    • The anchor went 1 to 0 and the blob changed from ba277d29a305 to 8ceb535a34f5.
    • Result: 1 failed, 21 passed. The failure was the invariant at master_detail multiple=true ("expected true to be false").
    • Restored: the blob equals HEAD and git diff HEAD is empty.

Local verification

  • pnpm --filter @objectstack/verify test: 17 files and 131 tests pass, exit 0, at 283d003998. The final commit 2ea61a6178 adds only the changeset.
  • pnpm --filter @objectstack/verify typecheck: exit 0 (tsc --noEmit plus check:test-typecheck). tsc -p tsconfig.test.json --listFiles puts all 17 of 17 src/*.test.ts files in the program, derive.test.ts included.
  • packages/qa/dogfood/test/derive-topology.test.ts (the other caller of deriveCrudCases, through dist): 10 of 10 pass.
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at 2ea61a6178: 61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN, with an exit code recorded per command.
    • All 61 exit 0. check:dual-build-cjs-loads first answered PREREQUISITE NOT MET because seven packages outside the build closure had no dist. After building them (41 of 41 cache hits) it ran green: 106 require entry points across 66 packages load.
  • Lint, as a proven narrowing at 2ea61a6178, with all three pieces of evidence:
    • The population is eslint's own answer (ESLint.isPathIgnored / calculateConfigForFile): derive.ts and derive.test.ts are linted, and the changeset .md is ignored.
    • The --format json output of eslint --no-inline-config (the pnpm lint form) counts 2 files, 0 errors and 0 warnings.
    • Invariance: eslint.config.mjs enables no type-aware linting (no parserOptions.project, no projectService), 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.
  • NOT MEASURED locally, left to CI: Test Core, Dogfood Regression Gate, Dogfood Verify CLI, Build Core, Temporal Conformance, and the workspace type-check lanes.

Acceptance notes

  • Observation: user has no sample arm. It is multi-capable in the spec, but the derivation neither synthesizes it nor treats it as relational. An optional user field (app-showcase's f_user, f_users, f_owner and team_members) is skipped as no-synth, and a required one would block its object. It produces no false gap and no failure. Not filed, carrier: none.
  • Observation: field skips are invisible. CrudCase.skippedFields never reaches the os verify report, because ObjectVerifyResult has no field-level skip list. A field the derivation skips shows only as a lower checked count. Not filed, carrier: none.
  • Observation: app-todo has no CI leg. CI's verify job never runs os verify on examples/app-todo, which is why this gap could sit on main. Adding it is a CI change outside this card. Not filed, carrier: none.
  • Observation: lowercased type. derive.ts lowercases type before 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's FieldType enum refuses at parse.

Generated by Claude Code

claude added 3 commits October 3, 2026 02:43
… 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
@github-actions github-actions Bot added the size/m label Oct 3, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 3, 2026
@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 3 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 88fb5e85a02009344e9e2cf1abc929ae694c051f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from fe7676603f4723d9ac205b8ccfc6be7aec943e05 — the merge of head 2ea61a6178ec4f34d56673852e76862c76384a8f into base 88fb5e85a02009344e9e2cf1abc929ae694c051f, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 3, 2026 03:28
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 3, 2026 03:28
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit bee8d1c Oct 3, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21509-verify-select-multiple branch October 3, 2026 03:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants