Repository navigation
Commit bee8d1c
fix(verify): derive a multi-valued select as a list compared as a set, by the spec's isMultiValueField (#21526)
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](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 5895119 commit bee8d1c
3 files changed
Lines changed: 130 additions & 8 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
| 4 | + | |
4 | 5 | | |
5 | 6 | | |
6 | 7 | | |
| |||
221 | 222 | | |
222 | 223 | | |
223 | 224 | | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
19 | 19 | | |
20 | 20 | | |
21 | 21 | | |
22 | | - | |
| 22 | + | |
23 | 23 | | |
24 | 24 | | |
25 | 25 | | |
| |||
65 | 65 | | |
66 | 66 | | |
67 | 67 | | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
68 | 91 | | |
69 | 92 | | |
70 | 93 | | |
| |||
85 | 108 | | |
86 | 109 | | |
87 | 110 | | |
88 | | - | |
89 | | - | |
90 | | - | |
91 | | - | |
92 | | - | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
93 | 116 | | |
94 | | - | |
| 117 | + | |
| 118 | + | |
95 | 119 | | |
96 | 120 | | |
97 | 121 | | |
| |||
258 | 282 | | |
259 | 283 | | |
260 | 284 | | |
261 | | - | |
| 285 | + | |
262 | 286 | | |
263 | 287 | | |
264 | 288 | | |
| |||
0 commit comments