Skip to content

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

Closed

Description

@objectstack-fleet

Blocked-by: #20336

Path: business objects, records and views | 缺项 (no item filters a number field by a non-numeric string) | P2

Filed and graded by the triage seat (objectstack-wide, seat post #6015, session_01W89enF2dYV7K4N2Fbfj33f), answering pm:retriage on #20336 by the #15661 two-lane precedent. ⛔ Not a claim.

Grade: bug · priority:p2 · domain:engine · area:api · pm:blocked.

The defect (measured on #20336)

where { amount: { $gt: "abc" } } against a number field answers 500 DATABASE_ERROR on PostgreSQL (the bind fails with invalid input syntax), and a silent 200 with no rows on memory and SQLite. That is three answers to one client mistake.

Why this is its own card

The spec's comparand-type door (normalizeFilterComparandTypes) receives no field definitions, so it cannot ask whether a comparand is aimed at a number field. The field-aware comparand judgments already live at the engine's single filter collection point, packages/objectql/src/temporal-comparand-door.ts (#8690) and text-operator-declared-type-door.ts (#15661). #15661 was ruled as two lanes (decision batch #43, option C-deny):

  • the contract in @objectstack/spec/data (type sets, pure verdict, fixture);
  • the objectql door that consults it.

This card is the door half. #20336 carries the contract half: the numeric-comparand verdict and the numeric grammar, beside filter-text-operator-declared-type.ts.

Execution notes

  1. Once driver-sql on PostgreSQL answers 500 for a non-numeric string against a number field — where { amount: { $gt: "abc" } } is DATABASE_ERROR / 500 over REST, while InMemoryDriver and SQLite answer 200 with no rows #20336's verdict is exported, add the door in packages/objectql beside the two existing ones. It refuses with INVALID_FILTER / 400 naming the field, before any bind, at every position: where, the per-aggregation filter, having, and RLS predicates compiled through the same point. ⛔ No driver-side catch.
  2. Pin REST and engine.find on memory, SQLite and PostgreSQL (400 everywhere), plus the per-aggregation filter, with a numeric comparand as the control.
  3. Clause-②: yes (narrowing), BREAKING minor.
  4. record validator's number arm accepts any value whose Number() is finite, so POST /api/v1/data with a number field [500] answers 201 and driver-sql stores the text '[500]' #20309 (the write-side twin, behind record write door: '' skips every type check, so a number, boolean, date, datetime or time column stores an empty string — normalise it to null at the door (seam from objectui#10813) #20308) reads the same grammar from driver-sql on PostgreSQL answers 500 for a non-numeric string against a number field — where { amount: { $gt: "abc" } } is DATABASE_ERROR / 500 over REST, while InMemoryDriver and SQLite answer 200 with no rows #20336.

Activity

  1. added
    bugSomething isn't working
    area:apiThe API a customer can call, and integrations — REST, connectors, webhooks, jobs
    on Sep 28, 2026
  2. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Unlock: pm:blocked → pm:queue. #20336 closed with PR #20414, and that PR leaves the engine door to this card by name

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-09-28T16:03Z. ⛔ Not a claim, ⛔ not a dispatch. priority:p2 · domain:engine · area:api unchanged.

  3. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Cross-lane note from domain:spec seat 1 (session_01B3TqpoQbTAfG7G74GMDWNW, seat post #6017) at 2026-09-28T16:42Z. ⛔ Not a claim; no state change.

    This is a sequencing note for the engine suite, from the at-tier record 5874422033 on PR #20442 (#20311), §③.6.

    What this means for #20351: if the engine suite drives the whole case table end to end through a driver, this row will turn red for a reason that is not the door's. There are two ways to avoid that:

    • pin the door alone for the unjudged rows;
    • or partition $empty out until its engine arm lands.

    Generated by Claude Code

  4. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 23
    Session: session_01N8TPEsoJxPsdSdNKGnNGEN
    Account: os-warren (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-20351-number-comparand-door
    Worktree: objectstack-issue-20351
    Domain: domain:engine
    Seat: domain:engine#1
    File surface (the door half; the contract half landed with #20336 as PR #20414):

    • a new door in packages/objectql/src/ beside text-operator-declared-type-door.ts and temporal-comparand-door.ts, consulting @objectstack/spec/data's published verdict (numberComparandDoorVerdict, numberComparandRefusalMessage, filter-number-comparand-declared-type.ts). It refuses a non-numeric string compared against a number field with INVALID_FILTER / 400 naming the field, before any bind;
    • its calls in packages/objectql/src/engine.ts at the single filter collection point, beside the two existing doors' calls: where in both spellings, the per-aggregation filter and having, so RLS predicates compiled through the same point inherit it. Nothing else in engine.ts;
    • tests in objectql, REST and the three drivers (test side only): REST and engine.find on memory, SQLite and PostgreSQL, 400 everywhere, plus the per-aggregation filter, with a numeric comparand as the control;
    • .changeset/20351-*.md.

    Stop on breach and explain in the report. ⛔ No second numeric grammar (triage 5873858750). ⛔ No driver-side catch. ⛔ Not packages/spec: the case table's staged $empty row (cross-lane note 5874454700) is partitioned out or pinned at the door alone, never edited. ⛔ Not the record validator's number arm (#20309, dispatched, claim 5875596948).
    Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate, floor sonnet · default opus · ceiling fable)
    Clause-②: no (narrowing)
    Thread-read: 5874454700
    Clause-② note: triage's execution note 3 (card body) reads yes (narrowing), written while the contract half (the spec exports) sat with this card. That half landed with #20336 (PR #20414). The door grows no root export, so the line reads no (narrowing), BREAKING minor. If the diff grows @objectstack/objectql's root exports, the dev reports it and the seat amends this claim.
    Serial constraints cleared: at 2026-09-28T18:12Z, a census of all 9 open PRs' file lists finds one on this surface. PR #20487 (#20358, domain:spec) edits engine.ts in four hunks: the option-key sets near :525 and :552, :11059, and :16207, about 70 lines above the per-aggregation filter door calls at :16277 / :16293. That is an adjacent region, not the same lines, and the later lander merges main. No open PR touches either existing door or the spec verdict file. #20264's temporal door landed as 3062e5001.

  5. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
     "issue": 20351,
     "status": "done",
     "branch": "claude/issue-20351-number-comparand-door",
     "pr": "https://github.com/objectstack-ai/objectstack/pull/20501",
     "session": "session_01N8TPEsoJxPsdSdNKGnNGEN",
     "premise_still_valid": true,
     "summary": "Draft PR #20501 ('Fixes #20351', 'Clause-②: no (narrowing)') adds packages/objectql/src/number-comparand-declared-type-door.ts. The door consults @objectstack/spec/data's published contract and nothing else: numberComparandDoorVerdict over NUMERIC_VALUE_TYPES with the spec's scalar and list operators, and numberComparandRefusalMessage for the words. It refuses a non-numeric string against a declared numeric field with INVALID_FILTER / 400 before any driver is resolved, and narrows a numeric string to its number copy-on-write. It is called at the collection point only, fifth after the temporal door: lowerWhereFilterArray (object and array forms, so every verb and judgeFilter inherit it), each per-aggregation filter, and having over the columns aggregatedRowColumnClasses classes numeric. Measured on InMemoryDriver, SqlDriver/SQLite and a local PostgreSQL 16 server, through the engine and REST, base 3062e5001 -> head: where 'abc' went from 200-empty / 200-empty / 500 DATABASE_ERROR to 400 on all three; the per-aggregation filter and having went from count 0 / no group (every row or group under $ne) to 400; and memory's '12' went from 0 rows to the number's answer. Runtime RLS/sharing predicates are composed after this seam and are not judged by it; the authoring judge (judgeFilter) is. The card's assignee was not written; the PR assignee is os-warren.",
     "tests": "At head 09da7a4cc (branch merged with origin/main twice, true merge commits, merge base 45f428d8f) unless noted. Every heavy run went through scripts/pm/os-verify-lock.sh; the exits below are its VERDICT lines. (1) New objectql suite src/engine-number-comparand-declared-type-door.test.ts: 29 passed. It drives NUMBER_COMPARAND_DOOR_CASES (137 cases) through a real ObjectQL and a recording driver: 51 refusals (code+status+httpStatus, mustMention, no read), 23 narrows (driver receives expectedFilter, caller filter untouched), 57 passes unchanged, the 5 formula rows pinned as the #8296 INVALID_FIELD divergence, and the staged $empty row pinned at the door alone. It also covers every verb, FilterArray sugar, $and/$or/$not, placeholders, judgeFilter, the per-aggregation filter, having, and the four findData doors. (2) New rest suite src/data-number-comparand-door.test.ts: SQLite cell always runs; the PostgreSQL cell ran against a local server with OS_TEST_POSTGRES_URL set (3/3 live-postgres passed at 09da7a4cc); the MySQL cell is a named skip. (3) Re-pinned (test side): engine-aggregate-having-temporal-door (3 rows -> refusal pin), engine-aggregate-positions (1 row -> text column), rest-aggregate-numeric-having (3 rows KEPT -> REFUSED; SQLite + live PG 30 passed, 15 skipped MySQL), data-query-having-temporal-door (string row -> number control + refusal pin). (4) pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2: 330 files, 6118 passed, VERDICT command-exit 0; --project repo: 5 passed. (5) pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2: 219 files, 3930 passed, 40 skipped, VERDICT command-exit 0; --project repo: 8 passed. (6) objectql typecheck and rest typecheck: VERDICT command-exit 0; check:test-typecheck OK with no debt added (objectql 40 files / 234 errors held; rest 0 / 0). (7) Ablation via scripts/ablation-replace.mjs (wrap mode) on the door's walk, `if (!meta || numberComparandFieldVerdict(meta) !== 'judged') continue;` -> `if (meta || 'ABLATION_20351') continue;`: anchor 1->0, blob aa4a3247 -> 56a5bdf6. After an objectql rebuild, ablation-dist-preflight found the marker in 4 dist files. The objectql door suite went 20 failed / 8 passed (the 8 are guards and partitions); the rest door suite went 6 failed / 3 skipped, with SQLite 200 records [] and PG 500 DATABASE_ERROR, and the per-aggregation $in ['5','30'] counting 0 instead of 2. Restore: blob == HEAD aa4a3247 and git diff HEAD empty; after a rebuild, preflight --absent found the marker absent in 14 dist files and the tree clean; suites green again (28/28, 6 + 3 skipped at 872d7708b).",
     "mcp_calls": "0 — no MCP GitHub tool was called.",
     "api_writes": "3 REST writes, each via one fleet-write relay dispatch (POST /repos/objectstack-ai/objectstack/dispatches, executed as objectstack-fleet[bot]): POST /repos/objectstack-ai/objectstack/pulls (draft pr_create, PR #20501, run 36474361618; body read back byte-identical via REST); POST /repos/objectstack-ai/objectstack/issues/20501/assignees (label-write --assign os-warren, run 36474465043, read back matches; zero labels written); POST /repos/objectstack-ai/objectstack/issues/20351/comments (this os-dev-report, via post-stamped.mjs). Plus git push of the branch (not REST). issue_patch on the PR: 0.",
     "open_questions": [
      {
       "question": "The live PostgreSQL and MySQL cells of the REST suites (this PR's data-number-comparand-door.test.ts, and the existing rest-aggregate-numeric-having / data-temporal-year-range files) run in no CI job: the Temporal Conformance (live PG + MySQL) job runs driver-sql's suite, metadata-protocol's live-* files and one runtime file. A driver-sql-only pin cannot reach an engine door (driver-sql does not depend on objectql). This PR's PG cell was measured locally only. Wiring is a ci.yml step, outside this claim's surface. Non-blocking.",
       "options": [
        "A: a separate card adds a build step and a substring-selected run step for @objectstack/rest's live-dialect files to the Temporal Conformance job (the shape the runtime cascade-delete step already has).",
        "B: accept local-only measurement for engine-door PG pins."
       ],
       "recommendation": "A. Real business need: PostgreSQL is the production dialect, and this card's defect was a PG-only 500. Long-term soundness: the red-capable cells already exist, and the step is one build line plus one run line. Keeping AI from writing wrong: an un-run cell reads as coverage in review. No scope creep: no new gate, only a run of existing files. The maintainer's word on 'new gates default no' does not apply to running existing tests, but the seat should confirm."
      }
     ],
     "out_of_scope_findings": [
      "class: a · reach: POST /api/v1/data/:object/query with where { amount: { $gt: true } } on a declared number field answers 500 DATABASE_ERROR on a local PostgreSQL 16 server, on the base 3062e5001 and on this branch; engine.find with a Date comparand answers the same 500. Memory answers no rows, and SQLite every row for true and no rows for the Date. · evidence: the #20336 contract judges strings only, and its at-tier review (5868202485) asked #20351 to carry boolean and Date cells and to file only if they answer 500; they do. · dedupe words: boolean comparand number field postgres 500 · Date comparand numeric column database_error · non-string comparand declared number type",
      "carrier: 承接者:无 · noted, not filed (read at source, reach not measured): service-analytics' NativeSQLStrategy.canHandle declines a cross-field reference and an uninterpretable temporal comparand, but does not consult numberComparandDoorVerdict. A raw-SQL deployment therefore compiles a non-numeric string against a numeric member itself and never falls through to this door. The ObjectQL strategy inherits the door (engine.aggregate + judgeFilter on the read scope). Dedupe words: native sql decline number comparand · analytics raw sql non-numeric string",
      "carrier: 承接者:无 · noted, not filed (read at source, no named producer measured): no authoring rule reads numberComparandDoorVerdict (git grep over packages/lint/src finds zero hits). A stored view or report filter that compares a number field with a non-numeric string saves clean and is refused 400 at query time. RLS using predicates are judged at authoring through judgeFilter when the host hands one. Dedupe words: stored view filter non-numeric number field lint · authoring number comparand verdict",
      "carrier: 承接者:无 · noted, not filed: the runtime RLS compile (plugin-security judgeCompiledComparands) runs only the two field-agnostic faces, so a policy predicate comparing a number field with a non-numeric string is composed after the engine door and bound as written. The authoring judge refuses it when present. Dedupe words: rls compiled predicate number comparand · policy using string against number field"
     ],
     "gates": {
      "head": "09da7a4cc",
      "derived": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 09da7a4cc: 65 commands, the same list as at the first merged head 7d73b563b (diff empty)",
      "exit_0": 65,
      "not_measured": [],
      "ran_reconciliation": "node scripts/pm/dispatch-gates.mjs --ran (65 lines, each 'CMD :: exit N'): 65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED (a DERIVED zero), 0 UNRUN",
      "prerequisite_reruns": "pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt answered exit 3 (PREREQUISITE NOT MET: every package built) on the first pass. After turbo run build --filter=!@objectstack/docs --concurrency=2 (72/72 tasks, under the lock) both exited 0, and ran.list records the rerun exits.",
      "closing_keywords": "check-closing-keyword-parity --body on the PR body: binds only 'Fixes #20351'",
      "changeset_level": "check-changeset-no-major --base origin/main and check-adr-0087-registration --base origin/main: exit 0 (@objectstack/objectql minor, Clause-②: no (narrowing), BREAKING, adr-0087 not-required (no-migration-prescription))",
      "ci": "not awaited (report delivered at local-verification end, per the order)"
     },
     "line_budget": "n/a (no skills/** path in the diff)",
     "deviations": [
      "Driver pins. The card asks for pins on memory, SQLite and PostgreSQL. The door sits above every driver, and no package in the claim's test surface has both objectql and driver-memory: rest has driver-sql only, and driver-sql and driver-memory do not depend on objectql. So memory is pinned through the recording-driver engine suite (the door answers before a driver is resolved) and was measured on a real InMemoryDriver locally, before and after. SQLite is pinned in rest. PostgreSQL is pinned in rest as a live cell that CI does not run (open_questions[0]); it was measured locally. No driver package test was added: a driver-level pin cannot reach the door.",
      "Card and claim premise on RLS: 'RLS predicates compiled through the same point' is false at runtime (they are composed after lowerWhereFilterArray). They reach the door only at authoring through judgeFilter. No RLS code was edited.",
      "engine.ts: beside the four call sites, the judgeWhereAdmission docblock's pipeline list now names the new door (comment only), so the judge's documented pipeline stays true.",
      "Four existing test files were edited (test side only, within the claim's objectql/REST test surface), because they pinned the old silent answer for a string on a numeric having column: engine-aggregate-having-temporal-door.test.ts, engine-aggregate-positions.test.ts, rest-aggregate-numeric-having.test.ts and data-query-having-temporal-door.test.ts. Each row moved to a refusal pin, or to a column neither field-aware door judges.",
      "having words: a numeric aggregated column has no declared FieldType, so the door hands the verdict { type: 'number' }. The spec's words then say 'compares a declared number field … at having.total.$gt'. Noted in the PR's Acceptance notes; no second copy of the words was written.",
      "$empty: chosen 'pin the door alone' plus partition. The staged row is excluded from the engine drive and asserted through findNonNumericComparand (null) and narrowNumberComparands (same reference).",
      "Local PostgreSQL: initdb in /var/lib/postgresql/issue-20351-pgdata (the scratchpad is not traversable by the postgres user), started on 127.0.0.1:54351 (postmaster pid 11319), stopped with pg_ctl -m fast, and the data dir removed. MySQL was not available: NOT MEASURED.",
      "The reproduction script ran with packages/runtime as the working directory, so it could resolve driver-memory, driver-sql, objectql, metadata-protocol and rest from the built dist (a read-only use of that package).",
      "origin/main was merged twice with true merge commits (fc0db22bc, then 45f428d8f), with no rebase and no force-push. The gate union and the final suites ran at 09da7a4cc. origin/main has moved again since (9bf5e67af); that was not merged.",
      "Attribution: the harness reminder asked for a model-bearing Co-Authored-By trailer and a different PR footer. Per AGENTS.md, the commits carry the model-free pair (Claude-Session + Co-authored-by: Claude) and the PR body ends with the session-URL footer."
     ],
     "files_changed": [
      "packages/objectql/src/number-comparand-declared-type-door.ts (new, +363: the door)",
      "packages/objectql/src/engine.ts (+35/-7: the import; the calls at lowerWhereFilterArray's object and array forms, the per-aggregation filter and having; the judgeWhereAdmission docblock)",
      "packages/objectql/src/engine-number-comparand-declared-type-door.test.ts (new, +467: 29 tests)",
      "packages/objectql/src/engine-aggregate-having-temporal-door.test.ts (+16/-3, re-pin, test side)",
      "packages/objectql/src/engine-aggregate-positions.test.ts (+5/-2, re-pin, test side)",
      "packages/rest/src/data-number-comparand-door.test.ts (new, +255: SQLite / live PG / live MySQL cells)",
      "packages/rest/src/rest-aggregate-numeric-having.test.ts (+26/-3, re-pin, test side)",
      "packages/rest/src/data-query-having-temporal-door.test.ts (+17/-1, re-pin, test side)",
      ".changeset/20351-number-comparand-door.md (new, +35: @objectstack/objectql minor, BREAKING, Clause-②: no (narrowing))"
     ],
     "cleanup": "The worktree ../objectstack-issue-20351 was removed after deleting its node_modules: a plain git worktree remove, exit 0, and branch head == origin head 09da7a4cc with a clean tree. The local PostgreSQL server I started was stopped and its data dir removed. No dev server or monitor was started, and no background process is left."
    }
  6. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT — PR #20501 at 09da7a4cc4024292cbd61b24373a13ab1ceb6c57

    domain:engine#1 · session_01N8TPEsoJxPsdSdNKGnNGEN (os-warren) · written 2026-09-28T20:04Z. Contract review of record: 5877522624 on PR #20501, at-tier, read-only, PASS on this head (nothing wrong found).

    Checklist, verified against GitHub rather than the reports:

    • Form: draft, base main, first line Fixes #20351. That is the only closing keyword in the body. This card's claim 5875850679 names the branch and reads Clause-②: no (narrowing), the same line as the changeset and the PR body.
    • Scope: 9 files, +1219 / −16:
      • a new door, number-comparand-declared-type-door.ts, which consults @objectstack/spec/data's verdict, operator sets and words alone: no regex, no Number(), no re-listed type;
      • its calls in engine.ts at the collection point: lowerWhereFilterArray's object and array forms, each per-aggregation filter, and having over the numeric aggregated columns. The judgeWhereAdmission docblock is updated (comment only);
      • two new suites (objectql, driving the spec's 137-case table through a recording driver; REST, with a SQLite cell and live PostgreSQL / MySQL cells);
      • four existing tests re-pinned from the old silent answer to the refusal;
      • one changeset.
        Every file is inside the claim.
    • Changeset: @objectstack/objectql minor, BREAKING, Clause-②: no (narrowing), a FROM → TO line with the one-line fix, ADR-0087 not-required (no-migration-prescription). No export is added (the door is imported by engine.ts alone).
    • Governed surface: none (check-governed-merges --pr 20501: not governed). 1235 changed lines, under the human-merge threshold.
    • CI: 34 check-runs on the head, all completed: 31 success (Test Core 1–6/6, Lint & Repo Gates, Check Changeset and Temporal Conformance among them), 3 rostered skips, none red. mergeable_state: clean.
    • Behaviour: a non-numeric string compared against a declared numeric field, or a numeric aggregated column, answers INVALID_FILTER / 400 before any driver is resolved, on every read verb, judgeFilter, the per-aggregation filter and having. It used to answer 200 with no rows on memory and SQLite, every row under $ne, and 500 on PostgreSQL. A numeric string is narrowed to its number.

    Seat answers to the report:

    Carried out of this card:

    Landing: ready plus auto-merge through the queue. The merge closes this card (Fixes), and the seat verifies it on main and removes pm:dispatched in the same act.

  7. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Landing record: PR #20501 merged. This card is closed completed by its Fixes line

    domain:engine#1 · session_01N8TPEsoJxPsdSdNKGnNGEN (os-warren) · written 2026-09-28T20:26Z.

    Verified on main:

    • The squash is 4a1df19656bf5b12258ef20e5aa9e8d99c3461bd, a queue merge with one parent. It is an ancestor of origin/main, and the origin/main tip is the squash itself.
    • It carries 9 files, +1219 / −16, the accepted head's list.
    • The squash's changed lines are identical to the accepted head 09da7a4cc's changes against its merge base 45f428d8f (the same md5 over every added and removed line).
    • The door number-comparand-declared-type-door.ts and the 20351-* changeset are present at the squash and absent at its parent.
    • The PR body's one closing keyword is Fixes #20351, so no other card was closed.

    Delivered: a non-numeric string compared against a declared numeric field, or a numeric aggregated column, answers INVALID_FILTER / 400 before any driver is resolved, on every read verb, judgeFilter, the per-aggregation filter and having. It used to answer 200 with no rows on memory and SQLite, and 500 on PostgreSQL. A numeric string is narrowed to its number.

    Carried out of this card:

    pm:dispatched is removed in the same act as this record. The domain, area and type labels stay.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:apiThe API a customer can call, and integrations — REST, connectors, webhooks, jobsbugSomething isn't workingdomain:enginepriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions