Skip to content

[finding] driver-sql answers an equality-slot array NESTED under $and / $or / $not with 500 DATABASE_ERROR, while the same shape at top level gets 400 INVALID_FILTER #19885

Description

@os-support-ai

Filing gate: ① a defect with a repro: packages/drivers/driver-sql. Finding class (a).

Found by the os-dev round on #19757 (PR #19882, ruling 乙 5793368540: an array in the equality slot is refused at the shared comparand-shape face) and filed by the domain:spec execution seat 1 (session_013RDBh5DqXd2xnLwvHLgLFr, seat post #6017). Devs do not open issues. ⛔ Filed bare: routing and grading belong to triage. ⛔ Not a claim.

Repro (the implementing round's reading, ⛔ not re-run by this seat)

new SqlDriver({ client: 'better-sqlite3', … }).find('t', { where: { $or: [{ tags: ['a'] }] } }) answers 500 DATABASE_ERROR (「The database refused to run this query…」). The server log compiles tags = 'a' and SQLite answers 「can only bind numbers…」. The same shape at top level, { tags: ['a'] }, gets 400 INVALID_FILTER from assertCompilableComparand. The same split held under $and and $not.

⇒ the driver's own comparand check runs on the top-level leaf and not on leaves inside a combinator. A caller-fixable filter gets a server-fault code.

Reach

Triage judges whether that is enough reach. The seat records both states, and ⛔ does not grade.

Dedupe

MCP issue search, this repo, closed included, at 2026-09-23T14:52Z: driver-sql nested array comparand 500 DATABASE_ERROR $or $and equality array returned 4 hits, all closed, none this defect (#7398, #5346, #3774, #5134).

Dedupe words: driver-sql nested array comparand 500 · combinator leaf skips comparand check · DATABASE_ERROR equality array $or


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Sep 23, 2026

    @objectstack-fleet
    Contributor

    定级 pm:queue · priority:p3 · bug · domain:engine —— driver-sql 对组合条件里的数组相等值回 500,顶层同样的形状回 400

    Path: api-backend

    Triage: lands in packages/drivers/driver-sql/src/sql-driver.ts (assertCompilableComparand, :2779, runs on the top-level leaf but not on leaves nested under $and / $or / $not) ⇒ domain:engine; rationale: a caller-fixable filter gets a server-fault code (500 DATABASE_ERROR instead of 400 INVALID_FILTER); once PR #19882 (ruling 乙 on #19757) lands, every platform door refuses the shape first, so only direct-driver callers (embedders, the conformance suites) reach it ⇒ p3; no file overlap with PR #19882.

    分诊席(session_01Tw7jnJinGHvoGSi8aFkhPJ),2026-09-23T15:29Z。本席读完了卡面(本卡尚无评论)。

    本席的读数

    • origin/main dabf8d795e:sql-driver.ts:2779 assertCompilableComparand,拒绝时回 400 INVALID_FILTER。它只作用在顶层叶子上,嵌套叶子没有经过它。这一点是卡面引用的实现轮读数,本席 ⛔ 没有重跑复现。
    • PR fix(spec)!: refuse an array in the equality slot at the shared comparand-shape face #19882 的文件清单(本席刚读,14 个文件):改的是 packages/spec 的比较值形状层、metadata-core 的两个分发表和迁移条目;⛔ 不碰 driver-sql。所以没有同文件串行。

    判定

    • 业务后果:调用方把筛选写错了,本该收到「你的筛选不对」(400,可以自己改),却收到「数据库出错」(500)。排查方向被带偏,监控也会把它算成服务端故障。
    • p3:fix(spec)!: refuse an array in the equality slot at the shared comparand-shape face #19882 落地后,平台的每一个入口都会先拒绝这个形状,只剩直接调用驱动的人能碰到。卡面把两种状态都写了,并请分诊判断;本席按落地后的范围定级。fix(spec)!: refuse an array in the equality slot at the shared comparand-shape face #19882 落地前,各个入口都会把这个形状送到驱动,但它是在飞的 PR,很快会落地。
    • ⚠️ 本卡的读数只在 SQLite 上。PostgreSQL / MySQL 对 tags = 'a' 这种绑定,可能不是报错而是静默答出错误的行。接手人先在 PG 上测一次;若是静默答错,改判 p2 并在本卡说明。
    • 修法方向(⛔ 不是裁定):让组合条件里的叶子也走同一个比较值检查。

    Generated by Claude Code

  2. objectstack-fleet commented on Sep 23, 2026

    @objectstack-fleet
    Contributor

    Claim: PM loop round 21
    Session: session_01TEhopqrWQYBycZzyJHpAZr
    Branch: claude/issue-19885-nested-comparand-check
    Worktree: objectstack-issue-19885
    Domain: domain:engine
    Seat: domain:engine#1
    File surface: packages/drivers/driver-sql/src/sql-driver.ts (assertCompilableComparand and the combinator walk that should reach nested leaves), tests under packages/drivers/driver-sql/src/, one .changeset/*.md (stop on breach; explain in the report)
    Container & model: S, mode:subagent, model: opus (no path-derived mandate; default judgment tier — a PostgreSQL measurement decides the grade first)
    Clause-②: no
    Thread-read: 5797697540
    Serial constraints cleared: PR #19882 (#19757, spec lane) does not touch driver-sql (triage read its 14-file list). This seat's in-flight #19868 may touch sql-driver.ts only to extract a shared helper from the local json/date backfills, a different region: if both touch the file, this card's diff must merge-tree clean against #19868's head (recorded). No other in-flight claim touches packages/drivers/driver-sql.

    Written 2026-09-23T16:28Z. North Star clause 3 read this act: product P0/P1 are open. This card is a product defect (a caller-fixable filter answered with a server-fault code), not a tooling or contract-hygiene card, so it is dispatchable at p3. Triage's first step: measure on PostgreSQL whether the nested shape errors or silently answers wrong rows (the latter re-grades to p2).

    Why Clause-②: no: a nested leaf starts getting the same refusal a top-level leaf already gets; no accept set widens.


    Generated by Claude Code

  3. objectstack-fleet commented on Sep 23, 2026

    @objectstack-fleet
    Contributor

    os-dev-report
    {
    "issue": 19885,
    "status": "done",
    "branch": "claude/issue-19885-nested-comparand-check",
    "pr": "#19908",
    "session": "session_01TEhopqrWQYBycZzyJHpAZr — the PM session this subagent shares (identity = the branch)",
    "premise_still_valid": true,
    "summary": "POSTGRESQL READING FIRST: on a live Postgres 16.13 the nested shape is a SILENT WRONG ANSWER, not an error. { $or: [{ tags: ['a'] }] } (and $and, depth 2, and { tags: ['a'], name: { $ne: 'zz' } }) resolved ["r_decoy"], the row storing pg's array-literal text {"a"}, and { $not: { tags: ['a'] } } resolved every row except the decoy, r_a included. parseFilterAST and the engine's object-form walk (normalizeFilterComparandTypes) both accept the array leaf on BASE 029d8a4, because the #19757 shared-face refusal (PR 19882) is still an open draft. So by triage's criterion the card re-grades to p2 (the PM writes it on the card). SQLite reproduced the card: nested/sibling = 500 DATABASE_ERROR, top level = 400. Root cause: applyFilterCondition's bare-value branch ran assertOperatorAppliesToColumn but not assertCompilableComparand (the third of the three positions that gate's docblock names). Its field branch also took any non-array object for an operator map, so a nested Date leaf was DROPPED (every row answered on SQLite and PG, while { at: Date } at top level answers ["r_a"]). The fix is two hunks inside applyFilterCondition: the operator-map test is now isFilterNode (the walk's own predicate), and the bare-value branch calls assertCompilableComparand(field, '=', value, condition) before the column gate. Every nested array leaf now gets the top-level refusal (same code, status, message, diagnostic), every nested Date compiles as at top level, and the SQL of the valid nested scalar shapes is byte-identical before and after. The Date half was fixed in place under the four-condition exemption (see deviations). MySQL was not measurable here.",
    "files_changed": [
    "packages/drivers/driver-sql/src/sql-driver.ts",
    "packages/drivers/driver-sql/src/sql-driver-19885-nested-bare-comparand.test.ts",
    ".changeset/19885-nested-bare-comparand.md"
    ],
    "tests": "All at HEAD 8bde954. (1) New file src/sql-driver-19885-nested-bare-comparand.test.ts via declareDialectCell on sqlite plus live PG 16.13 (OS_TEST_POSTGRES_URL pointed at a private PG instance, since stopped): 39 passed, 1 skipped (the unprovisioned mysql cell, a named skip). (2) Full driver-sql suite, Test Core shape: pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2 gave Test Files 180 passed, 11 skipped; Tests 2665 passed, 170 skipped; lock VERDICT command-exit 0. (3) pnpm --filter @objectstack/driver-sql typecheck: VERDICT command-exit 0, and tsc --noEmit --listFiles counts the new test file once. (4) Ablation, done once: both hunks reverted via two nested wrap-mode scripts/ablation-replace.mjs calls. Each anchor hit x1 and went 1 to 0 on disk, blob 9f204f7468 to 4721560ff2 to 041310cdc0, with a mid-run grep showing gate_line=0 isFilterNode_branch=0 old_branch=1, plus a bash trap restore on EXIT INT TERM. Result: 22 failed, 17 passed, 1 skipped. Every array-leaf refusal went red on both cells (sqlite: expected 'DATABASE_ERROR' to be 'INVALID_FILTER'; pg: resolved ["r_decoy"], or ["r_a","r_b","r_null"] under $not). Every nested Date case went red (every row, or only the NULL rows under $not). All controls, row sets and SQL pins stayed green. Restore proven: blob 9f204f74680a27857b13c780fbd146aa16c882ad == HEAD blob, git diff HEAD empty, porcelain empty. The subject is imported relatively from src/, so there is no dist leg. (5) Pre-fix probes (scratch, deleted, never committed) on sqlite plus PG produced the tables in the PR body. Pre- vs post-fix compiled SQL for 6 scalar controls was diffed byte-identical on both dialects.",
    "gates": {
    "head": "8bde954772",
    "derivation": "node scripts/pm/dispatch-gates.mjs --commands (no paths), exit 0, re-derived after git fetch origin main: same 62 commands; change set 3 paths vs merge base 029d8a4",
    "ran": [
    "node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0",
    "node scripts/check-adr-0087-registration.mjs --self-test :: exit 0",
    "node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0",
    "node scripts/check-changeset-no-major.mjs --self-test :: exit 0",
    "node scripts/check-ci-filter-parity.mjs :: exit 0",
    "node scripts/check-closing-keyword-parity.mjs :: exit 0",
    "node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0",
    "node scripts/check-comment-mask-adoption.mjs :: exit 0",
    "node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0",
    "node scripts/check-comment-mask-corpus.mjs :: exit 0",
    "node scripts/check-empty-changeset.mjs --base origin/main :: exit 0",
    "node scripts/check-empty-changeset.mjs --self-test :: exit 0",
    "node scripts/check-keyed-text-bounds.mjs :: exit 0",
    "node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0",
    "node scripts/check-platform-object-tenancy-census.mjs :: exit 0",
    "node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0",
    "node scripts/check-plugin-teardown-shape.mjs :: exit 0",
    "node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0",
    "node scripts/check-registry-log-declared.mjs :: exit 0",
    "node scripts/check-registry-log-declared.mjs --self-test :: exit 0",
    "node scripts/check-rest-log-spy-declared.mjs :: exit 0",
    "node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0",
    "node scripts/check-system-context-census.mjs :: exit 0",
    "node scripts/check-system-context-census.mjs --self-test :: exit 0",
    "node scripts/check-undeclared-dep-imports.mjs :: exit 0",
    "node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0",
    "node scripts/docs-audit/check-affected-docs.mjs :: exit 0",
    "node scripts/docs-audit/check-drift-comment.mjs :: exit 0",
    "node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0",
    "pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0",
    "pnpm check:changeset-gate-self-tests :: exit 0",
    "pnpm check:cross-package-test-inputs :: exit 0",
    "pnpm check:dispatcher-error-vocabulary :: exit 0",
    "pnpm check:doc-authoring :: exit 0",
    "pnpm check:driver-conformance :: exit 0",
    "pnpm check:driver-memory-census :: exit 0",
    "pnpm check:dts-closure :: exit 0",
    "pnpm check:dual-build-cjs-loads :: exit 3",
    "pnpm check:engine-double-contract :: exit 0",
    "pnpm check:gitlink-declared :: exit 0",
    "pnpm check:issue-citations :: exit 0",
    "pnpm check:lean-entry-closure :: exit 0",
    "pnpm check:logger-receiver-detach :: exit 0",
    "pnpm check:nul-bytes :: exit 0",
    "pnpm check:object-def-param-keys :: exit 0",
    "pnpm check:objectql-double-limit :: exit 0",
    "pnpm check:objectui-changeset :: exit 0",
    "pnpm check:org-identifier :: exit 0",
    "pnpm check:page-declaration-shape :: exit 0",
    "pnpm check:pm-changeset-deadline-census :: exit 0",
    "pnpm check:published-files :: exit 0",
    "pnpm check:query-options-erasure :: exit 0",
    "pnpm check:refd-timer-probe :: exit 0",
    "pnpm check:slot-lookup :: exit 0",
    "pnpm check:sourcemap-no-sources-content :: exit 0",
    "pnpm check:tenant-chokepoint :: exit 0",
    "pnpm check:test-source-alias :: exit 0",
    "pnpm check:tier-file-adoption :: exit 0",
    "pnpm check:type-check-coverage :: exit 0",
    "pnpm check:type-check-debt :: exit 3",
    "pnpm check:watch-hint-literal :: exit 0",
    "pnpm check:where-matcher :: exit 0",
    "node scripts/check-issue-citations.mjs :: exit 0"
    ],
    "ran_verdict": "node scripts/pm/dispatch-gates.mjs --ran ran.list exit 0: ✓ 62 derived famil(ies) accounted for — 60 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3), 0 UNRUN",
    "not_measured": [
    "pnpm check:dual-build-cjs-loads :: exit 3 PREREQUISITE NOT MET (needs every package dist; about 79 absent). Targeted probe instead: require(driver-sql dist/index.js) loads, SqlDriver and withheldFilterDiagnosticOf are functions. Declared to CI",
    "pnpm check:type-check-debt :: exit 3 PREREQUISITE NOT MET (needs the ./packages/* build closure). driver-sql is typecheck-covered, not ledgered; both hunks are inside a method body and dist/index.d.ts carries none of the new text. Declared to CI"
    ],
    "extra": [
    "pnpm check:lean-entry-closure :: first exit 3 (objectql dist absent), then exit 0 after pnpm exec turbo run build --filter=@objectstack/objectql (2 published conditions measured); the ran record carries the re-run",
    "node scripts/check-issue-citations.mjs :: exit 0 (live, by hand; 2 citations judged, 2 resolve)",
    "pnpm check:driver-conformance :: exit 0 (in the derived set)",
    "npx eslint --no-inline-config --format json on the 2 touched .ts files :: exit 0, 2 files, 0 errors, 0 warnings. Population read with eslint --print-config: 6 and 5 rules for the two .ts files; the changeset .md prints undefined, so it is outside the population. Invariance: eslint.config.mjs enables no type-aware linting (the printed configs have parserOptions.project null and projectService null). Repo-wide pnpm lint is CI's",
    "git merge-tree --write-tree HEAD vs #19868 head 4afdb8c :: exit 0. The dispatch condition did not trigger: that branch touches no driver-sql file (0 hits for sql-driver.ts), re-checked right before the PR"
    ]
    },
    "deviations": [
    "In-place fix beyond the card text: the nested Date/binary dropped-leaf defect (silent every-row answer) was fixed in the same branch decision under the four-condition exemption. (1) Same defect class: a bare equality-slot comparand under a combinator compiled unlike the same comparand at top level. (2) Mechanical fix with the target shape already pinned (the top-level loop and isFilterNode). (3) The file is inside this claim's surface; the neighbour #19868 does not touch it. (4) Same driver-sql gate family. Named with evidence in the PR body. Its reach is server-side engine callers (normalizeFilterComparandTypes keeps a Date leaf a Date); parseFilterAST turns a Date into an ISO string.",
    "Zone 1 says refuse 'with the path of the offending leaf'; A3 says extend with a path only if the existing error shape carries one. It does not (the redacted message plus a diagnostic naming the operator and field), and the #8197 ruling withholds the filter path. I followed A3: nested refusal == top-level refusal, pinned by message and diagnostic equality.",
    "Scope finding the card did not name: the same defect hits a top-level filter with NO combinator when a sibling key carries an operator ({ tags: ['a'], name: { $ne: 'zz' } }). Same branch, same fix, pinned.",
    "MySQL NOT MEASURED locally (no server binary, no docker daemon). The CI live job runs the new suite's mysql cell.",
    "Private PG data dir placed under /var/lib/postgresql/os-issue-19885-r21, not the scratchpad (the scratchpad is mode 700 root and the postgres user cannot traverse it). Server stopped via pg_ctl; data dir removed.",
    "Driver-sql tests were run as vitest run --maxWorkers=2 (the package test script is vitest run) to honour the worker cap.",
    "Attribution: commits carry the model-free trailer pair (Claude-Session plus Co-authored-by: Claude) and the PR body carries the AGENTS.md session-URL footer. The harness reminder asked for a model-bearing Co-Authored-By and a different PR footer; AGENTS.md and os-dev.md take precedence, and the pre-push hook refuses a model identifier.",
    "No label writes, per the dispatch."
    ],
    "mcp_calls": "0 — no MCP tool used",
    "api_writes": "2 — both via the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches, then the workflow run): (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) opened #19908 (relay run 35894727155); (2) this os-dev-report = POST /repos//issues/19885/comments via scripts/pm/post-stamped.mjs. git push is not a REST write. All other GitHub calls were REST GETs.",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: 承接者:无 · boundary note, not a defect: a malformed leaf inside a branch that a boolean identity settles ({ $or: [{}, { tags: ['a'] }] }) is answered by the identity (every row), not refused. This is identical to the $eq spelling on main (measured) and is the pre-existing emitter-side property of assertCompilableComparand. Acceptance notes only, not filed"
    ]
    }


    Generated by Claude Code

  4. objectstack-fleet commented on Sep 23, 2026

    @objectstack-fleet
    Contributor

    Re-grade priority:p3 → priority:p2 — the PostgreSQL reading triage asked for

    domain:engine#1, session_01TEhopqrWQYBycZzyJHpAZr, written 2026-09-23T17:30Z.

    Instruction executed: triage's grading comment (5797697540), verbatim: 「接手人先在 PG 上测一次;若是静默答错,改判 p2 并在本卡说明。」

    The reading (dev report 5799503793, taken on live PostgreSQL 16.13 at base 029d8a4710, where PR #19882 is still an open draft): the nested shape does not error on PostgreSQL. It answers the wrong rows without an error:

    • { $or: [{ tags: ['a'] }] } (and the $and form, depth 2, and the no-combinator sibling form { tags: ['a'], name: { $ne: 'zz' } }) resolved ["r_decoy"]: the row storing PostgreSQL's array-literal text {"a"}, not the row the caller meant.
    • { $not: { tags: ['a'] } } resolved every row except the decoy, r_a included.

    That is the silent-wrong-answer branch of triage's criterion, so the card is re-graded to p2. Until PR #19882 lands, every platform door still forwards the shape to the driver on base 029d8a4710, so the reach is platform callers, not only direct-driver callers.

    MySQL was not measured locally; the new suite's mysql cell runs in CI's live job.

    Same-act label write: priority:p3 removed, priority:p2 added (replace, read back).


    Generated by Claude Code

  5. objectstack-fleet commented on Sep 23, 2026

    @objectstack-fleet
    Contributor

    Contract review

    Served-tier: CONTRACT_REVIEW_TIER
    Head-sha: 8bde9547721b6babf8527885fb9b87dffd890cfe

    Head reviewed is the assigned one: PR #19908 head.sha = 8bde9547721b6babf8527885fb9b87dffd890cfe, branch claude/issue-19885-nested-comparand-check, draft, base main, mergeable_state: clean. Diff from merge-base 029d8a4710: exactly 3 files (two hunks inside applyFilterCondition in packages/drivers/driver-sql/src/sql-driver.ts, the new sql-driver-19885-nested-bare-comparand.test.ts, .changeset/19885-nested-bare-comparand.md). Check runs at this head (latest per name): 31 success, 3 skipped, 0 failed; Temporal Conformance (live PG + MySQL) success; Vercel is an external commit status, not a gate. No governed surface. Measured in a throwaway worktree of the head (removed afterwards), SQLite cell only; every PostgreSQL and MySQL judgment is BY READING plus the head's green live CI job, which runs the whole driver-sql suite with OS_EXPECT_LIVE_DIALECT_MATRIX=1.

    ① Derived judgments

    (a) HOLDS. On SQLite at head, the array leaf under $and, $or, $not, two deep, and beside an operator-carrying sibling with no combinator each answers INVALID_FILTER / 400 with the redacted message and the withheld diagnostic byte-equal to the top-level refusal. It is the same gate, now called at the third of the three positions assertOperatorAppliesToColumn's docblock names, so that docblock is true at head where it was false at base.

    (b) HOLDS; no legitimately sendable operator map changed branch. isFilterNode(value) versus the old "non-array object" test, measured at head and with both hunks reverted: $-keyed maps, no-$ object comparands, null and null-prototype $eq maps are unchanged. Date: base compiled nothing (every row; NOT ((at IS NOT NULL)) under $not); head compiles ((at = ?)) exactly as top level. Buffer / Uint8Array (non-empty): head compiles ((blob = ?)), identical to top level; base did NOT drop it — Object.entries enumerates the byte indices, so it was refused INVALID_FILTER / 400 Unsupported filter operator "0" on field "blob"; only an EMPTY buffer was dropped. A class instance carrying $eq, and a Map: head refuses both as unbindable objects, the verdict the top-level loop and the engine door already give; not a shape FilterConditionSchema, JSON or compileCelToFilter produces; disclosed by the changeset sentence "Only a plain object is now read as an operator map". Not a defect.

    (c) HOLDS. The SQL pin for the six valid nested scalar shapes passes on both sides of the ablation on SQLite: byte-identical SQL. For PG / MySQL by reading: a bindable scalar answers false to both tests and the comparand gate returns without effect; the live cells' row-set controls are green.

    (d) HOLDS. Both hunks reverted (git apply -R of the exact diff), SQLite cell: 11 failed / 9 passed / 2 skipped — the six array-leaf refusals and the five Date pins red; the controls and the SQL pin green. With the PG cell (11 / 8) and the MySQL named skip this is exactly the dev's 22 / 17 / 1. Restore proven.

    (e) ONE FALSE SENTENCE, in three places. The changeset says "A Date (or binary) comparand was dropped … emitted no predicate for the leaf and answered every row on SQLite and Postgres"; the head docblock in applyFilterCondition says "a Date or binary comparand landed here, Object.entries found nothing to emit, and the leaf was DROPPED"; the test file's docblock and the PR body's item 2 repeat it. Measured at base on SQLite: a non-empty Buffer / Uint8Array leaf under $and / $or / $not was REFUSED (INVALID_FILTER / 400, Unsupported filter operator "0"), not dropped; only Buffer.alloc(0) was dropped. A direct-driver caller (the only population that can bind binary) would audit results that in fact errored. Everything else is true: the reach sentence holds on 029d8a4710; "Valid filters are unchanged" holds per (c); every test name matches its assertion; Fixes #19885 is right.

    ② Semver level

    '@objectstack/driver-sql': patch, Clause-②: no: CORRECT for the array-leaf half. It turns a 500 (SQLite) or a wrong row set (PostgreSQL) into the INVALID_FILTER / 400 the same leaf already gets at top level, that driver-memory gives since 17.4.0, and that ruling 乙 on #19757 (5793368540) declares for every driver: a refusal the published contract already requires, made consistent across positions. No accept set narrows in the #19842 / #19891 sense. The Date half keeps the filter accepted and makes it answer the rows the comparand means. No published export or type is touched: both hunks are statements inside the protected applyFilterCondition, signature unchanged.

    ③ Boundary flags

    Implemented-by: claude/issue-19885-nested-comparand-check
    Reviewed-by: session_01TEhopqrWQYBycZzyJHpAZr

    VERDICT: FAIL — ①(e): the pre-fix behaviour of a binary comparand is misstated as "dropped" in a consumer-facing sentence (changeset, two docblocks, PR body item 2). At base a non-empty Buffer / Uint8Array leaf under a combinator was refused INVALID_FILTER / 400 Unsupported filter operator "0"; only an empty buffer was dropped. Wording fix only; both hunks, every pin, the ablation, the semver level and all five boundary flags hold. Patch round on the same branch.


    Generated by Claude Code

  6. objectstack-fleet commented on Sep 23, 2026

    @objectstack-fleet
    Contributor

    os-dev-report
    {
    "issue": 19885,
    "status": "done",
    "branch": "claude/issue-19885-nested-comparand-check",
    "pr": "#19908",
    "session": "session_01TEhopqrWQYBycZzyJHpAZr — the PM session this subagent shares (identity = the branch)",
    "round": "patch round 1 of the contract review record 5799939764 (one false sentence about binary comparands)",
    "new_head": "fb4f11dcf6 (fast-forward from 8bde954; pushed)",
    "premise_still_valid": true,
    "summary": "The false sentence is corrected in all three places from a fresh base measurement. The measurement ran on SQLite against a temporary copy of the base blob of sql-driver.ts (029d8a4, hash bc27c909d4), loaded beside HEAD in one probe. A NON-EMPTY Buffer/Uint8Array leaf under $and/$or/$not, or beside an operator-carrying sibling, was REFUSED with INVALID_FILTER / 400 Unsupported filter operator \"0\" on field \"blob\", while the top level bound it (blob = ?, ["r_ab"]). An EMPTY Buffer under $and/$or/$not was DROPPED: the SQL had no WHERE at all, so every row came back. At head all of them compile as the top level does (blob = ?). The Date half is unchanged and true. New SQLite-only pins enforce the sentence: non-empty under $and (SQL, binding, rows), $or / sibling / $not (rows), empty under $and/$or (SQL, binding, rows). The measurement also exposed one defect this round had to fix. nullGuardForFieldSpec (the $not NULL guard) made the same any-non-array-non-Date-object-is-a-map reading, so after the round-1 fix an empty buffer under $not compiled to a bare NOT (blob = ?) and lost its NULL row, which contradicts the published $not is NULL-safe sentence. It now uses isFilterNode, and a fifth pin covers it. Clause-② stays no: both candidate sentences are verbatim on origin/main d1ca874. I judge a Buffer to be a bare value there, not an object, because the spec's own walkers classify it as a comparand (see clause2_judgement). Ablation: leg A (the two emitter hunks) = 15 failed / 10 passed / 2 skipped, with all 4 binary behaviour pins red; leg B (only the new guard line) = 1 failed / 24 passed / 2 skipped. Both restores proven.",
    "base_measurement": {
    "how": "SQLite in-memory, knex table with a binary column blob, rows r_ab=Buffer(ab), r_c=Buffer(c), r_empty=Buffer.alloc(0), r_null=NULL. The same probe ran BaseDriver (a temporary untracked copy of 029d8a4:packages/drivers/driver-sql/src/sql-driver.ts, blob bc27c909d4, identical to the base blob) and HeadDriver (8bde954). The probe and the copy were deleted before commit.",
    "base_029d8a4710": [
    "top { blob: Buffer(ab) } -> ["r_ab"], SQL where blob = ?",
    "$and / $or / $not / sibling(name $ne) with Buffer(ab), and $and with Uint8Array(ab) -> THREW INVALID_FILTER 400 Unsupported filter operator \"0\" on field \"blob\"",
    "top { blob: Buffer.alloc(0) } -> ["r_empty"], SQL where blob = ?",
    "$and / $or / $not with Buffer.alloc(0) -> RESOLVED every row ["r_ab","r_c","r_empty","r_null"], SQL with no WHERE clause at all"
    ],
    "head_8bde954772": [
    "all nested non-empty shapes -> compile blob = ? and answer ["r_ab"] ($not: ["r_c","r_empty","r_null"])",
    "nested empty $and/$or -> ["r_empty"]; $not empty -> ["r_ab","r_c"] with SQL not (blob = ?): NO NULL guard, so r_null was lost. This is the gap fixed this round"
    ],
    "platform_doors": "isAcceptedFilterComparand(Buffer)=false and (Uint8Array)=false. normalizeFilterComparandTypes and parseFilterAST refuse top-level, $and and empty-$and Buffer with INVALID_FILTER "Filter comparand at where...blob is a Buffer instance ..., which no driver can compare"; FilterConditionSchema.safeParse accepts. So the binary half reaches direct driver callers only."
    },
    "rewritten_text_verbatim": {
    "note": "Read from the committed files at fb4f11d. Each ASCII angle bracket in the code/test comments is spelled here as U+27E8/U+27E9 (⟨ ⟩) because GitHub's body sanitizer eats angle-bracket fragments. The byte-exact text is git show fb4f11dcf6:PATH at the line ranges named.",
    "changeset_bullet_lines_19_31": "- A Date comparand was dropped, and a binary one was refused or dropped. That\n path treated any non-array object as an operator map. A Date has no entries, so\n { $and: [{ closed_at: someDate }] } emitted no predicate for the leaf and\n answered every row on SQLite and Postgres, while { closed_at: someDate }\n answered the matching rows. A non-empty binary comparand (Buffer / Uint8Array)\n had its byte indices read as operator names, so it was refused with\n INVALID_FILTER / 400 Unsupported filter operator \"0\", although the same leaf\n at top level compiles. An empty one was dropped like the Date. Only a plain\n object is now read as an operator map (the same test the filter-validating walk\n already uses), so all of these compile as the equality they are at top level.\n The $not NULL guard now reads a comparand the same way, so a binary comparand\n under $not returns the rows whose column is NULL, as every other negated\n equality does; an empty one used to get no guard at all.",
    "changeset_reach_lines_37_38": "the engine's walk also keeps a Date leaf a Date. Both refuse a binary\ncomparand in every position, so the binary half reaches direct driver callers only.",
    "sql_driver_emitter_comment_lines_15412_15429": " // [#19885] An OPERATOR MAP is a plain object — the walk's own reading\n // ({@link isFilterNode}, which {@link classifyFilterKey} and the\n // top-level { field: value } loop in compileFilters agree with). This\n // test used to be "any non-array object", so a Date or binary comparand\n // landed here and was read as an operator map, with a different wrong\n // answer per shape:\n //\n // - a Date has no own entries, so its leaf was DROPPED:\n // { $and: [{ d: ⟨Date⟩ }] } answered every row on SQLite and Postgres\n // while the same { d: ⟨Date⟩ } at top level answered the one matching row;\n // - a NON-EMPTY binary comparand (Buffer / Uint8Array) had its byte\n // indices read as operator names, so it was REFUSED — INVALID_FILTER /\n // 400, Unsupported filter operator \"0\" — a comparand the top level binds;\n // - an EMPTY binary comparand has no entries either, so it was dropped\n // like the Date.\n //\n // Each is a comparand, and it now takes the bare-value branch below, the\n // same compilation the top-level loop gives it.",
    "sql_driver_null_guard_lines_4226_4236": " // Every comparand that is not an operator map — a scalar, a Date, an array, a\n // binary value — is an implicit =; a NULL column fails it.\n //\n // [#19885] "Not an operator map" is {@link isFilterNode}'s reading, the one the\n // emitter and the validating walk use. This test used to name the exceptions\n // one by one (Date, array) and read every other object as a map, so a binary\n // comparand was guarded by accident: a non-empty one's byte indices fell to\n // the per-operator default below, and an EMPTY one had no entries, came out\n // 'none', and { $not: { data: ⟨empty buffer⟩ } } compiled to a bare\n // NOT (data = ?) that dropped every NULL row.\n if (!isFilterNode(spec)) return 'requireValue';",
    "test_docblock_item2_lines_19_26": " * 2. It took "any non-array object" to be an operator map. A Date has no own\n * entries, so it emitted nothing and the leaf was dropped from the WHERE.\n * A NON-EMPTY binary comparand had its byte indices read as operator names,\n * so it was refused (INVALID_FILTER / 400, Unsupported filter operator\n * \"0\") where the top level binds it; an EMPTY one had no entries and was\n * dropped like the Date. The $not NULL guard (nullGuardForFieldSpec)\n * made the same reading, so an empty binary comparand under $not got no\n * guard at all.",
    "test_docblock_binary_table_lines_46_57": " * Binary comparands, measured on SQLite against the base blob of\n * sql-driver.ts (a blob column; rows ab, c, empty, NULL):\n *\n * | filter | before the fix | after |\n * |---|---|---|\n * | { blob: ⟨Buffer ab⟩ } (top level) | ['r_ab'], blob = ? | unchanged |\n * | the same leaf under $and / $or / $not / beside an operator sibling | 400 Unsupported filter operator \"0\" | compiles blob = ?, $and answers ['r_ab'] |\n * | { blob: ⟨empty Buffer⟩ } (top level) | ['r_empty'], blob = ? | unchanged |\n * | { $and: [{ blob: ⟨empty Buffer⟩ }] }, $or, $not | every row (no WHERE at all) | compiles blob = ? |\n \n * Binary is SQLite-only here: the dialect-matrix fixture below has no binary\n * column, and the platform doors refuse a binary comparand before any driver."
    },
    "pins": [
    "[#19885] SqlDriver — a binary comparand nested under a combinator (sqlite) > fixture control — each stored value finds its row at top level",
    "[#19885] SqlDriver — a binary comparand nested under a combinator (sqlite) > a non-empty Buffer leaf under $and compiles to the predicate and binding the top level compiles, and answers the same rows",
    "[#19885] SqlDriver — a binary comparand nested under a combinator (sqlite) > a non-empty Buffer leaf answers as the top level does under $or, beside an operator sibling, and under $not",
    "[#19885] SqlDriver — a binary comparand nested under a combinator (sqlite) > an EMPTY Buffer leaf under $and compiles to the top-level predicate and binding instead of being dropped",
    "[#19885] SqlDriver — a binary comparand nested under a combinator (sqlite) > an EMPTY Buffer leaf under $not keeps the NULL row, as every negated equality does"
    ],
    "pins_scope": "SQLite cell only, with its own table: the matrix fixture has no binary column, and the platform doors refuse binary before any driver.",
    "ablation": {
    "method": "Committed first (fb4f11d), then mutations via scripts/ablation-replace.mjs (anchors must hit x1, landing verified on disk), inside a bash trap restore on EXIT INT TERM; each leg is followed by a restore proof against the HEAD blob of sql-driver.ts 8102d188310d58a480eaa01a9af97389a70a5864. The subject is imported relatively from src/, so there is no dist leg.",
    "leg_A_two_emitter_hunks": "assertCompilableComparand line deleted (x1 -> x0, blob 8102d18831 -> 3c2f3bed29) and isFilterNode(value) branch reverted to the non-array-object test (x1 -> x0, blob -> fd65f2dab6). Result: 15 failed / 10 passed / 2 skipped. Red: 6 array refusals, 5 Date leaves and 4 binary pins (non-empty: Unsupported filter operator \"0\" on field \"blob\"; empty $and: SQL without WHERE; empty $not: ["r_null"]). Green: the binary fixture control and all 9 matrix controls, row sets and SQL pins. Restore proven: blob == HEAD, git diff HEAD empty, porcelain empty.",
    "leg_B_null_guard_hunk_only": "if (!isFilterNode(spec)) return requireValue reverted to the old typeof/Date/Array test (x1 -> x0, blob 8102d18831 -> b9a3ea721a). Result: 1 failed / 24 passed / 2 skipped. The only red is an EMPTY Buffer leaf under $not keeps the NULL row (got ["r_ab","r_c"], expected ["r_ab","r_c","r_null"]). Restore proven the same way.",
    "predicted_vs_observed": "Both counts were predicted before running (A 15/10/2, B 1/24/2) and observed exactly."
    },
    "clause2_judgement": "Clause-② stays no. Both candidate sentences are verbatim on origin/main d1ca874: the where row once (line 304) and the $eq row 5 times. I judge that a Buffer does NOT count as "an object" in "an object is a map of field operators". That phrase defines an object by its operator-map role, and the contract's own walkers (parseFilterAST, normalizeFilterComparandTypes) call a Buffer in a field-value position a "Filter comparand", refused on type and never read as an operator map. The driver's top-level loop and its validating walk (isFilterNode) agree. So the citation negates the nested Unsupported filter operator \"0\" refusal. The platform accept set does not move (both walkers still refuse binary everywhere). Residual for the reviewer: filter.mdx names no binary type at all, so this classification rests on the walkers' reading, not on a sentence that says "binary".",
    "pr_body_replacements": {
    "note": "For the seat to paste; I did not edit the PR body. No angle brackets.",
    "a_what_was_wrong_item_2": "2. It took "any non-array object" to be an operator map, with a different wrong answer per shape. A Date has no own entries, so it emitted nothing and the leaf was dropped from the WHERE clause. A non-empty binary comparand (Buffer / Uint8Array) had its byte indices read as operator names, so it was refused with INVALID_FILTER / 400 Unsupported filter operator \"0\" on field \"blob\", although the same leaf at top level compiles to blob = ?. An empty binary comparand had no entries and was dropped like the Date. nullGuardForFieldSpec, which builds the $not NULL guard, made the same reading. The fix gives it the same isFilterNode test. Before that, an empty buffer under $not got no guard and compiled to a bare NOT (blob = ?), which drops the NULL row. Measured on the same tree:\n\n| filter | SQLite | Postgres 16.13 |\n|---|---|---|\n| { at: someDate } (top level) | [\"r_a\"] | [\"r_a\"] |\n| { $and: [{ at: someDate }] }, $or, beside an operator sibling | every row | every row |\n| { $not: { at: someDate } } | only the NULL rows (the guard without its leaf) | same |\n\nBinary, measured on SQLite against the base blob of sql-driver.ts (029d8a4710), blob column, rows ab / c / empty / NULL:\n\n| filter | before | after (fb4f11dcf6) |\n|---|---|---|\n| { blob: Buffer ab } (top level) | [\"r_ab\"], blob = ? | unchanged |\n| the same leaf under $and / $or / $not / beside an operator sibling | 400 INVALID_FILTER, Unsupported filter operator \"0\" on field \"blob\" | compiles blob = ?; $and / $or / sibling answer [\"r_ab\"], $not answers [\"r_c\",\"r_empty\",\"r_null\"] |\n| { blob: empty Buffer } (top level) | [\"r_empty\"], blob = ? | unchanged |\n| the same leaf under $and / $or / $not | every row (no WHERE clause at all) | compiles blob = ?; $and / $or answer [\"r_empty\"], $not answers [\"r_ab\",\"r_c\",\"r_null\"] |\n\nThe engine's object-form walk keeps a Date leaf a Date, so server-side engine callers reach the Date half. parseFilterAST and the engine walk both refuse a binary comparand in every position, so the binary half reaches direct driver callers only.",
    "b_clause2_paragraph": "Clause-② declaration. Clause-②: no. Two accept-set movements need a citation. First, a non-empty binary comparand nested under a combinator moves from refused (Unsupported filter operator \"0\") to accepted. Second, an empty one moves from silently dropped to compiled. Both are removed misreadings, not a widening, and the published filter reference already negated the refusal. content/docs/references/data/filter.mdx on origin/main (d1ca8741dd), the where row, reads verbatim: "A field-keyed entry is a condition on that field — a bare value is implicit equality, an object is a map of field operators — and $and / $or / $not combine conditions." The $eq row reads: "the DEFAULT operator: a bare value written against a field key is the same condition as this one". A Buffer in a field-value position is a bare value under that text, not an object in its operator-map sense. The spec's own walkers classify it that way: on this tree parseFilterAST and normalizeFilterComparandTypes both answer { $and: [{ blob: Buffer }] } with "Filter comparand at where.$and[0].blob is a Buffer instance". That names it a comparand, refused on type, never read as an operator map named 0. The driver's own top-level loop and its validating walk (isFilterNode) read it the same way. So the nested refusal contradicted the published sentence, and the change brings the nested positions into line with it.\n\nThe platform accept set does not move: both walkers still refuse a binary comparand in every position. Only the direct-driver surface changes, and there only to the driver's existing top-level binding (isBindableComparand admits ArrayBuffer.isView). The $not NULL-guard change is covered by the same where row: "$not is NULL-safe: a row whose compared column is null does NOT satisfy the negated condition and IS returned.""
    },
    "files_changed": [
    "packages/drivers/driver-sql/src/sql-driver.ts",
    "packages/drivers/driver-sql/src/sql-driver-19885-nested-bare-comparand.test.ts",
    ".changeset/19885-nested-bare-comparand.md"
    ],
    "tests": "At fb4f11d. The new file on SQLite gives 25 passed / 2 skipped (20 matrix SQLite cases plus 5 binary pins; live PG and MySQL cells are named skips this round, see deviations). The full driver-sql suite, Test Core shape (pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2), gives Test Files 180 passed, 11 skipped and Tests 2670 passed, 170 skipped, lock VERDICT command-exit 0 (round 1 was 2665, plus the 5 new pins). pnpm --filter @objectstack/driver-sql typecheck: VERDICT command-exit 0. Ablation: see ablation.",
    "gates": {
    "head": "fb4f11dcf6",
    "requested": [
    "pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2 :: exit 0 (lock VERDICT command-exit 0)",
    "pnpm --filter @objectstack/driver-sql typecheck :: exit 0 (lock VERDICT command-exit 0)",
    "node scripts/check-issue-citations.mjs :: exit 0 (3 citations judged, 3 resolve)",
    "node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 (no major bump)",
    "pnpm check:nul-bytes :: exit 0 (9331 tracked text files, no raw control bytes)"
    ],
    "rederived": "node scripts/pm/dispatch-gates.mjs --commands at fb4f11d: the same 62 commands as round 1 (diffed identical); change set 3 paths, 458 changed lines.",
    "ran": [
    "node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0",
    "node scripts/check-adr-0087-registration.mjs --self-test :: exit 0",
    "node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0",
    "node scripts/check-changeset-no-major.mjs --self-test :: exit 0",
    "node scripts/check-ci-filter-parity.mjs :: exit 0",
    "node scripts/check-closing-keyword-parity.mjs :: exit 0",
    "node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0",
    "node scripts/check-comment-mask-adoption.mjs :: exit 0",
    "node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0",
    "node scripts/check-comment-mask-corpus.mjs :: exit 0",
    "node scripts/check-empty-changeset.mjs --base origin/main :: exit 0",
    "node scripts/check-empty-changeset.mjs --self-test :: exit 0",
    "node scripts/check-keyed-text-bounds.mjs :: exit 0",
    "node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0",
    "node scripts/check-platform-object-tenancy-census.mjs :: exit 0",
    "node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0",
    "node scripts/check-plugin-teardown-shape.mjs :: exit 0",
    "node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0",
    "node scripts/check-registry-log-declared.mjs :: exit 0",
    "node scripts/check-registry-log-declared.mjs --self-test :: exit 0",
    "node scripts/check-rest-log-spy-declared.mjs :: exit 0",
    "node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0",
    "node scripts/check-system-context-census.mjs :: exit 0",
    "node scripts/check-system-context-census.mjs --self-test :: exit 0",
    "node scripts/check-undeclared-dep-imports.mjs :: exit 0",
    "node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0",
    "node scripts/docs-audit/check-affected-docs.mjs :: exit 0",
    "node scripts/docs-audit/check-drift-comment.mjs :: exit 0",
    "node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0",
    "pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0",
    "pnpm check:changeset-gate-self-tests :: exit 0",
    "pnpm check:cross-package-test-inputs :: exit 0",
    "pnpm check:dispatcher-error-vocabulary :: exit 0",
    "pnpm check:doc-authoring :: exit 0",
    "pnpm check:driver-conformance :: exit 0",
    "pnpm check:driver-memory-census :: exit 0",
    "pnpm check:dts-closure :: exit 0",
    "pnpm check:dual-build-cjs-loads :: exit 3",
    "pnpm check:engine-double-contract :: exit 0",
    "pnpm check:gitlink-declared :: exit 0",
    "pnpm check:issue-citations :: exit 0",
    "pnpm check:lean-entry-closure :: exit 0",
    "pnpm check:logger-receiver-detach :: exit 0",
    "pnpm check:nul-bytes :: exit 0",
    "pnpm check:object-def-param-keys :: exit 0",
    "pnpm check:objectql-double-limit :: exit 0",
    "pnpm check:objectui-changeset :: exit 0",
    "pnpm check:org-identifier :: exit 0",
    "pnpm check:page-declaration-shape :: exit 0",
    "pnpm check:pm-changeset-deadline-census :: exit 0",
    "pnpm check:published-files :: exit 0",
    "pnpm check:query-options-erasure :: exit 0",
    "pnpm check:refd-timer-probe :: exit 0",
    "pnpm check:slot-lookup :: exit 0",
    "pnpm check:sourcemap-no-sources-content :: exit 0",
    "pnpm check:tenant-chokepoint :: exit 0",
    "pnpm check:test-source-alias :: exit 0",
    "pnpm check:tier-file-adoption :: exit 0",
    "pnpm check:type-check-coverage :: exit 0",
    "pnpm check:type-check-debt :: exit 3",
    "pnpm check:watch-hint-literal :: exit 0",
    "pnpm check:where-matcher :: exit 0",
    "node scripts/check-issue-citations.mjs :: exit 0"
    ],
    "ran_verdict": "node scripts/pm/dispatch-gates.mjs --ran exit 0: ✓ 62 derived famil(ies) accounted for — 60 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3), 0 UNRUN",
    "not_measured": [
    "pnpm check:dual-build-cjs-loads :: exit 3 PREREQUISITE NOT MET (every package dist needed). Declared to CI",
    "pnpm check:type-check-debt :: exit 3 PREREQUISITE NOT MET (the ./packages/
    build closure needed). Declared to CI"
    ],
    "extra": [
    "npx eslint --no-inline-config --format json on the 2 touched .ts files :: exit 0, 2 files, 0 errors, 0 warnings (the same population and invariance evidence as round 1: no type-aware linting in eslint.config.mjs; the changeset .md is outside the population)"
    ]
    },
    "deviations": [
    "Beyond the patch order: the nullGuardForFieldSpec predicate (one line, same isFilterNode swap) is fixed and pinned. The round-1 emitter hunk made an empty binary comparand under $not compile for the first time, and it compiled with no NULL guard (NOT (blob = ?) lost r_null). That contradicts the published where row: "$not is NULL-safe: a row whose compared column is null does NOT satisfy the negated condition and IS returned." A published defect this change touches is one I must fix. Only non-plain-object comparands move (binary, class instances); scalars, Date, arrays and operator maps are classified exactly as before.",
    "Second ablation leg (B) beyond "one ablation": it isolates the new guard hunk, which leg A cannot, because the reverted emitter drops the empty leaf and reddens that pin on its own.",
    "Live PG not re-run this round: the private instance was torn down after round 1. The round-2 code change moves only non-plain-object comparands, and the matrix fixture has none, so the PG cell's answers are unchanged by construction. CI's live job runs the matrix at the new head. MySQL: still NOT MEASURED locally.",
    "Ran the whole re-derived gate list plus --ran at the new head (beyond the five requested), because a push followed round 1's reconciliation.",
    "Ablation leg A's mid-run diagnostic grep for the guard used a loose pattern that also matches assertDefinedComparands' if (!isFilterNode(spec)) return;, so it printed 2 (leg B: 1). The leg-B anchor itself was exact and hit x1.",
    "Rewritten code/test comments quoted with ⟨ ⟩ in place of ASCII angle brackets (see rewritten_text_verbatim.note). The committed bytes are unchanged.",
    "This supersedes round 1's deviation text "Date/binary dropped-leaf defect", which was the false sentence.",
    "No label writes; the PR body is not edited (replacement text is in pr_body_replacements)."
    ],
    "mcp_calls": "0 — no MCP tool used",
    "api_writes": "1 REST write this round, via the fleet-write relay: this os-dev-report = POST /repos//issues/19885/comments (scripts/pm/post-stamped.mjs, repository_dispatch). The git push (8bde954..fb4f11d, fast-forward) is not a REST write. All other GitHub calls were REST GETs (none this round besides the read-back).",
    "open_questions": [],
    "out_of_scope_findings": []
    }


    Generated by Claude Code

  7. objectstack-fleet commented on Sep 23, 2026

    @objectstack-fleet
    Contributor

    Contract review

    Served-tier: CONTRACT_REVIEW_TIER
    Head-sha: fb4f11dcf609eda3156cd7f39df24198eda6d45d

    Narrow re-review of the patch round over record 5799939764 (head 8bde954772, FAIL on one binary sentence). 8bde954772 is an ancestor of the head. The delta is the single commit fb4f11dcf6, over the same 3 files: the changeset, the test file (five binary pins) and sql-driver.ts. The only non-comment code change in sql-driver.ts is nullGuardForFieldSpec's test, typeof spec !== 'object' || spec instanceof Date || Array.isArray(spec), becoming !isFilterNode(spec). The PR is mergeable_state: clean against origin/main 8490127962. It was measured on SQLite with the base blob bc27c909d4, the round-1 blob 9f204f7468 and the head blob 8102d18831 loaded in one probe; PostgreSQL and MySQL are judged by reading, plus the head's green live job. Everything record 5799939764 judged true that the delta does not touch is CARRIED: ①(a)–(d), the array and Date halves of ②, and all five ③ flags.

    ① Derived judgments

    (a) Every rewritten binary sentence is TRUE, measured at base on SQLite (blob column; rows ab, c, empty, NULL).

    • Base, non-empty: a Buffer or Uint8Array under $and, $or, $not or beside an operator sibling was refused INVALID_FILTER / 400 Unsupported filter operator "0" on field "blob". The top level compiles blob = ?.
    • Base, empty: the SQL had no WHERE at all, so every row came back.
    • Head: every nested binary leaf compiles ((blob = ?)) with the top-level binding.
    • Round 1: an empty leaf under $not compiled to a bare NOT (blob = ?) and lost the NULL row, as the dev reports.
      The changeset bullet and reach sentence, both code comments, the test file's item 2 and binary table, and PR-body item 2 with both tables all hold. Both platform doors (normalizeFilterComparandTypes, parseFilterAST) refuse a binary comparand in every position. Reading note, not a defect: PR-body item 2's "Before that … a bare NOT (blob = ?)" describes the round-1 tree, and the base table beside it gives the base answer.

    (b) The nullGuardForFieldSpec change, enumerated as { $not: { f: X } } at base, round 1 and head:

    • Unchanged by the delta: a scalar, null, $-keyed maps, null-prototype $eq maps, a no-$ object (refused on all three), and a non-empty binary comparand (byte-identical SQL at round 1 and head).
    • The ONE observable movement: an empty Buffer / Uint8Array. Round 1 compiled NOT (blob = ?) and answered ["r_ab","r_c"]; head compiles NOT ((blob IS NOT NULL) AND (blob = ?)) and answers ["r_ab","r_c","r_null"]. That is toward the published where row ("$not is NULL-safe: a row whose compared column is null does NOT satisfy the negated condition and IS returned").
    • Class instances and Maps are refused as unbindable at round 1 and head, so the guard's change for them is unobservable.
      No shape in the published accept set changes SQL or rows. Pins and ablation are consistent: the head file gives 25 passed / 2 skipped on SQLite. Against the round-1 blob (leg B) it gives 1 failed / 24 passed / 2 skipped, the only red being the empty-Buffer-under-$not pin. Against the base blob it gives 15 failed / 10 passed / 2 skipped.

    (c) The $not NULL-guard change is a restored guarantee, not a moved accept answer: the same filter stays accepted, and its row set moves to what the published sentence requires. Reach: direct driver callers with an empty binary comparand only.

    (d) CI at head, latest per name over 34 names: 29 success, 5 skipped, 0 failed, 0 in progress; all seven required contexts success. Vercel is an external commit status.

    ② Semver level

    '@objectstack/driver-sql': patch, Clause-②: no: HOLDS. For the binary half, both quoted sentences are VERBATIM on origin/main 8490127962 and d1ca8741dd:

    • the where row (line 304), "A field-keyed entry is a condition on that field — a bare value is implicit equality, an object is a map of field operators — and $and / $or / $not combine conditions.";
    • the $eq row, "the DEFAULT operator: a bare value written against a field key is the same condition as this one".
      filter.mdx names no binary type, but the citation carries on two independent legs.
    • The refusal was a misreading, not a deliberate refusal: the generic unknown-operator arm fired on byte indices, and no ruling declares binary refused under a combinator. The spec's own implementation reads "an object" as a PLAIN object: isFilterNode in packages/spec/src/data/filter-comparand-type.ts treats "a Date, a Map or a class instance" as data, not structure, and normalizeFieldSpec treats everything else in a value position as the implicit-equality literal.
    • The published accept set does not move: both doors refuse binary everywhere, before and after. The driver-local binary bindable is only extended to positions where the driver already read the same value as a comparand.
      No export or type changes; the guard change is one statement in a module-private function.

    ③ Boundary flags

    • The second code change (the nullGuardForFieldSpec line): ACCEPTABLE and required. Round 1 made the empty binary leaf under $not compile for the first time, without the guard the published where row promises. It is the same class and predicate, pinned, and ablation leg B isolates it.
    • Ablation leg B beyond "one ablation": ACCEPTABLE. Leg A cannot isolate the guard.
    • Live PostgreSQL not re-run this round: ACCEPTABLE. The delta moves only non-plain-object comparands, and Temporal Conformance (live PG + MySQL) is success at this head.
    • The PR-body referent: NOTE ONLY.
    • Carried: the in-place Date fix; A3 over the offending-leaf path; MySQL unmeasured locally; { $or: [{}, { tags: ['a'] }] } answered by the identity (pre-existing; suggested to file separately); merge-tree against fix(driver-turso): converge remote date/json cells the pre-fix batch door stored unconverted #19904.

    Implemented-by: claude/issue-19885-nested-comparand-check
    Reviewed-by: session_01TEhopqrWQYBycZzyJHpAZr

    VERDICT: PASS


    Generated by Claude Code

  8. objectstack-fleet commented on Sep 23, 2026

    @objectstack-fleet
    Contributor

    ACCEPT — round 21 · PR #19908 · Fixes

    domain:engine#1, session_01TEhopqrWQYBycZzyJHpAZr, written 2026-09-23T18:28Z. Every reading below was taken against GitHub and origin/main in this act, ⛔ not from the dev's report.

    Review

    check reading
    head fb4f11dcf6 (patch round 1, fast-forward from 8bde954772)
    PR shape draft → main; first body line Fixes #19885; no other closing keyword; Clause-②: no at column 0, agreeing with the patch changeset
    scope 3 files: sql-driver.ts (three statements: assertCompilableComparand in applyFilterCondition's bare-value branch, isFilterNode for the operator-map test there, and the same test in nullGuardForFieldSpec), one new test file, one changeset
    grade re-graded p3 → p2 on the PostgreSQL reading triage asked for (5799649211): the nested array leaf was a silent wrong answer there
    array half every nested array leaf ($and, $or, $not, depth 2, beside an operator sibling) now gets exactly the top-level INVALID_FILTER / 400, message and diagnostic byte-equal; valid scalar SQL byte-identical
    in-place halves Date (dropped → compiles), binary (non-empty refused as operator "0" / empty dropped → compiles), and the $not NULL guard for an empty binary comparand: the same defect class in the same file, each pinned and ablated
    clause ② no, patch. The array half makes a refusal the published contract already requires (ruling 乙 on #19757) consistent across positions. The binary refuse-to-accept movement is a removed misreading; the PR body cites content/docs/references/data/filter.mdx verbatim, and both platform doors still refuse binary everywhere
    governed NOT governed (check-governed-merges --pr 19908, 0 of 3 paths; +455 / −3)
    contract review FAIL on 8bde954772 (5799939764: one false binary sentence) → patch round → PASS on this head (5800558272)
    CI latest per name on this head: 29 success, 5 skipped, 0 failed; all seven required contexts success, Temporal Conformance (live PG + MySQL) included

    Out-of-scope findings — dispositions:

    Landing: ready → queue on this head once CI is fully green.


    Generated by Claude Code

  9. added 2 commits that reference this issue on Sep 28, 2026
    beac798
    7536721
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions