Skip to content

批量写上 hook 的 previous:B1 已拍板(fail loud 无例外,并入 #4775);本条转为「批量写 hook 按行触发」设计卡孵化点 #4800

Description

@xuyushun441-sys

来源

#4784(PR #4799)把 hook condition 的 CEL 作用域扩成 record + previous。单记录 update 那条路已经完整:previous 搭 engine.update 既有的那一次 prior 取数,总全、不回灌、insert 上不绑定(与 validation 侧逐字一致)。

predicate(multi: true)批量更新这条路没有答案,#4784 的拍板意见里也没有 —— 它恰恰是 issue 原文点名「要先定『拿不到时 previous 是什么』」的那一格。

现状

engine.ts 的批量分支:

对照:validation 侧在批量上是有答案的 —— rulesNeedRows 逐行取 prior,evaluateValidationRules 每行调一次(#3106)。hook 没有这个「每行一次」的形状,所以不能照抄。

为什么现在值得单独定夺

#4775 已拍板方案 B:条件求不出值 → 抛错中断该次操作。落地之后,只要某个对象上有一条引用 previous 的 hook 条件,该对象的每一次批量更新都会写入失败 —— 失败原因还指向一个跟这次写入无关的 hook。#4784 修好的是「照文档写的 hook 不再静默失效」,这一格是它剩下的、会在 #4775 之后变成事故的部分。

派发 #4784 时给的成本提示是「引用 previous 会让 bulk predicate update 逐行取数」。逐行取数本身不难(复用 rulesNeedRows 那条路),难的是取回来之后绑给谁 —— 一次 hook 调用配 N 行前态。所以 #4799 的文档写的是实际行为(bulk 上不绑定),没有写那句提示,以免又一次 declared ≠ delivered。

可选方案(两条轴)

A. 批量写上按行触发带 previous 条件的 hook

引用 previous 的条件 → 逐行取 prior → 该 hook 按行求值、按行触发,ctx.previous = 该行。

  • 长远合理性:好 —— 语义上批量更新就是 N 次记录变更,审计/过渡类 hook 本来就该按行看到。与 validation 侧 Validation rules, requiredWhen and option visibleWhen are silently skipped on multi-row updates (options.multi) #3106 的「一份 payload,N 个前态」同源。
  • 防 AI 写错:好 —— 作者写的过渡条件在单记录和批量上含义一致,不需要记住「批量上这条不成立」。
  • 代价:改变 hook 契约 —— 同一个 hook 在批量写上从触发 1 次变成 N 次,ctx.result 的形状也要跟着定(整批 vs 单行)。只对引用 previous 的 hook 这么做会更糟:行为取决于条件文本,那是隐性规则。要做就得想清楚是否对所有 after 型 hook 统一按行。

B. 明确批量上 previous 不可用,并给一条专门的诊断

保持不绑定,但在 #4775 的 fail loud 里为这一格出一条点名的错误(「hook 条件引用了 previous,但这是一次 predicate 批量更新,没有单一前置记录;请改用单记录写入,或把过渡语义放到 record-change flow trigger」),而不是默认的 No such key: previous。

  • 长远合理性:中 —— 不动 hook 契约,但把「过渡语义在批量写上不可表达」写死成平台性质。
  • 防 AI 写错:中上 —— 错误响亮且指路,但 AI 仍会先写出错的形状再被拒。
  • 代价:批量写入仍然会失败(只是失败信息有用了)。等于把成本转嫁给作者。

C. 批量写上跳过引用 previous 的条件(视作不适用,不阻断写入)

建议

A,若一次做不动则先上 B(它与 #4775 同批即可完成,成本低),把 A 留给「after 型 hook 在批量写上是否统一按行触发」那次更大的定夺。理由沿两条轴:长远上 A 才是让 hook 与 validation 对同一件事给同一个答案(#3106 已经在 validation 侧付过这笔设计成本);防 AI 写错上,A 让作者只需要记一套过渡写法,而 B 要求他们记住一条例外 —— 但 B 至少让例外响亮,远好过今天的静默。

关联

Activity

  1. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    CollaboratorAuthor

    PM 深化分析:原三选项里 B 内部还有一个分叉,且 A 的真实规模比看起来大

    维护者要求对本决策做深入分析。在原 A/B/C 之上,读代码后有三点修正认知,然后是细化后的选项与建议。

    三点修正认知

    1. A 的真实规模:它不是「修这一格」,是「批量写上的 hook 触发粒度」这个平台级设计。
    实现 #4784 的 dev 已经点破:只对「条件文本里含 previous」的 hook 按行触发,是最坏的形态 —— 行为取决于条件字符串,作者无法从任何声明推理出自己的 hook 会触发 1 次还是 N 次。所以 A 的真问题是:after 型 hook 在批量写上是否统一按行触发。这一改动波及所有存量 hook:通知类 hook 从 1 封邮件变 N 封、聚合/缓存失效类 hook 重复执行 N 次;ctx.result 的形状(整批 vs 单行)、onError 的按行语义(某行 handler 失败是废整批还是废该行)、大批量下的资源上限(10k 行 × handler)都要一并定。这是一张 ADR 级设计卡,不是一次 dispatch。

    2. B 内部有分叉:「求不出值」在这一格里有两种失败方向,分别对应不同的受害者。

    • B1 —— fail loud 无例外:批量写上引用 previous 的条件求不出值 → 该次批量写失败,错误点名 hook、说明原因(「predicate 批量更新没有单一前置记录」)、给出路(改单记录写入 / 过渡语义放 record-change flow trigger)。守住 hook 的 condition 求不出值时:全局 fail loud —— 抛错并中断该次操作(方案 B 已拍板;Blocked-by #4770) #4775 的「求不出值 = 失败,无例外」。代价:一个照文档写的合法过渡 hook,会让该对象的所有批量更新失败 —— 惩罚落在正确行为上,直到作者迁走。
    • B2 —— 这一格开例外(loud-skip):批量上含 previous 的条件以 error 级上报后跳过该 hook,写入放行。代价:审计缺口仍然存在(批量 update 完成的任务不留痕),只是从今天的 warn 升到 error;并且给「求不出值 = 失败」开了第一个例外 —— 例外的理由可陈述(「作者拼错 key」和「平台尚未支持该语境」是两类事),但例外一旦存在就会被引用第二次。

    3. B 给的「出路」本身需要核实。「过渡语义请用 record-change flow trigger」这条指路,前提是 flow trigger 在批量写上真的按行拿到 previous —— 这一点我没有证据,不能写进错误文案再来一次 declared ≠ delivered。无论选哪条,派发时都要求 dev 先核实这条路在批量上通不通;不通则错误文案只指「单记录写入」一条路,并把 flow trigger 的批量语义单独立 issue。

    两轴评估(细化后)

    项目长远合理性 防 AI 写错
    A(统一按行,独立设计卡) 终局正确:批量更新语义上就是 N 条记录变更,validation 已按行(#3106),hook 不对齐是同一概念两套答案 最好:一种过渡写法,单/批语义一致,无例外要记 —— 但设计成本必须先付,抢通会留下一堆没定的形状
    B1(fail loud 无例外) 好:规则只有一条、无例外;不封死 A(B1 是可逆的过渡,A 落地后这条错误自然消失) 好:AI 写出含 previous 的 hook 后第一次批量测试就被指路;代价可见、可修
    B2(loud-skip 例外) 中:保住批量写,但「无例外」被打破,且审计缺口固化为平台行为 中:错误有日志但结果不可见 —— 漏掉的审计记录没人会去找,这正是 #4649 反对的形状的温和版
    C(静默跳过) 差 差 —— #4649 判过死刑,列出只为完整性

    建议

    B1 与 #4775 同批实现,同时把 A 立为独立设计卡(不阻塞 #4775)。

    理由:B1 是唯一既守住「声明即强制、错误必须响亮」又不需要现在就付 A 的设计成本的选项。它造成的「合法 hook 打挂批量写」是真实代价,但这个代价响亮、指路、可修;B2 省下的这个代价换来的是安静的审计缺口,而缺失的审计记录是所有故障形态里最难被发现的一种。A 的终局价值不因选 B1 而减损 —— 设计卡落地之日,B1 的那条错误自然退役。

    若你判断「批量写打挂」在当前使用形态下不可接受(例如存量 app 里已有大量含 previous 条件的对象高频批量更新 —— 目前仓内 examples 尚无此形态),B2 是可辩护的次选,但请连同「例外仅此一格、A 落地即收回」一起写死。


    Generated by Claude Code

  2. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    CollaboratorAuthor

    维护者已拍板:方案 B1 —— fail loud 无例外,A 立为独立设计卡

    决定:predicate 批量更新上 previous 保持不绑定;#4775 的 fail loud 落地时,这一格不开例外 —— 引用 previous 的 hook 条件在批量写上求不出值 → 该次批量写失败,但错误必须是专门的、点名的、指路的,不是默认的 No such key: previous。A(批量写上 hook 统一按行触发)另立设计卡,不阻塞 #4775;A 落地之日,这条错误自然退役。B2、C 不采纳。

    随决定生效的实现约束(并入 #4775 的派发范围):

    1. 专门诊断文案,形如:「hook <name> 的条件引用了 previous,但这是一次 predicate 批量更新,没有单一前置记录。改用单记录写入,或将过渡语义移至 record-change flow trigger。」点名 hook、点名语境、给出路。
    2. 「flow trigger」这条出路写进文案前必须先核实:record-change flow trigger 在批量写上是否真的按行拿到 previous。通则写;不通则文案只指单记录写入一条路,并把 flow trigger 的批量语义单独立 issue —— 不允许在错误信息里再造一次 declared ≠ delivered。
    3. 文档同步:skills/objectstack-formula/SKILL.md §5 的绑定作用域表(fix(objectql,skills): hook condition 的 CEL 作用域补上 previous —— 过渡语义可写,与 validation 谓词对齐 (#4784) #4799 刚加的那张)在「predicate 批量更新 → unbound」一行补一句「hook 的 condition 求不出值时:全局 fail loud —— 抛错并中断该次操作(方案 B 已拍板;Blocked-by #4770) #4775 后:求不出值 = 该次写入失败」,让作者在写之前就知道,而不是在生产里遇到。

    排队:本 issue 的答案已并入 #4775 的实现约束,不再阻塞 —— #4775 的 Blocked-by 里本 issue 一项视为已解(其余前置不变:#4784 / PR #4799 待合)。本 issue 保持开启作为 A 设计卡的孵化点:A 的设计范围(触发粒度统一、ctx.result 形状、onError 按行语义、大批量资源上限)已在上面的分析里列明,待有人认领时转正为设计 issue。

    标签:移除 needs-user-decision。


    Generated by Claude Code

  3. changed the title [-]需定夺:predicate 批量更新上 hook `condition` 的 `previous` 语义 —— 现在不绑定,#4775 落地后会打挂批量写入[/-] [+]批量写上 hook 的 previous:B1 已拍板(fail loud 无例外,并入 #4775);本条转为「批量写 hook 按行触发」设计卡孵化点[/+] on Aug 3, 2026
  4. claude commented on Aug 4, 2026

    @claude
    Contributor

    维护者已裁(2026-08-04,与 #4862 同一次拍板):A(按行)为平台契约,分阶段交付

    1. 契约先定:批量写上 after 型 hook 与 record-change flow trigger 按行求值、按行触发,ADR 化(含 v17 文档口径);
    2. rc 止血 = 本卡的 B:hook 条件引用 previous 撞上批量写时,把泛化的求值失败换成点名批量限制、指路的专门诊断 —— 新立执行单即派;
    3. A 的实现(hook 一次触发 N 次、ctx.result 形状、匹配行集一次性读取、trigger 侧 record-change flow trigger 在 predicate 批量写上:previous 不绑定、record 只是裸 payload —— 官方文档与 showcase 正在教的 transition 起始条件在批量写上不成立 #4862 同单)在 17.x 作为「向已声明契约收敛的修复」交付,实现单已立、Blocked-by 止血单与 ADR。

    C 按原分析弃。


    Generated by Claude Code

  5. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    Contributor

    PM 分诊(p0 #5088 集中处理的同一族,第 1 轮):标记 needs-user-decision,并确认本条不阻塞 #5088。

    1. 与 #5088 的边界(要紧,避免被读成一件事)

    今天开的 p0 #5088(updateMany 对不存在的 id 报 INTERNAL_ERROR 而非 RECORD_NOT_FOUND)读起来像本条的一个实例,实际是另一条代码路径,已实测确认可分离:

    本条(#4800 / #4862) #5088
    触发形状 predicate 批量写 multi: true,无 id records: [{id, data}],每行自带 id
    落点 packages/objectql/src/engine.ts 批量分支 packages/metadata-protocol/src/protocol.ts 的三个 bulk 写面
    「前置记录是哪一条」 真正未决 —— 一次写入匹配 N 行,一次 hook 调用配 N 个前态 有唯一答案 —— 每行一个 id,逐行探针即可

    所以 #5088 已按「恢复 #4435 既有不变量」直接派发,不等本条拍板;派发词里明确要求 dev 不要顺手改 predicate 批量分支的 hook 语义。本条的决定权范围不因 #5088 落地而缩小。

    2. 为什么标 needs-user-decision 而不是入队

    正文的 A/B/C 已按两条评估轴写得很完整,不需要 PM 再补一份分析。要拍板的是 hook 契约的形状:同一个 after 型 hook 在批量写上从触发 1 次变成 N 次(A),还是把「过渡语义在批量写上不可表达」写死成平台性质(B)。这落在升级门槛的第一条 —— 产品语义 / 公开契约形状,且 issue、AGENTS.md、ADR 与既有代码规范都不决定答案 —— 属于必须维护者拍板的类型,PM 不代答。

    正文那句「要做就得想清楚是否对所有 after 型 hook 统一按行」是这次拍板的真正标的:只对引用 previous 的 hook 按行触发会更糟(行为取决于条件文本,是隐性规则),这一点 PM 复核同意。

    3. 建议与 #4862 同批拍板

    #4862(record-change flow trigger 在同一格上 previous 不绑定、record 只是裸 payload)是同一个物理限制在另一层的投影,其 A 方向与本条 A 同源。两条分开拍板会产出两套规则 —— 正是 #4862 正文点名要避免的。已同步标记 needs-user-decision。

    对照值得维护者注意:同一个平台对同一个物理限制,hook 层现在会响亮失败(#4775 的 B1),flow trigger 层依然静默。这个不一致本身可能就是拍板的主要输入。


    Generated by Claude Code

  6. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    Contributor

    标签修正(PM 循环第 1 轮,会话 session_01Pbu27iNUfQCHeuS551Rqo7):移除 needs-user-decision。维护者已于 2026-08-04 00:37Z 在本 issue 拍板(A 为平台契约、分阶段交付,与 #4862 同一次裁定),实现单 #5038 已立且本轮已派发。04:06Z 那条「标记 needs-user-decision」的分诊评论写自拍板前的快照、未见 00:37Z 裁定,属陈旧前提 —— 决策不重开。本卡保持开启作为 A 设计孵化点,随 #5038 交付关闭。


    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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions