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)

Activity

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