Repository navigation
feat(spec): the number-comparand declared-type door's contract and the platform's numeric grammar - #20414
Conversation
…rammar (#20336) Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
…kept as escapes (#20336) Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
…-comparand contract (#20336) Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
…meric grammar (#20336) Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
…meric-comparand-verdict
…meric-comparand-verdict # Conflicts: # packages/spec/src/data/index.ts
…d tree (#20336) Claude-Session: https://claude.ai/code/session_01B3TqpoQbTAfG7G74GMDWNW Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 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 da7fe006f4d4aa651b83f54754b779e33b3f139a && git checkout da7fe006f4d4aa651b83f54754b779e33b3f139a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 40b315b03345e334069dd454aecaf7016adbea4f 93ec07654bed8f640cde61920756fb0f74d10d85 && git checkout -B drift-repro 40b315b03345e334069dd454aecaf7016adbea4f && git merge --no-ff 93ec07654bed8f640cde61920756fb0f74d10d85
node scripts/docs-audit/affected-docs.mjs --json 40b315b03345e334069dd454aecaf7016adbea4f
|
Contract reviewServed-tier: Read for this record: card #20336 (body and all eight comments, the seat's ACCEPT ① Derived judgmentsPublic surface and accept sets.
The contract the module declares, decision by decision.
Wrong: none found. Not verified here, and not load-bearing: the reading of the sibling repository that ② Semver level
Clause-②: yes ③ Boundary flagsDev
Dev Observation, no action asked. The platform now has three numeric-string readers: this grammar (the filter door and the record write arm), Deviations reported. Main merged twice through Gates and shape. At my first read Implemented-by: VERDICT: PASS Generated by Claude Code |
…e expansion (objectstack-ai#20311) (objectstack-ai#20442) Fixes objectstack-ai#20311 Clause-②: yes Declares the emptiness operator `$empty: boolean` in `@objectstack/spec`. Its description is ruling B's per-type table. The one expansion every compile surface will call is exported beside it. The operator is **staged the way `$like` was**: it is declared, but deliberately absent from `FILTER_OPERATORS`, and the `is_empty` / `is_not_empty` lowering still emits `$null`. Ruling-ref: 5861435168 (ruling B on objectstack-ai#20311), 5865693155 (ruling A on objectstack-ai#20399, the spelling), 5868169573 (the maintainer's amendment, 「照 $like 先例分阶段」). ## What changed - **`FieldOperatorsSchema` and `SpecialOperatorSchema`** (the enforced copy and the documentation copy, one shared constant) gain `$empty: z.boolean().optional()`. The description is the ruled table: text-like (`STRING_VALUE_TYPES`) = null or `''`; multi-value (`isMultiValueField`: multiselect, checkboxes, tags, and select / radio / lookup / user / file / image with `multiple: true`) = null or `[]`; every other type = null only; `false` is the exact complement; a face with no field declaration judges by value. It also says in plain words that the operator is staged and that no face answers it yet. - **New module `packages/spec/src/data/filter-empty-operator.ts`**, published on the data entry: `expandEmptyOperator(field)` keys on the field DEFINITION (type plus `multiple`) and returns one of the frozen `EMPTY_OPERATOR_ARMS` rows `{ arm, emptyString, emptyList }`. `isEmptyFilterValue(value, expansion?)` is the value-level half: with an expansion it applies the declared row; without one it applies the by-value reading for the formula matcher and `having`. The result is surface-neutral, deliberately not a `FilterCondition`, because `[]` stays refused as an equality comparand (ruling 乙 on objectstack-ai#19757, untouched). - **`FILTER_OPERATORS` is unchanged.** Its docblock gains a `$empty` staging paragraph with the measured per-face table below. The flip card is named as the one that adds the operator. `filter-operator-vocabulary.test.ts`' `STAGED_AHEAD_OF_BACKENDS` becomes `['$empty', '$ilike', '$like']`. - **Two reconciliation pins that derive from `FieldOperatorsSchema`'s keys**, kept green the way `$like`'s staging kept them: - the comparand-type face's scalar set (`filter-comparand-type.ts`) gains `$empty`; - the save door's boolean-flag set (`filter-save-door-refusals.ts`) gains `$empty`. A non-boolean `$empty` is then refused at save, as `$null` / `$exists` already are, with the flags' first sentence and an `$empty`-specific prescription. - **Changeset** `@objectstack/spec` `minor`, `Clause-②: yes (widening)`. It says plainly that authoring `$empty` today is refused at query time. ## Why the expansion is its own module, not in `filter.zod.ts` The first version put the functions in `filter.zod.ts`, importing the sets from `field-value.zod.ts`. `gen:skill-refs` then rewrote `skills/objectstack-query/references/_index.md` and `skills/objectstack-api/references/_index.md`: that import pulled `field-value.zod.ts`, `field.zod.ts` and four shared modules into those skills' transitive reference lists. Any `skills/**` path would make this PR Tier H. The two files also meet in the `field.zod` import cycle. So the expansion sits in a sibling module, the precedent being `filter-text-operator-declared-type.ts`, which reads the same sets from the same position. `filter.zod.ts` takes no new import. At the final head `check:skill-refs` is green with zero `skills/**` changes. The description names its type lists literally, and `filter-empty-operator.test.ts` pins each list to the sets the function reads, so the two cannot drift. ## A1: what every compile surface does with a hand-authored `$empty` (measured) Probe: a scratch script run once and not committed. It drove each surface with `{ f: { $empty: true } }`, `{ f: { $empty: false } }` and `{ $and: [{ g: 'x' }, { f: { $empty: true } }] }`, after this change was built. Two controls ran beside it: the declared `{ f: { $null: true } }`, and an undeclared `{ f: { $bogus: true } }`. | surface | `$empty` (all three shapes) | control `$null` | control `$bogus` | |---|---|---|---| | (1) driver-sql `applyFilterCondition`, via `SqlDriver.find` on better-sqlite3 (driver-sqlite-wasm and driver-turso local inherit this compiler; not driven separately) | REFUSED `INVALID_FILTER` / 400 | answered `['2']` | REFUSED `INVALID_FILTER` / 400 | | (2) driver-turso `RemoteTransport.buildWhereSQL`, via `find` | REFUSED `INVALID_FILTER` / 400 | compiled `IS NULL` | REFUSED `INVALID_FILTER` / 400 | | (3) service-analytics `compileScopedFilterToSql` | REFUSED `READ_SCOPE_COMPILE_FAILED` / 500 (fail-closed) | compiled `IS NULL` | same 500 | | (4) service-analytics `lowerAnalyticsWhere` | passes the condition on unchanged (a lowering, not an executor); the compile right after it, `normalizeAnalyticsFilterTree`, REFUSES `INVALID_FILTER` / 400 | lowered to `notSet` | same 400 | | (5) formula `matchesFilterCondition` | NOT LOUD: answers `false` for every record, with flag `true` and with flag `false` | answered the null row | same silent `false` | | objectql `applyHaving` / `matchesHaving` | REFUSED `INVALID_FILTER` / 400 | answered | REFUSED | | driver-memory `find` and `match` (`checkCondition`) | REFUSED `INVALID_FILTER` / 400 | answered `['2']` | REFUSED | | driver-mongodb `translateFilter` (`translateFieldOperators`) | REFUSED `INVALID_FILTER` / 400 | `{ f: { $eq: null } }` | REFUSED | **Conclusion per surface, for this card: explicitly out of scope; each gets its `$empty` arm from its lane card.** No surface drops the predicate, so nothing widens. Two readings differ from premise A1, "the staging leaves every surface loud": - **formula is not loud.** It answers `false` for any operator it has no arm for: its decided fail-closed posture (objectstack-ai#6520), identical for the undeclared `$bogus`, and unchanged by this PR. It denies a write-side `check` rather than widening anything. But its own docblock says a DECLARED operator must not get that silent `false` ("the same defect under a new name"), and `$empty` is now declared. So that claim is stale until formula's lane card lands. `$like` got its formula arm in the PR that declared it; this card is barred from formula by the order and by the amendment's placement of arms in lane cards. Flagged in the report as an open question for the seat. - **read-scope-sql refuses with `READ_SCOPE_COMPILE_FAILED` / 500**, not `INVALID_FILTER` / 400. It is loud and fail-closed, and it does the same for every unknown operator. The description and the changeset say "refuse", not "400". ## A2 to A4 - **A2.** Two in-package pins derive from `FieldOperatorsSchema`'s own keys and saw `$empty`: `filter-comparand-type.test.ts` (judged set) and `filter-save-door-face-parity.test.ts` (`BOOLEAN_SLOTS`). Both are updated, with the source sets they reconcile. `filter-operator-vocabulary.test.ts` records the staging. Suites that enumerate `FILTER_OPERATORS` (`filter-view-operator-parity`, `page-component-filter-record-to-rule-array`, service-analytics' echo coverage) are untouched, because the array is. No test outside `packages/spec` enumerates `FieldOperatorsSchema`'s keys (repo grep: only prose mentions). No gate needed an edit outside `packages/spec`. - **A3, re-measured at base `50e273fd7`:** `STRING_VALUE_TYPES` has the 14 text types; `isMultiValueField` = `MULTI_OPTION_TYPES` or a `MULTI_CAPABLE_TYPES` member with `multiple: true`. The sets are disjoint, a pin asserts it, and `lookup` vs `lookup` with `multiple: true` land on different rows. - **A4:** `isEmptyFilterValue(value)` with no expansion is the declaration-free reading (null, `undefined`, `''`, `[]`). It differs from the declared table only on a non-text column holding `''`, and a pin shows exactly that divergence. ## Pins (`filter-empty-operator.test.ts`) 1. Both copies' description equals the ruled table; each type list it names equals the set the expansion reads. 2. `{ tags: { $empty: true } }` parses at `FieldOperatorsSchema` (kept, not stripped), at `FilterConditionSchema` (any depth) and through the normalized AST. `{ tags: [] }` and `{ tags: { $eq: [] } }` are still refused (issue at the slot; the query face answers `INVALID_FILTER` / 400). A non-boolean `$empty` is refused at the slot and at the save door. 3. `expandEmptyOperator` returns the text, multi-value and null arms for the three kinds, and every `FieldType` lands on its value class's arm. `isEmptyFilterValue` answers each arm, plus the by-value reading. 4. `$empty` is ABSENT from `FILTER_OPERATORS`; a comment names the flip card as the one that adds it. `is_empty` / `isempty` / `is_not_empty` / `isnotempty` still lower to `$null`. ## Verification (final head `32e926db1`) - `packages/spec` full local suite: `vitest run --project local`, 563 files, 16536 passed, 1 todo. `pnpm --filter @objectstack/spec typecheck`: exit 0 (tsc, scripts typecheck, test typecheck). - **Ablation**, run against the committed tree through `scripts/ablation-replace.mjs`. It added `'$empty'` to `FILTER_OPERATORS`: anchor count 1 to 0, blob `0912c2776e5e` to `74b0e4d23fe0`. The run went red on exactly the two staging pins (`filter-empty-operator` §4 and `filter-operator-vocabulary`), with 2 failed and 23 passed. The file was restored to blob == HEAD with `git diff HEAD` empty. The second ablation (dropping `$empty` from the save-door flag set) was **not run**: the verify-lock queue timed out. - **Consumer suites** (filter / operator files per package, positional vitest filters; spec and each package's upstream closure rebuilt from this branch first). Direction: consumers of `@objectstack/spec`, i.e. downstream. | package | files | tests | |---|---|---| | formula | 16 | 570 passed | | driver-memory | 10 | 392 passed | | driver-mongodb | 6 (+1 skipped) | 219 passed, 38 skipped | | objectql (filter / operator / having) | 16 | 624 passed | | driver-sql | 14 | 331 passed, 4 skipped | | driver-turso | 4 | 170 passed | | metadata-protocol | 4 | 51 passed | | lint | 2 | 76 passed | | service-analytics (filter / operator / read-scope) | 42 | 951 passed | | rest | 6 | 109 passed | | plugin-sharing (criteria / sharing-rule) | 6 | 146 passed | - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 108 commands at this head (a superset of the dispatch list's 72). All were run, and `--ran` reconciled: 106 exited 0; 2 are NOT MEASURED (exit 3, PREREQUISITE NOT MET) because they need every package built. Those two are `check:dual-build-cjs-loads` and `check:type-check-debt`, and CI runs them. `check:skill-examples` first exited 3 (client-react unbuilt) and exited 0 after building it. `pnpm --filter @objectstack/spec check:generated`: every artifact current. - **Lint, a declared narrowing:** `eslint --no-inline-config --format json` over the 9 changed TypeScript files reported 9 files, 0 errors, 0 warnings. The population is `eslint.config.mjs`' `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` block. That config never enables type-aware linting (its own note says so), so this diff cannot move a verdict on any untouched file. The full `pnpm lint` is CI's. ## Patch round: reconciled with objectstack-ai#20414 (head `d581aed71`) - **Why.** PR objectstack-ai#20414 (objectstack-ai#20336) landed as `b28550818`. Its partition pin holds `FieldOperatorsSchema`’s keys equal to its judged positions, the text operators and `$null` / `$exists`. With `$empty` added, that pin went red in every merge group carrying both PRs (audit `5871530372`; the diagnosis is on objectstack-ai#20455). This PR is the later lander, so it carries the reconciliation. - **What changed in objectstack-ai#20336’s files.** - `$empty` joins the flag operators in the partition; the test title now says "three flag operators". - The module’s "Not judged" docblock sentence and its unjudged-positions item name `$empty`. - One unjudged case row is added. - The door’s verdict logic is unchanged, and no count pin on `NUMBER_COMPARAND_DOOR_CASES` exists. - **Merge.** `origin/main` `b28550818` was merged through `os-regen-merge.sh` (`43801c9ed`) and regenerated in `859d9bd82`. Both PRs’ exports are present, once each, in `api-surface/data.json` and `export-origins/data.json`, and `filter.mdx` carries `main`’s frontmatter and the `$empty` rows. - **Verified at `d581aed71`.** - Passed: - spec `test` (local): 566 files, 16687 passed; - spec `typecheck`: exit 0; - `check:generated`: exit 0; - formula: 592 passed; driver-memory: 392 passed. - Dispatch-gates: 108 derived, 106 exit 0. Two are NOT MEASURED (`check:dual-build-cjs-loads`, `check:type-check-debt`): both need the whole-repo build. - Spec `test:repo` ran in 9 shards: 8 passed, and one was cut by the local time cap, so it is NOT MEASURED locally. CI’s `Test Core`, which runs `test:repo`, is green on this head. ## Stored sharing rules (ruling B, parameter 3) - objectstack `50e273fd7`: the criteria sharing rules in `examples/` that use emptiness = 0. - objectui `b45d463a9`: none either; the 22 emptiness hits in sharing-rule-related files are builder code and tests, not stored rules. - This PR changes no lowering, so no stored rule changes result. - Production rules are NOT MEASURED; the changeset says so. ## Acceptance notes - PR objectstack-ai#20414’s number-comparand partition pin now names `$empty` among the boolean flag operators (see the patch round above). The flip card objectstack-ai#20446 must keep it there when `$empty` enters `FILTER_OPERATORS`. - formula's docblock claim ("the silent answer is reachable only for a name the protocol does not declare") goes stale with this declaration. Carrier: formula's lane card, which gives it the `$empty` arm (by value, `isEmptyFilterValue(value)`). - read-scope-sql answers every unknown operator with `READ_SCOPE_COMPILE_FAILED` / 500 rather than the ADR-0112 `INVALID_FILTER` / 400 the other faces use. It is pre-existing, fail-closed and loud. Carrier: service-analytics' lane card. - `packages/spec/src/ui/view-grouping-query.ts` says the empty-group predicate and the view filter's `is_empty` agree on what "empty" means. That stays true while the lowering is `$null`. Carrier: the flip card. - `is_empty` still means null-only on every face until the flip card: the gap ruling B closes is still open for users, by design of the staging. The patch-round section and the first acceptance note were added by the `domain:spec` seat 1 (`session_01B3TqpoQbTAfG7G74GMDWNW`) from the dev’s round report. --------- Co-authored-by: Claude <noreply@anthropic.com>
…rammar and stores its number (objectstack-ai#20309) (objectstack-ai#20496) Fixes objectstack-ai#20309 Clause-②: no (narrowing) A number, currency, percent, rating, slider or progress field now reads a **string** by the platform's one numeric grammar, `parseNumericString` from `@objectstack/spec/data` (landed with PR objectstack-ai#20414), instead of `Number()`-finite, and **stores an admitted string as the number it denotes**. A string the grammar does not read answers `400 VALIDATION_FAILED` / `invalid_number` on every write door, with nothing written. This is the card's string half. The non-string half (arrays, booleans, objects) landed as PR objectstack-ai#20370 (`db74b169dc`), so this PR completes the card. Measured head: **`6bf61e75a`** (this branch after a true merge of `origin/main` `fc0db22bc`). ## What changes (read from the code at that head) - **`packages/objectql/src/validation/record-validator.ts`** - The number arm (`NUMERIC_VALUE_TYPES` minus `COMPUTED_VALUE_TYPES`, now spelled once as `isJudgedNumberType` and shared with the rewrite below) judges a string by `parseNumericString`. ⛔ No private grammar: the spec's case table `NUMERIC_STRING_GRAMMAR_CASES` decides hex, padded, exponent and every other form, and this PR pre-decides none of them. `min`, `max`, `scale` and `precision` read the parsed number. The existing code and message key (`invalid_number`) are reused. - New `normalizeNumericStringValues`, beside `normalizeBlankTypedValues` and with its contract (one record or an array of them; pure; the same reference back when nothing changed, else a shallow copy). An admitted string on a field the arm judges becomes its number. It touches only the fields `validateRecord` walks (never a `SKIP_FIELDS` name, a `system` or a `readonly` field), never `summary` or another computed type, never a non-string, and never a string the grammar refuses. - **`packages/objectql/src/engine.ts`**: the rewrite runs right after `normalizeBlankTypedValues` at its three call sites: `insert()`, `update()` (by id and by predicate) and `validate()` (the dry run). So the middleware, the caller snapshots, the hooks, the `readonlyWhen` locks and the validator all see the number. Nothing else in `engine.ts`. The blank rule and its `COMPUTED_VALUE_TYPES` exemption are untouched. - **Tests** (test side only): the three pin files of this card gain the string half, each driven by the spec's own `NUMERIC_STRING_GRAMMAR_CASES`. - **`.changeset/20309-number-arm-numeric-string-grammar.md`**: `@objectstack/objectql` `minor`, BREAKING banner, `Clause-②: no (narrowing)`, ADR-0087 `not-required (no-migration-prescription)`, the disposition PR objectstack-ai#20370's changeset took for this arm. ## Measured, base to head (H1, H3, H4) Instrument: a scratch script, not committed, booting the real `ObjectQL`, `ObjectStackProtocolImplementation` and `RestServer` from the built packages, once on `InMemoryDriver` and once on `SqlDriver` over better-sqlite3 in memory. Types: the six judged types. Doors: engine `insert`, `insertMany`, `update` by id, `update` by predicate; REST `POST /data/:object`, `createMany`, batch create, `PATCH /data/:object/:id`, batch update, `updateMany`, and `/import` (JSON rows). Each cell records the answer, the physical cell (memory's own store; on SQLite the column and its `typeof()`) and `engine.findOne`. Base `851af0c27` (the branch point), head `c67623f22` (the validator and engine code measured here is what `6bf61e75a` carries, plus the date arm that arrived from `main`). 20 inputs x 6 types x 11 doors x 2 drivers = **2640 cells**. | input | base, memory | base, SQLite | head, both drivers, every door but `/import` | |---|---|---|---| | `'12'`, `'12.5'`, `'-3'`, `'-0'`, `'0.10'`, `'1e3'` | accepted, **stored the string**, read back a string | accepted, stored a number by column affinity | accepted, **stored the number**; the SQLite cell is byte-identical to base | | `'0x10'` | accepted, stored the string | accepted, stored the **TEXT** `'0x10'`, read back as `16` | `invalid_number`, nothing written | | `' 12 '`, `'12\n'` | accepted, stored the string | accepted, stored `12` | `invalid_number`, nothing written | | `'+5'`, `'.5'`, `'5.'`, `'007'` | accepted, stored the string | accepted, stored `5` / `0.5` / `5` / `7` | `invalid_number`, nothing written | | `'1,000'`, `'Infinity'`, `'NaN'`, `'1e400'`, `'abc'` | `invalid_number` | `invalid_number` | unchanged | | `''` | `null` (blank rule) | `null` | unchanged | | `12` (a number) | stored `12` | stored `12` (`real`; `integer` on `rating`) | unchanged | Of 2640 cells, **1200 moved**, exactly 60 per moved input (6 types x the 10 non-`/import` doors). The `/import` door moved **0** of its 240 cells: its own cell reader turns a numeric cell into a number before the engine sees it (below). Refused cells answer `400 VALIDATION_FAILED` with the field code `invalid_number` on POST and PATCH, a `VALIDATION_FAILED` row on batch / `createMany` / `updateMany`, and leave an existing cell unchanged on every update door. **H4, the narrowing.** Read off the spec table rather than listed by hand, the strings `Number()` read as finite that the grammar refuses are exactly `' 12 '`, `'12\n'`, `'\t-3'`, `'0x10'`, `'0X1A'`, `'0o17'`, `'0b101'`, `'+5'`, `'.5'`, `'5.'`, `'007'` (pinned in `record-validator.number-value.test.ts`). The changeset names them, with the before and after answer and the fix (send a JS number or its plain JSON spelling). **H5.** Bounds, `scale` and `precision` read the parsed number, so a string answers byte-for-byte as its number does (pinned over 14 cases): `'12.50'` passes `scale: 1` and is stored as `12.5`; `'12.55'` and `'1e-7'` are `max_scale`; `'150'` over `max: 100` is `max_value` (on `progress` too); `'1234.5'` at `precision: 5, scale: 2` is `max_precision`; a fraction-stored `percent` keeps its `scale + 2` allowance. There is no integer check on `rating`, before or after: `'3.5'` on a `rating` passes unless it declares `scale: 0`. ## The server `/import` route and the grammar (H3) The route's cell reader, `parseNumberCell` (`packages/rest/src/import-coerce.ts`), coerces a numeric cell to a JS number before the write, so the engine's grammar never sees a string from it on a typed field. Over the 41 rows of `NUMERIC_STRING_GRAMMAR_CASES` the two **agree on 33** (every admitted row reads to the same number; hex, octal, binary, non-finite, placeholders, `'5.'`, `'1_000'`, `'1 000'` refused by both) and **disagree on 8**, each one the import reader accepting what the grammar refuses: `' 12 '` / `'12\n'` / `'\t-3'` (it trims), `'1,000'` (it strips commas), `'1.000,5'` (read as `1.0005`), `'+5'`, `'.5'`, `'007'`. That is the import route's own documented tolerance and is not changed here (not in this card's surface). ## Tests, all at `6bf61e75a` unless noted - `pnpm --filter @objectstack/objectql test`: 329 files, **6584 passed**. - `pnpm --filter @objectstack/rest test`: 218 files, **4160 passed**, 34 skipped. - `pnpm --filter @objectstack/objectql --filter @objectstack/rest typecheck`: exit 0, both test layers OK (`tsc --listFiles` over each `tsconfig.test.json` includes the edited test files). - Downstream sweep at `b78c66612` (before the merge): `@objectstack/service-automation` 149 files, 1837 passed; `@objectstack/metadata-protocol` 189 files passed, 3 skipped, 2750 tests passed. - Pin files: `record-validator.number-value.test.ts` 369 tests, `engine-number-value-door.test.ts` 275, `rest-data-number-value.test.ts` 277. - ESLint, narrowed and declared: the 5 changed `.ts` files, `eslint --no-inline-config --format json`: 5 files linted, 0 errors, 0 warnings. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot move any untouched file's verdict. The repo-wide `pnpm lint` is CI's. **Ablations** (each through `scripts/ablation-replace.mjs`, which proved the anchor 1 to 0 and the blob change on disk and restored with blob == HEAD and an empty `git diff HEAD`; objectql rebuilt and `ablation-dist-preflight` confirmed the marker in 4 built files before each run, and absent from all 14 after each restore rebuild, with a clean tree): - **A, the arm reads strings by `Number()` again** (`parseNumericString(value)` replaced). Predicted 201 reds: 68 validator, 67 engine, 66 REST. Measured objectql **135** failed of 644 (68 + 67) and REST **66** failed of 277, all in the named narrowed strings, the table-parity and named-narrowing tests, and the dry-run test. - **B, the rewrite made a no-op.** Predicted 79 objectql reds and **0** REST reds, because SQLite's column affinity stores the plain numeric strings as numbers anyway. Measured objectql **79** failed of 644 (60 driver-payload cases, the hook test, 4 rewrite tests, 14 H5 cases) and REST **0** failed of 277. So on SQLite the physical-cell pin cannot see the rewrite; the engine pin on the driver payload is what covers memory (and MongoDB, which stores the payload as given). - After both restores: the three pin files 644 / 644 and 277 / 277. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `6bf61e75a`: 66 commands, each run with its exit code recorded before any pipe. **64 exit 0.** 2 are **NOT MEASURED** with exit 3 (PREREQUISITE NOT MET, both need every package built; CI runs them): `pnpm check:dual-build-cjs-loads`, `pnpm check:type-check-debt`. `--ran` reconciliation: 66 derived, 64 run, 2 NOT-MEASURED, 0 UNRUN. ## Acceptance notes - **Producer census (triage direction 2).** The seat measured it (answer 5863923799 on the card, objectui source): every interactive form widget (`NumberField`, `CurrencyField`, `PercentField`, `RatingField`, `SliderField`, grid inline edit) sends a JS number or `null`, and the kanban quick add a number or a blank that the blank rule turns into `null`. One shipped path sends numeric **strings** to the record write door: objectui's CSV import wizard, legacy per-row fallback (`plugin-grid/src/ImportWizard.tsx`, `legacyImport` via `validateRow`), used only when the client cannot reach the server `/import` route. Re-read in this run at the local objectui checkout `b8e09415c9`: it posts the raw cell after a client check `!isNaN(Number(value))`, which covers `number` / `currency` / `percent` only (a `rating`, `slider` or `progress` cell reaches the server unchecked), and its parser (`importParsers.ts` `parseDelimited`, and the xlsx reader) trims every cell. So of the refused forms it can send the radix literals and the non-JSON spellings (`'0x10'`, `'+5'`, `'.5'`, `'5.'`, `'007'`), and those rows now fail per row with `invalid_number` where they used to store a string. Prescription (in the changeset): import through the server `/import` route, the wizard's default path. The in-repo rows (example seeds and defaults, flow templates, the `/import` route, the client SDK, driver read-back) send numbers, as PR objectstack-ai#20370 recorded. - **`/import` and the grammar disagree on 8 rows** (above). Not changed here. One of them is reported to the seat as a finding: a decimal-comma cell is misread at the `/import` door, measured through the route on both drivers: `'3,14'` stored `314`, `'1,5'` stored `15`, `'1.000,5'` stored `1.0005`, `'1,2,3'` stored `123`, each with `ok: 1, errors: 0`. - **The earlier pending changeset** `.changeset/20309-number-arm-non-string-refused.md` says a string "is still judged by `Number()` and stored as sent. Which strings a number field accepts is a separate change." This PR is that separate change, and its own changeset says so. The earlier file is left untouched: editing another PR's pending changeset is refused by `check:empty-changeset` (the foreign-changeset rule) unless confirmed as a deliberate correction. - **The dispatch asked for a "FROM → TO" line** in the changeset. With that label `check-adr-0087-registration` reads a migration prescription and refuses `not-required (no-migration-prescription)`, the disposition PR objectstack-ai#20370 used for this arm. The changeset carries the same mapping as a "before → after" line with the fix, the spelling the sibling value narrowing `20386-progress-min-max-enforced.md` uses. Nothing authored moves, so there is no ledger row to register. - **Memory stores `-0` for `'-0'`**, the grammar's own value; SQLite stores `0`. - **Hooks now see the number.** A `before*` hook reading a numeric field that a caller sent as a string sees a JS number (pinned). A value a hook itself writes after the door is not rewritten; the arm still judges it by the same grammar. - `driver-memory` is measured at every REST door (the table above) and pinned at the engine door on the driver payload, not with a new REST test consumer: `check:driver-memory-census` refuses one without a ruling (the constraint PR objectstack-ai#20370 met). --- _Generated by [Claude Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… field at the engine's filter door, and narrow a numeric one (objectstack-ai#20351) (objectstack-ai#20501) Fixes objectstack-ai#20351 Clause-②: no (narrowing) ## What this adds Lane (2) of the two-lane route objectstack-ai#20336 took on objectstack-ai#15661's precedent: the engine door that consults the contract PR objectstack-ai#20414 published in `@objectstack/spec/data` (`filter-number-comparand-declared-type.ts`). The contract half is untouched; `packages/spec` is not in this diff. - **The door**, `packages/objectql/src/number-comparand-declared-type-door.ts`, beside the text-operator and temporal doors. For each comparand at a judged position on a declared numeric field it asks `numberComparandDoorVerdict` and routes the answer: - `door-refusal`: throws `INVALID_FILTER` / 400 (the existing `invalidFilterError` envelope) in the contract's words, `numberComparandRefusalMessage`, before any driver is resolved; - `narrows`: rewrites the numeric string to its number, copy-on-write (the caller's filter is never edited, and a filter with nothing to narrow comes back by reference); - `passes` / `deferred`: leaves it alone. The door reads no string itself. The grammar, the judged types (`NUMERIC_VALUE_TYPES` by identity), the judged operators (`NUMBER_COMPARAND_DOOR_SCALAR_OPERATORS` / `NUMBER_COMPARAND_DOOR_LIST_OPERATORS`) and the words are all the spec's. - **Its calls in `engine.ts`, at the collection point only**, fifth after the temporal door in the same order everywhere: - `lowerWhereFilterArray`, object form (before `normalizeFilterComparandTypes`) and array form (on the lowered condition). So `find` / `findOne` / `count` / `aggregate` / `update` / `delete` and the judge-only `judgeFilter` (`judgeWhereAdmission` calls the same function) all inherit it; - each per-aggregation `filter`, rooted at `aggregations[i].filter`, against the object's declared fields; - `having`, after the temporal `having` door, over the columns `aggregatedRowColumnClasses` classes `numeric` (`count` / `sum` / `avg`, and a groupBy or `min` / `max` of a numeric field). The `judgeWhereAdmission` docblock's pipeline list names the new door (comment only). - **A changeset**, `.changeset/20351-number-comparand-door.md`: `@objectstack/objectql` `minor`, BREAKING, `Clause-②: no (narrowing)`, a FROM → TO line, and the ADR-0087 disposition `not-required (no-migration-prescription)` in the form PR objectstack-ai#20469 and PR objectstack-ai#20370 used. `@objectstack/objectql`'s root exports are unchanged: the door module is not re-exported from `index.ts` or `core.ts`, like its two siblings. ## What it does to the card's three answers Measured through `engine.find` / `engine.aggregate` and `POST /api/v1/data/:object/query`, three rows (5, 12, 30), on InMemoryDriver, SqlDriver on SQLite and SqlDriver on a local PostgreSQL 16.13 server: | position | comparand on a `number` field | base `3062e5001`: memory · SQLite · PostgreSQL | this branch, all three | |:--|:--|:--|:--| | `where` | `$gt` / `$eq` / implicit / a `$in` member `"abc"` | 200 no rows · 200 no rows · 500 `DATABASE_ERROR` | 400 `INVALID_FILTER` | | `where` | `$ne "abc"` | every row · every row · 500 | 400 | | `where` | `$eq ""` | no rows · no rows · 500 | 400 | | `where`, REST | `$gt "{current_user_id}"` (resolved to the user's id) | no rows · no rows · 500 | 400 | | per-aggregation `filter` | `$gt "abc"` / `$ne "abc"` | count 0 / count 3, on all three | 400 | | `having` on `sum(amount)` | `$gt "abc"` / `$ne "abc"` | no group / every group, on all three | 400 | | `where` | `$gt "12"` / `$eq "12"` | **no rows** · 1 row · 1 row | 1 row on all three | | all three positions | `$gt 10` (the numeric control) | 2 rows / count 2 / both groups | the same | The last-but-one row is the narrowing's point: InMemoryDriver compared `"12"` as a string and matched nothing. ## Premise check, and the order's hypotheses - **H1 holds, reproduced at `3062e5001`** (the table above). SqlDriver's server-side log line on PostgreSQL reads `(22P02) … invalid input syntax for type numeric: "abc"`. - **H2: the collection point is where the order says**, and the new door sits after the temporal door at each call. `judgeFilter` passes through it: `judgeWhereAdmission` calls `lowerWhereFilterArray` (pinned: `judgeFilter` answers `INVALID_FILTER` / 400 for `"abc"` and `{ ok: true }` for `"12"`). **RLS / sharing / tenant predicates do NOT pass through it at runtime.** The middleware chain composes them onto the AST after this seam, and `plugin-security`'s `judgeCompiledComparands` runs only the two field-agnostic faces (`rls-compiler.ts`, the `[objectstack-ai#20212]` block). A policy predicate reaches this door at authoring instead: `validateRlsPredicateEnforceability` asks the engine's `judgeFilter` when the host hands the rule a judge. - **H3 holds.** The verdict is `numberComparandDoorVerdict` over `NUMBER_COMPARAND_DOOR_JUDGED_TYPES` with the scalar and list operators, and the words are `numberComparandRefusalMessage`. A numeric string is **narrowed** to its number (the verdict's `narrows`, as the contract review's judgment 7 asks). The pins assert the rewritten filter the driver receives, not only the 400s. - **H4: MySQL is NOT MEASURED.** No MySQL server is available in this container. The REST suite carries a MySQL cell, a named skip without `OS_TEST_MYSQL_URL`. - **H5: neither consults the same verdict everywhere.** - `service-analytics`: the ObjectQL strategy sends the caller's `where` into `engine.aggregate` and asks `judgeFilter` about the read scope (`assertReadScopeAdmittedByEngine`), so both inherit the door. The **NativeSQL strategy's decline** (`NativeSQLStrategy.canHandle`) declines a cross-field reference and an uninterpretable temporal comparand, but does not consult the number verdict. So a raw-SQL deployment compiles `amount > 'abc'` itself (read at source, not measured). - **The metadata save door:** RLS `using` is judged through `judgeFilter`, as above. No lint rule reads `numberComparandDoorVerdict` (`git grep` over `packages/lint/src` finds zero hits), so a stored view or report filter comparing a number field with a non-numeric string saves clean and is refused at query time. Both are reported as findings below and are not edited here. ## The staged `$empty` row: pinned at the door alone `NUMBER_COMPARAND_DOOR_CASES` carries PR objectstack-ai#20442's `unjudged` `$empty` row. The engine suite partitions it out of the end-to-end drive and pins it at the door alone: `findNonNumericComparand` answers `null`, and `narrowNumberComparands` returns the same reference. A partition guard asserts the table is split exactly. So the row can neither turn this suite red for a reason that is not the door's, nor vanish unnoticed. The contract's `formula` rows are partitioned the same way the text door's suite does it: they are pinned in the direction they answer (`INVALID_FIELD` / 400 from the objectstack-ai#8296 materializable door, one door earlier). The door's own walk is pinned to judge `f_formula_number` by its `returnType`. ## Tests (at `09da7a4cc`, the merged head, unless noted) - **New: `packages/objectql/src/engine-number-comparand-declared-type-door.test.ts`, 29 tests.** It drives the contract's case table through a real `ObjectQL` and a recording driver, per the contract header: - of the table's 137 cases, 51 refusals (the 52nd is the `f_formula_number` row), asserting `code` + `status` + `httpStatus`, every `mustMention` substring, and no driver read. All 8 refusal forms and every judged position are covered, both ways; - 23 `narrows` cases, asserting the driver receives `c.expectedFilter()` and the caller's filter is untouched; - 57 `passes` cases, reaching the driver unchanged; - the formula (5) and `$empty` (1) partitions above. Beside the table: - every verb (read and write, no read and no write on refusal); - `FilterArray` sugar, both refused and narrowed; - `$and` / `$or` / `$not`; - a placeholder refused unresolved; - `judgeFilter`; - the per-aggregation `filter`, refused at its path, with numeric strings counting what their numbers count; - `having` on `count` / `sum` / a numeric `min`, refused, narrowed, and a placeholder on `count`; - the four `findData` doors (`where` object, `$filter`, filter AST, implicit query parameter), both ways; - the registry-less, unknown-key, by-reference and unrecognised-combinator guards. - **New: `packages/rest/src/data-number-comparand-door.test.ts`.** It runs `POST /api/v1/data/:object/query` and `engine.find` / `engine.aggregate` over SqlDriver, with a cell per dialect: - `where`: 9 refused spellings; - the per-aggregation `filter`; - `having` on `sum` and `max(currency)`, on the native and the rows path; - numeric-string controls, equal to their numbers at all three positions. The SQLite cell always runs. The PostgreSQL cell ran against the local server: 3/3 passed at `09da7a4cc`.⚠️ **No CI job provisions `OS_TEST_POSTGRES_URL` for `@objectstack/rest`.** The `Temporal Conformance (live PG + MySQL)` job runs `driver-sql`'s suite, `metadata-protocol`'s `live-*` files and one `runtime` file, and a `driver-sql`-only pin cannot reach an engine door. So the live cells are red-capable and un-run in CI; the local run above is their measurement. - **Re-pinned, test side only.** Four existing pins asserted the old silent answer for a string on a numeric column: - `engine-aggregate-having-temporal-door.test.ts`: the three "a string on sum / count / avg keeps no group" rows move to a refusal pin in the number door's words; - `engine-aggregate-positions.test.ts`: the "unknown token on count" row moves to a text column, which neither field-aware door judges, and the count-column case is pinned in the new suite; - `rest-aggregate-numeric-having.test.ts`: three rows move from `KEPT` to a `REFUSED` table, SQLite and PostgreSQL both run locally; - `data-query-having-temporal-door.test.ts`: "a string on sum" becomes a number control plus a refusal pin. - `pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2`: 330 files, 6118 tests passed. `--project repo`: 1 file, 5 passed. - `pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2`: 219 files, 3930 passed, 40 skipped. `--project repo`: 1 file, 8 passed. - The live PostgreSQL run of the two PostgreSQL-capable REST files: 30 passed (15 live-postgres), 15 skipped (MySQL). - `pnpm --filter @objectstack/objectql typecheck` and `pnpm --filter @objectstack/rest typecheck`: exit 0. `check:test-typecheck` is OK for both, with no debt added (objectql 40 files / 234 errors held; rest 0 / 0). ## Ablation (reverse verification) The mutation is in the door's walk, which every position routes through: `if (!meta || numberComparandFieldVerdict(meta) !== 'judged') continue;` → `if (meta || 'ABLATION_20351') continue;`. It is made with `scripts/ablation-replace.mjs`: anchor 1 → 0, and blob `aa4a3247` → `56a5bdf6`. - **Mutated leg:** after `pnpm --filter @objectstack/objectql build`, `ablation-dist-preflight` found the marker in 4 built files. The objectql door suite went **20 failed / 8 passed**; the 8 are the guards and partitions that do not depend on the door firing. The REST door suite went **6 failed / 3 skipped**. The SQLite cell answered `200` with `records: []`, the PostgreSQL cell `500 DATABASE_ERROR`, and the per-aggregation `$in ["5","30"]` counted 0 instead of 2: the card's defect, back. - **Restore leg:** the blob is back to `aa4a3247` = HEAD and `git diff HEAD` is empty. After a rebuild, `ablation-dist-preflight --absent` found the marker absent from all 14 built files and the tree clean. Both suites passed again (28/28 and 6 + 3 skipped at that commit, `872d7708b`). ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at **`09da7a4cc`** derives 65 commands, the same list as at the first merged head. All 65 ran with each exit code recorded before any pipe: - 63 exited 0 on the first pass; - `check:dual-build-cjs-loads` and `check:type-check-debt` answered exit 3 (PREREQUISITE NOT MET) until the whole workspace was built (`turbo run build --filter=!@objectstack/docs`, 72/72), then exited 0. `dispatch-gates --ran`: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN. The branch merged `origin/main` twice with true merge commits, no rebase and no force-push; the last merge base is `45f428d8f`. ## Acceptance notes - **`having` words.** A numeric aggregated column has no declared `FieldType`, so the door hands the verdict `number` (the member of the numeric class the column holds). The spec's words then read "compares a declared number field against … at having.total.$gt". The `not-a-number` clause ("backends answer it differently (PostgreSQL with a server error)") is the `where` fact: `having` is evaluated by the engine on every driver, and there it kept no group, or every group under `$ne`. The words are the contract's, and the path names the position. - **Out of the contract, measured, unchanged:** a boolean or a `Date` compared against a number field is not judged (the contract judges strings). `$gt true`: no rows on memory, every row on SQLite, 500 on PostgreSQL. A `Date`: no rows · no rows · 500. Both hold on the base and on this branch. Handed to the seat below. - **Not measured:** MySQL (no server in this container); `driver-mongodb` (the door sits in front of it); the NativeSQL analytics path (read at source). - **Line budget:** n/a (no `skills/**` path in the diff). ## Out of scope, handed to the seat (not filed by this dev) 1. **Class (a), reach measured at REST.** A boolean or a `Date` comparand against a number field answers `500 DATABASE_ERROR` on PostgreSQL. It is `POST /api/v1/data/:object/query` with `where: { amount: { $gt: true } }` against a `number` field, on a local PostgreSQL 16 server, on the base and on this branch. The contract review of PR objectstack-ai#20414 said to file this only if it answered 500; it does. Dedupe words: `boolean comparand number field postgres 500` · `Date comparand numeric column database_error` · `non-string comparand declared number type`. 2. **Carrier: none. Noted, not filed (read at source, reach not measured).** `NativeSQLStrategy.canHandle` does not consult the number verdict, so a raw-SQL analytics deployment does not fall through to this door. Dedupe words: `native sql decline number comparand` · `analytics raw sql non-numeric string`. 3. **Carrier: none. Noted, not filed (read at source, no named producer).** No authoring rule reads `numberComparandDoorVerdict`, so a stored view or report filter with a non-numeric string on a number field saves clean and is refused at query time. Dedupe words: `stored view filter non-numeric number field lint` · `authoring number comparand verdict`. 4. **Carrier: none. Noted, not filed.** The runtime RLS compile (`judgeCompiledComparands`) does not consult the number verdict. The authoring judge does, when present. Dedupe words: `rls compiled predicate number comparand` · `policy using string against number field`. --- _Generated by [Claude Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ield is refused like a non-numeric string (objectstack-ai#20502) (objectstack-ai#20545) Fixes objectstack-ai#20502 Clause-②: no (narrowing) ## What this changes One verdict, widened, as triage directed on the card: the published number-comparand contract now refuses any comparand compared against a declared numeric field that is neither a number nor a string the numeric grammar admits. The engine door that consumes it is unchanged in behaviour and carries no second rule. - **The verdict**, `packages/spec/src/data/filter-number-comparand-declared-type.ts`. `numberComparandDoorVerdict(field, comparand)` answers `door-refusal` (`INVALID_FILTER` / 400) for a boolean, a `Date` and an array at a judged position, beside the non-numeric string it already refused. The string rule is byte-for-byte what PR objectstack-ai#20414 published. What still passes: a number, a `bigint` (the comparand-type door narrows it one call later), `null` (the null test), and every value outside the comparand-type door's accepted set (`undefined`, a plain object, a `{ $field }` reference, a `Map`), which that door already refuses on every field in its own words (see *Objects* below). - Three additive exports: `NON_NUMERIC_VALUE_FORMS` (`boolean`, `date`, `array`) and the types `NonNumericValueForm` / `NonNumericComparandForm`. The refusal's `form` and the refusal site's `value` widen to carry a non-string. - Three refusal clauses, each stating only what was measured; a `Date` renders as `Date(ISO)` so it does not read as the quoted string the grammar refuses. - The derived case table gains a `value` group: `false` at `$gt` on every judged field, `true` and a `Date` at every judged position of `f_number`, an array at every judged position except the equality slots (implicit, `$eq`, `$ne`, where the comparand-shape door refuses an array one door earlier), and four passing rows (the null test twice, a boolean on a `boolean` field, a `Date` on a `datetime` field). `filterAt` now gives each filter its own copy of a `Date` or an array. - One table-derivation fix the engine suite caught: the table's `unjudged` rows carry the flag operators' booleans (`$null: true`, `$exists: false`, `$empty: true`), which the door never hands to the verdict. The table now asks whether the slot is judged before consulting the verdict, so those rows stay `passes`. The engine never refused them; only the table's label would have been wrong. - **The door**, `packages/objectql/src/number-comparand-declared-type-door.ts`: the refusal site carries `value: comparand` instead of `comparand as string`, plus comments. No rule was added or changed; the compiled walk is the same. - **Tests**: the spec contract suite, the objectql door suite and the REST three-dialect suite (below). - **Generated**: `packages/spec/api-surface/data.json` and `export-origins/data.json`, three added lines each, regenerated by `gen:api-surface` / `gen:export-origins` after `check:generated` named exactly those two. - **Changeset**: `.changeset/20502-number-comparand-non-string.md`, `@objectstack/spec` and `@objectstack/objectql` `minor`, BREAKING banner, a FROM → TO line, the ADR-0087 disposition `not-required (no-migration-prescription)`. ## Before and after A declared `number` field, rows 5, 12 and 30. Measured through `engine.find` / `engine.aggregate` and `POST /api/v1/data/:object/query` (the two doors agree), on `InMemoryDriver`, `SqlDriver` on SQLite and `SqlDriver` on PostgreSQL 16. Before: base `4a1df19656`. After: this branch at `0dc69019da`. | position | comparand | before: memory · SQLite · PostgreSQL | after, all three | |:--|:--|:--|:--| | `where` | `$gt true` (the card) | no rows · every row · 500 `DATABASE_ERROR` | 400 `INVALID_FILTER` | | `where` | `$gt false` | no rows · every row · 500 | 400 | | `where` | `$eq true` / implicit `true` | no rows · no rows · 500 | 400 | | `where` | `$ne true` | every row · every row · 500 | 400 | | `where` | a `$in` member `true` | no rows · no rows · 500 | 400 | | `where` | `$between [true, 20]` | no rows · two rows · 500 | 400 | | `where` (in-process) | `$gt` / `$eq` / implicit / a `$in` member, a `Date` | no rows · no rows · 500 | 400 | | `where` | `$gt [1]`, `$lt [100]` | 400 in each driver's own words (SQL withholds the detail) | 400 in the contract's words | | `where` | a `$in` member `[1]` | no rows · driver 400 · driver 400 | 400 | | per-aggregation `filter` | `$gt true` / `$gt [1]` | count 3 on all three | 400 | | per-aggregation `filter` | `$gt` a `Date` | count 0 on all three | 400 | | `having` on `sum(amount)` | `$gt true` / `$gt [1]` | every group on all three | 400 | | `having` on `sum(amount)` | `$gt` a `Date` | no group on all three | 400 | | all three positions | `$gt 10` (the numeric control) | 2 rows / count 2 / both groups | the same | | `where` | `$gt {"a":1}`, a `$in` member `{"a":1}` | 400, the comparand-type door | the same, same words | | `where` | `$eq null` | no rows | the same | The per-aggregation `filter` and `having` rows are the engine's own evaluator on every driver, which is why they agree across drivers before the change: JS coercion read `true` and `[1]` as 1. ## Premise check, and the dispatch's hypotheses - **H1 holds.** The card's table reproduces at `4a1df19656` on all three drivers (rows above), through REST and `engine.find`. PostgreSQL ran on a private PostgreSQL 16 cluster in this container (the same major as the `Temporal Conformance (live PG + MySQL)` job's `postgres:16` service), started for this run and stopped after it. - **H2 holds: the door had no second rule.** Its only routing is the verdict (`judgeComparand` calls `numberComparandDoorVerdict` and turns the answer into an outcome); the one assumption it carried was the type cast `comparand as string`, erased at compile time. Widening the verdict alone reaches `where` (both spellings), the per-aggregation `filter` and `having`: the door file's diff is that cast plus comments, and the after-state above is uniform. - **H3, the producer census: no real producer.** `git grep` over `examples/**` and `packages/**` for a filter comparing with a boolean, a `Date` or an array (filter rules, `FilterArray` triples, `$op` literals), then each named field's declared type: every non-test hit names a boolean field (`active`, `is_active`, `archived`, ...) or a date field, or sits in driver-internal code. The one number-field hit, `amount: { $gt: new Date(0) }` in `driver-sql`'s `sql-driver-cross-field-reference.test.ts`, calls `SqlDriver` directly, beneath the engine door, and stays green (driver-direct callers keep native binding, as the changeset says). No fixture needed triage. ### Objects: served by the comparand-type door, deliberately Triage's list names objects. Measured on the base, an object comparand against the number field is already refused with `INVALID_FILTER` / 400, naming the path, on all three drivers and at all three positions, by the comparand-type door (`normalizeFilterComparandTypes`), and at the REST ingress with `VALIDATION_FAILED`. The widened verdict passes such a value instead of refusing it a second time, for one measured reason: the engine runs the two doors in a different order per position (this door first on the object spelling of `where` and on a per-aggregation `filter`; the comparand-type door first on the `FilterArray` spelling and on `having`). A second refusal here would answer one mistake with two sets of words depending on where it was written, and would change the comparand-type door's pinned words for every numeric field. The engine suite pins it: for a plain object, `undefined` and a `Map`, the engine's message equals the comparand-type door's own message, on both spellings. ## Tests (at `829106fd06`, the final head, unless noted) Every package run below is at `829106fd06` (this branch merged with `origin/main` `31d281d3b2` through `scripts/pm/os-regen-merge.sh`, whole workspace rebuilt: 72/72). Each run went through `scripts/pm/os-verify-lock.sh`; exit codes are the lock's `VERDICT command-exit` lines. - **`@objectstack/spec`**: `filter-number-comparand-declared-type.test.ts` 41/41. `--project local` 573 files, 16842 passed, 1 todo. `--project repo`: 39 files, 701 passed. - **`@objectstack/objectql`**: `engine-number-comparand-declared-type-door.test.ts` 34/34. `--project local` 332 files, 6641 passed. `--project repo` 1 file, 5 passed. - **`@objectstack/rest`**, with `OS_TEST_POSTGRES_URL` set: `data-number-comparand-door.test.ts` 8 passed, 4 skipped (the MySQL cell); the SQLite and PostgreSQL cells both ran. `--project local` 222 files, 4256 passed, 22 skipped. `--project repo` 1 file, 8 passed. - **`@objectstack/driver-sql`**, the whole suite under `TZ=America/New_York` against the live server set to `Asia/Shanghai` (the `Temporal Conformance` job's provisioning): 206 files, 3997 passed, 93 skipped (3 files skipped). - **`@objectstack/driver-memory`**: 59 files, 1419 passed. - **Typecheck**: `pnpm --filter @objectstack/spec typecheck`, `@objectstack/objectql` and `@objectstack/rest`: exit 0, and `check:test-typecheck` holds each ledger with no debt added (spec 53 files / 251 errors, objectql 40 / 234, rest 0 / 0). - **Lint, a proven narrowing** (the repo-wide `pnpm lint` is CI's): ① population from `eslint.config.mjs` itself: all 5 touched lintable files answer `isPathIgnored` false; the other 3 changed files are JSON / Markdown, outside its globs. ② `eslint --no-inline-config --format json` over them: 5 files, 0 errors, 0 warnings. ③ The config never enables type-aware linting (no `parserOptions.project`, no `projectService`), so this diff cannot move any untouched file's verdict. - **Before/after table**: a scratch script against the built packages, not a committed test; the after column was taken at `0dc69019da` (this change before the later case-table fix, which changes test data only) and is re-pinned at the final head by the REST suite's SQLite and PostgreSQL cells and the objectql suite. ## Ablation (reverse verification) From the committed head `829106fd06`, with `scripts/ablation-replace.mjs` in wrap mode (the mutation lives only while the tool's trap is armed). The mutation removes the widening at its one routing line in the verdict: `if (form === null) return { verdict: 'passes' };` becomes the same line with `|| globalThis.ABLATION_20502 === undefined` added to its condition (spelled with a cast to a string-keyed record so the DTS pass type-checks it), so every non-string comparand passes again. The string rule is untouched. - **Mutated leg**: anchor 1 → 0, blob `fdb42a2a` → `55919fda`. After `pnpm --filter @objectstack/spec build`, `ablation-dist-preflight` found the marker in 4 built files (exit 0). Result: the spec suite had **5 failed, 36 passed**; the objectql door suite **4 failed, 30 passed**; the REST door suite **2 failed, 6 passed, 4 skipped**, one failure each for the SQLite and PostgreSQL cells. Every failure is a new non-string pin, or the objectql guard that counts the refused forms. Every string pin and every numeric control stayed green. - **Restore leg**: the blob is back to `fdb42a2a` (the HEAD blob), `git diff HEAD` is empty and `git status --porcelain` is clean. After a rebuild, `ablation-dist-preflight --absent` found the marker absent from all 224 built files and the tree clean. The three suites passed again: 41/41, 34/34, and 8 passed with 4 skipped. - **A first attempt that did not count.** It spelled the marker as a constant, `|| 'ABLATION_20502'`. The suites went red the same way, but `ablation-dist-preflight` answered exit 1 because the marker was absent from `dist/`: spec's tsup config runs with `treeshake: true`, and the bundler folded the constant condition away together with its marker. The mutation was therefore not proven to have reached the built artifact, so that reading is discarded. The redo above uses a `globalThis` property read, which the bundler cannot fold because the read could have side effects. I checked that it survives an esbuild bundle even with `minifySyntax`, and the preflight found it in `dist/`. ## Gates `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at **`829106fd06`** derives 88 commands (the 86 derived before the merge, plus `check:generated` and `check:pm-widening-tells`, which the merged gate map added). All 88 ran, and each exit code was captured before any pipe: **88 exited 0**. That includes `check:dual-build-cjs-loads` and `check:type-check-debt`, which answered exit 3 (PREREQUISITE NOT MET) before the whole workspace was built. Reconciliation with `dispatch-gates --ran` (recorded as `command :: exit N`): 88 derived, 88 run, 0 NOT-MEASURED, 0 UNRUN, and that zero is derived from the recorded codes. `check:adr-0087-registration` reads the changeset as `[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)`. `check-changeset-no-major`: no `major` bump. ## Acceptance notes - **InMemoryDriver.** Its cell is pinned by construction in the objectql door suite: the door answers before any driver is resolved, and the suite's recording driver asserts no read. Its live before/after rows above come from a scratch run against the built packages. A permanent real-`InMemoryDriver` cell would need a new dependency edge (`@objectstack/rest` or `@objectstack/objectql` on `@objectstack/driver-memory`), which this change did not add. - **PostgreSQL in CI.** No CI job provisions `OS_TEST_POSTGRES_URL` for `@objectstack/rest`, so the REST suite's PostgreSQL cell is red-capable and un-run in CI (the predecessor PR recorded the same). It ran locally here, against the private cluster. - **MySQL: NOT MEASURED.** No MySQL server in this container; the REST suite's MySQL cell is a named skip. - **`having` words.** A numeric aggregated column has no declared type; the door hands the verdict `number`, so the words read "compares a declared number field" for `having.total.$gt`. Inherited from the predecessor, unchanged. - **The flag operators.** `$null` / `$exists` / `$empty` take a boolean and are never judged; pinned in both the spec suite and, through the table, the engine suite. - **Out of scope, measured and handed to the seat, not filed here:** a plain object with no `$` key in a scalar field's value position, `where { amount: { "a": 1 } }`. This is filter STRUCTURE (a nested-relation / deep-equality condition), which no field-aware door descends into. On the base it answers 200 with no rows on InMemoryDriver and a driver 400 on SQLite and PostgreSQL, through `POST /api/v1/data/:object/query` and `engine.find`. It is not specific to numeric fields, and this change leaves it as it was. - **The save-time door, one family over.** No lint or save rule reads the number verdict, so a stored filter comparing a number field with a string, and now also with a boolean or an array, saves clean and is refused at query time. The predecessor's report already carries that family. This change widens its population, and the census above found no in-repo producer of the new shapes. --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20336
Clause-②: yes
What this adds
This is lane (1) of the split triage routed on #15661's precedent: the contract for the number-comparand declared-type door, plus the platform's one numeric grammar for a string. It adds one module to
@objectstack/spec/data,filter-number-comparand-declared-type.ts, besidefilter-text-operator-declared-type.ts, together with its test, the barrel line, the regeneratedapi-surface/data.jsonandexport-origins/data.json, and aminorchangeset. No door is written here, and no evaluator changes. The engine door and its three-driver, REST and per-aggregationfilterpins are #20351's, which stays open and isBlocked-by:this card. The write side's string half adopts the same grammar under #20309, which also stays open.The new exports (27, all additive;
api-surface/data.json+27 / -0):NUMERIC_STRING_PATTERN.parseNumericString(s)returns the number, orundefined.readNumericString(s)returns the number, or one of eight named forms (NON_NUMERIC_STRING_FORMS).NUMERIC_STRING_GRAMMAR_CASESis the table of forms: 41 rows, each with its reason.numberComparandFieldVerdict(field)returnsjudged/not-judged/deferred.numberComparandDoorVerdict(field, comparand)returns one of:door-refusal(INVALID_FILTER/ 400, with aform);narrows(with the number);passes;deferred.NUMBER_COMPARAND_DOOR_JUDGED_TYPESisNUMERIC_VALUE_TYPESitself, by identity.NUMBER_COMPARAND_DOOR_SCALAR_OPERATORS/NUMBER_COMPARAND_DOOR_LIST_OPERATORS.numberComparandRefusalMessage(site, context?). It names the field, the declared type, the bounded comparand, its position and what is wrong with the string, all ahead of the remedy.NUMBER_COMPARAND_DOOR_FIXTUREhas one field perFieldType, plus the formula variants.NUMBER_COMPARAND_DOOR_CASEShas 136 derived cases: 52 refusals, 23 narrowings, 60 passes, 1 deferred.Decisions this contract takes (each argued in the module header; the four-axis reasons are below)
The grammar is a JSON number literal that names a finite double. Its forms were checked one by one against the card's A2 list (
NUMERIC_STRING_GRAMMAR_CASESis the record):"12","-3","12.5","0.10","1e3","2.5E+3","1e-7","1e+21".String(n)for a finitenis admitted and parses back ton. This is pinned as a round-trip property over 418 numbers.?amount=5is lowered to an implicit{ amount: "5" }inmetadata-protocol'sfindData.URLSearchParams, nor a plain CSV cell.String(1e-7)is"1e-7".Number()would have read them:""," "," 12 ","0x10","0o17","0b101","+5",".5","5.","007".Number()stays deliberate."Infinity","NaN","1e400","1,000","1.000,5","1_000","1 000", full-width digits, U+2212, and every{placeholder}.The door narrows a numeric string to its number, copy-on-write.
bigint, and the write side's "store the parsed number"."12"is read three ways:driver-memory's matcher, read at source).driver-mongodbcompares by BSON type: its filter compiler coerces temporal comparands only (read at source).summaryis judged. Its column is numeric on every SQL dialect:numericColumnForis defined for every judged type, and that is pinned. The write door exemptssummarythroughCOMPUTED_VALUE_TYPES, but that set answers who writes, not what may be compared. This answers A1.formulais judged by itsreturnType, through the text door's ownFORMULA_RETURN_TYPE_AS_FIELD_TYPE:numberis judged;text,booleananddateare not number fields;returnTypeis deferred.As with the text door, no formula filter reaches the engine seam, because the unmaterializable-field door refuses every one of them first with
INVALID_FIELD. The case table says the engine suite must partition those rows out. This also answers A1.Judged positions: implicit equality;
$eq/$ne/$gt/$gte/$lt/$lte; and each member of$in/$nin/$between.$null/$existspartitionFieldOperatorsSchema's keys exactly.null, aDate, a boolean or abigintpasses.A
{placeholder}against a number field is refused, not stepped around.YYYY-MM-DDday or an ISO instant (resolveFilterTokenin@objectstack/core). None of these is numeric, so a resolved token reaches PostgreSQL as the same 500.A blank string is refused.
nullbefore the number arm (record write door:''skips every type check, so a number, boolean, date, datetime or time column stores an empty string — normalise it to null at the door (seam from objectui#10813) #20308).is_emptylowers to$null, and [Decision] what 「is empty」 means on a text column and on a multi-value column: null only (the spec's lowering today), or null OR''/[]— three objectui builders disagree, and a stored sharing rule's rows depend on the answer #20311's ruling B keeps a number field on null alone.viewFilterFold.tsandListView.convertFilterGroupToAST, read at objectui9f0c84a. So the console sends no{ amount: "" }.The words and the envelope (A4): no new error code.
INVALID_FILTER/ 400 is spelled as a literal, asfilter-comparand-type.tsdoes, and pinned toStandardErrorCode. Each form's clause states only what holds for that form, so"+5"is not claimed to diverge between backends. Every refusal in the case table fits inside the 500-character client bound, and that is pinned.Four-axis reasons (for the grammar and the narrowing)
metadata-protocolfindData).String(n)or a spreadsheet writes for a plain number. Every such form is admitted.+5,.5,5.or007. The objectui filter builder sends JS numbers (convertScalarToFamily).Premise check (rule 6)
normalizeFilterComparandTypestakes no schema. This was confirmed atab6fb027, which is why this is a new module (the triage retriage route).@objectstack/objectqlimports@objectstack/spec/dataalready. Its build closure builds against this spec (turbo, 14/14 tasks atb49a49b0), andcheck:lean-entry-closureholds its admitted set (15 packages).git grep -l "filter-number-comparand-declared-type" -- packagesfinds only the barrel, the module's own test and the generatedexport-origins/data.jsonshard.Tests (at
b49a49b0unless noted)pnpm --filter @objectstack/spec typecheck: exit 0.check:test-typecheckOK, with the new test file compiled and no debt added.vitest run --project local(spectest): 562 files, 16551 tests passed (1 todo).vitest run --project repo(spectest:repo): 35 files, 634 tests passed. It also passed ata5680789, before the main merge.Number()list;summary's numeric column;FieldSchema/ObjectSchema);parseFilterASTaccepting every case.pnpm --filter @objectstack/spec check:generatedatb49a49b0, after rebuilding from the merged tree: all 15 artifacts are up to date.Gates (
dispatch-gates --commands --repo objectstack-ai/objectstackatb49a49b0: 85 derived)83 exited 0, and 2 are NOT MEASURED (exit 3, PREREQUISITE NOT MET):
check:dual-build-cjs-loads: needs every package built.check:type-check-debt: needs the full build closure.CI runs both.
dispatch-gates --ran: 85 derived families are accounted for (83 run, 2 NOT-MEASURED, derived from the recorded exit 3), 0 UNRUN.For comparison, the dispatch list derived at
ab6fb027had 72 lines. The diff and changeset added 13 families, all of them run above:check-empty-changeset×2release-rehearsal-clone --self-testcheck:generatedcheck:engine-double-contractcheck:objectql-double-limitcheck:objectui-changesetcheck:pm-changeset-deadline-censuscheck:pm-widening-tellscheck:query-options-erasurecheck:type-check-coveragecheck:type-check-debtcheck:where-matcherOn the first pass these gates needed a prerequisite and were rerun green:
check-plugin-teardown-shape --self-testneeded its pinned fixture commit fetched at depth 1 on this shallow clone.check:doc-formula-expressionsandcheck:lean-entry-closureneeded the@objectstack/objectqlclosure built.Acceptance notes
"2026-01-01"/"12:00"for the temporal fields,"abc"otherwise. That choice is a design claim for objectql: refuse a non-numeric string compared against a number field at the engine's field-aware filter door (400, every driver and position) — the door half of #20336 #20351 to measure, not something measured here.driver-mongodb's numeric-string answer (read at source);Datecompared against a number field is not judged here. The card and its direction are about strings. PostgreSQL's answer for those is not measured.skills/**path in the diff).Generated by Claude Code