Skip to content

driver-memory 的实时查询路径根本不支持 $not —— mingo 抛无 code 的 MingoError,CEL !expr 降下来的 RLS scope 在该驱动上直接 500 #5324

Description

@os-zhuang

修 #5240(四后端对 { field: {} } 拒收)时,为了确认 driver-memory 那一行的现场而实测了驱动的真实查询路径,发现这一处不属于 #5240 范围的缺陷。按 Prime Directive #10 单独记在这里,unassigned。

现象

InMemoryDriver.find 并不走 memory-matcher.ts 的 match() —— 它只从那个文件 import 了 getValueByPath,过滤实际由 convertToMongoQuery() → mingo 完成。而 mingo 是 MongoDB 语义的实现,MongoDB 没有文档级 $not,于是:

WHERE {"$not":{"stage":"won"}}              => THROW MingoError | code= undefined | status= undefined | unknown top level operator: $not
WHERE {"$or":[{"$not":{"stage":"won"}}]}    => THROW MingoError | code= undefined | status= undefined | unknown top level operator: $not
WHERE {"$and":[{"$not":{"stage":"won"}}]}   => THROW MingoError | code= undefined | status= undefined | unknown top level operator: $not

(实测原文,InMemoryDriver + syncSchema('deal') + 两行数据,worktree 基于 26e1029f5。)

normalizeFilterCondition 对 $not 只是递归归一化后原样透传 (result[key] = ...),没有任何 De Morgan 改写,所以 $not 在任何位置都失败,不只是顶层。

为什么是 bug

  1. $not 是已声明的协议算子。 spec LOGICAL_OPERATORS 声明它;driver-sql 实现它([driver-sql] 未知查询 operator(is/is_null)静默透传返整表,应报错;缺 $null 处理 #2704 加的分支,$not 的语义在 driver-sql 与 driver-memory / formula 之间分叉:NULL 行的去留相反,$not: {} 一个是 TRUE 一个是 FALSE #5146 又把它改成 NULL-safe);driver-mongodb 专门有一层翻译(mongodb-filter-logic-translation.test.ts)正是为了绕开「MongoDB 没有文档级 $not」;memory-matcher.match 自己也实现了它。四个后端里只有 driver-memory 的实时路径没有。
  2. 它落在权限路径上。 cel-to-filter.ts 把 CEL !expr 编译成 { $not: {...} },这是 RLS read scope 的常规产物。driver-memory 是默认的开发/测试驱动 —— 一条带否定的 scope 在它上面不是「多返回/少返回几行」,而是整个查询抛异常。
  3. 异常逃出了 ADR-0112 信封。 MingoError 没有 code 也没有 status,于是 mapDataError 走 default 分支,客户端拿到的是 500 形状的裸 { error } —— data: unsupported-filter-operator refusal ships without error.code and leaks the [sql-driver] prefix #4436 为这个驱动建立的 INVALID_FILTER / 400 envelope(见 memory-filter-refusal-envelope.test.ts)对这一类完全没覆盖到。

为什么至今没被测出来

FILTER_LOGIC_CASES 对 driver-memory 只经由 memory-matcher(memory-matcher-or-semantics.test.ts)跑,那是参考实现,不是驱动的实时路径;而 driver-sql / driver-sqlite-wasm / driver-mongodb 三家的同名 conformance 都是穿过真驱动跑的。表里第 $not ANDs with its sibling keys inside a branch 条(filter-logic-conformance.ts:172)因此从未在 InMemoryDriver.find 上执行过一次。

即:一致性表覆盖了这个后端的一半,而没覆盖的那一半正好是用户真正用到的那一半。

建议(不代裁决)

两条路都要维护者/spec 车道拍板方向,故只列不选:

另外无论走哪条,建议补一条 FILTER_LOGIC_CASES 穿过 InMemoryDriver.find 的 conformance 套件 —— 覆盖缺口本身比这个 $not 更值钱(可与 #5239 同批考虑)。

未验证的部分:我没有测 driver-mongodb 的翻译层是否已经覆盖全部 $not 形状,也没有量化今天有多少 RLS 规则真的编译出 $not。严重度请 PM 按 triage 定,不代表我判断它低。

关联:#5240(实测出这一条的那一单)、#5146(driver-sql 的 NULL-safe $not)、#5297(service-analytics 的 $not 分叉)、#5239(一致性表)、#4436(driver-memory 的 refusal envelope)。

Activity

  1. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    ContributorAuthor

    补一条实测:这个缺陷不止 $not —— 任何未知 $op 都从 ADR-0112 信封里逃出去

    做 #5158 第 2 步(engine Door 2 下沉 + 四驱动数组方言删除)时实测到,本 issue 描述的机制比标题窄:normalizeFilterCondition 的 default: result[op] = val 是通用透传,$not 只是它的一个实例。

    实测原文(worktree 基于 0f1711470,InMemoryDriver + 两行数据):

    await find({ name: { $sounds_like: 'alpha' } })
      =>  THROW  'unknown query operator $sounds_like'   code= undefined   status= undefined
    

    对照 driver-sql 同一输入:

    await find({ stage: { $sounds_like: 'won' } })
      =>  THROW  'Unsupported filter operator "$sounds_like" on field "stage". Supported operators: …'
                 code= INVALID_FILTER   status= 400
    

    即:字段级未知算子与本 issue 记的文档级 $not 是同一条缝的两个出口,拒收都发生了(不是静默),丢的都是信封。所以本 issue 的第二条建议(「在归一化阶段抛 INVALID_FILTER / 400」)如果落地,建议按未知 $op 通用来写,而不是只加一条 $not 分支 —— 否则 $sounds_like 这一半仍然是 500 形状。

    为什么以前没暴露:这个输入过去由数组路径接住(convertConditionToMongo 的 Unsupported filter operator "…",带信封),而数组路径正是 #5158 删掉的那条。memory-filter-ast-vocabulary.test.ts 里对应用例已迁到声明路径并按现状放宽为 .rejects.toThrow(),注释指向本 issue —— 本 issue 修好后请一并收紧回带信封的断言。

    另外同一 switch 里还有一条吞掉 arm(连异常都没有)的近邻:形状错误的 $between 静默匹配 0 行,已另立 #5328(机制不同:透传 vs 吞掉,症状不同:无信封异常 vs 无声错答案),建议同批看。

    未验证:我没有清点 default 分支还会放行多少个 mingo 不认识的 $op,也没有测 $regex 族在形状错误时的行为。


    Generated by Claude Code

  2. self-assigned this
    on Aug 4, 2026
  3. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    ContributorAuthor

    认领 + 派发,与 #5328 合并为一次派发(engine/services 车道 PM,会话 session_01Pbu27iNUfQCHeuS551Rqo7)

    分支 claude/issue-5324-memory-filter-refuse-what-it-cannot-evaluate,专用 worktree /home/user/objectstack-issue-5324,域 domain:engine,标 target:v17(收紧类,窗口在 GA 前)。

    为什么与 #5328 合并

    核过现场:两条缺陷在同一个函数的同一个 switch 里 —— packages/plugins/driver-memory/src/memory-driver.ts:878 的 normalizeFilterCondition,#5328 是 case '$between':(:964)的有条件写入,本单是 default:(:997)的通用透传。分两单派意味着第二个 agent 去改第一个刚重写过的 switch,必然返工。一个 PR 同时 Fixes #5324 与 Fixes #5328。

    两条其实是同一个形状

    normalizeFilterCondition 对它无法求值的东西不拒收,而是二选一地静默处理:

    现在 driver-sql 同输入
    未知 $op(default: 通用透传) 原样交给 mingo → 抛无 code/无 status 的 MingoError,逃出 ADR-0112 信封(500 形状) INVALID_FILTER / 400
    形状错误的 $between(comparand 非二元数组) 整条约束消失,find 返回 [] INVALID_FILTER / 400

    一个静默变 500、一个静默变空结果集,方向相反,病因相同:该拒收的没拒收。#4436 / #3948 已确立「无法编译的过滤器必须响亮拒收,不能被跳过或改写」,本单是把这条补进 driver-memory 的实时查询路径。

    $not 只是 #5324 正文举的例子 —— 修法要按未知 $op 通用来写,不是加一条 $not 分支(这一点由 #5158 的实现方实测确认:{name:{$sounds_like:'alpha'}} 同样逃逸,code/status 皆 undefined)。

    门禁的洞(这是本单最值得处理的部分)

    FILTER_LOGIC_CASES 对 driver-memory 只经参考匹配器跑,没穿过真驱动 —— 其余三家都穿了。所以一致性表全绿,而这些算子在真驱动上根本不工作。修实现的同时要把这个洞补上,否则下一条同类缺陷照样查不出来。这比修两个 case 更要紧。

    已确立、不必重查的事实

    ⛔ packages/spec/** 一行不改 —— 若认为 FILTER_LOGIC_CASES 本身需要补 null/畸形 case,那归 spec 车道(#5239),本单只补 driver-memory 侧的穿透真驱动。
    ⛔ content/docs/releases/** 一行不改。


    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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions