Skip to content

$gt/$gte/$lt/$lte with a null COMPARAND is the one null-comparand position the filter contract still accepts — and the two driver-memory faces answer it differently #14080

Description

@zhuangjianguo

Filed unassigned by the #13553 dev. Recording only — no severity asserted, routing is triage's. It carries a contract question as well as a divergence, so it may be a decision-box card rather than a bug.

#13553 repaired the ordering arms on the VALUE axis (a no-value ROW). This is the COMPARAND axis of the same four arms — the sibling relationship #13495 and #13549 had for $between, and it was deliberately left undecided by PR #14077.

Measured — the contract door, by position

Executed on 43b799bc66, calling parseFilterAST from @objectstack/spec/data directly:

filter contract door
{n: {$in: [null]}} REFUSED INVALID_FILTER / 400
{n: {$nin: [null]}} REFUSED INVALID_FILTER / 400
{n: {$between: [null, null]}} REFUSED INVALID_FILTER / 400
{n: {$between: [null, 1]}} REFUSED INVALID_FILTER / 400
{n: {$gt: null}} ACCEPTED
{n: {$gte: null}} ACCEPTED
{n: {$lt: null}} ACCEPTED
{n: {$lte: null}} ACCEPTED
{n: {$eq: null}} ACCEPTED (ruled — #5332, it IS the null predicate)
{n: {$ne: null}} ACCEPTED (ruled — same)

The maintainer's ruling of 2026-08-31 (option C, #13357) refused a null member of $in / $nin and a null $between endpoint at the validation entrance. The four ordering positions were not in its scope, so they are now the only null-comparand positions that are neither RULED (as $eq / $ne are) nor REFUSED. #5332's landing had already named this position in writing as one "no ruling covers" — quoted in #13357's own thread as: "it still serves comparand positions NO RULING COVERS ($gt: null, $in: [null])." The $in: [null] half has since been ruled. The $gt: null half has not.

Measured — the two faces disagree on it

Same commit, both faces of packages/drivers/driver-memory, fixture {id:'1', n:5} {id:'2', n:0} {id:'3', n:null} {id:'4'} (key ABSENT). These cells are byte-identical before and after PR #14077 — that PR's guard is scoped to leave them exactly as it found them:

filter live mingo path reference matcher
{n: {$gt: null}} [] ['1'] DISAGREE
{n: {$gte: null}} ['3'] ['1','2','3'] DISAGREE
{n: {$lt: null}} [] [] agree
{n: {$lte: null}} ['3'] ['2','3'] DISAGREE

The live path treats two absences as EQUAL — $gte/$lte admit the no-value row, $gt/$lt do not — and treats a valued row as not comparable to an absent bound. The reference matcher compares through JS coercion instead: 5 > null is 5 > 0.

A STRING fixture hides this exactly as it hid #13553: on {id:'4', v:null} among ISO date strings both faces answer ['4'] for $gte: null, because '2026-07-01' >= null is a comparison against NaN. Measured both ways.

Why it was not folded into #13553

Two independent reasons, both measured rather than reasoned:

  1. The obvious repair needs a rule shape that is landed nowhere. Reusing valueWithinRange's comparability half looks right, but that function is built from >= and <= only and has no notion of strictness. Applied to the ordering arms it would answer {$gt: null} with ['3'] where the live path answers [] — a NEW divergence created by the repair. Matching the live path needs the extra rule "two absences compare EQUAL", which no ruling on file states.
  2. It would pick a camp the platform declined to pick. The only sibling position that ever reached the maintainer was settled by REFUSING the shape, not by choosing an answer for it — and that ruling explicitly declined cross-backend alignment work (「⛔ 不做跨后端对齐工程」) and declined repairing a path that refusal makes unreachable (「⛔ 不单独修一个到不了的路径」). Deciding this in a driver arm would be a seat making that call.

For triage — the shape of the decision

If the answer is refuse at the contract door, this is a packages/spec change in the same place and the same shape as the 2026-08-31 ruling, the divergence becomes unreachable, and no driver moves. Note it is a clause ② narrowing of the published filter accept-set, like its sibling.

If the answer is define the semantics, then the live path's reading is the obvious candidate (it is this file's standing tie-break) but it requires the strictness rule above to be stated, and the other backends' answers have not been measured here — only driver-memory's two faces were.

⚠️ Degenerate input, as its sibling was: {$gte: null} is not a filter anyone writes deliberately. The #13357 ruling's own basis was a measured zero of authored occurrences, and the same census has NOT been run for this shape. That census, with a positive control, is the first thing an executor should do.

Related

#13553 / PR #14077 (the VALUE axis of the same four arms) · #13357 (the sibling positions, ruled 2026-08-31 and refused) · #13495 (the $between comparand axis) · #5332 (the ruling that named this position as uncovered) · #5298 (the no-value row family)

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions