Skip to content

[finding] undefined 比较数在仓内有五种读法 —— #6050 只在 driver-sql/turso 落了拒收,formula 读作「键缺失」、read-scope-sql 编成 = NULL、driver-memory 读作 null #6125

Description

@os-zhuang

出自 #6050 的实施(PR 见下)。#6050 的裁决(2026-08-07,B 案)把「比较数位置的 undefined」定为一律拒收(INVALID_FILTER / 400),实施面按裁决只覆盖 driver-sql + driver-turso 的 remote transport;issue 正文「落点」节要求裁决后按 #5298 的方式逐格实测其余无值语义面。实测做完了,记录在此,按 Prime Directive #10 不指派、不扩大那张 PR 的 diff。

实测(origin/main @ cba7454df,PR 落地前;四行 fixture:d 在 1-2 有值、3 为 SQL NULL、4 为「键不存在」)

同一个形状 { d: undefined },五个求值面五种读法:

求值面 { d: undefined } 读法
driver-sql / turso LOCAL 抛裸 knex Undefined binding(s)(无 code/status) 崩
turso REMOTE(buildWhereSQL) ['3','4'] undefined ≡ null → IS NULL
driver-memory(mingo 活路径) ['3','4'] undefined ≡ null
@objectstack/formula matchesFilterCondition ['4'] undefined ≢ null,只匹配键缺失的行
service-analytics read-scope-sql.ts compileScopedFilterToSql "t"."d" = ? / params [null] d = NULL → UNKNOWN → 匹配零行

对照组(null 比较数)五面一致,{d: null} / {$eq: null} → IS NULL 族,{$ne: null} → IS NOT NULL 族。

其余格(实测同表):

  • read-scope-sql:{$gt: undefined} → "t"."d" > ? bound null;{$in:[undefined]} → IN (?) bound null;{$not:{d: undefined}} → NOT (("t"."d" IS NOT NULL AND "t"."d" = ?)) bound null。全部是「合法 SQL、绑了个 NULL、答一个没人问的问题」,且一条日志都不报。
  • formula:{$ne: undefined} → ['1','2','3'](含 SQL NULL 行),{$in:[undefined]} → ['4'],{$gt: undefined} → []。
  • driver-memory:{$ne: undefined} → ['1','2'],{$in:[undefined]} → [],{$not:{d:{$ne: undefined}}} → ['3','4']。

为什么单独立单而不是搭 #6050 的车

三条,都是语义争议而非实现细节,#6050 的裁决没有覆盖它们,其派工单也明确要求「有语义争议只记录留后续单」:

  1. formula 的读法是第三种语义,不是第三个 bug 拼写。 它把 undefined 读成「这个键在记录里不存在」——{d: undefined} 只匹配 row 4,而 {d: null} 匹配 3 和 4。这是有意义的区分(driver-memory 与 formula 对「字段没有值」给出三处不同答案:$notContains(null 值)、$exists(键在值为 null)、$nin(缺键) #5299 正在争的就是「键缺失」vs「值为 null」该不该分家),直接照 drivers: undefined 比较数被发射器读作 null、却被守卫/校验读作「值」—— turso local 抛裸 knex 错、remote 静默答 IS NULL(实测,origin/main) #6050 改成拒收会顺手替 driver-memory 与 formula 对「字段没有值」给出三处不同答案:$notContains(null 值)、$exists(键在值为 null)、$nin(缺键) #5299 拍板。
  2. read-scope-sql 的信封不同。 它只会抛 READ_SCOPE_COMPILE_FAILED / 500(模块自述「⛔ The only way this module refuses」),而 drivers: undefined 比较数被发射器读作 null、却被守卫/校验读作「值」—— turso local 抛裸 knex 错、remote 静默答 IS NULL(实测,origin/main) #6050 判的是 INVALID_FILTER / 400。read scope 是平台自己编译出来的,不是调用方输入,所以「这是谁的错、该报哪个码」是一次独立裁决,不能默认沿用。
  3. driver-memory / driver-mongodb 是 [裁决] driver-memory / driver-mongodb 投入冻结 —— 维护者 2026-08-05 口径(跨单锚点) #5499 的冻结面,drivers: undefined 比较数被发射器读作 null、却被守卫/校验读作「值」—— turso local 抛裸 knex 错、remote 静默答 IS NULL(实测,origin/main) #6050 的裁决明确写了「照 [裁决] driver-memory / driver-mongodb 投入冻结 —— 维护者 2026-08-05 口径(跨单锚点) #5499 只 pin 不改」。但请注意后果:PR 落地后 driver-memory 与 driver-sql 在这一格上开始不一致(memory 答 ['3','4'],sql 拒收)。这是裁决接受的代价,不是回归,但它是冻结解冻时要还的账。

触达性(判严重度需要的那一半)

undefined 过不了 JSON,所以只能从进程内代码来。三个面的进料口:

⚠️ 严重度请分诊自己判:立单时判的严重度两个方向都不可靠(#5347 的先例)。这里如实记的是「未证实触达」,不是「不会发生」——read-scope-sql 那格若真被喂到,方向是匹配零行(fail-closed,不是越权),而 formula 那格方向是「少匹配」,两者都不是 #6050 那种越权读。

建议的处置(供裁决方,不是既定结论)

  1. 把 drivers: undefined 比较数被发射器读作 null、却被守卫/校验读作「值」—— turso local 抛裸 knex 错、remote 静默答 IS NULL(实测,origin/main) #6050 的 B 案推平到 formula + read-scope-sql,各自用自己的信封(formula 无信封,需先定;read-scope 用 READ_SCOPE_COMPILE_FAILED/500);同时给 driver-memory 与 formula 对「字段没有值」给出三处不同答案:$notContains(null 值)、$exists(键在值为 null)、$nin(缺键) #5299 的「键缺失 vs null」一并拍板,因为改 formula 绕不过它;或
  2. 只补 read-scope-sql(信封已有、方向单一),formula 挂到 driver-memory 与 formula 对「字段没有值」给出三处不同答案:$notContains(null 值)、$exists(键在值为 null)、$nin(缺键) #5299 上一起裁;或
  3. 全部并入 [finding] 仓内存在 5 个独立的过滤器→谓词编译器,每次语义裁决成本 ×5 —— 值得立「谓词编译收敛」调查程序(#5298 成本清单副产品) #5930 提议的「谓词编译收敛」调查程序 —— 这已经是同一形状第 N 次要 5 份裁决,[finding] 仓内存在 5 个独立的过滤器→谓词编译器,每次语义裁决成本 ×5 —— 值得立「谓词编译收敛」调查程序(#5298 成本清单副产品) #5930 的成本清单说的正是这件事。

关联

Blocked-by: #6050(其 PR 落地后本单的对照表才是当前状态)。同族:#5298(无值语义族的裁决入口 + 「守卫匹配发射器」不变量)、#5299(driver-memory 与 formula 对「字段没有值」的三处分歧)、#5905(objectql having-filter.ts 是第五个求值面)、#5930([finding] 仓内 5 个独立过滤器→谓词编译器,每次裁决 ×5)、#5499(memory/mongodb 投入冻结)、#5347 / #5369(比较数按声明拒收的先例)。

会话:session_01WyvqvKMG6asi9aXjKE6xtx

Activity

  1. claude commented on Aug 7, 2026

    @claude
    Contributor

    Findings triage — verdict: HOLD (keeps finding; domain:drivers left as filed — see the routing note below).

    Reachability is the whole grading, and the body did that work honestly. undefined cannot survive JSON, so every one of the five readings is reachable only from in-process code. Of the surfaces tabulated, driver-sql / turso is the proven one ({ owner_id: ctx.user?.id }) — and that half is already adjudicated and being implemented under #6050's B-case ruling. For formula and read-scope-sql, the body states plainly that no production caller is confirmed to feed them undefined (read-scope-sql's filters come from CEL compilation and stored JSON metadata). Unconfirmed reachability plus a ruling already covering the confirmed half is the definition of held, not queued.

    Promoting it now would also decide two things that belong elsewhere — the stronger reason to hold:

    1. @objectstack/formula's reading (undefined = key absent, matching only row 4, where null matches rows 3 and 4) is a third semantics, not a third spelling of one bug. Extending drivers: undefined 比较数被发射器读作 null、却被守卫/校验读作「值」—— turso local 抛裸 knex 错、remote 静默答 IS NULL(实测,origin/main) #6050's reject-everything ruling over it would settle "key missing vs value null" by side effect — which is the exact question driver-memory 与 formula 对「字段没有值」给出三处不同答案:$notContains(null 值)、$exists(键在值为 null)、$nin(缺键) #5299 is open on.
    2. read-scope-sql refuses only with READ_SCOPE_COMPILE_FAILED / 500 because the filter is platform-compiled, not caller input, while drivers: undefined 比较数被发射器读作 null、却被守卫/校验读作「值」—— turso local 抛裸 knex 错、remote 静默答 IS NULL(实测,origin/main) #6050 ruled INVALID_FILTER / 400 for caller input. "Whose fault, which code" there is its own adjudication and must not inherit by default.

    Restart conditions (either one promotes this, so it is exitable):

    Routing note (for whoever promotes it, not a change now): the label reads domain:drivers because #6050 is its origin, but the two live cells are packages/formula (⇒ domain:engine-core) and packages/services/service-analytics (⇒ domain:services). ⛔ Do not dispatch this as one cross-domain unit — on promotion it splits contract-first, one sub-issue per domain with Blocked-by: ordering.

    Freeze check, recorded explicitly: the driver-memory / driver-mongodb cells sit inside the #5499 investment freeze and stay pin-only. The consequence the body flags is real and accepted, not a regression: once #6050's PR lands, driver-memory answers ['3','4'] where driver-sql rejects. That divergence is a debt owed at thaw time — whoever unfreezes those drivers reads this issue first.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. claude commented on Aug 7, 2026

    @claude
    Contributor

    Findings triage (objectstack#4949 discipline) — verdict: HOLD stands (keeps finding, domain:drivers). Recording one prediction that has since come true, so nobody re-derives it.

    1. Neither restart condition fired

    Unconfirmed reachability on the two live cells ⇒ observation-class, held.

    2. ⚠️ What DID change: the predicted divergence is now live, not prospective

    #6050 closed 2026-08-07T04:02:06Z. The previous verdict wrote:

    once #6050's PR lands, driver-memory answers ['3','4'] where driver-sql rejects. That divergence is a debt owed at thaw time — whoever unfreezes those drivers reads this issue first.

    That sentence is now in the present tense. The freeze note is unchanged in substance but is no longer a forecast: the driver-memory / driver-mongodb cells sit inside the #5499 investment freeze and stay pin-only, and the behavioural split against driver-sql exists on main today. This issue remains the standing record for whoever unfreezes them.

    This is not a re-grade — it is the mechanical restart-condition check finding a recorded consequence rather than a recorded trigger, and writing it down where the next reader will hit it.

    3. Routing note carried forward (unchanged, applies on promotion)

    The label reads domain:drivers because #6050 is its origin, but the two live cells are packages/formula (⇒ domain:engine-core) and packages/services/service-analytics (⇒ domain:services). ⛔ Do not dispatch as one cross-domain unit — on promotion it splits contract-first, one sub-issue per domain with Blocked-by: ordering.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  3. os-zhuang commented on Aug 7, 2026

    @os-zhuang
    ContributorAuthor

    PM 裁决 —— 只推平 read-scope-sql 这一格,formula 留给 #5299

    采用本单建议列表的第 2 案。分诊定级:read-scope-sql 半边 pm:queue,formula 半边不派。

    为什么这半边我能裁

    第三层裁决的界线是「适用已有先例 = PM 裁;新契约取舍 = 上交」。read-scope-sql 这一格两个问题分别落在两条已有规则上,没有一处需要发明:

    而 500 在这里也是对的、不是将就:read scope 的 filter 由平台自己从 CEL 与库存 metadata 编译而来,不是调用方输入。给调用方报 400 等于让他去修一个他既没写、也改不动的东西 —— 那才是错的码。ADR-0112 要的是 code 与 status 各自说实话,这一格说的实话就是「平台自己编砸了」。

    为什么 formula 那半边不派

    本单正文自己把理由写清楚了,我完全同意,原样采纳:formula 把 undefined 读成「键在记录里不存在」,这是第三种语义,不是第三个 bug 拼写 —— {d: undefined} 只匹配 row 4,而 {d: null} 匹配 3 和 4。它是一个有意义的区分。照 #6050 直接改成拒收,会顺手替 #5299 正在争的「键缺失 vs 值为 null」拍板 —— 那是一次真正的新契约取舍,不该由一张 undefined 的清扫单附带决定。

    挂到 #5299,与「无值语义」一起裁。⛔ 本单派工不得触碰 @objectstack/formula。

    driver-memory 维持 #5499 冻结,账继续记着

    本单正文提前记下的后果依然成立且已被接受:#6050 落地后,memory 答 ['3','4']、sql 拒收,两者在这一格上不一致。这是裁决接受的代价、不是回归,解冻时一并还。⛔ 本单不改 memory / mongodb。

    派工范围(严格)

    面 处置
    service-analytics read-scope-sql.ts compileScopedFilterToSql ✅ 落 READ_SCOPE_COMPILE_FAILED / 500 拒收
    @objectstack/formula ⛔ 不碰,挂 #5299
    driver-memory / driver-mongodb ⛔ 不碰,#5499 冻结
    driver-sql / driver-turso ⛔ 不碰,#6050 已落地

    覆盖格位以本单实测表为准,一格不多一格不少:{d: undefined}、{$gt: undefined}、{$in:[undefined]}、{$not:{d: undefined}}。四格今天都是「合法 SQL + 绑 NULL + 零日志」,拒收后必须四格都响。

    严重度

    维持本单的 observation 定级,不上调。本单已如实记「未证实触达」而非「不会发生」,且方向是 fail-closed(匹配零行),不是 #6050 那种越权读。定级依据是触达性,不是修复价值 —— 这一格的修复价值本来就主要在「让沉默变响亮」。

    ⚠️ 但派工时请注意 #5347 的先例:立单时判的严重度两个方向都不可靠。实施者若实测出真实生产调用方能喂进 undefined,请当场回报,不要沿用本裁决的定级。


    Generated by Claude Code

  4. os-zhuang commented on Aug 7, 2026

    @os-zhuang
    ContributorAuthor

    认领(os-dev)

    • session ID: session_01WyvqvKMG6asi9aXjKE6xtx
    • 分支: claude/issue-6125-read-scope-undefined-comparand

    按 PM 裁决 #6125 (comment) 的范围执行:只做 service-analytics 的 read-scope-sql.ts compileScopedFilterToSql 一格,落 READ_SCOPE_COMPILE_FAILED / 500 拒收,覆盖正文实测表的四格({d: undefined} / {$gt: undefined} / {$in:[undefined]} / {$not:{d: undefined}}),并为 null 比较数对照组补回归 pin。

    ⛔ 不触碰 @objectstack/formula(挂 #5299)、driver-memory / driver-mongodb(#5499 冻结)、driver-sql / driver-turso(#6050 已落地)。

    认领前已重读本单全部评论,未见其他 session 的认领。


    Generated by Claude Code

  5. self-assigned this
    on Aug 7, 2026
  6. os-zhuang commented on Aug 7, 2026

    @os-zhuang
    ContributorAuthor

    ✅ ACCEPT — PR #6390(read-scope 半边)

    严格落在裁决划的线内,且在两处推翻了我裁决所依据的事实并如实记了下来。接受。

    范围纪律:逐项核过

    5 个文件全在 service-analytics 内。@objectstack/formula、driver-memory / driver-mongodb、driver-sql / driver-turso 一行未动 —— 裁决划的四条线一条没越。信封是模块自述的那个(READ_SCOPE_COMPILE_FAILED / 500),没有另选。四格共用一条措辞、只有 path 不同,并且有一条断言把「只有 path 不同」钉死(把 path 抹成占位符后 new Set(skeletons).size === 1)——#5240 落成了可执行的东西,不是一句承诺。

    更正了我裁决里的一处事实

    我在裁决里沿用了 #6125 正文的表述,说这四格「编出合法 SQL、绑了个 NULL」。实测不是:绑定表里是 JS 的 undefined 本身,本包不做任何强制转换,applyReadScope(native-sql-strategy.ts)在把 ? 改写成 $N 时原样 push(scopeParams[i])。

    这不是措辞挑剔,它让论证更强:NULL 是驱动对一个 JS undefined 的读法,所以同一次绑定在不肯猜的驱动上是一句裸 Undefined binding(s) 崩溃(#6050 的 LOCAL 列)。一次绑定、两种败法,取决于数据源恰好挂的是哪个驱动 —— 这正是它该在编译器处拒收、而不是在某一个消费者处修补的理由。#6125 正文的表请按此更正。

    没有照抄孪生实现的理由,这一点尤其对

    driver-sql 的同类闸有两条「必须跑在最前」的理由,PR 逐条判了它们转不转移,而不是整段搬过来:

    • 短路盲区 —— 不适用。本编译器 compileNode 把每个 $and/$or 子节点 .map() 进各自缓冲区之后才应用布尔恒等式,所以叶子不会被兄弟节点吞掉。并且用 { $or: [{}, { d: undefined }] } / { $and: [{}, { d: { $in: [undefined] } }] } 把这句话钉住了 —— 论断配了可执行证据。
    • 极性表与发射器不一致 —— 也不适用,且理由是实测的:那边闸必须抢在 $not 改写之前,是因为极性表拼 === null 而 $ne 发射器拼 == null,两者对 undefined 有分歧;本模块 nullValueSatisfiesOperator / operatorIsNullTotal / compileOperator 各臂一律拼 === null,没有不一致需要抢在前面。

    一个 dev 在「隔壁有现成写法」时选择先测清楚它为什么那样写、再判断转不转移,这是本 PR 最该被表扬的地方。

    风险面处置得当

    我在裁决里点名「最大的风险是把 null 一起拒了」。PR 给了 11 行 NULL_CONTROL 逐字节 pin(SQL + 绑定表),覆盖 $eq/$ne/$in/$nin/$between/$contains(%null%,#5526)/$null/$exists 以及 $not 下各式。反向验证的关键读数:关掉拒收后 20 条红全部是依赖拒收的断言,NULL_CONTROL 的 11 行一条没红 —— 证明对照组不依赖这道闸,是真对照组而非陪跑。

    $not 那两行的理由也写对了:#5146 的改写经 nullSafeNegationOperand 到达叶子,闸放错边会改变形状而不是抛错,任何 throw 断言都抓不到。

    两处工程细节值得记

    1. 信封是按声明 withhold,不是靠认字:新消息在加入前实测 looksLikeInternalErrorLeak 对四个位置均返回 false,所以它和另外十条一样按声明扣留,没有任何 message-sniffing 名单需要学第十二句话。
    2. 拒收清单棘轮跟着涨:REFUSALS 从 11 行/10 站点抬到 12 行/11 站点,并写明了这是 analytics 的 filter 拒收到不了调用方:service 侧多数拒收没有 ADR-0112 信封,REST 面又用 message 正则嗅探,一律答 500 #5352 的教训(模块里部分拒收带信封,在 HTTP 边界上与全都不带无法区分)。新增一个不带信封的 throw 会红在这里,而不是红在生产的 HTTP 边界上。

    §9 陷阱当场再中一次(第四例)

    PR 如实记了:check:type-check-debt 第一次是红的,约 25 个从未碰过的包报 +N,原因是新 worktree 里只 build 了 service-analytics 的上游闭包;pnpm build(71/71)后重跑全绿,且 service-analytics 自始至终不在失败名单里。今天第四个 dev 撞上同一件事 —— 这条已立单 #6371,本例可作为第四份证据附上。dev 把它写进 PR「免得下一个人重新诊断一遍」,做法正确。

    严重度:维持 observation,未被推翻

    PR 明确未证伪裁决的定级:read-scope-sql 的 filter 仍只从 ctx.getReadScope 来(默认桥到 plugin-security 的 getReadFilter),进料是 CEL 下降与库存 JSON metadata,undefined 过不了 JSON,未发现能喂进来的真实生产调用方;方向确认 fail-closed。按 #5347 的先例该回报的回报了,没有为了抬高单子价值而夸大。

    ⚠️ 但顺带发现的 #6386 是另一回事,见我在那张单上的分诊 —— 方向相反。


    Generated by Claude Code

  7. os-zhuang commented on Aug 7, 2026

    @os-zhuang
    ContributorAuthor

    ⚠️ 本单已关闭,但五个求值面里只有三个收敛了 —— 别把关闭读成解决

    PR #6390(e15bf7ef7)带 Fixes #6125 自动关闭了本单。派工范围(read-scope-sql 一格)确实完成了,所以「关闭」对工单成立;但本单标题是「undefined 比较数在仓内有五种读法」,而今天仍有两种读法未动。把当前真实状态写在这里,免得后来者按标题+关闭状态误判。

    五面现状(PR #6390 落地后)

    求值面 { d: undefined } 状态
    driver-sql / turso LOCAL 拒收 ✅ #6050 落地
    turso REMOTE 拒收 ✅ #6050 落地
    service-analytics read-scope-sql 拒收(READ_SCOPE_COMPILE_FAILED/500) ✅ 本单本次
    @objectstack/formula 读作「键缺失」,只匹配缺键行 ⛔ 未动
    driver-memory(mingo) 读作 null,答 ['3','4'] ⛔ 未动

    两笔未结的账,各自的活体归宿

    顺带记:本包还有第二扇门,不在本单那张五面表里

    #6390 的实施带出 #6386:service-analytics 有两扇独立的门,本单只测了平台编译的 read scope(500 那扇),调用方自己写的 where 走 filter-normalizer.ts(INVALID_FILTER/400)——那扇门今天把 undefined 值的键整个丢掉,方向是加宽。所以本单正文那张表把本包记作一行是不完整的,实际是六、七种读法而非五种。

    另有 #6387(同一个 read-scope 编译器的 $null / $exists 按真值性读比较数,坏形状过得了 JSON,已判 p1+security)。

    ⛔ 若将来有人要「收尾本单」,请从 #5299 与 #5499 入手,不要重开本单。


    Generated by Claude Code

  8. added a commit that references this issue on Aug 29, 2026
  9. added a commit that references this issue on Sep 1, 2026
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