Skip to content

fix(objectql,lint): 服务端补上 parent 作用域 readonlyWhen 的绑定与执行 (#4889) - #4972

Merged
xuyushun441-sys merged 2 commits into
mainfrom
claude/issue-4889-parent-readonlywhen-server
Aug 3, 2026
Merged

fix(objectql,lint): 服务端补上 parent 作用域 readonlyWhen 的绑定与执行 (#4889)#4972
xuyushun441-sys merged 2 commits into
mainfrom
claude/issue-4889-parent-readonlywhen-server

Conversation

@xuyushun441-sys

@xuyushun441-sys xuyushun441-sys commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Fixes #4889

结论先说:issue 的判断成立,而且原因就在它指出的地方

packages/objectql/src/validation/rule-validator.tsisReadonlyWhenLocked()
只绑定了 recordprevious,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,服务端才是执行点、客户端只是礼貌层;这里被彻底反过来了 ——
唯一在执行的是礼貌层。

关于 #4861(今天刚重写过这个文件)的核对:isReadonlyWhenLocked
fail-open 分支在 origin/main 上原样健在,#4649 明确把自己限定在对象级校验
谓词、并在模块头注释里写明「字段级 readonlyWhen 保持 fail-open」。前提没有
过期。(本分支已 merge 当前 main,含 #4811validate-expressions.ts
的重构,冲突已按新形状解掉。)

改动一:把 parent 绑上(主体)

引擎在 strip 之前解析 master-detail 表头并作为 parent 传入,单条更新与
multi: true 批量更新两条路径都覆盖(PD #10 的教训:case 标签不是执行,要看
调用点)。

  • 表头 id 取值顺序是「先 payload、后 prior row」。改挂到另一张表头的写入,
    必须按它落到的那张表头判定,而不是它离开的那张。
  • 以 system 身份读取。这个锁是表头状态的数据完整性属性,不是调用方对表头
    的可见性;调用方能不能动这条明细,上游 RLS / controlled_by_parent
    (ADR-0055)早已裁定过了。
  • 有门控,不白付代价。只有当 payload 真的碰到了 parent 作用域的谓词才去读,
    而「是否引用了 parent」由解析后的 CEL AST 判定(collectCelRootIdentifiers),
    不是字符串扫描 —— 叫 parent_id 的字段、'parent' 字面量都不会误判。
  • 批量路径一次读完。N 行可能挂在 M 张表头上,按 id $in 批量取一次,
    不是每行一次。

parent 的定义刻意收窄:恰好一个 master_detail 关系才成立。零个显然无从
绑定;两个的话元数据并没有说哪个是「the parent」,按字段声明顺序挑一个会让一条
数据完整性锁取决于字段排序 —— 那正是 PD #12 说的「declared, not guessed」。

改动二:绑不上的作用域不再等于「没锁」(次要,但刻意做窄)

原来所有求值失败共用一个出口:WARN + false(未锁)。但这一个答案要服务两种
完全不同的情形:

情形 判断 本 PR
谓词在这条记录上坏了(未声明的 key、null 比较无重载、解析错误、引擎抛错) 作者写错了,而且引擎故障作者也无从修 保持 fail-open,一字未动
谓词合法且受支持,只是这个求值点绑不上它命名的根 记录本身没有任何一处说这个字段没锁 —— 我们只是没能问 判为已锁(strip)

两者用 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 要治的病。
  • 构建期 gate 没有一并泛化到「只允许 record/previous/parent」。原因是实测
    collectCelRootIdentifiersuser.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。
  • 反证过:把两处改动临时还原成改前行为后,10 条新用例全部转红;还原修复后
    全绿 —— 这些用例确实钉住的是这个 bug,不是恒真断言。
objectql:  Test Files 112 passed (112)   Tests 1779 passed (1779)
lint:      Test Files  53 passed  (54)   Tests  983 passed  (984)
           ↑ 唯一红的是 runtime-lazy-deps.test.ts 的 5s 超时,与本 PR 无关:
             把 packages/lint 换回 origin/main 内容后同样超时(容器负载下的既有 flake)。
dogfood:   showcase-readonly-when-parent  5 passed
dogfood:   expression-conformance         3 passed
typecheck: @objectstack/objectql + @objectstack/lint — Done
gates:     check:doc-authoring / nul-bytes / adr-anchors / release-notes /
           type-check-coverage / role-word — 全绿
eslint --no-inline-config(全部改动文件)— 干净

未做 / 边界

  • 没有动 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

#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
@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 3, 2026 5:50pm

Request Review

…ent-readonlywhen-server

# Conflicts:
#	packages/lint/src/validate-expressions.test.ts
#	packages/lint/src/validate-expressions.ts
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/xl labels Aug 3, 2026
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/lint, @objectstack/objectql, @objectstack/dogfood.

17 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx (via @objectstack/lint)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/permissions/authorization.mdx (via @objectstack/lint, packages/qa/dogfood)
  • content/docs/permissions/delegated-administration.mdx (via packages/qa/dogfood)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql)
  • content/docs/releases/v17.mdx (via @objectstack/lint)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@xuyushun441-sys
xuyushun441-sys marked this pull request as ready for review August 3, 2026 18:07
@xuyushun441-sys
xuyushun441-sys added this pull request to the merge queue Aug 3, 2026
Merged via the queue into main with commit 5b8f95b Aug 3, 2026
26 checks passed
@xuyushun441-sys
xuyushun441-sys deleted the claude/issue-4889-parent-readonlywhen-server branch August 3, 2026 18:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Parent-scoped readonlyWhen is unenforced server-side — the field lock fails open, so a paid invoice's frozen lines can be rewritten over the API

2 participants