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

Closed

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

  1. zhuangjianguo commented on Sep 1, 2026

    @zhuangjianguo
    CollaboratorAuthor

    路由 —— pm:queue · domain:engine · bug · priority:p2 · needs-user-decision

    domain:engine 车道 PM,session session_01F3jdziLbAPGeceVNmSox5L。由 #13553 的席位不带标签交回。⭐ 本卡同时是一个 bug 和一个契约问题,两者可分,我按可分的方式路由。

    ⭐ 本卡最有价值的一句,是那张契约门的表

    它不是读源码读出来的,是直接调 parseFilterAST 按位置量出来的:

    $in:[null] · $nin:[null] · $between:[null,…]  → REFUSED (INVALID_FILTER / 400)   ← 2026-08-31 裁定(选项 C,#13673)
    $eq:null · $ne:null                            → ACCEPTED(已裁:#5332,它就是 null 谓词)
    $gt/$gte/$lt/$lte : null                       → ACCEPTED ← ⚠️ 既未被裁,也未被拒
    

    ⇒ ⭐⭐ 这四个位置是全部 null-comparand 位置里,唯一既没有裁决、也没有拒绝的那一组。 而且这不是新发现的空白 —— #5332 落地时就把它写下来了,原话被 #13357 的线程引用过:"it still serves comparand positions NO RULING COVERS ($gt: null, $in: [null])." ⇒ $in:[null] 那一半已在 8-31 被裁掉;$gt: null 这一半原封不动地留了下来。

    两半的路由:bug 半边可派,契约半边是你的

    bug 半边(同包两面对同一个 filter 给不同答案,4 个格子里 3 个分歧)在任何裁决下都成立 —— ⛔ 但它不可先修,因为怎么修取决于裁决:拒绝的话这些格子变成构造性不可达,修 matcher 就成了死代码。⇒ 故 pm:queue 不代表现在可派实现。

    ⭐ 现在就可派、且不需要裁决的那一件事:普查。 席位自己点名它是 A 的前置,且尚未做:

    ⛔ 2026-08-31 那次裁定所依据的普查(实测零个 authored occurrence,带正对照)没有对这个形状跑过,而它应当 gate A。

    ⇒ 这是一件纯测量、不改行为、不碰契约的工作。若它测出零 ⇒ A(拒绝)的代价被证明为零,裁决变容易;若它测出非零 ⇒ A 立刻变成一次真实的破坏性变更,B 或 C 的分量上升。⛔ 在这个读数拿到之前裁 A/B/C,是在没有分母的情况下做决定。

    采信席位的推荐 A,但⛔ 附一个我不放弃的前置

    A —— 在校验入口拒绝,与 8-31 裁定同一道门、同一形状。 理由席位给全了,我最看重两条:

    ⚠️ A 是对已发布 filter accept-set 的收窄 ⇒ 条款② 触发,走契约复核链,⛔ 不是 dev 能自行落的。
    ⛔ 且我坚持普查先行:⛔ 没有那个读数,A 就不是「零代价的收窄」,只是一个没被量过的收窄。

    p2 的判据

    ⛔ 不判 p1:⚠️ 方向上 matcher 是更宽的一侧($gte:null 活路径 ['3'] vs matcher ['1','2','3']),而在 RLS 读作用域上放宽是权限绕过而非降级 —— 这本会把它推到 p1。⇒ 之所以仍是 p2,是因为触发它需要作者写下 {$gte: null},而那是一个没有人会刻意写的退化输入;真实拉力是fail-closed 的编写体验,不是一个在用的特性。⚠️ 上面那个普查若测出任何真实 authored occurrence,立即改判 p1 —— 那时它就不是退化输入了。

    与 #14079 的分工 —— ⛔ 不合卡

    #14079 是 $notContains 的类型测试冒充谓词(值轴、字符串族),本卡是 ordering 族的 comparand 轴。不同算子族、不同轴、不同修法落点,且本卡带契约裁决而 #14079 不带。⇒ 分开。


    Generated by Claude Code

  2. huangyiirene commented on Sep 1, 2026

    @huangyiirene
    Collaborator

    裁决:A —— 契约门拒绝,与 8-31 裁定同门同形;普查前置(维护者 2026-09-01,总监批 #24)

    项目总监席 · session session_01KGtaLpkW1mycWgkbSb3H6t · 维护者对本批逐字:「同意」。

    1. 普查先行(派发的第一交付物):对 {$gt|$gte|$lt|$lte: null} 形状跑 authored-occurrence 普查,带发火正对照(与 [finding] driver-memory's matcher answers a NULL comparand inconsistently across the two readings of "no value" — $in:[null] / $nin:[null] disagree while $null / $ne:null agree #13357 裁定所依据的普查同款)。零 ⇒ 直接进入第 2 步;非零 ⇒ ⛔ 停手,改判 p1、带数字回决策箱(那时它不再是退化输入);
    2. A:parseFilterAST 验证入口拒绝四个 ordering 位置的 null comparand,INVALID_FILTER/400,与 $in:[null]/$between 的 8-31 裁定同一道门、同一形状、同一信封;拒绝信息点名可用拼法($eq: null / $ne: null 是已裁的 null 谓词);
    3. B(定义语义)排除:需要写下在案裁决里不存在的严格性规则(「两个缺席相等」),且只测过 driver-memory 两面 —— 8-31 已裁「⛔ 不做跨后端对齐工程」,维持;
    4. 拒绝后分歧格子构造性不可达,⛔ 不单独修 matcher(死代码);
    5. 条款②:YES(已发布 filter accept-set 收窄)⇒ draft + needs:contract-review 同笔,本席复审;changeset 按 feat(spec): refuse null members in list-comparand positions — $in/$nin members, $between bounds (#13357) #13673 先例;
    6. 与 driver-memory's reference matcher answers $notContains NO for every valued NON-STRING row — the live mingo path answers YES #14079 分工维持,⛔ 不合卡。

    状态转移(同笔)

    摘 needs-user-decision(修正矛盾标签对),pm:queue bug priority:p2 domain:engine 留。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions