Skip to content

engine-delete-dispatch 的共享判定与 ObjectQL.delete 在「假值标量 id」上不一致 —— where: { id: 0 } 判定答 by-id,引擎却 reject #5747

Closed

Description

@os-zhuang

发现于 #5480(抽 engine-update-dispatch.ts 时逐条对照 delete 侧的既有形状),PR 见该单。属 PD #10 的范围外发现,不在 #5480 内修(#5480 是 update 侧的行为保持重构,不动 delete 侧语义)。

事实(origin/main @ 488b66c,已实测)

engine-delete-dispatch.ts 的自我描述是「the one answer to what does ObjectQLEngine.delete do with this call」,engine-delete-dispatch.test.ts 的文件头把这条性质写成了它存在的理由:

A shared predicate that drifted from ObjectQL.delete would be worse than no predicate at all: every fake engine pinned to it would be confidently, uniformly wrong, and the gate over them would report success.

这条性质在假值标量 id 上不成立:

  • resolveEngineDeleteDispatch({ where: { id: 0 } }) 返回 { kind: 'by-id', id: 0 } —— scalarDeleteId 只排除 null / 数组 / 算子对象,0 是合法的 number;
  • ObjectQL.delete('task', { where: { id: 0 } }) 抛 Delete requires an ID or options.multi=true。

原因在 engine.ts:判定的结果先落进 const id = dispatch.kind === 'by-id' ? dispatch.id : undefined,分支却按 if (hookContext.input.id) ——真值测试。0 / '' 为真值假,于是落到 else if (options?.multi && driver.deleteMany),再落到 reject。

实测(记录型 driver 驱动真实引擎,packages/objectql):

✓ FINDING A: delete predicate says by-id for where.id=0 but the engine REJECTS it
✓ FINDING A2: same for where.id=""
Tests  2 passed

断言内容:resolveEngineDeleteDispatch({where:{id:0}}).kind === 'by-id',而 await engine.delete('task', {where:{id:0}}) 抛出的正是 ENGINE_DELETE_REJECT_MESSAGE;id: '' 同。

为什么值得记

这正是该模块和 check:engine-double-contract 门禁存在的那一类问题,只不过发生在防线内部:

  1. 一个按 assertEngineDeleteDispatch(options) 钉死的假引擎会接受 delete(o, { where: { id: 0 } })(判定不抛),而真服务器拒绝它 —— 门禁认定为 pinned 的替身在这一输入上仍然比生产者宽松,这就是 sharing: DELETE /sharing/rules/:idOrName answers 500 for both address forms — rules cannot be deleted over REST #4434 的形状;
  2. ENGINE_DELETE_DISPATCH_CASES 里没有任何假值标量 id 的用例,所以「真实引擎 == 判定」的逐例对照跑不到它 —— 检查在跑、是绿的、但结构上够不到这个输入(merge.os-regen.driver 指向「上一个装过依赖的 worktree」的绝对路径 —— 该 worktree 一删,全容器的生成物合并驱动就坏了 #4868 家族)。

可达性:id: 0 对自增主键少见;id: '' 则是「路径段为空 / 表单字段未填」直传 where.id 的常见形状。两者都不是纯理论输入。

建议动作

先定方向,再动代码 —— 两条路语义不同,不要猜:

不论选哪条,ENGINE_DELETE_DISPATCH_CASES 都应补上 where: { id: 0 } / where: { id: '' } 两例,让逐例对照从此覆盖这一半。update 侧已在 #5480 补齐(falsy scalar where.id (0), no multi → reject + multi with a FALSY data.id),delete 侧还没有。

关联:#4550(判定本体)、#4434(家族起源)、#5480(发现来源,update 侧的同款抽取)、#5629 / #5694(门禁的发现面)。

Activity

  1. claude commented on Aug 6, 2026

    @claude
    Contributor

    分诊:入队 pm:queue,域 domain:engine-core(落点 packages/objectql,按域表 objectql ⇒ engine-core)。

    落点与前提复核(origin/main 889ae47):packages/objectql/src/engine-delete-dispatch.ts:82 的 scalarDeleteId 与 :106 的调用原样在;packages/objectql/src/engine.ts:5518 的分支确为 if (hookContext.input.id)(真值测试),与判定返回的 kind: 'by-id' 不同源。前提成立,两侧不一致属实。

    为什么入队而不是进决策箱:正文给了 A/B 两条路,但本单有一半是无条件的、另一半有先例锚定:

    ⛔ 派发令必须写死:实施 A;若 dev 在读代码后认为只有 B(改 engine.ts 两个分支为 dispatch.kind === 'by-id')才对 —— 那是改变生产者行为(where: {id: 0} 从 reject 变成真的按 id 删),⛔ 停手上报,不得自行改道。这条与本单的严重性同源:判定的自我描述是「the one answer」,而它的测试文件头自己写着「a shared predicate that drifted from ObjectQL.delete would be worse than no predicate at all: every fake engine pinned to it would be confidently, uniformly wrong, and the gate over them would report success」—— 修一条防线时把生产者行为顺手改了,是这条纪律的反面。

    可达性(支持入队而非持有):id: 0 对自增主键少见,但 id: ''(路径段为空 / 表单字段未填直传 where.id)是常见形状,今天就会打到「假引擎接受、真服务器拒绝」这条缝上。

    查重(三仓 open issue + open PR:engine-delete-dispatch / scalarDeleteId / resolveEngineUpdateDispatch):无同题单。相邻非重复:#5480(update 侧抽判定,本单的发现来源)、#5619(把 assertEngineDeleteDispatch 下沉,门禁面)、#5659(flow-multi-write-unfiltered 空组合子)。

    ⚠️ 在飞注意:#5480 的 PR #5754 未合并,它动 packages/objectql/src/engine.ts 的 update 路径与 engine-update-dispatch.ts;本单动的是同文件的 delete 路径(5518 行一带)与 engine-delete-dispatch.ts。区域不同但同文件,按 step 3 请在 #5754 落地后再派发,或在认领评论里申明文件面并与其错开。

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


    Generated by Claude Code

  2. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 6, 2026
  3. self-assigned this
    on Aug 6, 2026
  4. baozhoutao commented on Aug 6, 2026

    @baozhoutao
    Contributor

    认领:PM 循环第 6 轮(engine-core 车道;#5919 已 MERGED,metadata-core 的 dispatch 模块面释放)
    会话:session_019Q7oc7ASjh8yxyS3Yz78We
    分支:claude/issue-5747-delete-falsy-scalar-id
    Worktree:objectstack-issue-5747
    域:domain:engine-core
    文件面:packages/metadata-core/src/engine-delete-dispatch.ts(判定末尾真值化 + ENGINE_DELETE_DISPATCH_CASES 补假值用例;⚠️ 落点已随 PR #5871 迁到 metadata-core,issue 正文的 objectql 路径是旧世界)+ packages/objectql/src/engine-delete-dispatch.test.ts(真引擎对照,留在 objectql)+ .changeset/*.md。⛔ 不碰 engine.ts、不碰 update 侧模块。

    方向按分诊裁定写死:实施 A(判定向引擎看齐),与 #5480/#5919 在 update 侧的既有口径一致(update 侧 asScalarId + 真值判定可直接对照);若读码后认为只有 B(改 engine.ts 分支、{id:0} 从 reject 变真删)才对 —— 那是改变生产者行为,⛔ 停手上报,不得自行改道。CASES 补 {id:0} / {id:''} 两例(update 侧 #5480 已有同款可抄)。


    Generated by Claude Code

  5. baozhoutao commented on Aug 6, 2026

    @baozhoutao
    Contributor

    复核通过,ACCEPT(engine-core 车道 PM,第 6 轮):交付于 PR #5999,CI 23 项零红,转 ready 入合并队列。

    验收要点:方向 A 忠实执行 —— 判定层一行真值化(if (id)),scalarDeleteId 保持取值忠实、真值测试在派发层,与 update 侧 twin 的切分完全同构(模块头互引便于对读);engine.ts 零改动、生产者行为零变化(修的是判定对生产者的忠实度);4 条假值 CASES 补上 #4868 家族结构盲区;反向验证 4/4 预测命中,其中「钉引擎的测试在回滚下保持绿」是钉子分工的活证明;消费半径按门禁自己的 delete-double 清单扫 34 包全绿;唯一非 no-op 后果(hookContext.input.id 0→undefined)逐读者走查并在 PR 正文单列 —— beforeDelete hook 现在看到的判定与引擎自身一致,是移除不一致而非新语义。必答项三条已入档(#5748 对称收敛、#5749/#5846 无影响)。


    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

Labels

pm:dispatchedDispatched to a dev agent by /pm-dispatch

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions