Repository navigation
Commit b057434
Fixes #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 #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>
1 parent ba5927f commit b057434
8 files changed
Lines changed: 544 additions & 47 deletions
File tree
- .changeset
- packages
- objectql/src
- rest/src
- spec
- api-surface
- export-origins
- src/data
| 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 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
45 | 45 | | |
46 | 46 | | |
47 | 47 | | |
| 48 | + | |
48 | 49 | | |
| 50 | + | |
49 | 51 | | |
50 | 52 | | |
51 | 53 | | |
| |||
157 | 159 | | |
158 | 160 | | |
159 | 161 | | |
160 | | - | |
161 | | - | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
162 | 165 | | |
163 | 166 | | |
164 | 167 | | |
| |||
383 | 386 | | |
384 | 387 | | |
385 | 388 | | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
386 | 493 | | |
387 | 494 | | |
388 | 495 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
8 | | - | |
9 | | - | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
10 | 13 | | |
11 | 14 | | |
12 | 15 | | |
| |||
45 | 48 | | |
46 | 49 | | |
47 | 50 | | |
48 | | - | |
49 | | - | |
50 | | - | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
51 | 57 | | |
52 | 58 | | |
53 | 59 | | |
| |||
178 | 184 | | |
179 | 185 | | |
180 | 186 | | |
181 | | - | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
182 | 192 | | |
183 | 193 | | |
184 | 194 | | |
| |||
195 | 205 | | |
196 | 206 | | |
197 | 207 | | |
198 | | - | |
199 | | - | |
| 208 | + | |
200 | 209 | | |
201 | 210 | | |
202 | 211 | | |
| |||
292 | 301 | | |
293 | 302 | | |
294 | 303 | | |
295 | | - | |
| 304 | + | |
296 | 305 | | |
297 | 306 | | |
298 | 307 | | |
| |||
314 | 323 | | |
315 | 324 | | |
316 | 325 | | |
317 | | - | |
| 326 | + | |
318 | 327 | | |
319 | 328 | | |
320 | 329 | | |
| |||
0 commit comments