fix(objectql,lint): 服务端补上 parent 作用域 readonlyWhen 的绑定与执行 (#4889) - #4972
Merged
xuyushun441-sys merged 2 commits intoAug 3, 2026
Conversation
#4889) `readonlyWhen: parent.<field>` was enforced only in the client grid. The server-side strip bound `record` and `previous` and had no `parent`, so every parent-scoped predicate faulted, took the fail-open branch, and the write landed — one PATCH rewrote quantity and unit price on a settled invoice's line (HTTP 200, value persisted, grid still drawing the cell locked). ADR-0057 D10 puts enforcement on the server; only the courtesy layer enforced. - Bind `parent`: the engine resolves the master-detail header (payload FK first so a repoint is judged against the master it lands on, else the prior row) and passes it into the strip, on the single-id and bulk update paths. Gated on the payload touching a parent-scoped predicate, decided from the parsed CEL AST; the bulk path batch-reads distinct headers in one query. - An unbindable scope root no longer waives the lock: the field is stripped rather than written. A merely broken predicate (undeclared key, null overload, parse error, throw) keeps the documented fail-open policy, and `requiredWhen` / option `visibleWhen` are untouched. Recorded as an ADR-0058 D5 addendum next to the same narrowing made by #4649 and #4775, and anchored in scripts/adr-anchors.json. - `objectstack compile` now rejects a parent-scoped `readonlyWhen` on an object with no `master_detail` relationship, or two of them, so the runtime branch is a backstop rather than the plan. Tests: rule-validator unit cases, an engine-level regression over a real driver (the issue's INV-1003 shape, single-id + bulk + repoint + fail-closed), the record-scoped contrast, lint cases, and a dogfood proof that replays the issue's PATCH against the real showcase over HTTP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NrmBxj8rK2uGCnh9aipjwX
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
…ent-readonlywhen-server # Conflicts: # packages/lint/src/validate-expressions.test.ts # packages/lint/src/validate-expressions.ts
Contributor
📓 Docs Drift CheckThis PR changes 3 package(s): 17 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
This was referenced Aug 3, 2026
xuyushun441-sys
marked this pull request as ready for review
August 3, 2026 18:07
xuyushun441-sys
deleted the
claude/issue-4889-parent-readonlywhen-server
branch
August 3, 2026 18:18
This was referenced Aug 3, 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 #4889
结论先说:issue 的判断成立,而且原因就在它指出的地方
packages/objectql/src/validation/rule-validator.ts的isReadonlyWhenLocked()只绑定了
record与previous,parent从头到尾没有出现过。于是 showcase 里showcase_invoice_line.{product,quantity,unit_price}上那三条readonlyWhen: P`parent.status == 'paid'`—— 注释白纸黑字写着「发票一旦 Paid,行项目就冻结」 —— 每次求值都落到
!res.ok分支,而该分支是 fail-open,写入照常落库。issue 里那条 PATCH 把已结清发票行的数量改成 9999、单价改成 0.01
并且返回 200,是真实可复现的。
同一对象族里
record作用域的showcase_invoice.tax_rate一直是对的,这正是这个洞难被发现的原因:同一个「paid」条件,两种相反结果。
按 ADR-0057 D10,服务端才是执行点、客户端只是礼貌层;这里被彻底反过来了 ——
唯一在执行的是礼貌层。
改动一:把
parent绑上(主体)引擎在 strip 之前解析 master-detail 表头并作为
parent传入,单条更新与multi: true批量更新两条路径都覆盖(PD #10 的教训:case标签不是执行,要看调用点)。
必须按它落到的那张表头判定,而不是它离开的那张。
的可见性;调用方能不能动这条明细,上游 RLS /
controlled_by_parent(ADR-0055)早已裁定过了。
parent作用域的谓词才去读,而「是否引用了
parent」由解析后的 CEL AST 判定(collectCelRootIdentifiers),不是字符串扫描 —— 叫
parent_id的字段、'parent'字面量都不会误判。id $in批量取一次,不是每行一次。
parent的定义刻意收窄:恰好一个master_detail关系才成立。零个显然无从绑定;两个的话元数据并没有说哪个是「the parent」,按字段声明顺序挑一个会让一条
数据完整性锁取决于字段排序 —— 那正是 PD #12 说的「declared, not guessed」。
改动二:绑不上的作用域不再等于「没锁」(次要,但刻意做窄)
原来所有求值失败共用一个出口:WARN +
false(未锁)。但这一个答案要服务两种完全不同的情形:
null比较无重载、解析错误、引擎抛错)两者用 cel-js 自己的错误就能区分,不需要猜:
Unknown variable: ‹root›对应后者,No such key/ 重载 / 解析错误对应前者(复用 #4775 建立的cel-fault.ts)。方向上与两个相邻的写入闸门一致 —— #4649(校验谓词)、#4775(hook
condition),本 PR 把这次收窄连同那两次一起记进 ADR-0058 D5 的 Addendum(PD #13:推翻一条
已记录的决定本身就是一个决定),并在
scripts/adr-anchors.json留下锚点,免得下一个读到这段代码的人以为它「与 D5 表格不一致」而把它改回去。
但运行时分支只是兜底,不是方案。
objectstack compile现在会拒绝:对象没有
master_detail关系、或有两个,却写了parent作用域的readonlyWhen。错误信息点名对象并给出改法。常见的作者失误在最便宜的地方就被挡住,根本走不到
需要运行时判断的那一步。
一处需要维护者知情的行为面扩大
fail-closed 判据是「任何绑不上的根」,不只
parent。也就是说,一条readonlyWhen: P`user.positions...`这样的谓词(strip 从来不绑user)以前是被静默忽略的,现在会把字段判为已锁。
readonlyWhen只用了record.和parent.两种形态,所以本仓零影响。
这个 issue 要治的病。
collectCelRootIdentifiers对user.positions.exists(p, p == 'x')会把宏的绑定变量
p也算作根,泛化的 gate 会误伤推导宏并挂掉构建。宁可留一条运行时兜底,也不要一条会误报的构建期红线。这一点如果维护者希望补上,值得单独一轮
(需要先让 root 提取区分宏绑定变量)。
测试
packages/objectql/src/engine-readonly-when-parent.test.ts(新增,真实 driver跑通引擎):issue 的 INV-1003 形状、
droppedFields上报、同一冻结行上未加锁字段仍可写、draft 表头不误锁、改挂按落点判定、表头读不到时 fail-closed、批量路径
逐行绑定、以及
record作用域对照组。packages/qa/dogfood/test/showcase-readonly-when-parent.dogfood.test.ts(新增):直接在真实 showcase 上、走真实 HTTP 重放 issue 的那条 PATCH。
rule-validator.test.ts/validate-expressions.test.ts:单元与构建期 gate。全绿 —— 这些用例确实钉住的是这个 bug,不是恒真断言。
未做 / 边界
packages/spec,一行都没有。readonlyWhen的 Zod 声明本来就够用,这是运行时没兑现声明,不是声明写错了。
packages/metadata-protocol/src/protocol.ts同样未触碰。
requiredWhen的同类缺口未修:它同样只绑record/previous,parent作用域的requiredWhen在服务端也评估不了。本轮 issue 的范围是readonlyWhen,而且给requiredWhen加绑定会改变它的成败面(它当前的失败策略是 skip),属于会扩大影响面的改动 —— 按纪律留在范围外,已在报告中记录。
@proof:标签:proof 注册表在packages/spec/scripts/liveness/下,而本轮 spec 是零改动区,不去动它的 ratchet。
🤖 Generated with Claude Code
https://claude.ai/code/session_01NrmBxj8rK2uGCnh9aipjwX