Repository navigation
fix(objectql)!: refuse a non-numeric string compared against a number field at the engine's filter door, and narrow a numeric one (#20351) - #20501
Conversation
… field at the engine's filter door WIP: the door module and its calls at the collection point (where, both spellings; the per-aggregation filter; having). Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…the engine Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…er SqlDriver Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
… set (minor, breaking) Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…mber-comparand-door
…answers A string on a sum / count / avg column was pinned as kept-no-group beneath the temporal door; the number-comparand door refuses it now, so the row moves to a refusal pin in that door's words. The unknown-token having row moves to a text column, which neither field-aware door judges. Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…d door now refuses Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
… number door's refusal now Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…mber-comparand-door
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 17 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 00d4232fcd058e0e2ed54e9c609e838bae2c5f62 && git checkout 00d4232fcd058e0e2ed54e9c609e838bae2c5f62
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2b24b8b82304e925110800efb0e092845a931d16 09da7a4cc4024292cbd61b24373a13ab1ceb6c57 && git checkout -B drift-repro 2b24b8b82304e925110800efb0e092845a931d16 && git merge --no-ff 09da7a4cc4024292cbd61b24373a13ab1ceb6c57
node scripts/docs-audit/affected-docs.mjs --json 2b24b8b82304e925110800efb0e092845a931d16
|
Contract reviewServed-tier: Read for this record: card #20351 (body and all four comments: triage unlock ① Derived judgmentsThe accept-set change the diff implies. One narrowing, at one seam: a string that the spec's numeric grammar does not read as a number, compared against a declared numeric field (or a numeric aggregated column) at the implicit comparand, the six scalar operators or a member of
Wrong: none found. ② Semver level
Clause-②: no (narrowing) ③ Boundary flagsDev Out-of-scope findings.
The nine deviations. (1) Driver pins: accepted, judgment 9. (2) The RLS premise false at runtime: TRUE at the head, accepted, finding 4 above. (3) The Shape and gates. Draft PR, base Implemented-by: VERDICT: PASS |
…ongs is refused INVALID_FILTER / 400 on every driver (objectstack-ai#20546) (objectstack-ai#20744) Fixes objectstack-ai#20546 Clause-②: no (narrowing) ## What this changes A plain object with no `$`-operator key where a scalar field's value belongs, for example `where: { amount: { a: 1 } }` on a `number` field, is now refused with `INVALID_FILTER` / 400. The refusal names the field, its declared type, the object's keys (never its values) and the path. It runs before any driver is resolved, on every driver, at the three positions the engine judges: `where` (object form and `FilterArray` sugar, on `find` / `findOne` / `count` / `aggregate` / `update` / `delete` and the judge-only `judgeFilter`), `aggregations[i].filter`, and `having`. **Landing site: the number-comparand door's walk, as a second arm. It adds no second traversal.** Triage said: "If the same walk is the natural site, it lands serially after that PR, in the same walk. ⛔ No second traversal of the filter." PR objectstack-ai#20545's walk (`walkCondition` in `number-comparand-declared-type-door.ts`) is the only filter walk the engine runs at all three positions with each column's declaration in hand. It already stood on the exact branch: a field spec with no `$` key, which it stepped past (`return kept(spec)`). It now asks one question per field key before the number arm runs: - `packages/objectql/src/no-operator-object-door.ts` (new) holds the arm's classification (`holdsScalarValues`), its structure test (`isNoOperatorObject`) and its words. ⛔ Nothing in it walks a filter. - `number-comparand-declared-type-door.ts`: the walk's per-key resolver now supplies two facts, the number arm's meta and the column's scalar-valued type. The first refusal the walk meets is either arm's. - `having-filter.ts`: `aggregatedRowColumnTypes` reads each aggregated column's type off the query. `aggregatedRowColumnClasses` is now derived from it, so the class and the type are one reading of the query. The `having` arm needs the type because the `text` class lumps a `json` or `lookup` groupBy in with a real text column. - `engine.ts`: the `having` call passes the types; the other hunks are comments. PR objectstack-ai#20738's warning-text region is untouched. **Which columns are judged (H3): a closed definition from spec's classes.** `SCALAR_FILTER_HEAD_TYPES` (spec's published "stores one scalar value" set, derived from the ADR-0104 value classes; the objectstack-ai#8371 dotted-head verdict reads the same set) with or without `multiple: true`, plus `MULTI_OPTION_TYPES`. The accepted side is never judged: relation types (`lookup`, `master_detail`, `user`, `tree`, single or multiple), structured-JSON types, file and media types (the objectstack-ai#8371 carve-out: a legacy stored value is an inline object), `formula` (refused one door earlier, `INVALID_FIELD`), undeclared keys, and unknown types. ## Before, measured on `origin/main` `fbec216e2d` Through `engine.find` / `engine.aggregate` and `POST /api/v1/data/:object/query` (both doors answered alike). Three rows (`amount` 5 / 12 / 30; `owner` u1 / u2 / u1 with u1 in region NA; `meta` `{a:1}` / `{a:2}` / `{b:1}`). InMemoryDriver, SqlDriver on SQLite (better-sqlite3), SqlDriver on a live PostgreSQL 16.13: | position · filter | InMemoryDriver | SQLite | PostgreSQL 16 | |:--|:--|:--|:--| | `where` `{ amount: { a: 1 } }` (number, the card) | 200, no rows | 400 `INVALID_FILTER`, the driver's words ("cannot be bound") | same as SQLite | | `where` `{ title: { a: 1 } }` (text) | 200, no rows | 400, the driver's words | 400, the driver's words | | `where` single select, boolean, date, autonumber, `multiple: true` select, `multiselect`, `tags` | 200, no rows | 400, the driver's words | 400, the driver's words | | `where` `{ $not: { amount: { a: 1 } } }` | 200, **every row** | 400 ("not one this driver evaluates") | same | | `where` `{ $or: [{ amount: { a: 1 } }, { amount: 30 }] }` | 200, 1 row | 400 | 400 | | `where` sugar `[['amount', '=', { a: 1 }]]` | 200, no rows | 400 | 400 | | `where` `{ amount: {} }` | 400, the objectstack-ai#5240 words | 400, the objectstack-ai#5240 words | same | | `aggregations[1].filter` `{ amount: { a: 1 } }`, `{ title: { a: 1 } }`, `{ amount: {} }` | 200, count 0 | 200, count 0 | 200, count 0 | | `having` `{ total: { a: 1 } }` (a `sum`), `{ title: { a: 1 } }` (a groupBy), `{ total: {} }` | 200, no group | 200, no group | 200, no group | | control: `where` `{ owner: { region: 'NA' } }` (lookup; `master_detail` and a multiple lookup alike) | 200, no rows | 400, the driver's words | same | | control: `where` `{ meta: { a: 1 } }` (json) | 200, 1 row (deep equality) | 400, the driver's words | same | | control: `where` `{ amount: { $gt: { $field: 'cap' } } }` | 200 | 200, 2 rows | 200, 2 rows | ## After, the same run on this branch Every non-control row above answers `400 INVALID_FILTER` in the engine's words on all three drivers, at the path the object sits at (`where.amount`, `where.$not.amount`, `where.$or[0].amount`, `aggregations[1].filter.amount`, `having.total`). No read of the object runs. Every control answers exactly as before: the lookup, master-detail, multiple-lookup and JSON filters reach the driver as written, and so do the file field, the `$field` reference, the undeclared key and the `id` key. Example of the words: ```text find('rp_ledger_20546'): filter on 'amount' puts an object with no operator key (keys "a") at where.amount, where a value of the declared number field 'amount' belongs. An object with no "$" operator is filter structure, not a value: beneath a field it is a nested-relation condition, which only a relation field (lookup, master-detail, user, tree) can carry, or a whole-value match, which only a JSON-bearing field can hold. A number column holds scalar values — one, or a list of them — so no record can match an object there, and an empty answer would read exactly like a real one. The filter was NOT applied. Compare 'amount' with a value ({ "amount": VALUE }) or an operator ({ "amount": { "$eq": VALUE } }). ``` ## Hypotheses (zone 2), which held - **H1: held, with one refinement.** `lowerWhereFilterArray` is the seam, and `narrowNumberComparands` is called there on both branches (the object branch and the lowered array branch). The number door's walk was number-specific only at its per-field gate (`numberComparandFieldVerdict(meta) !== 'judged'`), and its `where` resolver already returned every declared field's type. The text door and the temporal door each walk too, but neither runs at `having` with a column declaration, so neither covers every position. The number door's walk is the one walk that does. The arm rides it, and no traversal was added. - **H2: held, and all three positions are reached.** Measured above: `where` answered per driver, and `aggregations[i].filter` and `having` answered a silent empty on every driver. Each is pinned. - **H3: refined.** The closed definition is above. Multi-value fields were measured on their own: a `multiple: true` select, `multiselect` and `tags` split exactly as a scalar field does (memory 200 no rows, SQL 400). That includes `{ tags: { 0: 'x' } }`, the spelling the objectstack-ai#8371 multi-value carve-out exists for: the nested-object form does not reach an array member on InMemoryDriver. So they are judged. A multiple lookup stays on the relation side. A `{ $field }` reference carries a `$` key, so it is never this arm's (measured: served 2 rows on SQL, as before). - **H4: held.** No driver file changes (`git diff fbec216 HEAD -- packages/drivers` is empty). The SQL driver's own `INVALID_FILTER` stays as defence in depth for driver-direct callers and for the columns this arm does not judge (the lookup and JSON controls above still meet it). ## Tests All from `b50627aca9` or from a commit whose non-test source is byte-identical to it (the last two commits touch only the changeset). - `pnpm --filter @objectstack/objectql test`: **338 files / 6704 tests passed**. `test:repo`: 1 file / 5 passed. - `pnpm --filter @objectstack/objectql typecheck`: exit 0 (`check:test-typecheck` OK, the debt ledger held). - `pnpm --filter @objectstack/rest typecheck && pnpm --filter @objectstack/rest test`: **229 files / 4391 passed / 63 skipped** (the live-dialect cells, no URL set). - New pin `packages/objectql/src/engine-no-operator-object-door.test.ts` (17 tests). It uses a recording driver, which is InMemoryDriver's cell by construction because the arm answers before a driver is resolved. It covers every scalar class, `{}`, every verb and the judge, `$and` / `$or` / `$not` paths, sugar, the three REST doors into `findData`, the per-aggregation filter, `having` (sum, groupBy, max of a date, a month bucket), the accepted side at all three positions, a `Map` and the classification GUARD over every `FieldType`. - New pin `packages/rest/src/data-no-operator-object-door.test.ts`: SQLite always, PostgreSQL and MySQL where `OS_TEST_POSTGRES_URL` / `OS_TEST_MYSQL_URL` are set. `where` refusals, the per-aggregation filter, `having`, and the **two controls** (a lookup nested-relation filter and a JSON object comparand: the driver is asked, and the answer is never the arm's). Local run with a live PostgreSQL 16.13: **8 passed (sqlite 4, live postgres 4) / 4 skipped (mysql, no URL)**.⚠️ As with the sibling door suites, no CI job sets these URLs for `@objectstack/rest`, so the live cells run only locally. - Consumer suites (downstream of `@objectstack/objectql`): `service-analytics` 137 files / 3216 passed; `plugin-security` 147 files / 3202 passed / 23 skipped. The other downstream consumers are declared to CI. **Reverse verification (ablation), from the committed fix.** It ran through `scripts/ablation-replace.mjs` (WRAP mode, trap-restored). The anchor `if (facts.scalarType !== null && isNoOperatorObject(value)) {` was replaced by `if (facts.scalarType === '__ablated_20546__' && …) {`. On disk the anchor went 1 → 0 and the marker 0 → 1, with blob `16151b29f6c1` → `0e8acf882100`. Then objectql was rebuilt and `ablation-dist-preflight` reported the marker present in 4 built files. Predicted direction: red. Observed: red. The objectql pin went **10 failed / 7 passed**: every refusal case failed, and every control and GUARD stayed green. The rest pin went **4 failed / 4 passed / 4 skipped**: the `where` and aggregate refusals failed on SQLite and live PostgreSQL, and the controls stayed green. Restore leg: blob equals HEAD (`16151b29f6c1`), `git diff HEAD` empty, the whole-tree `git status --porcelain` empty, rebuilt, `--absent` preflight (marker absent from all 14 built files), then both pins green again (17 / 17; 8 passed + 4 skipped). ## Gates `node scripts/pm/dispatch-gates.mjs --commands` at `b50627aca9` derived 65 commands. All were run on `b50627aca9`, and `--ran` reconciles them: **65 derived, 63 run, 2 NOT-MEASURED, 0 UNRUN**. 63 exit 0, including `check:adr-0087-registration --base origin/main` (`not-required (no-migration-prescription)` accepted), `check:changeset-no-major`, `check:empty-changeset`, `check:doc-authoring`, `check:nul-bytes`, `check:engine-double-contract`, `check:where-matcher`, `check:driver-memory-census`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:type-check-coverage` and `check:query-options-erasure`. - NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt`. Reason: each exits 3 (PREREQUISITE NOT MET) because it reads the built closure of every package, and this box built only the objectql/rest closure. CI's `Lint & Repo Gates` builds that closure. - Driver-related families read before (on `fbec216e2d`, a detached comparison worktree) and after (on `b50627aca9`): - `check:where-matcher`: 440 matchers, 440 correct or loudly refusing, before and after. - `check:driver-memory-census`: 12 bindings / 2 ruled consumers, before and after. - `check:engine-double-contract`: pinned rows 825 → 825 and discovered files 953 → 953. Test files went 4231 → 4233 and production files 2997 → 2998, which are the two new tests and the new module. No new fake engine. - Lint, narrowed and proven: `pnpm exec eslint --no-inline-config --format json` over the 7 changed `.ts` files, at `b50627aca9`, found **7 files, 0 errors, 0 warnings**. The checked population comes from eslint's own config: `calculateConfigForFile` answers `isPathIgnored=false` for all 7. The file count comes from the JSON output (7 results). Untouched files cannot change verdict: `parserOptions.project` and `projectService` are `null` for every file, so type-aware linting is not enabled and this diff cannot move any untouched file's lint result. ## Changeset `.changeset/20546-no-operator-object-on-scalar.md`: `@objectstack/objectql` `minor`, a BREAKING banner, `Clause-②: no (narrowing)` and the ADR-0087 marker `not-required (no-migration-prescription)`, following the objectstack-ai#20501 / objectstack-ai#20545 precedent. Its "Who is affected" section names a caller that sends the shape to the in-memory driver: a test suite, a local or embedded deployment on `InMemoryDriver`, or a flow or hook calling the engine in-process. No export or published type changes: the door modules are internal, and `@objectstack/objectql`'s root and `./core` exports are unchanged. ## Acceptance notes - **Out of scope, reported to the PM, not filed:** the two controls still answer two ways, because this card's direction keeps them accepted. `{ owner: { region: 'NA' } }` on a `lookup` gives memory 200 with no rows and SQL 400. `{ meta: { a: 1 } }` on a `json` field gives memory 200 with 1 row and SQL 400. So does the undeclared `id` key (`{ id: { a: 1 } }`: memory 200 no rows, SQL 400), because the registry's declared map carries no `id`. Spec's `FilterCondition` declares the nested-relation form, but no data-path driver serves it. objectstack-ai#20546 is not the card for that. - File and media fields keep the objectstack-ai#8371 carve-out and stay unjudged. `{ photo: { url: 'x' } }` answered memory 200 with no rows (on fresh rows) and SQL 400. A legacy inline value could still match on memory. - At `where`, a `{}` under a judged column is now answered in the engine's words instead of each driver's objectstack-ai#5240 words, with the same `INVALID_FILTER` / 400 envelope. Under a column this arm does not judge, `{}` keeps the drivers' refusal. - `findNonNumericComparand` (internal, tests only) still answers the number arm alone. When the walk's first refusal is the new arm's, it answers `null`, and its docblock says so. --- _Generated by [Claude Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20351
Clause-②: no (narrowing)
What this adds
Lane (2) of the two-lane route #20336 took on #15661's precedent: the engine door that consults the contract PR #20414 published in
@objectstack/spec/data(filter-number-comparand-declared-type.ts). The contract half is untouched;packages/specis 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 asksnumberComparandDoorVerdictand routes the answer:door-refusal: throwsINVALID_FILTER/ 400 (the existinginvalidFilterErrorenvelope) 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_TYPESby 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 (beforenormalizeFilterComparandTypes) and array form (on the lowered condition). Sofind/findOne/count/aggregate/update/deleteand the judge-onlyjudgeFilter(judgeWhereAdmissioncalls the same function) all inherit it;filter, rooted ataggregations[i].filter, against the object's declared fields;having, after the temporalhavingdoor, over the columnsaggregatedRowColumnClassesclassesnumeric(count/sum/avg, and a groupBy ormin/maxof a numeric field).The
judgeWhereAdmissiondocblock's pipeline list names the new door (comment only).A changeset,
.changeset/20351-number-comparand-door.md:@objectstack/objectqlminor, BREAKING,Clause-②: no (narrowing), a FROM → TO line, and the ADR-0087 dispositionnot-required (no-migration-prescription)in the form PR fix(core,objectql)!: a date or datetime names a year from 0001 to 9999, refused at the comparand door and the write door (#20264) #20469 and PR fix(objectql)!: a number field refuses an array, a boolean or an object with invalid_number (#20309) #20370 used.@objectstack/objectql's root exports are unchanged: the door module is not re-exported fromindex.tsorcore.ts, like its two siblings.What it does to the card's three answers
Measured through
engine.find/engine.aggregateandPOST /api/v1/data/:object/query, three rows (5, 12, 30), on InMemoryDriver, SqlDriver on SQLite and SqlDriver on a local PostgreSQL 16.13 server:numberfield3062e5001: memory · SQLite · PostgreSQLwhere$gt/$eq/ implicit / a$inmember"abc"DATABASE_ERRORINVALID_FILTERwhere$ne "abc"where$eq ""where, REST$gt "{current_user_id}"(resolved to the user's id)filter$gt "abc"/$ne "abc"havingonsum(amount)$gt "abc"/$ne "abc"where$gt "12"/$eq "12"$gt 10(the numeric control)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.
judgeFilterpasses through it:judgeWhereAdmissioncallslowerWhereFilterArray(pinned:judgeFilteranswersINVALID_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, andplugin-security'sjudgeCompiledComparandsruns only the two field-agnostic faces (rls-compiler.ts, the[#20212]block). A policy predicate reaches this door at authoring instead:validateRlsPredicateEnforceabilityasks the engine'sjudgeFilterwhen the host hands the rule a judge.H3 holds. The verdict is
numberComparandDoorVerdictoverNUMBER_COMPARAND_DOOR_JUDGED_TYPESwith the scalar and list operators, and the words arenumberComparandRefusalMessage. A numeric string is narrowed to its number (the verdict'snarrows, 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'swhereintoengine.aggregateand asksjudgeFilterabout 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 compilesamount > 'abc'itself (read at source, not measured).usingis judged throughjudgeFilter, as above. No lint rule readsnumberComparandDoorVerdict(git grepoverpackages/lint/srcfinds 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
$emptyrow: pinned at the door aloneNUMBER_COMPARAND_DOOR_CASEScarries PR #20442'sunjudged$emptyrow. The engine suite partitions it out of the end-to-end drive and pins it at the door alone:findNonNumericComparandanswersnull, andnarrowNumberComparandsreturns 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
formularows 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 #8296 materializable door, one door earlier). The door's own walk is pinned to judgef_formula_numberby itsreturnType.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 realObjectQLand a recording driver, per the contract header:f_formula_numberrow), assertingcode+status+httpStatus, everymustMentionsubstring, and no driver read. All 8 refusal forms and every judged position are covered, both ways;narrowscases, asserting the driver receivesc.expectedFilter()and the caller's filter is untouched;passescases, reaching the driver unchanged;$empty(1) partitions above.Beside the table:
FilterArraysugar, both refused and narrowed;$and/$or/$not;judgeFilter;filter, refused at its path, with numeric strings counting what their numbers count;havingoncount/sum/ a numericmin, refused, narrowed, and a placeholder oncount;findDatadoors (whereobject,$filter, filter AST, implicit query parameter), both ways;New:
packages/rest/src/data-number-comparand-door.test.ts. It runsPOST /api/v1/data/:object/queryandengine.find/engine.aggregateover SqlDriver, with a cell per dialect:where: 9 refused spellings;filter;havingonsumandmax(currency), on the native and the rows path;The SQLite cell always runs. The PostgreSQL cell ran against the local server: 3/3 passed at⚠️ No CI job provisions
09da7a4cc.OS_TEST_POSTGRES_URLfor@objectstack/rest. TheTemporal Conformance (live PG + MySQL)job runsdriver-sql's suite,metadata-protocol'slive-*files and oneruntimefile, and adriver-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 fromKEPTto aREFUSEDtable, 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 typecheckandpnpm --filter @objectstack/rest typecheck: exit 0.check:test-typecheckis 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 withscripts/ablation-replace.mjs: anchor 1 → 0, and blobaa4a3247→56a5bdf6.pnpm --filter @objectstack/objectql build,ablation-dist-preflightfound 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 answered200withrecords: [], the PostgreSQL cell500 DATABASE_ERROR, and the per-aggregation$in ["5","30"]counted 0 instead of 2: the card's defect, back.aa4a3247= HEAD andgit diff HEADis empty. After a rebuild,ablation-dist-preflight --absentfound 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/objectstackat09da7a4ccderives 65 commands, the same list as at the first merged head. All 65 ran with each exit code recorded before any pipe:check:dual-build-cjs-loadsandcheck:type-check-debtanswered 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 mergedorigin/maintwice with true merge commits, no rebase and no force-push; the last merge base is45f428d8f.Acceptance notes
havingwords. A numeric aggregated column has no declaredFieldType, so the door hands the verdictnumber(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". Thenot-a-numberclause ("backends answer it differently (PostgreSQL with a server error)") is thewherefact:havingis 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.Datecompared against a number field is not judged (the contract judges strings).$gt true: no rows on memory, every row on SQLite, 500 on PostgreSQL. ADate: no rows · no rows · 500. Both hold on the base and on this branch. Handed to the seat below.driver-mongodb(the door sits in front of it); the NativeSQL analytics path (read at source).skills/**path in the diff).Out of scope, handed to the seat (not filed by this dev)
Datecomparand against a number field answers500 DATABASE_ERRORon PostgreSQL. It isPOST /api/v1/data/:object/querywithwhere: { amount: { $gt: true } }against anumberfield, on a local PostgreSQL 16 server, on the base and on this branch. The contract review of PR feat(spec): the number-comparand declared-type door's contract and the platform's numeric grammar #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.NativeSQLStrategy.canHandledoes 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.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.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