Skip to content

fix(objectql): 字段 readonlyWhen 求值补 materializeDeclaredFields —— 服务端第三个接缝 (#4953) - #6454

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-4953-readonly-when-materialize
Aug 7, 2026
Merged

fix(objectql): 字段 readonlyWhen 求值补 materializeDeclaredFields —— 服务端第三个接缝 (#4953)#6454
baozhoutao merged 2 commits into
mainfrom
claude/issue-4953-readonly-when-materialize

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Part of #4953

维护者 2026-08-06 裁决第 1 条的 engine-core 份额:字段 readonlyWhen 求值补
materializeDeclaredFields。母单是决策锚点,不随本 PR 关闭;flow 触发记录播种
(services 席)与 objectui 稀疏面文档 / lint 评估不在本 PR 内,#4811 的 null-guard
闸门扩面按裁决第 3 条要等两处服务端接缝都齐,故本 PR 不触 packages/lint

问题

materializeDeclaredFields(#1871 / #4649)只接在两个求值接缝上:
evaluateValidationRules(对象级规则 + 字段 requiredWhen + option visibleWhen)
hook-wrappers.ts 的 hook condition。写入路径上的 stripReadonlyWhenFields /
stripReadonlyWhenFieldsMulti{ ...previous, ...data } 原样交给 CEL。

于是同一个字段上的两条谓词对「记录是什么」给出相反答案:requiredWhen: record.approved_at == null 是可用的守卫,同字段的 readonlyWhen: record.approved_at == null 只要驱动没回读该列就 fault —— 而 readonlyWhen fault 是 fail-open,
作者声明为冻结的字段被照常写入。是否被拦取决于驱动回读了哪些列,作者看不见也控制不了。

改动

packages/objectql/src/validation/rule-validator.ts 新增 readonlyWhenBindings(),
record(merged)与 previous 两个根过共用的 materializeDeclaredFields,单行与
bulk 两条路径共用;bulk 的行视图按行构造一次(不随字段重复构造),且仅在载荷确实写了
readonlyWhen 字段时才构造。packages/objectql/src/declared-fields.ts 的接缝台账
同步更新(哪些面已物化、哪两面仍稀疏、以及两者的区别是「尚未接线」还是「已裁决保持稀疏」)。

前提复核(动手前对 origin/main 逐条实测)

前提 结论 证据
P1 stripReadonlyWhenFields 未物化,而同文件 requiredWhen 已物化 ✅ 成立 改前 rule-validator.ts 第 418 行 const merged = { ...(previous ?? {}), ...data };(bulk 同形 { ...(row ?? {}), ...data }),对照第 1186 / 1197 行 materializeDeclaredFields#6440 落地后重核形状,该函数只多了 parent 形参,合并仍未物化
P2 materializeDeclaredFields 签名可直接复用 ✅ 成立 (record, fields),fields 直接传 objectSchema.fields,无需第二套实现
P3 语义后果方向 ⚠️ 主方向成立,另有一格反向 见下节实测格子表

语义后果方向(实测,非断言)

改前的实测网格(稀疏前序行 = 驱动只回读写过的列):

谓词 改前(稀疏) 改后(稀疏) 方向
record.b == null fault → 放行 true → 剥离 ✅ 恢复 enforcement
previous.b == null fault → 放行 true → 剥离 ✅ 同上
record.b != null fault → 放行 false → 放行 结果同,少一条 fault 告警
has(record.b) false → 放行 true → 剥离 ✅ 同向(更严)
!has(record.b) true → 剥离 false → 放行 ⚠️ 反向翻转
record.a < record.b fault → 放行 fault(no such overload)→ 放行 不变,fail-open 分支仍活
record.stauts(未声明键) fault → 放行 fault → 放行 不变(#4649 的线未动)

唯一反向格 !has(record.< 已声明字段 >) 的处置:实现并钉住,不静默。 理由:全量绑定下
has(已声明字段) 恒真是 CEL 自身规则,也是 declared-fields.ts#4649 起写明的契约
(「has() 守的是未声明的键,不是空值;判空用 != null」);它在另外两个已物化接缝上
早已如此。而且它改前也不是一条保证 —— 在回读全部列的驱动上同一声明从不锁 —— 所以这次是把
一个取决于存储细节的判定换成确定的 false。两种拼写都由测试钉住,!has 那条的注释写明
作者应改用 == null(正是 lint null-guard 闸门一直建议的写法)。此格已在报告中单列上报。

爆炸半径

反向验证(方向先写死,再实测)

摘掉两处 materializeDeclaredFields 调用(测试不动),预测 6 红 / 137 绿:

 × evaluates `record.< declared > == null` on a SPARSE prior instead of faulting through
 × reads the same verdict on a sparse prior as on a total one (the point)
 × materialises the `previous` root too, not just `record`
 × applies on the BULK path identically — one payload, N sparse rows
 × `has(record.< declared >)` is uniformly TRUE — so it locks even on a sparse prior
 × `!has(record.< declared >)` is uniformly FALSE — a lock spelled that way STOPS locking
 Test Files  1 failed (1)
      Tests  6 failed | 137 passed (143)

(上面引用的用例名里 < > 两侧加了空格,以免被 GitHub 正文的 HTML 标签清洗吞掉;源码中为不带空格的尖括号形式。)

实测与预测逐条一致(红名单、绿名单、总数)。其中 !has(...) 那条的红是反向的:
摘掉物化后字段被剥离、断言的 { amount: 999 } 不成立 —— 这正是上表那一格,PR 按实际方向
记录而不是套模板。恢复物化后 143/143 全绿。

命令输出

$ pnpm --filter @objectstack/objectql test -- --maxWorkers=2
 Test Files  142 passed (142)
      Tests  2393 passed (2393)

$ pnpm --filter @objectstack/objectql typecheck
> tsc --noEmit          (无输出,退出码 0)

$ pnpm check:engine-double-contract
check-engine-double-contract: OK — 80 pinned, 133 in the DEBT ledger, 4 exempt.

$ turbo run build --filter='./packages/*' --filter='./packages/*/*'   # 棘轮前置全量 build
 Tasks:    70 successful, 70 total

$ pnpm check:type-check-debt
check-type-check-coverage: OK — 62/77 workspace packages type-checked …
  ℹ @objectstack/objectql: TEST_DEBT records 355, tsc now reports 345 (-10) …
check-type-check-coverage --re-measure: OK — 34 ledger entr(ies) re-measured, none above its recorded number.

$ node scripts/check-nul-bytes.mjs
check-nul-bytes: OK (scanned 6085 tracked text file(s); … no raw ASCII control bytes).

TEST_DEBT 未抬账(台账 355,实测 345);新增测试代码在 objectql 测试层 tsc 下 0 报错。

changeset

.changeset/readonly-when-total-record.md@objectstack/objectql patch,正文写明这是
可见行为变化及其两个方向(含 !has() 写法不再锁字段与替代写法)。


Generated by Claude Code

claude added 2 commits August 7, 2026 21:39
`readonlyWhen` 是三个服务端 CEL 求值接缝里唯一没有物化的一个:写入路径上的
`stripReadonlyWhenFields` / `stripReadonlyWhenFieldsMulti` 把
`{ ...previous, ...data }` 原样交给求值器。于是同一字段上的 `requiredWhen`
(同文件、已物化)与 `readonlyWhen` 对「记录是什么」给出相反答案 —— 后者在驱动
未回读某已声明列时 fault,而 `readonlyWhen` fault 是 fail-open,作者声明为冻结的
字段被照常写入。

按维护者 2026-08-06 裁决(#4953 第 1 条 engine-core 份额)统一服务端接缝:
`record`(merged)与 `previous` 两个根都过 `materializeDeclaredFields`,单行与
bulk 两条路径一致。

- `parent` 表头不物化:它是另一个对象的行,且「未绑定」正是 #4889 fail-closed
  判定所依赖的信号。
- 未读到前序行时不物化(与 `evaluateValidationRules` 的 groundTruth 同规则):
  那样是捏造与库中行矛盾的值,而非补齐缺失值。
- 对象级 `script` / `cross_field` 的 fail-closed 与文案不变,由测试钉住。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
@vercel

vercel Bot commented Aug 7, 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 7, 2026 9:46pm

Request Review

@github-actions github-actions Bot added the size/m label Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql.

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

  • 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/runtime-services/examples.mdx (via packages/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/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)

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.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 7, 2026
@baozhoutao
baozhoutao marked this pull request as ready for review August 7, 2026 21:59
@baozhoutao
baozhoutao added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit 3fb42d2 Aug 7, 2026
25 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-4953-readonly-when-materialize branch August 7, 2026 22:14
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/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants