Skip to content

Commit 45efcfa

Browse files
fix(spec)!: a number other than 1 / 0, a Date or an array compared against a boolean field is refused like a non-boolean string (#21382) (#21404)
Fixes #21382 Clause-②: yes (narrowing) ## What this changes One verdict, widened, as triage directed on the card (grade `5949762416`, inheriting the number door's direction `5877498426`): the published boolean-comparand verdict now refuses any comparand against a declared `boolean` / `toggle` field (or a `formula` returning `boolean`) that is outside its accepted set. The engine's boolean arm consumes that verdict and nothing else; it carries no second rule. This is the boolean twin of PR #20545 (`b05743433b`) on the number door, and it follows that PR's shape and words where the two contracts have the same parts. - **The verdict**, `packages/spec/src/data/filter-boolean-comparand-declared-type.ts`. `booleanComparandDoorVerdict(field, comparand)` answers `door-refusal` (`INVALID_FILTER` / 400) for a number other than `1` / `0` (`2`, `-1`, `0.5`, `NaN`), a `Date` and an array, at a scalar slot or as a list member. The string rule is byte-for-byte what PR #21372 published. The accepted set is unchanged: `true`, `false`, `1`, `0`, `"true"`, `"false"`, `"1"`, `"0"`; `null` is still the null test. - **A `bigint` is read as the number it names**: `1n` / `0n` narrow like `1` / `0`, and any other `bigint` is refused as a number. See *The bigint reading* below for why this is the only reading that gives one answer at every position. - **What still passes**: a boolean, `null`, a `{ $field }` reference, and every value outside the comparand-type door's accepted set (`undefined`, a plain object, a `Map`). That door already refuses those on every field, in its own words (see *Objects* below). - Three additive exports: `NON_BOOLEAN_VALUE_FORMS` (`number`, `date`, `array`) and the types `NonBooleanValueForm` / `NonBooleanComparandForm`. The refusal's `form` widens from `NonBooleanStringForm` to `NonBooleanComparandForm`, on `BooleanComparandDoorVerdict`, `BooleanComparandRefusalSite` and `BooleanComparandDoorRefusalCase`. The site's `value` was already `unknown`; it now carries a non-string. `booleanComparandDoorVerdict`'s signature is unchanged (relevant to #21376, which will consume it). - Three refusal clauses. None names a backend, because PostgreSQL's server error was measured at `where` only. The engine evaluates the per-aggregation `filter` and `having` itself, and this contract has no driver-bound site flag. A `Date` renders as `Date(ISO)` and a non-finite number by name, not as JSON (`NaN` would otherwise print as `null`, the null test). - The derived case table gains a `value` group: `-1` at `$ne` on every judged field, and `2` and a `Date` at every judged position of `f_boolean`. An array sits at every judged position except the equality slots, where the comparand-shape door refuses one first. Beside them are four passing rows. The `2` / `-1` reading rows, and a new `0.5` row, now derive refusals. `freshComparand` copies a `Date` and an array per filter, as the number table does. No `bigint` row is in either table, so every filter a suite builds survives `JSON.stringify`. - **The arm**, `packages/objectql/src/boolean-comparand-declared-type-door.ts`: comments only. The walk already routed every comparand to the verdict and carried the refused value as written. `number-comparand-declared-type-door.ts` (the shared walk) is untouched. - **Tests**: the spec contract suite, the objectql arm suite, and a new REST three-dialect cell `packages/rest/src/data-boolean-comparand-door.test.ts`, beside `data-number-comparand-door.test.ts`. In that cell SQLite always runs, and PostgreSQL / MySQL are named skips without their URL. - **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. - **Changesets**, one per published package moved, both BREAKING `minor` with `!`, a `Clause-②: yes (narrowing)` line and one ADR-0087 marker (`not-required (no-migration-prescription)`): `.changeset/21382-spec-boolean-comparand-non-string.md` and `.changeset/21382-objectql-boolean-comparand-non-string.md`. ## Clause-②, measured `node scripts/pm/check-widening-tells.mjs` on `git diff 69a12a0...HEAD`: - `--declaration no`: exit 4, three T3 tells. These are the three new rows in `packages/spec/api-surface/data.json` (lines 469, 505, 507). - `--declaration yes`: exit 0. So this PR declares `Clause-②: yes (narrowing)`. It adds an export listing row, and what it changes in behaviour is a narrowing. No T1 / T2 / T4 tell fired. ## Before and after A declared `boolean` field, two rows (`rt` true, `rf` false), through `engine.find` / `engine.aggregate`. The probe is a scratch script against freshly built dists, not a committed test. A `where` cell reads implicit, `$eq`, `$ne`, and a `$in` member beside `false`. Before: base `69a12a0952`. After: this branch, spec and objectql source as at `196afd75cd`. PostgreSQL ran on a private PostgreSQL 16.14 cluster in this container, started for the run and stopped after. | position | comparand | before: memory · SQLite · PostgreSQL 16 | after, all three | |:--|:--|:--|:--| | `where` | `2`, `-1`, `0.5`, a `Date` (the card) | no row, no row, both, the false row · the same · **500 `DATABASE_ERROR` at every slot** | 400 `INVALID_FILTER` at every slot | | per-aggregation `filter` | the same | count 0, 0, 2, 1 on all three | 400 | | `having` on a groupBy of the field | the same | no group, no group, both, the false group on all three | 400 | | `where` | an array `[true]` as a `$in` member (the card) | **the false row (200)** · a driver 400 · a driver 400 | 400, in the contract's words | | per-aggregation `filter` / `having` | the same | count 1 / the false group on all three: the member silently dropped | 400 | | all three | `[true]` at implicit / `$eq` / `$ne` | 400 (the comparand-shape door) | the same | | `where` | `2n` (in-process) | no row · no row · 500 | 400 | | `where` | `1n` (in-process) | **no row · the true row · the true row** (`$ne 1n`: both rows on memory) | the true row on all three | | all three | `{ "a": 1 }` | 400 (the comparand-type door) | the same, same words | | all three | `true`, `1`, `"true"` (the controls) | the true row, count 1, the true group | the same | ## Premise check, and the dispatch's hypotheses - **H1 holds.** On `69a12a0952`, `readBooleanComparand` returned `null` for every non-string except `1` / `0`, the verdict answered `passes`, and the reading rows read `unread(2)` / `unread(-1)`. The card's table reproduces on memory, SQLite and PostgreSQL (rows above). The number contract's non-string branch was the template. - **H2 holds: no walk change.** `judgeBooleanComparand` already passed `value: comparand` and `form: verdict.form` straight through, so widening the verdict reaches `where` (both spellings), the per-aggregation `filter` and `having` with no code change in objectql. The shared walk file is not in the diff. - **H3**: the after-column is re-measured on memory, SQLite and PostgreSQL 16.14 (above). MySQL is NOT MEASURED, because no MySQL server is available in this container. The REST cell's MySQL leg is a named skip. - **H4 holds.** A `formula` returning `boolean` is still refused one door earlier, by the unmaterializable-field door, with `INVALID_FIELD` / 400. The arm suite's NAMED DIVERGENCE test drives every formula row, including the new `value` row on `f_formula_boolean`. ### The bigint reading The ruling does not name `bigint`. Measured on the base, `1n` against the boolean field gave different answers per driver at `where`: no row on InMemoryDriver, the true row on SQLite and PostgreSQL. `2n` gave PostgreSQL's 500. That is the card's defect class, through an in-process caller. The comparand-type door rewrites a `bigint` to its number. It runs AFTER this door on the object spelling of `where` and on a per-aggregation `filter`, and BEFORE it on the `FilterArray` spelling and at `having`. So: - passing a `bigint` leaves the divergence; - refusing every `bigint` would refuse `1n` on one spelling and narrow it on the other; - reading it as the number it names gives one answer at every position. This is not a widening. A `bigint` passed the verdict before, so it was accepted, and now it is either narrowed correctly or refused. The arm suite pins it on both spellings and at all three positions. ### Objects: served by the comparand-type door, deliberately Triage's list names objects. On the base, an object comparand against the boolean field is already refused with `INVALID_FILTER` / 400 by the comparand-type door, on all three drivers and at all three positions (table above). The widened verdict passes such a value rather than refusing it a second time. The reason is the one PR #20545 measured: the engine runs the two doors in a different order per position, so a second refusal would answer one mistake with two sets of words. The arm suite pins it: for a plain object, `undefined` and a `Map`, the engine's message equals the comparand-type door's own message. ## Tests (at `196afd75cd`, the final head) Each heavy run went through `scripts/pm/os-verify-lock.sh` with exit codes from the lock's `VERDICT command-exit` lines. - **`@objectstack/spec`**: `filter-boolean-comparand-declared-type.test.ts` 34/34. `--project local`, run as two `--shard` halves to fit the container's foreground cap: 300 files / 8983 passed + 1 todo, and 299 files / 8580 passed (599 files in all, 0 failed). `typecheck` exit 0; `check:test-typecheck` holds its ledger (52 files / 246 errors, none added). - **`@objectstack/objectql`**: `engine-boolean-comparand-declared-type-door.test.ts` 34/34, and `engine-number-comparand-declared-type-door.test.ts` still green (its boolean-arm partition reads the widened verdict). `--project local` as two shards: 182 files / 3583 passed, and 182 files / 3777 passed. `typecheck` exit 0 (ledger 40 / 234, none added). - **`@objectstack/rest`**: `data-boolean-comparand-door.test.ts` with `OS_TEST_POSTGRES_URL` set: 6 passed, 3 skipped (the MySQL leg). The SQLite and PostgreSQL legs both ran. `--project local` (no live URL): 255 files, 4808 passed, 322 skipped. `typecheck` exit 0 (test layer 0 / 0). - **Lint, a proven narrowing** (the repo-wide `pnpm lint` is CI's): 1. Population, from `eslint.config.mjs` itself: all 5 touched lintable files answer `isPathIgnored` false. The other 4 changed files are JSON / Markdown, outside its globs. 2. `eslint --no-inline-config --format json` over them: 5 files, 0 errors, 0 warnings. 3. The config never enables type-aware linting (no `parserOptions.project`, no `projectService`), so this diff cannot move any untouched file's verdict. - **Gates**: `node scripts/pm/dispatch-gates.mjs --commands` (no paths) at `196afd75cd` derives 90 families; all 90 ran with exit 0, and `--ran` reconciles 90 derived / 90 run / 0 NOT-MEASURED. `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET, because the workspace was not fully built. It was re-run after a full `turbo run build`, and exited 0. ## Ablation (reverse verification) From the committed head `196afd75cd`, with `scripts/ablation-replace.mjs` in wrap mode. The mutation lives only while the tool's trap is armed. It removes the widening at its one routing line in the verdict: `if (form === null) return { verdict: 'passes' };` gains `|| (globalThis as Record[string, unknown]).ABLATION_21382 === undefined` (angle brackets spelled as square brackets here), so every non-string passes again. The string rule is untouched. - **Mutated leg**: anchor 1 to 0, blob `41a24373` to `064410ff`. After `pnpm --filter @objectstack/spec build`, `ablation-dist-preflight` found the marker in 4 built files (exit 0). Results: - spec suite: **3 failed** / 31 passed (the non-string verdict, the bigint reading, the `value` group); - objectql arm suite: **5 failed** / 29 passed (the GUARD's form set, and the `where`, per-aggregation `filter`, `having` and bigint tests); - REST cell: **4 failed** / 2 passed / 3 skipped (the refusal tests on both the SQLite and PostgreSQL legs). - The control tests stayed green on every suite. The direction is the expected one: red. - **Restore leg**: blob back to `41a24373` equal to HEAD, `git diff HEAD` empty, and `git status --porcelain` empty for the whole tree. After a rebuild, `ablation-dist-preflight --absent` found the marker absent from all 230 built files (exit 0). Results: spec 34/34, objectql 34/34, REST 6 passed / 3 skipped. ## Acceptance notes - `comparandPreview` is a private copy in the boolean module. The number module's copy is private, and PR #21390 holds that file, so this PR does not touch it. Moving both copies into `filter-comparand-refusal-text.ts` is a consolidation for whichever PR next touches both. No carrier is named. - Two comments outside this claim's file surface still describe the string half only: `engine.ts` around the `narrowNumberComparands` call ("any other string is refused"), and the shared walk's `[#21333]` header section. Both are incomplete, not false. No carrier is named. - The REST cell's live legs are red-capable and un-run in CI (no job provisions `OS_TEST_POSTGRES_URL` for `packages/rest`), as with the number cell. This PR carries their local PostgreSQL run. --- _Generated by [Claude Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6d67ad5 commit 45efcfa

9 files changed

Lines changed: 788 additions & 42 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
fix(objectql)!: a number other than `1` / `0`, a `Date` or an array compared against a boolean field is refused with `INVALID_FILTER` / 400 at `where`, a per-aggregation `filter` and `having`, instead of a PostgreSQL 500 or an empty 200
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a refusal of filter COMPARANDS at the engine's query door, against a declared boolean or toggle field or a boolean aggregated column. No authorable key, spelling, export or stored shape moves: every query shape, every FilterCondition and every object definition parse as before, @objectstack/objectql exports nothing new and nothing less, and no stored row is read or rewritten. What is refused is a comparand that answered a server error on PostgreSQL and an empty 200 on InMemoryDriver and SQLite, and which boolean a caller meant by 2 is not something a ledger entry can decide. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers a filter comparand, and this diff adds none (not registered / already-registered); and the change is runtime behaviour, not a declaration (not runtime-interface-only / type-surface-only). -->
10+
11+
**BREAKING**: this narrows what a filter may compare a declared `boolean` or `toggle` field with, at every filter position and through every door that reaches the engine's filter walk (`engine.find` / `findOne` / `count` / `aggregate` / `update` / `delete`, and every spelling the data API hands it). It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type of this package changes; the rule is `@objectstack/spec/data`'s `booleanComparandDoorVerdict`, whose own changeset lists what moved there.
12+
13+
**What was answered before.** A non-string comparand outside the accepted set reached the driver as written. Measured on two rows (one `true`, one `false`) through `engine.find` / `engine.aggregate`, on InMemoryDriver, SqlDriver over SQLite and SqlDriver over PostgreSQL 16:
14+
15+
| position | comparand | before: memory · SQLite · PostgreSQL | now, on all three |
16+
|:--|:--|:--|:--|
17+
| `where` | implicit / `$eq` `2`, `-1`, `0.5`, a `Date` | no row · no row · `DATABASE_ERROR` (500) | `INVALID_FILTER` / 400 |
18+
| `where` | `$ne` the same | both rows · both rows · 500 | `INVALID_FILTER` / 400 |
19+
| `where` | a `$in` member `2` or a `Date` | the other members' rows · the same · 500 | `INVALID_FILTER` / 400 |
20+
| `where` | a `$in` member `[true]` | the other members' rows (200) · a driver 400 · a driver 400 | `INVALID_FILTER` / 400, in one set of words |
21+
| per-aggregation `filter` / `having` | any of the above | count 0 and no group (every row and group under `$ne`), a `$in` member ignored, on all three | `INVALID_FILTER` / 400 |
22+
| all three positions | `true`, `1`, `"true"` (the controls) | the true row, count 1, the true group | the same |
23+
24+
**The remedy.** Write `true` or `false` (or `1` / `0`). To match either value, use `$in`, each member a boolean. Compare a `Date` with a date or datetime field.
25+
26+
**Unchanged.** `true` / `false` pass as written, the accepted spellings (`1` / `0`, `"1"` / `"0"`, `"true"` / `"false"`) narrow as before, and any other string is refused in the same words as before. `null` (the null test) and the flag operators answer as before. A value outside the accepted comparand types (`undefined`, a plain object, a `Map`) keeps the comparand-type door's own refusal and words. A filter on a `formula` field is still refused one step earlier. Driver-direct callers that never pass through the engine keep each driver's native binding.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
fix(spec)!: the boolean-comparand verdict refuses a number other than `1` / `0`, a `Date` and an array compared against a boolean field, the same as a string that is not a boolean
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a refusal of a filter COMPARAND value at the engine's query door, decided by the published boolean-comparand verdict: no authorable key, spelling or stored shape moves, and no export is removed or renamed (the verdict's function, its case table and its words keep their names; three exports are added). No stored row is read or rewritten. What is refused is a number other than 1 / 0, a Date or an array compared against a declared boolean field or a boolean aggregated column; which boolean the caller meant by 2 is not something a ledger entry can decide (reading 2 as true is exactly the silent coercion this refuses). The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers a filter comparand (not `registered` / `already-registered`); and the change is runtime behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->
10+
11+
**BREAKING**: this narrows what a filter may compare a boolean field with. `booleanComparandDoorVerdict`, the published verdict the engine's boolean-comparand arm consumes, judged strings only; it now also answers `door-refusal` (`INVALID_FILTER` / 400) for a number other than `1` / `0`, a `Date` and an array, so the engine refuses them before any read, on every driver. It ships as `minor` under the launch-window convention for accept-set narrowings. The accepted set is unchanged: `true`, `false`, `1`, `0`, `"true"`, `"false"`, `"1"` and `"0"`, and `null` is still the null test.
12+
13+
What moves in `@objectstack/spec/data`:
14+
15+
- `booleanComparandDoorVerdict(field, comparand)` answers `door-refusal` with a new `form` for each non-string: `number`, `date` or `array`. `readBooleanComparand` reads a `bigint` as the number it names, so `1n` / `0n` narrow like `1` / `0` and any other `bigint` is refused as a number. That is the number the comparand-type door rewrites a `bigint` to, so the answer no longer depends on which door met it first.
16+
- Three additive exports: `NON_BOOLEAN_VALUE_FORMS` (`number`, `date`, `array`) and the types `NonBooleanValueForm` and `NonBooleanComparandForm`. The refusal's `form` (on `BooleanComparandDoorVerdict`, `BooleanComparandRefusalSite` and `BooleanComparandDoorRefusalCase`) widens from `NonBooleanStringForm` to `NonBooleanComparandForm`, and the refusal site's `value` now carries a non-string. A consumer that switches over `form` exhaustively gains three cases.
17+
- `booleanComparandRefusalMessage` gains one clause per non-string form, and renders a `Date` as `Date(ISO)` and a non-finite number by name instead of as JSON.
18+
- `BOOLEAN_COMPARAND_DOOR_CASES` gains a `value` group: `-1` at `$ne` on every judged field, and `2`, a `Date` and an array at every judged position of `f_boolean` (no array at the equality slots, where the comparand-shape door refuses one first), plus the passing rows beside them. The `2` / `-1` reading rows, and a new `0.5` row, now derive refusals.
19+
20+
FROM a number other than `1` / `0` (`2`, `-1`, `0.5`), a `Date`, or an array where one value belongs (a scalar operator's comparand, or a member of `$in` / `$nin` / `$between`), compared against a `boolean` or `toggle` field (or a groupBy / `min` / `max` column of one in `having`) → TO `INVALID_FILTER` / 400, naming the field, its declared type, the comparand, its position and what is wrong with it. The fix is one line: send `true` or `false`, or `1` / `0`; to match either value use `$in`, each member a boolean.
21+
22+
**Unchanged.** Every string the verdict accepted or refused is answered as before, in the same words. A boolean, `null` and the flag operators (`$null`, `$exists`, `$empty`) pass. A value outside the accepted comparand types (`undefined`, a plain object, a `Map`) keeps the comparand-type door's own refusal and words, and a `{ $field }` reference is not judged. A comparand against a field that is not boolean is not this verdict's subject.

‎packages/objectql/src/boolean-comparand-declared-type-door.ts‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,15 @@
1919
* `1` matched no row on InMemoryDriver;
2020
* - any other string (`"yes"`, `"TRUE"`, `""`, a `{placeholder}`) is refused
2121
* `INVALID_FILTER` / 400, naming the field and its declared type, before
22-
* any driver is resolved.
22+
* any driver is resolved;
23+
* - [#21382] and so is a number other than `1` / `0`, a `Date` and an array
24+
* (at a scalar slot or as a list member) — before, each reached the drivers
25+
* as written: PostgreSQL answered `2` or a `Date` with a 500, the others
26+
* with an empty 200, and an array `$in` member split 200 / 400 across
27+
* drivers. A `bigint` is read as the number it names. The spec's verdict
28+
* was widened; this file changed only in these words, because the arm
29+
* already routed every comparand to it and carried the refused value as
30+
* written.
2331
*
2432
* The contract — the accepted spellings, the pure verdict, the refusal words,
2533
* the case table — is lane (1), `@objectstack/spec/data`'s
@@ -77,9 +85,11 @@ export function booleanArmFieldMeta(meta: BooleanComparandDoorFieldMeta | null):
7785
}
7886

7987
/**
80-
* One comparand at a judged position: the spec's verdict, routed. `aggregated`
81-
* is the walk's site fact — `having`'s columns are the aggregated row's, not a
82-
* declared field — and only ever written when true.
88+
* One comparand at a judged position: the spec's verdict, routed. Whatever the
89+
* comparand is — a string, a number, a `Date`, an array (#21382) — the verdict
90+
* alone decides; this function only turns its answer into an outcome.
91+
* `aggregated` is the walk's site fact — `having`'s columns are the aggregated
92+
* row's, not a declared field — and only ever written when true.
8393
*/
8494
export function judgeBooleanComparand(
8595
meta: BooleanComparandDoorFieldMeta,

0 commit comments

Comments
 (0)