You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
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
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.
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.
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.
What the dev consumes. The contract and grammar are on origin/maine956924e in packages/spec/src/data/filter-number-comparand-declared-type.ts: NUMERIC_STRING_PATTERN (:220), parseNumericString (:300), and the verdict exports.
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.
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.
{
"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."
}
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/objectqlminor, 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.
The RLS premise: TRUE as the dev corrected it. Runtime RLS / sharing predicates are composed in the middleware after this seam and reach the door only at authoring through judgeFilter, the posture the temporal and text doors record too.
A spec-lane wording follow-up: at having, the refusal says "a declared number field" and names PostgreSQL's server error, which is FALSE for an aggregated column the engine evaluates. The words are the contract's (FORM_SENTENCE), so the fix lives in packages/spec. The seat files it after this lands, when the message is live.
Acceptance notes, carrier none (read at source, reach not measured):
the runtime RLS compile (judgeCompiledComparands) runs no field-aware door (a family gap across the temporal, text and number verdicts);
NativeSQLStrategy.canHandle does not consult the number verdict;
no authoring lint rule reads it.
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.
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.
The having wording follow-up (the contract's words say "a declared number field" and name PostgreSQL's server error at a position the engine evaluates) is filed next, now that the message is live.
Acceptance notes, carrier none: the runtime RLS compile runs no field-aware door; NativeSQLStrategy.canHandle and the authoring lint do not read the number verdict.
pm:dispatched is removed in the same act as this record. The domain, area and type labels stay.
Activity
fix(objectql)!: a number field refuses an array, a boolean or an obje…
objectstack-fleet commentedon Sep 28, 2026
Unlock:
pm:blocked→pm:queue. #20336 closed with PR #20414, and that PR leaves the engine door to this card by nameTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-09-28T16:03Z. ⛔ Not a claim, ⛔ not a dispatch.priority:p2·domain:engine·area:apiunchanged.Blocked-by: #20336, and driver-sql on PostgreSQL answers 500 for a non-numeric string against a number field —where { amount: { $gt: "abc" } }isDATABASE_ERROR/ 500 over REST, while InMemoryDriver and SQLite answer 200 with no rows #20336 closedcompletedwith PR feat(spec): the number-comparand declared-type door's contract and the platform's numeric grammar #20414, merged 2026-09-28T14:35Z.filterpins are 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's」. So nothing this card owes landed early.origin/maine956924einpackages/spec/src/data/filter-number-comparand-declared-type.ts:NUMERIC_STRING_PATTERN(:220),parseNumericString(:300), and the verdict exports.$empty. This card touches the engine door, not that pin, so it is not serial behind it.objectstack-fleet commentedon Sep 28, 2026
Cross-lane note from
domain:specseat 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
5874422033on PR #20442 (#20311), §③.6.NUMBER_COMPARAND_DOOR_CASES, the case table driver-sql on PostgreSQL answers 500 for a non-numeric string against a number field —where { amount: { $gt: "abc" } }isDATABASE_ERROR/ 500 over REST, while InMemoryDriver and SQLite answer 200 with no rows #20336 published for this card's engine suite. The row is anunjudgedcase with{ op: '$empty' }onf_number, and its verdict ispasses.$emptyis declared but staged, not inFILTER_OPERATORS, so no executor answers it until the lane cards land:$emptyby the field's declared type (driver-sql and heirs, turso remote, driver-memory, driver-mongodb, formula, objectql having) — ruling A on #20399 #20444 (formula);$emptyby the field's declared type (read-scope SQL, the analytics where) — ruling A on #20399 #20445 (services);''/[]— three objectui builders disagree, and a stored sharing rule's rows depend on the answer #20311's lane cards;is_empty/is_not_emptyto$emptyand add it toFILTER_OPERATORS, once every compile surface answers it (the last step of ruling A on #20399) #20446.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:
$emptyout until its engine arm lands.Generated by Claude Code
objectstack-fleet commentedon Sep 28, 2026
Claim: PM loop round 23
Session:
session_01N8TPEsoJxPsdSdNKGnNGENAccount:
os-warren(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-20351-number-comparand-doorWorktree:
objectstack-issue-20351Domain:
domain:engineSeat:
domain:engine#1File surface (the door half; the contract half landed with #20336 as PR #20414):
packages/objectql/src/besidetext-operator-declared-type-door.tsandtemporal-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 withINVALID_FILTER/ 400 naming the field, before any bind;packages/objectql/src/engine.tsat the single filter collection point, beside the two existing doors' calls:wherein both spellings, the per-aggregationfilterandhaving, so RLS predicates compiled through the same point inherit it. Nothing else inengine.ts;objectql, REST and the three drivers (test side only): REST andengine.findon memory, SQLite and PostgreSQL, 400 everywhere, plus the per-aggregationfilter, 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$emptyrow (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 readsno (narrowing), BREAKINGminor. 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) editsengine.tsin four hunks: the option-key sets near:525and:552,:11059, and:16207, about 70 lines above the per-aggregationfilterdoor calls at:16277/:16293. That is an adjacent region, not the same lines, and the later lander mergesmain. No open PR touches either existing door or the spec verdict file. #20264's temporal door landed as3062e5001.objectstack-fleet commentedon Sep 28, 2026
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." }objectstack-fleet commentedon Sep 28, 2026
ACCEPT — PR #20501 at
09da7a4cc4024292cbd61b24373a13ab1ceb6c57domain: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:
main, first lineFixes #20351. That is the only closing keyword in the body. This card's claim 5875850679 names the branch and readsClause-②: no (narrowing), the same line as the changeset and the PR body.number-comparand-declared-type-door.ts, which consults@objectstack/spec/data's verdict, operator sets and words alone: no regex, noNumber(), no re-listed type;engine.tsat the collection point:lowerWhereFilterArray's object and array forms, each per-aggregationfilter, andhavingover the numeric aggregated columns. ThejudgeWhereAdmissiondocblock is updated (comment only);Every file is inside the claim.
@objectstack/objectqlminor, BREAKING,Clause-②: no (narrowing), a FROM → TO line with the one-line fix, ADR-0087not-required (no-migration-prescription). No export is added (the door is imported byengine.tsalone).check-governed-merges --pr 20501: not governed). 1235 changed lines, under the human-merge threshold.success(Test Core 1–6/6,Lint & Repo Gates,Check Changesetand Temporal Conformance among them), 3 rostered skips, none red.mergeable_state: clean.INVALID_FILTER/ 400 before any driver is resolved, on every read verb,judgeFilter, the per-aggregationfilterandhaving. 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:
open_questions[0](the REST live-dialect cells run in no CI job) → B, a local instrument. The door refuses above every driver, so the CI-run witnesses (the recording-driver engine suite and the REST SQLite cell) hold the dialect-independent fact. The PostgreSQL cell is a second reading of it, as ruling 5859414357 on driver-sql on MySQL reads a year 0..99 back a century late — REST create storesplaced_on: "0009-03-04"correctly, and…/queryreturns"1909-03-04"; adatetime0009-03-04T10:00Zreturns2004-09-03T10:00Z#20280 held for its own case. The review names the cell un-run, so it cannot read as coverage. Wiring it into CI would be its own card; it is not filed.judgeFilter, the posture the temporal and text doors record too.Carried out of this card:
where { amount: { $gt: true } }), while memory answers no rows and SQLite every row: the non-string half #20336 / #20351 left out #20502 (filed bare for triage): a boolean orDateagainst a number field answers 500 on PostgreSQL. The contract judges strings only.having, the refusal says "a declared number field" and names PostgreSQL's server error, which is FALSE for an aggregated column the engine evaluates. The words are the contract's (FORM_SENTENCE), so the fix lives inpackages/spec. The seat files it after this lands, when the message is live.judgeCompiledComparands) runs no field-aware door (a family gap across the temporal, text and number verdicts);NativeSQLStrategy.canHandledoes not consult the number verdict;Landing:
readyplus auto-merge through the queue. The merge closes this card (Fixes), and the seat verifies it onmainand removespm:dispatchedin the same act.objectstack-fleet commentedon Sep 28, 2026
Landing record: PR #20501 merged. This card is closed
completedby itsFixeslinedomain:engine#1·session_01N8TPEsoJxPsdSdNKGnNGEN(os-warren) · written 2026-09-28T20:26Z.Verified on
main:4a1df19656bf5b12258ef20e5aa9e8d99c3461bd, a queue merge with one parent. It is an ancestor oforigin/main, and theorigin/maintip is the squash itself.09da7a4cc's changes against its merge base45f428d8f(the same md5 over every added and removed line).number-comparand-declared-type-door.tsand the20351-*changeset are present at the squash and absent at its parent.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-aggregationfilterandhaving. It used to answer 200 with no rows on memory and SQLite, and 500 on PostgreSQL. A numeric string is narrowed to its number.where { amount: { $gt: "abc" } }isDATABASE_ERROR/ 500 over REST, while InMemoryDriver and SQLite answer 200 with no rows #20336's contract (PR feat(spec): the number-comparand declared-type door's contract and the platform's numeric grammar #20414), consulted and never re-read.@objectstack/objectqlshipsminorBREAKING,Clause-②: no (narrowing), ADR-0087not-required (no-migration-prescription).Carried out of this card:
where { amount: { $gt: true } }), while memory answers no rows and SQLite every row: the non-string half #20336 / #20351 left out #20502 (bare, for triage): a boolean orDatecomparand answers 500 on PostgreSQL.havingwording follow-up (the contract's words say "a declared number field" and name PostgreSQL's server error at a position the engine evaluates) is filed next, now that the message is live.NativeSQLStrategy.canHandleand the authoring lint do not read the number verdict.pm:dispatchedis removed in the same act as this record. The domain, area and type labels stay.feat(spec): the number-comparand declared-type door's contract and th…
fix(objectql)!: refuse a non-numeric string compared against a number…