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.

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