Skip to content

driver-sql: a declared operator on a multiple: true (JSON array) column silently answers wrong — $in/$eq always zero rows, $nin returns the rows it was asked to EXCLUDE #7398

Description

@baozhoutao

Summary

A declared filter operator applied to a column the operator cannot mean anything for is compiled to SQL and executed anyway, returning a wrong answer with a 200. No error, no warning, nothing for a type checker to catch.

Concretely, on a field declared multiple: true (which driver-sql stores as a JSON text column):

  • $in / $eq / bare equality → always zero rows (fail-closed)
  • $nin → returns the rows it was asked to EXCLUDE (fail-open) ⚠️

The second one is the reason I am filing this rather than logging it as a footgun.

⚠️ $nin on an array column inverts the result — this is the dangerous half

{ members: { $nin: [U1] } } returned exactly the record whose members contains U1.

The mechanism is one line of SQL: the stored column value is the text ["U1","U2"], so
members not in ('U1') is true — the text genuinely is not equal to that uuid. "Exclude these"
therefore compiles to "return everything".

Why this matters more than the $in case:

Measured

Environment: @objectstack/* 17.0.0-rc.5, SqlDriver(better-sqlite3), single-tenant dev runtime,
queries issued over the REST list face (GET /api/v1/data/:object?filter={json}).

Fixture: one record in the table. Its multi-value lookup column members holds ["U1","U2"]
(shown here as U1/U2; they are ordinary record ids).

Multi-value (multiple: true) column — members

filter HTTP rows returned verdict
{members:{$in:[U1]}} 200 0 ✗ silently empty
{members:{$in:[U1,U2]}} 200 0 ✗ silently empty
{members:{$eq:U1}} 200 0 ✗ silently empty
{members: U1} (bare equality) 200 0 ✗ silently empty
{members:{$contains:U1}} 200 1 ✅ the only thing that works
{members:{$contains:U2}} 200 1 ✅
{$or:[{members:{$contains:U1}},{members:{$contains:U2}}]} 200 1 ✅ the only "any of" spelling
{members:{$nin:[U1]}} 200 1 ⚠️ inverted — this row contains U1
{members:{$overlaps:[U1]}} 400 — undeclared operator, loudly refused
{members:{$containsAny:[U1]}} 400 — undeclared operator, loudly refused
{members:{$in:[[U1,U2]]}} 500 — DATABASE_ERROR (this is the #5234 face, not this one)

Control — the same object's single-value lookup column owner

filter HTTP rows verdict
{owner:{$in:[U1]}} 200 1 ✅

So $in itself is fine. What is broken is $in meeting an array column.

Raw SQL, same table, same row

Reading the column and querying it directly, with no platform in the path:

stored value:  ["U1","U2"]

SELECT COUNT(*) … WHERE members IN ('U1')                 -> 0
SELECT COUNT(*) … WHERE members LIKE '%U1%' ESCAPE '\'    -> 1

That is the whole bug in two lines. driver-sql lowers $in straight to col in (?, ?, …)
(the list('in') arm of applyNormalizedComparison) without consulting the column's type,
and a JSON text column is never equal to any single member it contains. $contains "works" only
because it lowers to LIKE '%value%' and the JSON serialization happens to contain the substring.

Why I read this as a defect and not as documented behaviour

The platform already chose "refuse" on every adjacent seam — this is the one square on the grid
with no gate:

So writes check the field's type against the payload; reads do not check the field's type against
the operator. Given isJsonField(type, field) { return JSON_COLUMN_TYPES.has(type) || !!field.multiple }
already exists in driver-sql, the information needed for the check is present at the point of lowering.

And the failure mode is the worst available one: 200 + an empty array is byte-identical to a
successful query that legitimately matched nothing.
There is no signal for a caller to key on.

What it cost downstream (why I bothered to measure it)

In a downstream business app, a delete-guard was written as:

plans.find({ where: { assignees: { $in: memberIds } } })   // assignees: multiple lookup

The rule it implements is "refuse to delete a team while any of its members is still referenced by a
plan". Because the predicate matched nothing, the guard never fired once since the feature shipped.
It threw no error, logged nothing, and type-checked. It was found only when someone happened to test
the guard's positive path by hand. A full sweep of that codebase then had to be run to prove there
was no second occurrence.

The general shape: this bug class does not produce incidents, it produces rules that quietly do not
exist
. That is why "the caller should know better" is a weak answer here — there is nothing for the
caller to notice.

Asks (either would fix it; the first is the smaller change)

  1. Refuse it. Bring "declared operator × column type it cannot apply to" under the same
    400 INVALID_FILTER envelope that already covers unknown operators and malformed comparands —
    naming the operator, the field, and the fact that the filter was not applied. Minimum viable
    set: $in / $nin / $eq / bare equality / the ordering comparisons, against field.multiple
    (and the other JSON_COLUMN_TYPES) columns.
  2. Or give array columns a real membership operator. Today $contains is declared as a string
    operator ($contains: z.ZodString, in StringOperatorSchema) and works on array columns purely
    because the JSON serialization is text and LIKE '%v%' happens to hit. That is incidental, not
    designed — it is substring matching over a serialization, so it is also sensitive to how the
    column is serialized. There is likewise no way to express "any of these values" in one query;
    the only spelling is an $or of N $contains, since FILTER_OPERATORS is a closed set of 15
    with no $overlaps / $containsAny.

If the ruling is "1 only", that is completely fine for me downstream — a loud refusal is all the
downstream app needs, and $or + $contains is a perfectly workable spelling. The current state
(silently wrong, and inverted for $nin) is the only outcome that cannot be worked with.

Refs

Minor, same code path, mentioned only so it is not lost

The refusal message for an unknown operator advertises $regex as supported:

Unsupported filter operator "$overlaps" on field "members". Supported operators:
$eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $between, $contains, $notContains,
$startsWith, $endsWith, $regex, $null, $exists.

$regex is in RETIRED_FILTER_OPERATORS per the #4706 ruling, so the error text is pointing authors
at a retired operator. Not worth its own issue — just noting it since it is emitted from the same area.

Activity

  1. self-assigned this
    on Aug 10, 2026
  2. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    Contributor

    Claim: PM loop, drivers lane — queue-jumped on the maintainer's direct instruction (2026-08-10 ~09:4xZ:「7498 插队」).

    Session: session_01Hg9Pkg5nDedCRihRsdeCdX
    Branch: claude/issue-7398-multiple-column-operator-refusal
    Worktree: objectstack-issue-7398 (cloud session — own container, Opus)

    On the number, stated openly so a correction can land: the instruction said 7498; no issue or PR 7498 exists in objectstack, objectui, or cloud (three 404s), and this repo's counter stood at 7398 when the instruction arrived. This card — filed by the maintainer minutes earlier, driver-sql, this lane — is the unique plausible referent, so I am reading 7498 as a typo for 7398. If that reading is wrong, say so and I will re-route; nothing here forecloses that.

    This jumps ahead of the normal order while #7332's dev is still in flight — the maintainer's instruction overrides this lane's one-card cadence. Both cards touch sql-driver.ts but in disjoint regions (#7332: index introspection :6781-6864; this card: filter lowering :7838-8460); the merge queue serializes whatever lands second.

    Premises verified on origin/main @ d53bd0ba9 — with one delta the dev must know:

    • The lowering is where the card says: applyNormalizedComparison :7838; its list arm :7870 (case 'in': case '$in': return list('in') at :7895; case 'nin': case 'not_in': case 'notin': case '$nin': return list('not in')), with no column-type consultation. Bare equality routes through :8030. ⚠️ There is a second, plain-column lowering family — :8370 const mIn = logicalOp === 'or' ? 'orWhereIn' : 'whereIn' — so the fix must enumerate all lowering sites, not just the one the card names.
    • The type information is present at the point of lowering, as the card claims: this.jsonFields per-table (:2369, populated :5361/:5442), isJsonField :9052.
    • The refusal precedent is in-file: unsupportedFilterError :547, thrown from the unknown-operator default arm :8445.
    • ⭐ The card's "minor" note is ALREADY FIXED on main. The message at :8445 now lists $icontains, not $regex, and [#5702] routes retired spellings to a prescription (retiredFilterOperatorError). The card measured 17.0.0-rc.5. Verify and report; do not "fix" it again.

    Scope ruling for this dispatch — ask 1 only. The card offers two asks and itself says ask 1 suffices ("If the ruling is '1 only', that is completely fine… a loud refusal is all the downstream app needs"). Ask 2 ($overlaps / $containsAny) would open the closed 15-operator FILTER_OPERATORS spec set — a cross-lane spec change that needs its own maintainer ruling, not a rider on a driver fix. ⛔ Do not implement ask 2; if the measurement suggests it is warranted, file it as a finding for the decision box.

    The one behaviour that must NOT change: $contains (and $or-of-$contains) on these same columns — it is the downstream app's only working spelling, and the card's own tables depend on it. The refusal must be surgical: the operators that are meaningless against a JSON-array column, not the column itself.


    Generated by Claude Code

  3. claude commented on Aug 10, 2026

    @claude
    Contributor

    os-dev report

    Ask 1 implemented in driver-sql. Branch claude/issue-7398-multiple-column-operator-refusal, draft PR #7415 (3db8a3e, 4acc2cd). Ask 2 not implemented — see the findings at the end.

    Lowering sites enumerated

    Every filter this driver compiles passes through one funnel — applyFilters → applyFilterCondition — reached by find/findOne/count/aggregate/distinct/updateMany/deleteMany/findWithWindowFunctions/analyzeQuery. Inside it there are four emitters, not one:

    # emitter reached when what it compiles
    A applyFilterCondition operator switch — $in → whereIn/orWhereIn, $nin → orWhereNotIn, $eq/$ne/ordering → where(field, op, v) a { field: { $op: v } } node on an ordinary column the card's measured members case
    B applyNormalizedComparison — whereRaw, the list/binary arms filterColumnExpr(table, field, col) returns non-null a JSON column that is ALSO an un-canonicalised datetime/time column
    C applyFilters plain-map loop { field: value } when no key of the filter carries an operator bare equality
    D applyFilterCondition bare-value branch { field: value } when some key carries an operator bare equality

    The gate is installed at the three entry points (A/B share one, placed ahead of both), so all four are covered:

    • applyFilters plain-map loop, beside assertCompilableComparand → covers C
    • applyFilterCondition operator branch, beside assertCompilableComparand, before the calendar-day rewrites and before applyNormalizedComparison → covers A and B
    • applyFilterCondition bare-value branch → covers D

    Two other whereIn sites in the file are driver-internal, not caller filters, and are deliberately untouched: the id-list builder (getBuilder(...).whereIn('id', ids)) and the tenant-scope predicate.

    Positive control on the enumeration

    ⚠️ The card and the claim comment both name applyNormalizedComparison :7838 / its list arm as the site that lowers the measured $in. It is not that site. filterColumnExpr returns non-null only for a SQLite column needing legacy datetime/time repair; for a managed multiple: true lookup it returns null, so the card's members fixture is served entirely by family A. Verified by calling filterColumnExpr('team','members','members') → null. The claim comment's "second plain-column family at :8370" is in fact the primary family, and the only one the card's own table exercises.

    Family B is nevertheless real and reachable, and that is what makes it a control rather than a footnote. registerExternalObject populates datetimeFields but never runs backfillCanonicalDatetimes, so canonicalDatetimeFields stays empty and needsLegacyDatetimeRepair stays true. Measured on main, an external object with a multiple: true datetime column:

    filterColumnExpr: (case when typeof(??) in ('integer','real') then strftime(…) … end)   ← non-null
    | $in   | rows=0 |   | $nin  | rows=1 |   | $eq | rows=0 |   | bare | rows=0 |
    

    Identical defect, different emitter. The new test file carries this cell, and it asserts its own premise (filterColumnExpr is non-null, isJsonColumn is true) so it cannot silently degrade into a copy of the family-A block. See "Reverse verification" for what happens when the gate is placed in only one family.

    The predicate, and why

    Chosen: the existing jsonFields registry — i.e. isJsonField(type, field) = JSON_COLUMN_TYPES.has(type) || !!field.multiple. Not scoped to multiple, and not a new list.

    1. The mechanism is the storage, not the array-ness. A structured-JSON column shows the identical defect, including the fail-open half. Measured on this fixture before the change: {address:{$eq:'Beijing'}} → 0 rows; {address:{$nin:['Beijing']}} → the row it was asked to exclude. Scoping to multiple would leave that live at a different field type.
    2. The ordering comparisons are worse than empty on both. $lte on members matched, because ["usr_…" sorts below usr_… on the leading [. A lexicographic verdict over a serialization is a wrong answer, and it is no less wrong for {"city":…}.
    3. $eq on a structured-JSON column has no contract to preserve. It could only ever "work" by byte-exact serialization match, which is key-order dependent — not something the platform declares.
    4. Single source. JSON_COLUMN_TYPES carries a header calling itself the single source for the DDL switch and isJsonField "so the two can't drift". A filter-side copy of "which columns are JSON" would be a third list to keep in step.

    Deliberately absent from the refused set: the LIKE family ($contains, $notContains, $startsWith, $endsWith, $icontains) and the null predicates ($null, $exists) — presence is a well-formed question whatever the column holds.

    Deliberately conservative: a table with no jsonFields entry answers false. A table the driver was never told about (raw knex, as the conformance sweeps use) has no field types to consult, and inventing a refusal from an absent registry would refuse filters on ordinary columns.

    One addition beyond the card's stated minimum: $between. It is >= AND <=, and this driver decomposes a calendar-day $between into $gte/$lt a few lines above the emitter. Refusing the halves and compiling the compound would leave the same wrong answer at one more spelling. Flagging it explicitly since the card's minimum set stopped at the four ordering comparisons.

    Operators refused, all spellings either emitter answers: $eq/=/==, $ne/!=/<>, $gt/>, $gte/>=, $lt/<, $lte/<=, $in/in, $nin/nin/not_in/notin, $between/between, plus the bare { field: value } spelling.

    The card's table, before → after

    Fixture: one row, members = ["usr_1111","usr_2222"], stored as '["usr_1111","usr_2222"]' (asserted in the test).

    filter before (main) after
    {members:{$in:[U1]}} 200, 0 rows 400 INVALID_FILTER
    {members:{$in:[U1,U2]}} 200, 0 rows 400 INVALID_FILTER
    {members:{$eq:U1}} 200, 0 rows 400 INVALID_FILTER
    {members: U1} (bare) 200, 0 rows 400 INVALID_FILTER
    {members:{$contains:U1}} 200, 1 row 200, 1 row — unchanged
    {members:{$contains:U2}} 200, 1 row 200, 1 row — unchanged
    {$or:[{…$contains U1},{…$contains U2}]} 200, 1 row 200, 1 row — unchanged
    {members:{$nin:[U1]}} 200, 1 row — the excluded row ⚠️ 400 INVALID_FILTER
    {members:{$overlaps:[U1]}} 400 unknown operator 400 unknown operator — unchanged
    {owner:{$in:[U1]}} (control) 200, 1 row 200, 1 row — unchanged

    Rows the card did not have, measured on the same fixture:

    filter before note
    {members:{$ne:U1}} 200, 1 row the same fail-open inversion as $nin, unlisted in the card
    {members:{$lte:U1}} 200, 1 row wrong, not empty — lexicographic on the leading [
    {members:{$gt:U1}} 200, 0 rows
    {members:{$between:[U1,U2]}} 200, 0 rows
    {address:{$eq:'Beijing'}} 200, 0 rows structured JSON, not multiple
    {address:{$nin:['Beijing']}} 200, 1 row ⚠️ structured JSON — same inversion

    All of the above are 400 INVALID_FILTER after. Every reproduction ran on all of find/count/aggregate, with identical answers per row.

    Faces covered (#6203)

    The gate sits in the shared funnel, and the sweep asserts it per face rather than trusting that: find, findOne, count, aggregate, distinct, updateMany, deleteMany — each × each refused operator × bare equality, plus a $contains positive on each. The write faces additionally assert that a refusal left nothing mutated, and their $contains positive is measured on the row count against a scratch row (a where-clause reaching no row would satisfy a bare "it resolved" and prove nothing). Depth is covered too: the refusal holds inside $or, $and and $not.

    Conformance grid — checked, no cell affected

    • FILTER_LOGIC_CASES / FILTER_LOGIC_ROWS (packages/spec/src/data/filter-logic-conformance.ts): the fixture is nine t.string() columns (id,a,b,c,d,owner,status,parent_object,parent_id) created through raw knex, with no initObjects. jsonFields is therefore empty for that table and the gate is structurally inert there. No case exercises any operator on a multi-value column.
    • FILTER_TEXT_CASES: text/case folding on string columns. Unaffected.
    • pnpm check:driver-conformance (the registry gate, 分页读取在没有 orderBy 时同样不确定:tie-breaker 只覆盖了「排了序的翻页」 #4363) — green. No case-set added, no cell turned red.
    • All five conformance suites green: driver-sql's sql-driver-or-filter sweep, sqlite-wasm, turso (local + remote); driver-memory and driver-mongodb untouched and green in the full run.

    No STOP condition triggered.

    Reverse verification (predicted, then measured — both directions)

    Predicted before running:

    1. Zero existing driver-sql tests go red. Basis: only four test files declare a multiple: true or JSON-typed field (sql-driver-array-fields, sql-driver-bulk-json, sql-driver-schema, sql-driver-deferred-ddl, plus numeric-fidelity/external-remote-name), and every one of them filters on id/name only — none filters on the JSON column.
    2. Possible cross-package reds where a platform object declares multiple: true and something filters it (objectql, rest, runtime, services, plugins).

    Measured:

    1. Confirmed — 0 reds in driver-sql (82 files / 1285 tests green).
    2. Did not appear — 0 reds repo-wide (135/135 turbo tasks). Per the dispatch's rule I suspected the fixture first rather than declaring the absence a result: the new tests demonstrably do fire (129 pass; 95 of 129 fail when the gate is removed), so the gate is live and simply nothing in the repo was filtering a JSON column with a scalar operator.

    Removing the fix (all three gate calls deleted): 95 / 129 red, 34 green — the 34 being the $contains pins, the scalar-column controls and the $overlaps unknown-operator pin, exactly the set that should survive.

    Positive control on the site enumeration — the gate moved one line later, after applyNormalizedComparison instead of before it: exactly 6 red, 123 green. The six are the external multi-value-datetime cells; every managed-column cell stays green. That is the measurement behind the claim that family B is a separate site and not a restatement of family A.

    Existing tests asserting the OLD silent behaviour: none. Nothing was deleted, loosened, skipped or retried. The only edit to an existing gate's input was my own new test file (see the ratchet note below).

    Gates, per CI job

    job result
    CI / Test Core (turbo run test) 135 / 135 tasks green. driver-sql 82 files / 1285 tests (was 81 / 1156 — +1 file, +129 tests)
    CI / Temporal Conformance (live PG + MySQL) not runnable here (no servers provisioned). The gate throws before any SQL is built, so it is dialect-independent; the new test pins better-sqlite3 explicitly, like its sibling sql-driver-filter-refusal-envelope.test.ts
    Lint & Type Check / ESLint pnpm lint clean
    Lint & Type Check / TypeScript pnpm typecheck 126 / 126 green
    Lint & Type Check / ratchets all 43 check:* gates in lint.yml pass. ⚠️ One went red on the first pass and is fixed in 4acc2cd: check:query-options-erasure counted an as any at the aggregate face's options position (test surface 249 → 250). The cast was gratuitous — aggregations is on DriverQuery — so it is typed rather than laundered; back at 249
    changeset gates check-empty-changeset and check-adr-0087-registration green. patch, not declared-breaking, so no ADR-0087 marker is owed

    One flake, unrelated and not caused by this change: @objectstack/lint > runtime-lazy-deps.test.ts timed out at 30 s under a saturated runner (two full suites overlapping). It passes in isolation (70 files / 1852 tests), and the test's own comment records that a busy runner false-reds it.

    The card's "minor" note — verified, no change

    Confirmed stale, exactly as the claim comment said. On main the unknown-operator message at the default arm lists … $startsWith, $endsWith, $icontains, $null, $exists. — no $regex — and [#5702]'s retiredFilterOperatorError routes retired spellings to a prescription before the vocabulary list is ever reached, pinned by sql-driver-icontains-and-retired-operators.test.ts. Nothing done.

    Anything else the card got wrong

    1. The named lowering site is not the one the card measured — detailed above. This is the substantive correction; a fix installed only where the card pointed would have covered family B and missed the fixture in the card's own table, and a fix installed only in family A would have left the external case silently wrong.
    2. FILTER_OPERATORS is 16, not 15. The card says "a closed set of 15"; $icontains joined it in drivers: $regex 响亮拒收 + $icontains 各后端实现(#4706 裁决 B 案 · 驱动半边) #5702. Immaterial to the ask, but the closed-world sweep reads the export rather than a number, so it cannot drift either way.
    3. Everything else in the card reproduces byte-for-byte, including the raw-SQL two-liner.

    Findings recorded, not acted on

    • $overlaps / $containsAny (the card's ask 2) — not implemented, per the dispatch. Worth noting from the measurement side that the refusal makes the gap visible rather than closing it: an author who wants "any of these values" is now told to write an $or of N $contains, which is O(N) LIKE predicates over a serialization. That is a workable spelling, not a good one. Maintainer's call.
    • driver-memory / matchesFilter has the same gap, and worse — ⛔ FROZEN under [裁决] driver-memory / driver-mongodb 投入冻结 —— 维护者 2026-08-05 口径(跨单锚点) #5499, untouched. Reading packages/formula/src/matches-filter.ts: $in is v.some(x => looseEq(actual, x)) and looseEq bottoms out at a === b, so an array actual equals no scalar member → $in → no match, $nin → matches (the same fail-open inversion). And $contains there is guarded by typeof actual === 'string', so on an array field it is simply false — meaning driver-memory has no working membership spelling at all, not even the one this refusal prescribes. Not measured against a live driver-memory fixture (out of scope); recorded as a finding candidate for the decision box.
    • Cross-driver divergence is now explicit, and worth a ruling before anyone generalises this refusal: on driver-mongodb, $in against a real array column is meaningful (Mongo matches any element). So the same filter is a correct query there, a refused query here, and a silently inverted query on driver-memory. This change fixes the third case into the second; whether the first and second should converge — and in which direction — is a Filter Protocol question, not a driver one.

    Not relocated to the engine

    packages/objectql does have a chokepoint where a column-type gate could live — the comparand-shape gate landed there (collection-operator-scalar-comparand-400). It was not used, per the dispatch. The trade-off, stated so the decision is on the record: the engine sees the field metadata and would fix every driver at once, but "this operator cannot apply to this column" is not backend-independent here — it is true because driver-sql serialises the value to text, and it is false on driver-mongodb, where $in over an array column is the operator working as designed. Lifting the rule to the engine would refuse, on every backend, a query one backend answers correctly. It belongs in the driver whose storage decision makes it wrong.


    Generated by Claude Code

  4. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    Contributor

    PM step-7 gate: ACCEPTED — with the ready-flip held until one infra-red CI job reruns green (details at the end; it never executed a test and is not this diff's doing).

    PR #7415 @ 4acc2cd9d. Verified independently off the branch and main, not off the report:

    Claim Independent check
    filterColumnExpr returns non-null ONLY for legacy datetime/time repair — so the card's fixture never reaches applyNormalizedComparison ✅ read verbatim at sql-driver.ts:7848-7866: two needsLegacy*Repair branches, then return null. The dev's falsification of BOTH the card's site attribution AND my claim comment's is correct — the family I called "second" is the primary, and the family the card named serves only the external-object datetime cell
    Refusal envelope is ADR-0112 class 1 ✅ gate → jsonColumnOperatorError → unsupportedFilterError (:547), which sets code: INVALID_FILTER + status: 400 — the same envelope as the unknown-operator refusal
    Tests assert code AND status, never bare toThrow (#6144) ✅ expectJsonColumnRefusal asserts err.code === 'INVALID_FILTER' AND err.status === 400 (test :92-93), applied across all 129 cases; zero bare toThrow() in the file
    $contains family pinned unchanged ✅ 21 $contains references in the new file, positive pins per face; the LIKE family and $null/$exists deliberately absent from the refused set
    No existing test touched ✅ diff is pure addition — 697+/0− across exactly 3 files (gate + new test file + changeset); the unlisted-flip reject-condition is structurally impossible
    No docs/adr/** in the path surface ✅ path check clean (new-doctrine ACCEPT fork not triggered)
    CI @ 4acc2cd9d 24 of 25 green (2 justified paths-filter skips); 1 red = infra, see below

    What earns the accept, beyond the checklist:

    1. The enumeration was measured, not asserted, and it has a positive control. Moving the gate one line later — after applyNormalizedComparison instead of before — reds exactly the 6 external-datetime cells and nothing else. That is the measurement standing behind "two emitters, both wrong the same way", and it is what separates this from a fix that happened to land in the right place. A fix installed only where the card pointed would have missed the card's own fixture; only where I pointed, and it would have missed the external case. Both of us were falsified by measurement, and the falsification is the eighth and ninth time this lane's devs have corrected a stated premise — correct both times.
    2. The predicate widening is evidence-backed. Scoping to multiple: true alone would have left {address: {$nin: ['Beijing']}} — structured JSON, same fail-open inversion, measured on this fixture — silently wrong. Reusing the jsonFields registry (whose own header declares itself the single source "so the two can't drift") instead of minting a filter-side copy is the right ownership call.
    3. $between was caught by reasoning about the decomposition (calendar-day $between lowers to the very $gte/$lt halves being refused) — a gap in the card's minimum set that would have been a one-spelling bypass.
    4. The closed-world sweep future-proofs the partition: every FILTER_OPERATORS member must be refused or on the keep-working list, asserted whole — a newly declared operator cannot join the silent set without turning the file red. And it reads the export, not the card's "15" (which is stale — $icontains made it 16 in drivers: $regex 响亮拒收 + $icontains 各后端实现(#4706 裁决 B 案 · 驱动半边) #5702, the dev's third correction of the card).
    5. The engine-relocation refusal is the right call for the right reason, on the record: the refusal is true because driver-sql serialises to text and false on driver-mongodb, where $in-over-array is the operator working as designed. Lifting it to the engine would refuse, on every backend, a query one backend answers correctly.

    Findings noted for triage/decision-box, not acted on here (correctly recorded, not smuggled in): ask 2's visibility trade-off ($or-of-$contains is O(N) LIKEs — workable, not good); driver-memory has the same inversion AND no working membership spelling at all (frozen under #5499); the now-explicit three-way cross-driver divergence ($in-on-array: correct on mongodb, refused on sql, was-inverted on memory) is a Filter Protocol question awaiting a ruling.

    The one red, diagnosed per #4859 before any conclusion: Temporal Conformance (live PG + MySQL) failed in 57 seconds — the job log shows docker pull postgres:16 failing 3× with context deadline exceeded against Docker Hub, dying in service-container setup before checkout, zero tests executed. Pure runner-side registry outage, unreachable-by-construction from this diff. Rerun triggered (same head — valid for infra retries). Ready + auto-merge follow once that job's conclusion is success; on any re-red that actually executes tests, I re-diagnose from the job log — live-dialect coverage is exactly the surface this PR must clear, since the dev could not run PG/MySQL locally.


    Generated by Claude Code

  5. added 3 commits that reference this issue on Aug 17, 2026
    465a0fa
    a9df51c
    eccb8b2
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions