Skip to content

feat(spec,objectql): strictReadonlyWrites —— readonly 剥离的「响亮失败」出路 (#5126) - #5610

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-5126-strict-readonly-writes
Aug 5, 2026
Merged

feat(spec,objectql): strictReadonlyWrites —— readonly 剥离的「响亮失败」出路 (#5126)#5610
os-zhuang merged 2 commits into
mainfrom
claude/issue-5126-strict-readonly-writes

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5126

按维护者两次一致的裁定(2026-08-04 拍板 + 2026-08-05 誊写)取 B 方案:strictReadonlyWritesWriteObservabilityOptions(进程内 TS 契约,onFieldsDropped 所在),违例抛 ERR_READONLY_FIELD_REJECTED 并带字段清单。A(可序列化 Zod 袋)/ C(部署级开关)/ D(objectql 私加)均未采纳。

前置核实(issue 是线索,先在 origin/main 上验)

前提 结论
#4903 / PR #5123 已合入 ✅ 2026-08-04 06:47 merged
WriteObservabilityOptions + onFieldsDroppedpackages/spec/src/contracts/data-engine.ts ✅ 第 33 行
objectql 写路径剥离 readonly 并经 onFieldsDropped 上报 engine.tsreportDroppedFields,单条与 bulk 各两处(readonly / readonly_when)
存在 ERROR_CODE_LEDGER 及其登记纪律 packages/spec/src/api/error-code-ledger.zod.ts,SCREAMING_SNAKE + 按包归属,error-code-ledger.test.ts 执行准入规则

前提全部成立,未发现需要改写 issue 前提的情况。

改了什么

spec

  • WriteObservabilityOptions 新增 strictReadonlyWrites?: boolean(默认关)。TSDoc 写清三件事:语义(置位时「本会被剥离」的写入改为抛出,且什么都不写)、异常码与字段清单、以及 strict 是进程内语义——不跨 RPC / VDE,远程调用方既拿不到 onFieldsDropped 也拿不到 strict,其写入仍是「剥离后提交」的 200。
  • ERROR_CODE_LEDGER@objectstack/objectql 名下登记 ERR_READONLY_FIELD_REJECTED(按既有纪律:字母序、SCREAMING_SNAKE、附一行说明)。

objectql

  • 新增 ReadonlyFieldRejectedError(readonly-strict-errors.ts,code = 'ERR_READONLY_FIELD_REJECTED',携带 object / fields / drops),从 index 导出;沿用仓库既有的「以 code 而非 instanceof 跨包识别」约定。
  • 在既有剥离接缝处兑现:置位且确有字段将被剥离 → 在任何驱动调用之前抛出;未置位 → 行为与此前完全一致(剥离 + 事件 + 提交)。单条与 bulk 两条路径各一处 assertNoStrictDrops()
  • ENGINE_UPDATE_OPTION_KEYS 放行该键,并同步 engine-unknown-option.test.ts 的 drift pin —— 不做这一步,引擎会以「未知选项」拒掉一个契约已声明的键。

三个需要维护者过目的判断

1. 覆盖两种剥离原因,不只静态 readonly 裁定原文是「被剥离时抛」,未限定原因;而它被明确锚在 onFieldsDropped 旁边,后者两种 reason 都发。更关键的是:静态剥离对 isSystem 调用方本就不生效,所以只覆盖静态一半,会让本特性对它最想服务的调用方 —— 已声明 isSystem: true 的可信 cron —— 近乎失效(它恰恰豁免了静态剥离,却仍在静默丢掉被 readonlyWhen 锁住的列)。两种原因累积进同一个异常,fields 是全量并集。若维护者本意只覆盖静态 readonly,这是一处需要回退的判断,改动面很小(去掉 readonly_when 那一路的累积)。

2. strict 置位时不再回调 onFieldsDropped DroppedFieldsEvent 的契约文字是「字段被丢弃、写入在没有它们的情况下完成」;strict 下写入并未完成,照发会让监听 drop 的 flow 步骤为一次根本没发生的写入报告「部分成功」。两者是同一接缝的互斥产物,不是先后关系。

3. INSERT 保持惰性。onFieldsDropped 同因同理:insert 对两种剥离都豁免,没有可拒绝的东西。已在 insert 处补注释说明这不是 #4371 意义上的「静默忽略」。

验证

先证红。 契约测试先行,在未改 engine 的代码上跑:

Test Files  1 failed | 118 passed (119)
     Tests  9 failed | 1914 passed (1923)

Error: update('attendance') does not recognise option 'strictReadonlyWrites'.

实现后转绿(objectql 全量)

Test Files  119 passed (119)
     Tests  1923 passed (1923)

spec 全量:Test Files 315 passed (315) / Tests 8027 passed (8027)

反向验证(先定方向,再跑)。 两处,方向都事先写死并被证实:

  • 反 A 蔓延守卫非空转:预测「把该键加进 EngineUpdateOptionsSchema 会让 shape 检查与 smuggle 检查双双转红」→ 实测 Tests 2 failed | 10 passed,两条正是预测的那两条。
  • 拒绝逻辑:预测「保留选项合法性、只废掉 assertNoStrictDrops 的拒绝,应让 7 条期望拒绝的用例转红,5 条默认路径用例保持绿」→ 实测恰好 Failed Tests 7,全部报 expected the write to be refused, but it resolved

两次改动均已还原,已确认无残留。

typecheck / 闸门

pnpm --filter @objectstack/objectql --filter @objectstack/spec typecheck   # 干净
pnpm lint                                                                  # 干净
pnpm --filter @objectstack/spec check:generated                            # 10 个生成物闸门全过
node scripts/check-nul-bytes.mjs                                           # OK(5520 文件)
node scripts/check-error-code-casing.mjs                                   # OK(2928 文件)

生成物与一处刻意的「不改」

新错误码进入 ErrorCode 联合,故重新生成了 content/docs/references/api/*(11 个文件)。逐行核对过:除 error-code-ledger.mdx 新增的一条条目外,其余全部只是枚举里多了 ERR_READONLY_FIELD_REJECTED 一个成员。

packages/spec/authorable-surface.base.json 被我还原了,尽管 gen:schema 会顺手改写它。它的改动(baseRev + 三条 EmailServiceConfig 键)源自别人已合入的 system/email-config.zod.ts,与本 PR 无关;check:authorable-surface 在还原后仍然通过,并自己说明「trails the merge base by 3 key(s) —— expected right after a surface change lands」。AGENTS.md 也明确反对无差别重生成(会把真实语义改动埋进机械 diff)。

content/docs/releases/ 未触碰。

Changeset

.changeset/strict-readonly-writes.md:@objectstack/spec minor + @objectstack/objectql minor。两者都取 minor 而非 patch,因为两侧都是新增的公开面而非修复:spec 增加了一个契约成员与一个登记错误码,objectql 增加了一项选择加入的运行时行为与一个新导出的错误类。默认行为零变化,故不是 major。


🤖 Generated with Claude Code

https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D


Generated by Claude Code

按维护者对 #5126 的裁定(B 方案)补上 readonly 剥离的 strict 半边。

spec:
- `WriteObservabilityOptions` 新增 `strictReadonlyWrites?: boolean`(进程内
  TS 契约,与 `onFieldsDropped` 同处),TSDoc 写清语义、异常码与「不跨
  RPC/VDE、远程调用方拿不到」这一事实;
- `ERROR_CODE_LEDGER` 在 `@objectstack/objectql` 名下登记
  `ERR_READONLY_FIELD_REJECTED`;
- 契约测试钉住反 A 蔓延:该键不得出现在 `EngineUpdateOptionsSchema`
  / 插入袋 / 共享 base,且确实声明在契约上(防止守卫变空转)。

objectql:
- 在既有剥离 + `onFieldsDropped` 接缝处兑现:置位且确有字段将被剥离时,
  在任何驱动调用之前抛 `ReadonlyFieldRejectedError`,什么都不写;
- 覆盖静态 `readonly` 与 `readonlyWhen` 两种原因,累积成单个异常;
- 未置位时行为与此前完全一致(剥离 + 事件 + 提交)。

生成物:`content/docs/references/api/*` 随新错误码重新生成。
`content/docs/releases/` 未触碰。

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

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

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/spec.

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

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql, packages/spec)
  • content/docs/concepts/north-star.mdx (via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/tenancy-modes.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/apps.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

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 the size/l label Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

PM 预记(session_018fxLGQdatPbBUvCgiVxg6D):本 PR 的 ESLint job 将因 #5604(main 侧 check:engine-double-contract 断裂,#5584 遗留,与本 diff 无关)而红——验收时不计入本单质量账,#5604 修复落地后合 main 重跑。本 PR 带 spec/objectql 双 minor changeset,无需 skip 标签。

正文「三个需要维护者过目的判断」PM 初审意见(否决窗口模式,维护者可推翻):三条均认可——① 覆盖两种剥离原因:裁定锚点是 onFieldsDropped 所在接缝,该事件本就两种 reason 都发,且只盖静态半边会让 strict 对 isSystem cron(本特性最想服务的调用方)近乎失效;② strict 抛出时不发 onFieldsDropped:该事件契约文字明说「写入在没有它们的情况下完成」,strict 下写入未发生,互斥正确;③ INSERT 惰性:insert 对两种剥离本就豁免,无可拒之物。三条已列入轮次报告供维护者行使否决。


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 5, 2026 22:15
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 5, 2026
Merged via the queue into main with commit 23dba62 Aug 5, 2026
25 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5126-strict-readonly-writes branch August 5, 2026 22:27
os-zhuang pushed a commit that referenced this pull request Aug 5, 2026
Conflict: packages/objectql/src/engine.ts — the `insert()` doc comment only,
where #5610 (strictReadonlyWrites, #5126) and #5503 each rewrote the same block.
Resolved by SUPERPOSITION, not by taking a side.

Both incoming notes carried the same standing condition, and #5503 is what
discharges it:

  - #3407: "if insert ever gains a silent strip, wire the listener at that
    strip site";
  - #5126: "`strictReadonlyWrites` is inert here ... because insert strips
    nothing. If insert ever gains a strip, both members wire up together at
    that site."

#5503 gives insert a strip (caller-supplied runtime-owned `autonumber`), so
both members of `WriteObservabilityOptions` now discharge at that site:

  - default    → strip, commit, report via `onFieldsDropped` (unchanged);
  - strict ON  → `ReadonlyFieldRejectedError` before any driver call, and the
    listener deliberately does NOT fire (a refused write did not complete —
    #5126's own design point, mirrored).

UPDATE needed no new code: the autonumber limb rides `stripReadonlyFields` →
`reportDroppedFields` → `assertNoStrictDrops`, the seam #5126 already built, so
a caller-supplied record number is refused at equal rank with a declared
`readonly` field for free. Pinned by test rather than assumed.

`ReadonlyFieldRejectedError` gained an optional `operation` (default `'update'`,
so #5126's UPDATE message stays byte-identical) because the verb and the
remedies genuinely differ: an INSERT refusal is always about a runtime-owned
value, whose exempt writers are `isSystem` and the `preserveAudit` historical
import, while `readonlyWhen` cannot lock anything on a create.

No new policy was invented at the seam. #5126 states that strict "adds no
second policy — it reports the existing one", and that a field the strip does
not take is not rejected either; applying that verbatim is what keeps the
`isSystem` / `preserveAudit` exemptions accepted under strict. Pinned in tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[决策] readonly 剥离的 strict/reject 模式落在哪一层 —— B(WriteObservabilityOptions)推荐,A/C/D 各有代价(#4903 后续)

2 participants