Skip to content

Commit bee8d1c

Browse files
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

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/verify": patch
3+
---
4+
5+
`os verify` writes each derived sample in the shape the engine stores it: a `select` declared `multiple: true` is written as a list and compared as a set
6+
7+
Clause-②: no
8+
9+
- The CRUD round-trip derivation now asks `@objectstack/spec`'s `isMultiValueField` whether a field is multi-valued, the same predicate the engine stores by. Before, the `select` / `radio` sample was one scalar option code compared `equal` whatever the field declared, so a multi-valued `select` read back as a one-element list and was reported as a fidelity gap the engine does not have. The shipped `examples/app-todo` (`todo_task.tags`) failed `os verify` with exit 1 on exactly that, and now passes.
10+
- A single-valued `select` or `radio` keeps its scalar sample and its `equal` comparison. `multiselect` and `checkboxes` are unchanged.
11+
- A relational field's `multiple` is answered by the same predicate. A `lookup` declared `multiple: true` still receives a list of ids. A `master_detail` or `tree` field carrying `multiple: true` now receives one id, which is how the engine stores those types. The spec already refuses `multiple` on those types at parse, so only an unparsed config could reach this.
12+
- No export, type or accept-set change.

‎packages/verify/src/derive.test.ts‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4+
import { Field, FieldType, isMultiValueField } from '@objectstack/spec/data';
45
// `.js` extension, deliberately: under `moduleResolution: NodeNext` a relative
56
// import without it does not RESOLVE, so every symbol it names becomes `any` —
67
// and the type-layer ratchet reads this file (`TEST_DEBT`, via the package's
@@ -221,3 +222,88 @@ describe('deriveCrudCases — an UNREADABLE `reference` carrier is refused (#185
221222
expect(blocked?.blocked).not.toMatch(/rejected alias/i);
222223
});
223224
});
225+
226+
/**
227+
* [#21509] A sample is written in the shape the field is STORED in, and that
228+
* shape has exactly one answer: `@objectstack/spec`'s `isMultiValueField`, the
229+
* predicate the engine, `driver-sql` and `os generate migration` all store by.
230+
*
231+
* The `select` / `radio` arm used to write one scalar option code and compare
232+
* it `equal` whatever the field declared. A `select` declared `multiple: true`
233+
* is stored and served as a LIST, so it read back `[code]` and `os verify`
234+
* reported a fidelity gap the engine does not have — the shipped
235+
* `examples/app-todo` (`todo_task.tags`) failed on exactly that.
236+
*/
237+
describe('deriveCrudCases — a sample takes the shape the field is STORED in (#21509)', () => {
238+
const OPTIONS = [
239+
{ label: 'Important', value: 'important' },
240+
{ label: 'Quick Win', value: 'quick_win' },
241+
];
242+
const caseFor = (field: Record<string, unknown>) =>
243+
deriveCrudCases({
244+
objects: [
245+
{ name: 'company', fields: { title: { type: 'text' } } },
246+
{ name: 'probe', fields: { f: field } },
247+
],
248+
}).find((x) => x.object === 'probe');
249+
250+
it('a `select` declared `multiple: true` is written as a list and compared as a set', () => {
251+
// Built with the spec's own builder — the way `examples/app-todo` authors
252+
// `todo_task.tags` — rather than a hand-written literal.
253+
const c = caseFor(Field.select({ label: 'Tags', multiple: true, options: OPTIONS }));
254+
expect(c?.body?.f).toEqual(['important']);
255+
expect(c?.asserts).toEqual([{ field: 'f', type: 'select', value: ['important'], kind: 'set' }]);
256+
});
257+
258+
it.each([
259+
['omitted', {}],
260+
['false', { multiple: false }],
261+
])('control: a single-valued `select` (`multiple` %s) keeps one scalar code and `equal`', (_label, flag) => {
262+
const c = caseFor(Field.select({ label: 'Priority', options: OPTIONS, ...flag }));
263+
expect(c?.body?.f).toBe('important');
264+
expect(c?.asserts).toEqual([{ field: 'f', type: 'select', value: 'important', kind: 'equal' }]);
265+
});
266+
267+
/**
268+
* The claim's enumeration, made mechanical: over EVERY `FieldType` the spec
269+
* declares, with the flag and without it, whatever the derivation writes is
270+
* a list exactly when `isMultiValueField` says the field is stored as one —
271+
* scalar samples and relational refs alike. The loop is driven by the spec's
272+
* own enum, so a type added there is judged here without an edit.
273+
*/
274+
it('over every FieldType × `multiple`, the written shape IS `isMultiValueField`', () => {
275+
const judged: string[] = [];
276+
const listWritten = new Set<string>();
277+
for (const type of FieldType.options) {
278+
for (const multiple of [true, false]) {
279+
const at = `${type} multiple=${multiple}`;
280+
const stored = isMultiValueField({ type, multiple });
281+
const c = caseFor({ type, multiple, options: OPTIONS, reference: 'company' });
282+
expect(c?.blocked, at).toBeFalsy();
283+
284+
const ref = c?.relationalRefs?.find((r) => r.field === 'f');
285+
if (ref) {
286+
expect(ref.multiple, at).toBe(stored);
287+
} else if (c?.body && 'f' in c.body) {
288+
expect(Array.isArray(c.body.f), at).toBe(stored);
289+
const a = c.asserts?.find((x) => x.field === 'f');
290+
if (a) expect(a.kind === 'set', at).toBe(stored);
291+
} else {
292+
continue; // not written at all (computed / structured / media / no synth) — no shape to judge
293+
}
294+
judged.push(at);
295+
if (stored) listWritten.add(type);
296+
}
297+
}
298+
// Non-vacuity: the loop must actually have judged the multi-valued writers,
299+
// or every assertion above could pass over a derivation that writes nothing.
300+
expect([...listWritten]).toEqual(
301+
expect.arrayContaining(['select', 'radio', 'multiselect', 'checkboxes', 'tags', 'lookup']),
302+
);
303+
// …and the single-valued side: a flag the predicate does not honour (a
304+
// `text`, a `master_detail`) is judged as the scalar it is stored as.
305+
expect(judged).toEqual(
306+
expect.arrayContaining(['select multiple=false', 'text multiple=true', 'master_detail multiple=true']),
307+
);
308+
});
309+
});

‎packages/verify/src/derive.ts‎

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
// is reported `blocked` with a precise reason — the gate stays honest.
2020

2121

22-
import { referenceCarrierOf } from '@objectstack/spec/data';
22+
import { isMultiValueField, referenceCarrierOf } from '@objectstack/spec/data';
2323
import { declaredCollection } from './artifact-collections.js';
2424

2525
const COMPUTED = new Set(['formula', 'summary', 'autonumber', 'rollup', 'vector']);
@@ -65,6 +65,29 @@ function clampNum(f: any, fallback: number): number {
6565
return v;
6666
}
6767

68+
/**
69+
* [#21509] Is this field's value MULTI-VALUED — the one question, asked of the
70+
* one predicate `@objectstack/spec` publishes (`isMultiValueField`), and the
71+
* single seam every site in this file goes through: the option arm of
72+
* {@link synth} and the relational ref in {@link deriveCrudCases}.
73+
*
74+
* The engine stores by that predicate (objectql's `declaredMultiValued`, and
75+
* `driver-sql` and `os generate migration` ask it too), so a sample shaped by
76+
* any other answer is written in a form the engine never stores and reads back
77+
* as a fidelity gap the engine does not have. The option arm used to answer
78+
* per TYPE: a `select` declared `multiple: true` was written as one scalar code
79+
* and compared `equal`, then read back as `[code]`.
80+
*
81+
* ⛔ The predicate is called, never re-spelled. A raw `f.multiple` read, or
82+
* `MULTI_CAPABLE_TYPES` / `MULTI_OPTION_TYPES` membership written out here,
83+
* would be a second answer to a question the spec answers once — a raw read
84+
* says `true` on types the predicate stores as a scalar (`master_detail`,
85+
* `tree`, `text` …), and `false` on the inherently-multi option types.
86+
*/
87+
function declaredMultiValued(type: string, f: any): boolean {
88+
return isMultiValueField({ type, multiple: f?.multiple === true });
89+
}
90+
6891
/** Synthesize a valid value for a field type, or null if not synthesizable. */
6992
function synth(type: string, f: any): { value: unknown; kind: AssertKind } | null {
7093
switch (type) {
@@ -85,13 +108,14 @@ function synth(type: string, f: any): { value: unknown; kind: AssertKind } | nul
85108
case 'datetime': return { value: '2024-03-15T08:30:00.000Z', kind: 'equal' };
86109
case 'time': return { value: '14:30:00', kind: 'equal' };
87110
case 'json': return { value: { sample: true }, kind: 'equal' };
88-
case 'select': case 'radio': {
89-
const opt = f.options?.[0]?.value;
90-
return opt != null ? { value: opt, kind: 'equal' } : null;
91-
}
92-
case 'multiselect': case 'checkboxes': {
111+
// One declared option code, in the shape the field is STORED in: a list,
112+
// compared as a set, exactly when {@link declaredMultiValued} says so —
113+
// always for `multiselect` / `checkboxes`, and for a `select` / `radio`
114+
// declared `multiple: true`; otherwise the scalar code, compared `equal`.
115+
case 'select': case 'radio': case 'multiselect': case 'checkboxes': {
93116
const opt = f.options?.[0]?.value;
94-
return opt != null ? { value: [opt], kind: 'set' } : null;
117+
if (opt == null) return null;
118+
return declaredMultiValued(type, f) ? { value: [opt], kind: 'set' } : { value: opt, kind: 'equal' };
95119
}
96120
case 'tags': return { value: ['alpha', 'beta'], kind: 'set' };
97121
// Opaque-on-read: write a value but don't assert a round-trip (hashed/encrypted).
@@ -258,7 +282,7 @@ export function deriveCrudCases(config: any): CrudCase[] {
258282
d.skippedFields.push({ name, type, reason: `relation-target-external:${target}` });
259283
continue;
260284
}
261-
d.relationalRefs.push({ field: name, target, required: isRequired, multiple: !!(f as any)?.multiple });
285+
d.relationalRefs.push({ field: name, target, required: isRequired, multiple: declaredMultiValued(type, f) });
262286
if (isRequired) d.requiredTargets.push(target);
263287
continue;
264288
}

0 commit comments

Comments
 (0)