Part of objectstack-ai/hotcrm#788
现象
suppliedKeys 在 hook 运行之前从调用方 payload 快照,而 stripReadonlyFields 在 beforeUpdate hook 之后执行、且对每个「readonly 且 ∈ suppliedKeys」的 key 做 delete result[name] —— 删的是当前值,而当前值可能已被 hook 覆写。于是「调用方回传了该 readonly 字段」这一件事,会连带把 hook 在同一次写里打的戳一起抹掉。
const suppliedKeys = new Set(Object.keys(opCtx.data ?? {}));
…
await this.triggerHooks("beforeUpdate", hookContext);
…
hookContext.input.data = stripReadonlyFields(updateSchema, preRo, suppliedKeys, …);
实测(pinned 17.0.0-rc.2,真 ObjectQL + InMemoryDriver + 真 crm_knowledge_article schema + 真 hook + sys_fetch_previous_update 复刻,非系统上下文)
draft -> published,非系统 update,payload 整记录回传(含 readonly 的 published_at):
=> 落库 status = "published", published_at = null, last_reviewed_at = 2026-08-05T…
hook(knowledge_article_publish_timestamps)在首发转换上写入了 published_at = nowIso,随后剥离因「调用方 supplied 了这个 key」把 hook 刚写的值一起删掉。结果是一篇 status 为 published、published_at 为 null 的文章——按 published_at 排序/过滤的每个面(列表、报表)对这行都无定义。
可达性:REST/集成调用方「读出整记录 → 改一个字段 → 整记录写回」是常见写法;console 表单不回传只读字段所以 UI 路径不触发。
与在案记录的边界(先搜后立,均已读过)
期望
剥离作用于调用方提交的值而非执行时的当前值。实现方向任选:在 hook 之前剥离(caller 输入在进 hook 前就干净);或剥离时跳过「hook 已改写过的 key」(比较剥离时值与 supplied 快照值);或把 supplied 快照与 hook 写入分账。任一方向下,上面的实测都应落出 published_at = nowIso 而非 null。
发现过程:objectstack-ai/hotcrm#780 / PR objectstack-ai/hotcrm#789 的实测(探针脚本未入库,复现步骤在 hotcrm#788)。hotcrm 侧对照实测已证明该行为在 #780 修复前后完全一致(非新引入)。
Part of objectstack-ai/hotcrm#788
现象
suppliedKeys在 hook 运行之前从调用方 payload 快照,而stripReadonlyFields在beforeUpdatehook 之后执行、且对每个「readonly 且 ∈ suppliedKeys」的 key 做delete result[name]—— 删的是当前值,而当前值可能已被 hook 覆写。于是「调用方回传了该 readonly 字段」这一件事,会连带把 hook 在同一次写里打的戳一起抹掉。实测(pinned 17.0.0-rc.2,真 ObjectQL + InMemoryDriver + 真 crm_knowledge_article schema + 真 hook + sys_fetch_previous_update 复刻,非系统上下文)
hook(
knowledge_article_publish_timestamps)在首发转换上写入了published_at = nowIso,随后剥离因「调用方 supplied 了这个 key」把 hook 刚写的值一起删掉。结果是一篇 status 为 published、published_at 为 null 的文章——按published_at排序/过滤的每个面(列表、报表)对这行都无定义。可达性:REST/集成调用方「读出整记录 → 改一个字段 → 整记录写回」是常见写法;console 表单不回传只读字段所以 UI 路径不触发。
与在案记录的边界(先搜后立,均已读过)
readonlyon INSERT at the data-write ingress (#3043) #3162(insert 面入口级剥离)建立的语义——「非 system 调用方不得写 readonly 列」——本单不质疑;本单指出的是剥离实现删错了对象:它该丢弃的是「调用方提交的那个值」,实际丢弃的是「该 key 上执行到剥离时的值」,两者在 hook 已覆写时不同。successwhen written fields are silently stripped — no observability for dropped writes (split from #3356) #3407(剥离静默、无可观测性)是另一维度,不重复。期望
剥离作用于调用方提交的值而非执行时的当前值。实现方向任选:在 hook 之前剥离(caller 输入在进 hook 前就干净);或剥离时跳过「hook 已改写过的 key」(比较剥离时值与 supplied 快照值);或把 supplied 快照与 hook 写入分账。任一方向下,上面的实测都应落出
published_at = nowIso而非 null。发现过程:objectstack-ai/hotcrm#780 / PR objectstack-ai/hotcrm#789 的实测(探针脚本未入库,复现步骤在 hotcrm#788)。hotcrm 侧对照实测已证明该行为在 #780 修复前后完全一致(非新引入)。