fix(objectql): update 剥离作用于调用方提交的值,不再连坐抹掉 beforeUpdate hook 的写入 (#5591) - #6343
Merged
Conversation
…5591) The static-`readonly` write strip runs after `beforeUpdate`, but decided what to delete from a snapshot of the caller's KEY NAMES. Those are different facts the moment a hook writes to a read-only column: `delete data[name]` took the hook's value with it whenever the caller's payload happened to carry the same key. Measured downstream (hotcrm#788): a REST caller reads a whole `crm_knowledge_article`, flips `status` to `published`, and PUTs the whole record back -- `published_at: null` included, because that is what it read. The publish hook stamped `published_at` on the transition; the strip then deleted the stamp, and the row committed as `status = "published"` with `published_at = null`. The same hook's `last_reviewed_at` -- equally read-only, but not echoed by the caller -- landed in that same write. The entry snapshot now carries the caller's values (a copy: hooks mutate `opCtx.data` in place), and a read-only key is stripped only while it still holds the caller's own value. A key a hook overwrote is a platform write and survives -- the same verdict #4903 already pins for a read-only key a hook ADDS. Route chosen by measurement, not preference: stripping BEFORE the hooks would also work, but a `beforeUpdate` guard that reports on what the caller submitted reads `ctx.input.data` -- plugin-auth's ADR-0092 identity write guard NAMES the non-whitelisted keys it finds -- and pre-hook stripping empties that out. The caller's payload therefore still reaches the hooks unchanged; only the strip's verdict narrowed. Not a relaxation of #2948 / #3003 / #3015: a caller-supplied read-only value no hook overwrote is dropped exactly as before, on both the single-id and predicate paths, and `isSystem` / `preserveAudit` are untouched. INSERT is unchanged (its own narrower strip carries the same defect -- filed as #6339, not fixed here). Reverse-verified: restoring the pre-fix reading turns exactly the 8 predicted pins red -- including `expected null not to be null` on the reported scenario -- and leaves every #2948 and #4903 case green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
…ip-caller-values-only
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 16 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…h `as any` The #4918 query-options-erasure ratchet counts an erased engine query bag in test code too (test surface is outside the BLOCKING rule, not outside the count), and the new suite's `sys_fetch_previous_update` replica pushed it 267 -> 268. The bag is not deliberately off-contract here — it is an ordinary by-id lookup — so the remedy is the typed one, not `as unknown as`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
baozhoutao
marked this pull request as ready for review
August 7, 2026 15:00
This was referenced Aug 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5591
问题
静态
readonly剥离跑在beforeUpdate之后,但判据是入口处快照的调用方键名集合。一旦 hook 往某个 readonly 列写了值,「调用方提交过这个键」与「这个键上现在还是调用方的值」就是两件事,而delete data[name]删的是后者 —— 于是调用方 payload 里碰巧带了同名键,hook 同一次写入的戳就跟着被删。前提核验(三条,均在
origin/main实测,非纸面推断)suppliedKeys快照在 hook 前、strip 在 hook 后且 delete 当前值engine.ts:5697(快照)→:5781(triggerHooks('beforeUpdate'))→:5928/:6020(两处 strip);rule-validator.ts的delete result[name]published_at = null)可复现sys_fetch_previous_update复刻 + 真 publish hook,落库{"status":"published","published_at":null,"last_reviewed_at":"2026-08-05T10:00:00.000Z"}—— 与 issue 正文逐字一致,含last_reviewed_at活着这一非对称published_at = NOWP2 的那条非对称是本单最有力的证据:同一次写、同一个 hook、两个都是 readonly 的列,调用方回传过的那个死了,没回传的那个活着。存亡只取决于 payload 里有没有同名键 —— 不可能是有意语义。
三路线取舍(按实测,不按偏好)
["id","title","published_at"]。hook 前剥离会把 readonly 键从 hook 视野里抹掉否决 A 的实测依据具体到在案代码:
plugin-auth的 ADR-0092 身份写守卫(identity-write-guard.tsguardUpdate)读ctx.input.data的键集,并把非白名单键写进它抛出的错误正文和 warn 正文(None of the submitted fields (…) are editable)。hook 前剥离会让这些键在守卫看到之前就消失:纯 readonly 提交时该错误退化成(—),混合提交时 warn 不再点名 —— 一条 loud 诊断被静默削弱。B 路线让调用方 payload 原封不动地到达 hook,只收窄剥离自己的判定。修法
入口快照改为携带调用方的值(必须是浅拷贝:hook 原地改
opCtx.data,hookContext.input.data起初就是同一引用),剥离时多一道身份比对:两处细节写进了注释,因为都是会被后人「顺手简化」掉的:用
Object.is而非===(===认为NaN !== NaN,会把调用方伪造的 NaN 读成「hook 改过」而保留伪造值 —— 唯一一处松散运算符会反转结论的输入);用 own-property 而非in(字段机器名正则允许constructor,in会在任意普通对象上为它返回 true,从而剥掉一个 hook 戳)。stripReadonlyFields未从packages/objectql/src/index.ts导出,是包内函数,故签名可直接收敛,无对外破坏面。语义边界(#2948 / #3003 / #3015 一字未弱化)
调用方提交、无 hook 覆写的 readonly 值,照旧被剥 —— 单条与谓词两条路径都有用例钉住。
isSystem/preserveAudit未动。insert 面未动:其自身更窄的stripRuntimeOwnedFields带同类缺陷(实测:调用方提交 + hook 覆写 → hook 写入被删、序列补发),已另立 #6339,本 PR 不修,并留了一条 insert 不受影响的回归钉。反向验证(方向先写死,再跑)
肢 A = 还原剥离判据(删掉值身份比对,回到键集读法)。预测:正文场景用例翻红,
published_at回 null;#2948 与 #4903 全绿。 预测在跑之前写死在 8 条具名用例上。实测结果 —— 8 红,与预测逐条吻合,预测为绿的 130 条全绿:
expected null not to be null就是 issue 正文报告的那行数据本身。用例处置(三档分开判,不批量重拼)
engine-readonly-strip-signal.test.ts里那条a hook cannot rescue a key the CALLER supplied是整条替换,不是改写:它钉的正是本 PR 删掉的那条肢,原注释自己写着「pinned as EXISTING behaviour so the next change to it is deliberate. Nothing here endorses it」—— 本 PR 就是那个 deliberate change,替换后连同该文件头部的机制描述一并更新。其余单测是重拼(new Set([...])→ 调用方 payload 快照对象,值与 data 一致,判定不变)。测试
新增
packages/objectql/src/engine-readonly-strip-caller-values.test.ts(13 例):正文场景、非对称消失、#2948 两例(含「hook 存在但本次未触发」这一档)、#4903 对照、bulk 两面、onFieldsDropped两面、strictReadonlyWrites两面、hook 仍能看见调用方值(A 路线否决理由的钉子)、isSystem、insert 回归。远端 CI:24 个 check 全绿(2 个 skipped 属正常路径过滤),含 Check Changeset、TypeScript Type Check、Test Core ×3、Dogfood Regression Gate ×3。首推曾红一次,原因不是 ESLint 本身而是同 job 的后续步骤
check:query-options-erasure:新用例里复刻sys_fetch_previous_update的那次 by-id 查询用了as any,把 #4918 的测试面计数顶到 267 → 268。该查询并非有意脱离契约(就是一次普通 by-id 读),故按该 ratchet 指定的两条出路里选了「把 options 定型」而非as unknown as,计数回到 267。消费半径扫过(该规则只由
engine.ts消费,故按行为半径跑下游包):plugin-pinyin-search14 /plugin-audit112 /plugin-sharing359 /platform-objects275 /metadata-protocol502 /plugin-security768,全绿。顺带一提:plugin-pinyin-search的__search(readonly + system)正是被 beforeUpdate hook 写的投影列,整记录回传下旧行为会静默删掉 hook 重算的值、让搜索投影变陈旧 —— 本 PR 一并修好。packages/qa/dogfood/test/authz-conformance.matrix.ts的 enforcement 描述同步收敛为「caller-supplied VALUES」,避免留下一处 declared ≠ enforced 的陈述。已知边界(写进代码与 changeset,不粉饰)
快照是浅拷贝,所以 hook 若原地修改调用方提交的对象/数组,身份不变、无法与「hook 没动」区分,该字段仍会被剥。除非每次写都深拷贝 payload,否则任何比较都看不见这一档,这条路径不付这个代价;回落方向是 #5591 前的行为(剥离),即 fail-safe。已单独钉成用例并写明:hook 要写 readonly 列请赋值。
必答项
wantsPriorRecord门、没动sys_fetch_previous_update、没新增任何读。定价影响:中性偏易。单 id update 把同一行前置状态读了 3 次(engine 前置行门 + sys_fetch_previous_update + plugin-audit captureBefore),且后两次不受任何按对象需求门约束 #5846 的修法形状是把 delete 侧的时序(先读前像再派发 before 钩子)搬到 update 侧;本 PR 让 strip 不再依赖「执行到剥离时值是什么」,只依赖入口快照,因此那次时序搬迁少了一个必须同步推理的耦合点。check-engine-double-contract的 delete 切片仍 OK。text型字段不做类型校验,{ title: { $in: [...] } }原样写进库(number型会响亮拒绝) #5922 交互(实测答):拒收在剥离之前,且对 readonly 字段根本不适用,顺序不引入新问题。validateRecord在engine.ts:5831,strip 在:5928,故先拒后剥。但实测:readonly 字段提交算子对象 → 不抛,随后被剥(record-validator 对 readonly 字段本就跳过校验);非 readonly 字段提交算子对象 → 正常抛ValidationError: title has an invalid text value: $gt is a filter operator, not a value。两条顺序本 PR 均未移动,写入载荷里的算子对象:text型字段不做类型校验,{ title: { $in: [...] } }原样写进库(number型会响亮拒绝) #5922 与本单无冲突面。Generated by Claude Code