fix(driver-sql,driver-memory,driver-mongodb): 越界过滤输入在门口拒收,而不是每个后端各答一个 (#5347, #5348) - #5368
Merged
os-zhuang merged 2 commits intoAug 5, 2026
Merged
Conversation
…filter input at the door (#5347, #5348) Two shapes the Filter Protocol never declared reached the drivers, and every driver ANSWERED them — with a different answer. Both are now refused with INVALID_FILTER / 400, on the validating walk rather than in the emitter. #5347 — `$null` with a non-boolean comparand. `FieldOperatorsSchema` declares `$null: z.boolean()`. Measured against one row with `stage: 'won'` and one with `stage: null`, `{ stage: { $null: 'yes' } }` returned the NULL row on driver-sql / driver-sqlite-wasm / Turso local (IS NULL — anything but `false`), the valued row on driver-memory's query path and driver-mongodb (IS NOT NULL — anything but `true`), and BOTH rows through driver-memory's reference matcher, whose two conditionals a third value satisfies neither of, so the constraint vanished. Three readings of one declared operator; the third is new evidence the issue's own fixture could not show. Refused on all four backends per the ruling. #5348 — an undeclared `$op` in a node position. `FilterConditionSchema` declares three `$`-keys at a node; driver-sql compiled the rest as COLUMNS, so `{ $where: … }` / `{ $nor: … }` produced a predicate matching nothing and reporting nothing. Its FIELD position had refused the same class of input since #3948/#4436, so one driver answered two ways depending on depth. Both gates sit in `reduceFilterKey` / `assertFilterConditionShape`, not in the emitters, because the emitters are skipped wholesale by a boolean identity — `{ $or: [ {}, { $where: … } ] }` would otherwise be refused or ignored depending on its siblings. Same placement argument as #5240/#5327. `nullValueSatisfiesOperator`'s `$null` arm is tightened from `value !== false` to `value === true`: the two are equivalent only while the refusal holds, and the lenient spelling would silently resume answering if the gate ever moved. `$exists` keeps its lenient read deliberately — it has no comparand gate, so tightening it alone would create the divergence rather than close one. driver-sqlite-wasm and cloud's local/replica TursoDriver inherit both refusals from SqlDriver; both verified by execution, not assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7
…use-out-of-contract-filter-input
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 3 package(s): 12 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
August 5, 2026 02:05
os-zhuang
deleted the
claude/issue-5347-refuse-out-of-contract-filter-input
branch
August 5, 2026 02:17
This was referenced Aug 5, 2026
os-zhuang
pushed a commit
that referenced
this pull request
Aug 5, 2026
…ity-batch Textual conflict: packages/plugins/driver-mongodb/src/mongodb-filter.ts — both sides rewrote translateFilter/translateCondition (#5239 reduction vs #5329 array-dialect deletion + #5368 $null gate/path threading). Resolution keeps both: main's array refusal and path threading, this branch's three-valued reduction and shape gates; the three helpers both sides defined (unsupportedFilterError, describeFilterOperand, safeShapePreview) are de-duplicated onto main's copies. Semantic reconciliation the textual merge could not see (AGENTS.md s10): main's #5347 $null comparand gate sat in the emitter, and this branch's reduction makes emitters skippable by a boolean identity — { $or: [ {}, { stage: { $null: 'yes' } } ] } would have translated to match-all while driver-sql refuses it. The gate's load-bearing copy moved onto the validating walk (reduceFilterKey), mirroring driver-sql's #5368 placement; the emitter arm keeps its local check. Pinned in mongodb-null-comparand-refusal.test.ts (three identity-sibling fixtures). Fixture triage: the 'legacy array dialect is untouched' pin in mongodb-filter-boolean-identity.test.ts pinned a dialect #5329 deleted — replaced wholesale with the surviving boundary ([] = absent filter = match-all, non-empty array refused before the reduction runs). The reduceFilterKey field-arm comment on { field: {} } is recalibrated to current main: #5327 gated the shape on the other four backends; this driver remaining the one still answering it is now tracked by #5376. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB
This was referenced Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5347
Fixes #5348
两条是同一形状的两面:契约声明之外的输入没有在门口被拦下,于是每个后端都给了它一个「答案」——而且互不相同。 #5348 是节点位置的越界
$op被当成列名编译成静默空集;#5347 是字段位置的越界比较值被读成相反的意思。两条都改为INVALID_FILTER/ 400。PM 已裁定 #5347 取 A(两边都拒收非布尔比较值),本 PR 不重新论证 B/C。
一、现场核对(
sql-driver.ts今天已被改四次)worktree 基于
origin/main@e18e3da77(含60a7a2d37,即 #5349),逐条重核两个 issue 引用的行号:sql-driver.ts:6363的case '$null': opValue === false ? whereNotNull : whereNullapplyFilterCondition的发射器 switch 内),代码逐字未变nullValueSatisfiesOperator里case '$null': return value !== falsenormalizeFieldOperators的if (val === true) … else …memory-driver.ts:973,#5349 大改后仍逐字未变reduceFilterKey对不认识的$键return 'clause',注释「A field key always contributes a predicate」{field:{}}闸门,注释也说明了为什么闸门放在校验遍历而非编译分支)filter-refusal.ts的assertFilterConditionShape里if (key.startsWith('$')) throw unknownLogicalOperatorError(...)四次 churn 对本单的实际影响:#5327 决定了两条闸门的落点。它把
{field:{}}的拒收放在校验遍历reduceFilterKey而非编译分支,理由是编译分支会被布尔单位元短路。本单两条闸门落在同一层、同一位置,理由相同——PR 里有专门的用例证明这一点(见下「闸门落点」)。一处与 issue 正文有出入,已按现场记录:#5348 说「
$expr之类同样静默」。实测$expr/$elemMatch在改前其实会抛错,但抛的是字段级的错,把它们当成了字段名:——报错信息把
$expr当作字段名,是偶然因为它们的值恰好是带$键的对象才撞上了字段级守卫。病因与$where/$nor完全相同,消息误导性更强。改后统一为节点级措辞。二、四(五)个后端的实测
fixture:两行——
{id:'1', stage:'won'}、{id:'2', stage:null}。加上stage: null的第二行是本单的关键,#5347 的 fixture 只有一行非空值,而那正是让 driver-memory 两个面的分歧看不出来的原因。#5347
{ stage: { $null: 'yes' } }— 改前IS NULL(除false外都算 true)["2"]["2"]["2"]find(mingo)IS NOT NULL(除true外都算 false)["1"]translateFilter{"stage":{"$ne":null}}= 同上match(参考匹配器)["1","2"]WHERE "stage" IS NULL第三行是 issue 没能看到的新事实。 matcher 的
$nullarm 写成两条if(target === true && …/target === false && …),第三种值两条都不满足,于是该算子不再约束任何东西——匹配全表。在只有非空值那一行的 fixture 上,「匹配全表」和「IS NOT NULL」答案相同,所以 #5347 记录成了「两个面一致」。加一行stage: null就分开了:driver-memory 包内部的两个面本身就不一致,而且 matcher 这一侧是放大方向——在 RLS read scope 上是权限绕过,不是降级过滤(#3948)。这条把「必须拒收」的理由从「两个巧合挑一个」加强成「三个答案,其中一个是放大」。
#5348 节点位置越界
$op— 改前SQLite 把解析不到列的双引号名降级成字符串字面量,所以查询编译了、跑了、返回零行——正是 #5041 记录的那个形状。其他方言会报「未知标识符」,症状不同、病因相同;本 PR 的闸门在编译之前,与方言无关。
改后(同一组输入)
三、闸门落点与理由
两条都落在校验遍历上,不在发射器里:
reduceFilterKey($-键拒收放在三个已声明组合子分支之后、字段分支之前——因为「把$where当字段名」正是字段分支干的事)。filter-refusal.ts的assertFilterConditionShape(fix(driver-memory): refuse the filters the live query path cannot evaluate, compile the one it must (#5324, #5328) #5349 立的唯一的门),不在两处各写一遍。normalizeFieldOperators里那条throw是完整性地板,与 driver-memory 对形状错误的$between静默不发谓词(匹配 0 行),driver-sql 同一过滤器抛错 —— 一个过滤器两个答案 #5328 给$between留的那条同一形状、同一理由(注释里写明了)。$nullarm 内(与它现有的default: throw同层)。理由与 #5327 逐字相同:发射器会被布尔单位元整段跳过。PR 里有两条用例专门证明这一点,改前它们都 RESOLVED:
若把闸门放进发射器,这两条会「因兄弟节点不同而被拒收或被忽略」——
reduceFilterNode自己的注释称之为 "a gate conditional on evaluation order"。四、守卫表(#5296
nullValueSatisfiesOperator)的收紧结论$null收紧,$exists不动。$null:拒收之后到达这张表的只可能是true/false,value !== false与value === true等价。仍然收紧,因为等价只在上游闸门存在时成立;宽松写法在闸门被移动或删除后会静默恢复对没人裁定过的形状作答,而严格写法不会。这与拒收本身是同一个「declared = enforced」反射。$exists:没有配套的比较值闸门($null的比较值不是布尔时,driver-sql 与 driver-memory 给出**完全相反**的答案(一个 IS NULL,一个 IS NOT NULL)—— 实测 #5347 只裁定了$null),所以非布尔仍会到达这张表,守卫必须继续与发射器的opValue === false读法一致,否则两者会对同一行给出不同答案。单独收紧它才是制造分叉,不是修复分叉。 另行立单(见范围外清单)。五、cloud / Turso 面(维护者新增常设规则:SQL 单一律连带排查)
两条路性质不同,实测结论也不同。本 PR 不改 cloud 代码(跨仓 PR 做不到),缺口已在 cloud 仓立单。
1.
TursoDriver extends SqlDriver(local / replica)— 随基类自动生效,已实测不是静态推断:把 cloud 的 probe worktree 的
@objectstack/driver-sql软链指向本分支的 worktree,跑TursoDriver({url:':memory:'})实测,改前改后各一遍,结果见上表——改前与 driver-sql 逐条相同(两个缺陷都在),改后两条拒收逐条生效,code=INVALID_FILTER status=400。关于
:670-678的temporalFilterValue透传表:确认不构成绕过。该表在toRemoteFieldSpec内,只被toRemoteFilter→toRemoteQuery调用,而toRemoteQuery只服务 remote 模式;local/replica 模式根本不经过它。且即便经过,$null在那张表里是原样透传、不决定语义,而本 PR 的闸门读的是原始值、在编译之前——透传的值仍是被检查的那个值。2.
RemoteTransport.buildWhereSQL(remote 模式)— 独立的第五个 SQL 编译器,两条缺口都在PM 的预判「#5348 那一半在 remote 侧可能已经关上了」经实测不成立。
default:arm 的「Declared = enforced」注释、:1003的isFilterNode+uncompilableWhere、:1356的uncompilableSubFilter都只覆盖顶层 where 的形状与字段级算子,节点位置的$键没有任何门:——与 driver-sql 改前逐字同款:编成列名、语句真的发出去、静默空集。
$nor/$elemMatch只是偶然被 #1058/#1066 的比较值守卫拦下,且抛的是无code、无status的裸 Error([RemoteTransport]前缀也在)。$null侧:——正是 driver-sql 的
=== false约定。所以 A 落地后,remote Turso 成为非布尔$null上唯一的少数派(framework 四家全部拒收,remote Turso 仍编译 IS NULL)。已在 cloud 仓立单(立单前已搜重复),关联本仓 #5347 / #5348。
产出面计数修正:不是四个后端,是五个 SQL 产出面 —— driver-sql、driver-sqlite-wasm、TursoDriver(继承)、RemoteTransport(独立),加 driver-memory / driver-mongodb 两个非 SQL 后端。本 PR 覆盖 framework 侧全部四家;RemoteTransport 归 cloud 单。
六、测试
driver-sql/src/sql-driver-out-of-contract-filter-input.test.ts$where/$nor/$expr/$elemMatch× 顶层/$or/$and/$not;两条「闸门落点」用例;三个已声明组合子与布尔单位元不变;#5347 七种非布尔比较值 + 组合子内 +$not内;true/false逐条不变;$exists明确不收紧;合法 filter 与 regex 族逐条不变driver-memory/src/memory-null-comparand-refusal.test.tsfind与match两个面各断言一次,且断言两个面消息逐字相同;路径名(filter.$or[1].stage.$null);可满足兄弟/TRUE 单位元不短路driver-mongodb/src/mongodb-null-comparand-refusal.test.tstrue/false不变 + 周边词表不变 + FilterArray 信封不回归driver-sqlite-wasm/src/sqlite-wasm-out-of-contract-filter-input.test.tscode/status不被吞掉/重包)driver-mongodb 为什么测
translateFilter而不是真 mongod:该包的 live 套件依赖mongodb-memory-server(~123MB 下载,本地被墙时describe.skipIf跳过,见test-mongod.ts)——一个会被跳过的测试不是对裁定的测试。而translateFilter是纯函数、是本包唯一读$null的地方、其输出就是发给 MongoDB 的查询,分叉完整地落在这一层。mongodb-filter.test.ts也正是在这一层钉$null: true/false的。反向验证(stash 掉源码,新用例必须失败)
git stash push五个源文件后重跑四个新套件,47 条失败,失败原文:(mongodb 的 3 passed 与 wasm 的 3 passed 是「不变」类断言,本就该在改前也通过。)
全量与连带面
本地跳过 / CI 才跑的盲区(如实核查)
grep -rnE 'skipIf|describe\.skip|it\.skip|test\.skip|\.todo\(|\.only\(':describe.skipIf(!URL),门在活 Postgres / MySQL 连接串上(sql-driver-datetime-postgres-timezone、sql-driver-datetime-mysql-storage、sql-driver-time-live-dialects、sql-driver-date-now-default-live)。全部是 temporal 存储形态,不触及过滤拒收。本 PR 的闸门在编译之前(校验遍历),任何方言都到不了 SQL 生成阶段,所以方言差异无法改变拒收结果;driver-sql 把**文档级**未声明$op({$where:…}、{$nor:[…]})当成列名编译,静默返回空结果集 —— #5324 的文档级一半在 SQL 侧还在 #5348 的症状是方言相关的(SQLite 降级为字符串字面量 vs 其他方言报未知标识符),修复不是。describe.skipIf(!sharedMongod),门在mongodb-memory-server的 ~123MB 下载上,本容器下载不通所以本地跳过、CI 能下时会跑。逐一核查是否会被本改动顶红:其中mongodb-filter-logic-conformance/mongodb-temporal-conformance/mongodb-pagination-conformance消费 spec 的共享表FILTER_LOGIC_CASES/TEMPORAL_CASES;grep '$null|$where|$nor|$expr|$elemMatch' packages/spec/src/data/filter-logic-conformance.ts无非布尔$null、无节点位置越界$op(唯一命中是一句提到$nor的注释),故不受影响。mongodb-driver/mongodb-datetime-storage/mongodb-findone-query同样不含这两类输入。新增的 mongodb 用例刻意放在translateFilter层,因此不进这个盲区、到处都跑。.only(/.todo(/it.skip。七、合并 main
合并
origin/main@b4ad98435(无冲突)。重叠面:driver-sql包新进了adr0120-three-posture-conformance.test.ts。按 AGENTS.md §9/§10 已pnpm install --frozen-lockfile+ 重建 + 重跑四个 driver 包与 typecheck,全绿(上表即合并后的数字)。未触及packages/spec,无生成物待重生(os-regen-pending为空)。八、范围外清单(均已按 Prime Directive #10 处理,本 PR 一行未改)
$exists非布尔比较值 → 三个答案,与$null同类但轴不同,实测:$null更散:driver-memory 两个面在'yes'上就已分叉。另行立单(与 driver-memory 与 formula 对「字段没有值」给出三处不同答案:$notContains(null 值)、$exists(键在值为 null)、$nin(缺键) #5299「$exists语义是键存在还是有值」是不同的轴,已交叉引用)。$op原样透传给 MongoDB:translateFilter({$where:'return true'})→{"$where":"return true"},即服务端 JS 执行——正是该驱动自己字段级default:注释点名的 P0。driver-sql 把**文档级**未声明$op({$where:…}、{$nor:[…]})当成列名编译,静默返回空结果集 —— #5324 的文档级一半在 SQL 侧还在 #5348 正文推测 mongodb「至少不会编成列」,实测比那更糟。已作为实测评论追加到 driver-mongodb 带着 #5328 的同一条缝:形状错误的$between/ 非数组$and、$or/ 非对象$not全被静默吞掉,且它的算子拒收没有 ADR-0112 信封 #5346(该单已在要求给 mongodb 建filter-refusal.ts孪生 + 补组合子形状门,本发现落在它的完成范围内,故不另开孪生单)。driver-mongodb 带着 #5328 的同一条缝:形状错误的$between/ 非数组$and、$or/ 非对象$not全被静默吞掉,且它的算子拒收没有 ADR-0112 信封 #5346 正文自述「全部来自代码对读、没有实测」,这条评论正是它要的实测。RemoteTransport两条缺口:已在objectstack-ai/cloud立单,见上文第五节。$contains/$startsWith/$endsWith/$notContains)非字符串比较值(两边方向一致、fail-closed,[spec]$field跨字段比较:spec 声明 + cel-to-filter 产出,但无任何 SQL 执行层实现 —— enforce-or-remove 裁决位 #5041 明确排除;PR 里有用例钉住「没被顺手收紧」)、packages/spec/**、service-analytics/**(analytics 的 filter 拒收到不了调用方:service 侧多数拒收没有 ADR-0112 信封,REST 面又用 message 正则嗅探,一律答 500 #5352 在飞)、driver-memory/src/memory-analytics.ts(driver-memory 的 analytics 面静默丢弃大半个 filter:$or/$not整条丢,$between/$startsWith/$null/$regex因无 cube 映射而丢 —— 聚合结果被放大 #5345 待派)、content/docs/releases/**。Changeset
.changeset/refuse-out-of-contract-filter-input.md,三个包 patch,正文写明两条可观察的行为变更:靠 truthy/falsy 巧合工作的非布尔$null调用方会拿到 400(含"false"字符串这个 truthy 陷阱的完整说明与 FROM → TO 修法),文档级越界$op从静默空集变为 400。🤖 Generated with Claude Code
https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7
Generated by Claude Code