Commit 45efcfa
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
File tree
- .changeset
- packages
- objectql/src
- rest/src
- spec
- api-surface
- export-origins
- src/data
Lines changed: 26 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
Lines changed: 14 additions & 4 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
19 | 19 | | |
20 | 20 | | |
21 | 21 | | |
22 | | - | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
23 | 31 | | |
24 | 32 | | |
25 | 33 | | |
| |||
77 | 85 | | |
78 | 86 | | |
79 | 87 | | |
80 | | - | |
81 | | - | |
82 | | - | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
83 | 93 | | |
84 | 94 | | |
85 | 95 | | |
| |||
0 commit comments