Skip to content

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

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-20351-number-comparand-door
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-20351-number-comparand-door

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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/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 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 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 [#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 #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 #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 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.
  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

… 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>
… set (minor, breaking)

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
…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>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 20 documentable anchor(s).

5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via data.query (sdk, the route ledger binds it to POST /api/v1/data/:object/query))
  • content/docs/api/wire-format.mdx (via /api/v1/data/:object/query (route, a path literal in a comment on a changed line))
  • content/docs/data-modeling/queries.mdx (via /api/v1/data/:object/query (route, a path literal in a comment on a changed line))
  • content/docs/kernel/runtime-services/data-service.mdx (via data.query (sdk, the route ledger binds it to POST /api/v1/data/:object/query))
  • content/docs/protocol/objectql/query-syntax.mdx (via other_column (literal, a string literal in a comment on a changed line))
What this run could not see
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2b24b8b82304e925110800efb0e092845a931d16 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 00d4232fcd058e0e2ed54e9c609e838bae2c5f62 — the merge of head 09da7a4cc4024292cbd61b24373a13ab1ceb6c57 into base 2b24b8b82304e925110800efb0e092845a931d16, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2b24b8b82304e925110800efb0e092845a931d16 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 09da7a4cc4024292cbd61b24373a13ab1ceb6c57
Local-runs: none

Read for this record: card #20351 (body and all four comments: triage unlock 5873858750, the spec seat's $empty note 5874454700, the seat's claim 5875850679, the dev's report 5877278410), PR #20501 (body, the 9-file list, the net diff against main at the merge base 45f428d8f and the head's ten commits), the check-runs on the head (read first and again as the last step), and for the contract: #20336 with its thread, PR #20414 with its at-tier record 5868202485, ruling 5859414357 on #20280, and the head's own sources for every claim below (engine.ts, the door, having-filter.ts, filter-comparand-shape.ts, the spec module, rls-compiler.ts, security-plugin.ts, the lint rule and its hosts, native-sql-strategy.ts, ci.yml, AGENTS.md rule 3, the two changeset gates). Nothing was built, run or re-run.

① Derived judgments

The 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 $in / $nin / $between, is refused INVALID_FILTER / 400 where it used to reach the driver (memory and SQLite: no rows, every row under $ne; PostgreSQL: 500). One value rewrite: a numeric string is narrowed to its number before any driver. No public surface is added: the door module is imported by engine.ts alone and re-exported from neither index.ts nor core.ts (the two siblings are not either), and package.json's exports map is . and ./core only. RIGHT.

  1. The door consults the spec and nothing else. judgeComparand calls numberComparandDoorVerdict(meta, comparand) and routes its four verdicts; narrows keeps verdict.value (the spec's Number(value)), passes / deferred keep the comparand, door-refusal builds a NumberComparandRefusalSite and the words come from numberComparandRefusalMessage(site, context) through the existing invalidFilterError (code INVALID_FILTER, status 400, httpStatus 400). The operator sets are new Set(NUMBER_COMPARAND_DOOR_SCALAR_OPERATORS) and new Set(NUMBER_COMPARAND_DOOR_LIST_OPERATORS); the field gate is numberComparandFieldVerdict(meta) === 'judged'. No regex, no Number(), no re-listed type or operator in the door: git grep at the head finds the verdict consumed only here and in the spec. No private reading. RIGHT.
  2. Copy-on-write, and the spec's number. judgeFieldSpec, the list-member loop and walkCondition allocate (out ??= { ...spec }, members ??= [...comparand], arms ??= [...value], out ??= { ...node }) only along a changed path and return the caller's reference otherwise; narrowNumberComparands returns where by reference for a registry-less host and for a filter with nothing to narrow (pinned by the by-reference GUARD over six shapes and by the asWritten JSON check on every narrows case). The object-form return path in lowerWhereFilterArray keeps its old shape: the bag is replaced only when normalizeFilterComparandTypes(numeric) differs from the caller's where, which is exactly when either narrowing fired. RIGHT.
  3. Placement. Object form: after assertTemporalComparandsInterpretable, before normalizeFilterComparandTypes (engine.ts:1018). Array form: on the lowered condition after the temporal door (:1104). Per-aggregation filter: after the temporal door, rooted at aggregations[i].filter, and its result feeds the comparand-type door and assertAggregationFilterIsEvaluable (:16314, :16339). having: after assertHavingTemporalComparandsInterpretable, over havingColumnClasses from aggregatedRowColumnClasses (:16445), which classes count / count_distinct / sum / avg numeric and a groupBy or min / max column by its field's class, NUMERIC_VALUE_TYPES included. On having the door runs on the bigint-narrowed clause, so relative to the comparand-type door it sits after rather than before as on where; the two doors act on disjoint comparands (an exact-range bigint, a string) and the type door admits both strings and numbers, so no answer differs. RIGHT, and harmless.
  4. Every verb, before any driver. find (:11203), findOne (:11499), count (:16117), aggregate (:16221), update (:12955) and delete (:15600) call lowerWhereFilterArray before getDriver and before resolveWhereTokens and executeWithMiddleware; judgeFilter reaches it through judgeWhereAdmission (:1194, the same function). In aggregate the having door runs before getDriver (:16449). So a refusal resolves no driver on any verb, and a {placeholder} meets the door unresolved (pinned at where and having). The card's positions where, per-aggregation filter and having are all reached; the fourth, "RLS predicates compiled through the same point", is judged under 6. RIGHT.
  5. The having words. A numeric aggregated column is handed { type: 'number' }, so the message reads "filter on 'total' compares a declared number field against "abc" at having.total.$gt, which is not a number: it has no numeric reading, and backends answer it differently (PostgreSQL with a server error)". The load-bearing head is TRUE: the column name, the comparand, the path and the remedy. Two phrases are FALSE at this position: total is an aggregated column, not a declared field, and at having (as at the per-aggregation filter) the engine evaluates the clause on every driver, so PostgreSQL gave no server error there; that parenthetical is the where fact. The words are the contract's (FORM_SENTENCE and NumberComparandRefusalSite, whose only type slot is declaredType), packages/spec is out of this claim's surface by the seat's own bar, and the dev wrote no second copy. Accepted for this PR; the wording is a spec-lane follow-up, escalated under ③. RIGHT to leave the words where they live.
  6. The RLS premise. TRUE at the head, as the dev reports: plugin-security composes the compiled using onto opCtx.ast.where inside the middleware (security-plugin.ts:3724), which executeWithMiddleware runs after lowerWhereFilterArray, and judgeCompiledComparands (rls-compiler.ts:284) runs only assertListComparandShapes and normalizeFilterComparandTypes, the two field-agnostic faces, before filters.push. A policy predicate reaches this door at authoring: validateRlsPredicateEnforceability takes IObjectQLEngine['judgeFilter'], and the CLI (compile / lint / validate / scaffold), lint's runtime gate and service-analytics' plugin hand it. The temporal and text doors record the same posture in their own headers ("the middleware chain composes RLS / sharing / tenant predicates onto the AST, deliberately"). The card's phrase is conditional ("compiled through the same point"): what is compiled through the point is judged, and the runtime compile is a different seam where no field-aware door runs. Not a gap this PR owes; a family gap, escalated under ③.
  7. The four re-pins. engine-aggregate-having-temporal-door.test.ts: three rows that pinned "keeps no group" on total / n / mean become a refusal pin on both having paths with reads { aggregate: 0, find: 0 }. engine-aggregate-positions.test.ts: the "unknown token on count" row moves to customer_id, a text groupBy column (declared text, groupBy: ['customer_id']), where the resolver's FILTER_TOKEN_UNKNOWN still holds on having and its where twin, and the count-column placeholder is pinned in the new suite as the door's refusal. rest-aggregate-numeric-having.test.ts: three KEPT rows (no group) become a REFUSED table asserting code, status, path and 400 on engine and REST, native and rows; the extended-year ISO on mean is rightly the number door's, since mean is numeric and the string is not. data-query-having-temporal-door.test.ts: the string row becomes a number control ($gt 500 keeps c2 alone: c1 500, c2 1200, c3 50, c4 20) plus a refusal pin with no read. Every assertion that moved was the old silent answer; nothing else was loosened. RIGHT.
  8. $empty. Partitioned out of the engine drive by isStagedCase and pinned at the door alone (findNonNumericComparand null, narrowNumberComparands the same reference), with a GUARD that the table splits exactly and that the staged partition is one passes row; packages/spec is untouched. Both of the note's two ways, taken together. RIGHT per 5874454700.
  9. The pins and "400 everywhere". The refusal is thrown before any driver is resolved on every verb (4), so it is dialect-independent by construction; the committed CI-run witnesses are the objectql suite (a recording driver that records zero reads on 51 refusals, every verb, both spellings, the combinators, judgeFilter, the per-aggregation filter, having and the four findData doors) and the REST file's SQLite cell (nine where spellings, the per-aggregation filter, having on sum and max(currency) on both paths, reads.n 0). PostgreSQL is a live cell no CI job runs (ci.yml's Temporal Conformance job builds and runs driver-sql, metadata-protocol's live-mysql live-postgres files and runtime's cascade-delete file; no step provisions OS_TEST_POSTGRES_URL for @objectstack/rest); MySQL is a named skip. No test instantiates InMemoryDriver: memory's narrowing answer is pinned as the AST the driver receives ({ f_number: { $gt: 12 } } for "12", at every judged position and through the FilterArray sugar), not as a row count. The card's "400 everywhere" holds in the sense the door gives it (refused before any driver), with the SQLite cell and the recording driver as the CI witnesses; the PostgreSQL cell is measured locally only. Accepted, and the CI question is answered under ③.
  10. The case-table drive. 29 tests in the new objectql file (counted: 17 plain, two it.each over four doors, four GUARDs). The partition arithmetic (51 refusals, 23 narrows, 57 passes, 0 deferred, 5 formula, 1 staged) is feat(spec): the number-comparand declared-type door's contract and the platform's numeric grammar #20414's 136 plus feat(spec): declare the staged $empty filter operator and its per-type expansion (#20311) #20442's $empty row, with the five formula rows and the staged row moved out of the drive; consistent, not recounted. Every refused form and every judged position both ways are asserted by the GUARD, and the formula rows are pinned in the direction they answer (INVALID_FIELD from The FILTER axis has no unmaterializable verdict: a where on a virtual formula field returns 0 rows silently, while sort and search refuse the same field with a 400 #8296) with the door's own walk pinned by returnType. RIGHT.
  11. The door's scope guards. Undotted keys naming a declared field only; a registry-less host gets no verdict; an unrecognised $ combinator leaves its subtree ungated (a hole, pinned as deliberate); a { $field } reference and the flags are not judged; a non-string comparand passes. The same three conservative discards as the siblings. RIGHT.
  12. PR-body and changeset sentences. TRUE by reading: packages/spec untouched; the door reads no string; the collection-point placement and order relative to the temporal door; judgeWhereAdmission's docblock names the door (comment only); root exports unchanged; Fixes #20351 the only closing keyword; the branch merged main twice by true merge commits, merge base 45f428d8f; the changeset's level, Clause-② line, FROM → TO line and ADR-0087 arm, "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" (20263-*, 20264-* and 20309-* carry not-required (no-migration-prescription)); H2 (the RLS reading, judgment 6); H3 (the verdict, the words, the narrowing, and the pins assert the rewritten filter); H5 (the ObjectQL analytics strategy inherits the door through engine.aggregate and the wired judgeFilter, NativeSQLStrategy.canHandle declines a cross-field comparand and, by its recorded ruling, an uninterpretable temporal one, and no file in service-analytics or packages/lint/src names the number verdict); the $empty and formula partitions; the four re-pins; no skills/** path; the Temporal Conformance job's contents. NOT VERIFIED here and not load-bearing: the measured before-and-after table (memory, SQLite, PostgreSQL 16), the local suite counts, the typecheck and debt figures, the ablation legs and the 65-command gate union; the check-runs on the head are the gate verdicts, and the committed pins are what CI reruns.

Wrong: none found.

② Semver level

.changeset/20351-number-comparand-door.md: @objectstack/objectql minor, fix(objectql)!:, BREAKING, Clause-②: no (narrowing), a FROM → TO line with the one-line fix, and one ADR-0087 marker not-required (no-migration-prescription) with its argument (no authorable key moves, packages/spec untouched, no stored row rewritten, the other categories closed on facts). That matches what the diff publishes: an accept-set narrowing with no new export, which AGENTS.md rule 3 reads as no plus the (narrowing) arm, BREAKING; major is refused by check-changeset-no-major for the fixed group, so minor is the level; a breaking changeset must carry its disposition, and Check Changeset is success on the head. The card's execution note 3 (yes (narrowing)) was written while the contract exports sat with this card; they landed with #20414, and the claim 5875850679 amended the line to no (narrowing) with that reason. The PR body's line 2 and the changeset agree.

Clause-②: no (narrowing)

③ Boundary flags

Dev open_questions[0] (the REST live-dialect cells run in no CI job): B holds; the seat may still open A as its own card. The precedent 5859414357 (#20280, open question 2 → B) turned on a CI-run pin of the same read existing, in driver-sql under Temporal Conformance. Here a driver pin cannot reach an engine door, but the CI-run pins of the same behaviour exist all the same: the objectql recording-driver suite and the REST SQLite cell both witness the refusal before any driver, and the door's position makes the PostgreSQL cell a second reading of a fact that does not vary by dialect (the 500 was the symptom, the seam is above it; the narrowing on PostgreSQL is the driver's pre-existing numeric bind, 1 row before and after). So the live cell is a local instrument, as the file header says of itself in so many words, and this record names it un-run so it cannot read as coverage. A is a one-build-line, one-run-line ci.yml card that runs existing files, outside this claim's surface; the dev's "PostgreSQL is the production dialect" argument is fair, and the seat should decide it with the maintainer's "new gates default no" in view. Non-blocking either way.

Out-of-scope findings.

  1. A boolean or a Date compared against a number field answers 500 DATABASE_ERROR on PostgreSQL through POST /api/v1/data/:object/query and engine.find, on the base and on this branch (class a, reach measured at REST, local PostgreSQL 16). The feat(spec): the number-comparand declared-type door's contract and the platform's numeric grammar #20414 record asked 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 carry these cells and to file only if they answer 500; they do. The seat files it; the contract judges strings only, so it is not this PR's. The dev's dedupe words stand.
  2. NativeSQLStrategy.canHandle does not consult the number verdict (verified: no hit in service-analytics), so a raw-SQL analytics deployment compiles a non-numeric string itself. Carrier none, read at source, reach not measured. Noted; if the seat wants it carried, it is the analytics face's next arm in the family that 20010 / 20035 / 20040 began.
  3. No authoring rule reads numberComparandDoorVerdict (verified: zero hits under packages/lint/src), so a stored view or report filter with such a comparand saves clean and is refused at query time; RLS using is judged at authoring where the host hands judgeFilter. Carrier none, no named producer measured. Noted.
  4. The runtime RLS compile (judgeCompiledComparands) runs the two field-agnostic faces only, so a policy using that compares a number field with a non-numeric string is bound as written and answers PostgreSQL's 500 on every read of that object. Verified at source (judgment 6). The same seam already holds a field map when a fieldGuard is present (judgeCompiledFields runs on it), so a field-aware judgment is feasible there; the temporal and text doors share the gap. Recommended to the seat: one card for the three field-aware verdicts at the RLS compile seam, domain:engine or the security lane by triage. Not owed by this PR.

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 judgeWhereAdmission docblock: comment only, keeps the judge's documented pipeline true; within "its calls in engine.ts" in spirit and flagged as the letter asks; accepted. (4) Four test files edited: test side, inside the claim's objectql / REST test surface, each move judged in 7; accepted. (5) having words: judgment 5; accepted here, with a spec-lane follow-up: a site kind for an aggregated column ("a numeric aggregated column" rather than "a declared number field") and a not-a-number clause that does not name PostgreSQL at a position the engine evaluates. (6) $empty: judgment 8; accepted. (7) The local PostgreSQL server (started, used, stopped, data dir removed): process note; accepted. (8) The reproduction script run from packages/runtime's directory, read-only: process note, nothing of it in the diff; accepted. (9) Attribution: the nine non-merge commits carry Claude-Session and a model-free Co-authored-by, and the PR body ends with the session-URL footer, as AGENTS.md prescribes over the harness reminder; accepted.

Shape and gates. Draft PR, base main, first body line Fixes #20351, Clause-②: no (narrowing) on line 2, head branch the claim's, PR and card assignee os-warren. Nine files, +1219 / −16, all inside the claim's surface: the door, four hunks and an import in engine.ts, one changeset, two new suites and four re-pinned test files; no packages/spec, no driver package, no RLS code, no record-validator.ts. Check-runs on the head, read as the last step (2026-09-28, after 19:58Z): 32 runs, 23 success, 3 skipped (Build Docs, Console Pin Gate, the opt-in tarball smoke), 0 failures, and 6 in progress: Lint & Repo Gates, Test Core (1/6), (3/6), (4/6), (5/6) and Type Check · workspace. Completed and green: Build Core, Check Changeset, Governed Surface Queue Guard, the three claim guards, the four Type Check gates that finished, Temporal Conformance (live PG + MySQL), Test Core (2/6) and (6/6), Dogfood Regression Gate and Dogfood Verify CLI. The in-progress runs are recorded as in progress; the queue reads their final conclusions.

Implemented-by: claude/issue-20351-number-comparand-door
Reviewed-by: session_01N8TPEsoJxPsdSdNKGnNGEN

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 20:05
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit 4a1df19 Sep 28, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20351-number-comparand-door branch September 28, 2026 20:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants