Repository navigation
批量写上 hook 的 previous:B1 已拍板(fail loud 无例外,并入 #4775);本条转为「批量写 hook 按行触发」设计卡孵化点 #4800
Description
Activity
xuyushun441-sys commented
on Aug 3, 2026 CollaboratorAuthorMore actionsPM 深化分析:原三选项里 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
- B1 —— fail loud 无例外:批量写上引用
xuyushun441-sys commented
on Aug 3, 2026 CollaboratorAuthorMore actions维护者已拍板:方案 B1 —— fail loud 无例外,A 立为独立设计卡
决定:predicate 批量更新上
previous保持不绑定;#4775 的 fail loud 落地时,这一格不开例外 —— 引用previous的 hook 条件在批量写上求不出值 → 该次批量写失败,但错误必须是专门的、点名的、指路的,不是默认的No such key: previous。A(批量写上 hook 统一按行触发)另立设计卡,不阻塞 #4775;A 落地之日,这条错误自然退役。B2、C 不采纳。随决定生效的实现约束(并入 #4775 的派发范围):
- 专门诊断文案,形如:「hook
<name>的条件引用了previous,但这是一次 predicate 批量更新,没有单一前置记录。改用单记录写入,或将过渡语义移至 record-change flow trigger。」点名 hook、点名语境、给出路。 - 「flow trigger」这条出路写进文案前必须先核实:record-change flow trigger 在批量写上是否真的按行拿到
previous。通则写;不通则文案只指单记录写入一条路,并把 flow trigger 的批量语义单独立 issue —— 不允许在错误信息里再造一次 declared ≠ delivered。 - 文档同步:
skills/objectstack-formula/SKILL.md§5 的绑定作用域表(fix(objectql,skills): hookcondition的 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
- 专门诊断文案,形如:「hook
- changed the title
[-]需定夺:predicate 批量更新上 hook `condition` 的 `previous` 语义 —— 现在不绑定,#4775 落地后会打挂批量写入[/-][+]批量写上 hook 的 previous:B1 已拍板(fail loud 无例外,并入 #4775);本条转为「批量写 hook 按行触发」设计卡孵化点[/+]on Aug 3, 2026 维护者已裁(2026-08-04,与 #4862 同一次拍板):A(按行)为平台契约,分阶段交付
- 契约先定:批量写上 after 型 hook 与 record-change flow trigger 按行求值、按行触发,ADR 化(含 v17 文档口径);
- rc 止血 = 本卡的 B:hook 条件引用
previous撞上批量写时,把泛化的求值失败换成点名批量限制、指路的专门诊断 —— 新立执行单即派; - 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
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,无 idrecords: [{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
标签修正(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
- added 4 commits that reference this issue
on Aug 4, 2026
来源
#4784(PR #4799)把 hook
condition的 CEL 作用域扩成record+previous。单记录 update 那条路已经完整:previous搭engine.update既有的那一次 prior 取数,总全、不回灌、insert 上不绑定(与 validation 侧逐字一致)。predicate(
multi: true)批量更新这条路没有答案,#4784 的拍板意见里也没有 —— 它恰恰是 issue 原文点名「要先定『拿不到时previous是什么』」的那一格。现状
engine.ts的批量分支:triggerHooks('afterUpdate', hookContext)),hookContext.previous从不赋值(if (priorRecord)只在单 id 分支成立);condition的 CEL 作用域补上previous—— 过渡语义可写,与 validation 谓词对齐 (#4784) #4799 之后,批量更新上previous保持不绑定 —— 一条previous.done != true && record.done == true的条件不可求值,今天的表现是 warn + 当作 false(hook 跳过)。对照: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= 该行。ctx.result的形状也要跟着定(整批 vs 单行)。只对引用previous的 hook 这么做会更糟:行为取决于条件文本,那是隐性规则。要做就得想清楚是否对所有 after 型 hook 统一按行。B. 明确批量上
previous不可用,并给一条专门的诊断保持不绑定,但在 #4775 的 fail loud 里为这一格出一条点名的错误(「hook 条件引用了
previous,但这是一次 predicate 批量更新,没有单一前置记录;请改用单记录写入,或把过渡语义放到 record-change flow trigger」),而不是默认的No such key: previous。C. 批量写上跳过引用
previous的条件(视作不适用,不阻断写入)condition求不出值时:全局 fail loud —— 抛错并中断该次操作(方案 B 已拍板;Blocked-by #4770) #4775 判过死刑的「静默当 false」,只是换了个名字。建议
A,若一次做不动则先上 B(它与 #4775 同批即可完成,成本低),把 A 留给「after 型 hook 在批量写上是否统一按行触发」那次更大的定夺。理由沿两条轴:长远上 A 才是让 hook 与 validation 对同一件事给同一个答案(#3106 已经在 validation 侧付过这笔设计成本);防 AI 写错上,A 让作者只需要记一套过渡写法,而 B 要求他们记住一条例外 —— 但 B 至少让例外响亮,远好过今天的静默。
关联
condition的 CEL 作用域只绑定record—— 文档教的previous.x/ctx.record根本不存在,过渡型条件写不出来 #4784 / PR fix(objectql,skills): hookcondition的 CEL 作用域补上previous—— 过渡语义可写,与 validation 谓词对齐 (#4784) #4799(previous进 hook condition 作用域)condition求不出值时:全局 fail loud —— 抛错并中断该次操作(方案 B 已拍板;Blocked-by #4770) #4775 落地前需要有答案,否则批量写入会因为一条无关的 hook 条件而失败previous总全 + fail closed)