Skip to content

fix(objectql): autonumber 是运行时拥有的字段,写路径不再接受调用者提交的单号 (#5503) - #5627

Merged
os-zhuang merged 6 commits into
mainfrom
claude/issue-5503-autonumber-readonly-strip
Aug 5, 2026
Merged

fix(objectql): autonumber 是运行时拥有的字段,写路径不再接受调用者提交的单号 (#5503)#5627
os-zhuang merged 6 commits into
mainfrom
claude/issue-5503-autonumber-readonly-strip

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #5503

前提核验(先证伪,再动手)

origin/main e900015 上逐条核实,issue 的前提成立,且是用可执行的复现测试证明的,不是读代码得出的印象:

先写下 packages/objectql/src/engine-autonumber-runtime-owned.test.ts,在未改动的源码上跑一遍:

× INSERT strips a caller-supplied record number …
  AssertionError: expected 'ACC-777777' not to be 'ACC-777777'
× UPDATE strips a caller-supplied record number — the stored number is unchanged
  AssertionError: expected 'ACC-888888' to be 'ACC-0001'
× native-autonumber driver: hands the driver NO autonumber key …
  AssertionError: expected true to be false
 Tests  9 failed | 8 passed (17)

三条现象与 issue 描述逐字对应:POST 显式值原样落库、PATCH 改写落库、SQL 驱动
supports.autonumber)路径同样采纳显式值。另外 8 条一开始就是绿的 —— 它们断言
的是 isSystem / preserveAudit / 钩子写入这些豁免语义,改动前后都必须绿,
这也是这次改动"只收窄客户端、不动豁免"的边界证明。

缺陷

autonumber 的值一直被声明为运行时所有 —— applyAutonumbers 的注释写着
"the runtime owns the value, not the client",两个记录校验器也正因如此在 insert 与
update 上都豁免了它的 required 检查。缺的是另一半:没有任何一层写路径阻止客户端
自己填这个值

与已修复的 #4447created_at 可被普通 PATCH 伪造)是同一缺陷族。区别在于:声明了
readonly: true 的字段早已被 #2948 / #3043 保护,而 autonumber 身上根本没有这个
标记,stripReadonlyFieldsif (!def?.readonly) continue; 从它旁边直接走过去了。

修法:在引擎/校验层视为隐含 readonly,insert 与 update 同权

为什么这天然与驱动无关:剥离发生在引擎里、派发给任何驱动之前。声明
supports.autonumber === true 的 SQL 驱动拿到的行里根本没有这个键,它的持久序列必然
胜出 —— 没有任何驱动改动一行代码。测试直接断言递交给 driver.create 的负载:

expect('account_number' in (rig.createdRows[0] ?? {})).toBe(false);
expect(created.record.account_number).toBe('SEQ-0001'); // 驱动序列发的号

豁免语义原样保留,与 update 侧既有白名单完全一致:isSystem 写入整体跳过;
preserveAudit#3493)的历史导入仍可写入原始单号(把遗留系统的历史单号迁移进来正是
该白名单存在的业务场景,而 autonumber 是作者声明的业务字段,system !== true,恰好
落在 isPreservableUnderAudit 允许的范围内);只有调用者提交的键才是候选,因此
beforeInsert / beforeUpdate 钩子算出的值不受影响。

#5610strictReadonlyWrites#5126)的叠加

本 PR 在途期间 main 合入了 #5610,两者在 engine.ts 文本冲突。冲突体本身只是 insert()
的文档注释,但语义叠加是实打实的,按两个 PR 的意图合并而非取一边:

两条注释各自带着同一个待决条件,而本 PR 恰恰就是触发条件的那次改动:

于是叠加后的语义:

默认 strictReadonlyWrites: true
UPDATE 剥离 + onFieldsDropped 上报 + 提交 拒绝,与声明 readonly 字段同权
INSERT 剥离 + onFieldsDropped 上报 + 提交 拒绝ERR_READONLY_FIELD_REJECTED

接缝处没有新增任何策略。 #5126 明确写着 strict「不引入第二套策略,它只是把既有策略
报出来」,且「剥离拿不走的字段也不会被拒绝」。照此逐字适用即可回答 PM 点名的那个可能分叉
(strict 下 autonumber 是否豁免历史导入):isSystempreserveAudit 的值根本不会
进入剥离分支
,因此 strict 下依旧被接受 —— 这是既有规则的推论,不是新裁决,故未按
needs_decision 上报。已各自钉了测试。

ReadonlyFieldRejectedError 新增可选的 operation(默认 'update'#5126 的 UPDATE
文案逐字节不变
,其测试对 code/fields/drops/message 的断言全部原样通过)。动词与
补救办法确实因操作而异:INSERT 的拒绝必然关于运行时拥有的值,其合法写入者是 isSystem
与历史导入 preserveAudit,而 readonlyWhen 在 create 上根本锁不住任何东西。

顺带钉住一个此前没人面对过的角落:insertMany 的部分成功模式剔除坏行,而 strict 是
拒绝整笔写入。两者同时传入时以拒绝为准 —— 错误契约写的是「什么都没写」,降级成「其余
行保留了」会让这句话变成假话。这条现在是决定,不是意外。

静默剥离必须被上报,而不是被吞掉

createManyDatainsertManyData 也补上了监听器转发。后者保持逐行精度:引擎
事件是整批的并集(监听器签名没有行下标),但剥离只会移除行自身提交过的键,因此可以
准确归属回具体行 —— 导入器优先走的正是 insertManyData 这条部分成功路径。

全仓 pin 扫描(逐条判定,非批量重写)

按规则的消费半径扫(objectql / metadata-protocol / rest / runtime / qa / cli /
lint / examples / driver-*),命中三处既有断言,均属系统路径豁免语义,按要求原样保留

位置 断言 判定
packages/objectql/src/validation/record-validator.test.ts:30 accepts an explicitly-provided autonumber value 保留。validateRecord 不负责写路径所有权;剥离跑在它之前,所以还能带着值走到这里的只剩豁免写入者,这条断言现在恰好钉的是"豁免写入者不会反被校验器拒掉"。已就地加注说明,避免被误读成"客户端可以提交单号"。
driver-sqlsql-driver-autonumber.test.ts respects caller-provided values and never overrides them 保留、未改动(认领约束 ⛔ 不碰 driver-*)。此处的 "caller" 是引擎而非 HTTP 客户端;改动后只有豁免写入者的行会带着值到达 create(),这条钉的是该豁免的驱动半边。
driver-sqlite-wasm 的同名用例 同上 同上。

未发现任何"显式 autonumber 值被采纳"的客户端伪造语义断言需要翻转 ——
protocol-data.test.ts 早已断言 clone 会丢弃 autonumber 让其重新派生,方向一致。

反向验证(方向是先预测、后执行的)

预测方向:。这是一个谓词式守卫,不喂计数、也不存在 ?? 链首为规范键的倒挂情形。
把两处限肢摘掉(stripReadonlyFields 的判定退回只看 def.readonly,引擎 insert 剥离
关闭)后重跑:

 Tests  13 failed | 178 passed (191)

13 条红,与预测一致。同批中保持绿的三类,都是有理由保持绿的:isSystem /
preserveAudit / 钩子写入的豁免断言(本就与限肢无关)、stripRuntimeOwnedFields
单元测试(只关掉了它在引擎里的调用点,函数本身没动)、以及 record-validator.test.ts
全部(validateRecord 的 autonumber 豁免不受剥离影响 —— 它在两个方向上都绿,这一点如实
记录,不硬凑成"改动前红")。

多行更新那条曾经可能因为什么都没匹配到而空绿:谓词用了内存驱动不认识的 $ne
0 行命中也能让"单号没变"通过。已改成两行共享的标量谓词,并补上"两行的 name 都被改写"
与"同一次写入的合法部分确实落库"的断言,让它只能因为逻辑正确而绿。

验证(合并 #5610 之后重跑)

pnpm --filter @objectstack/objectql typecheck      → Done(tsc --noEmit)
pnpm --filter @objectstack/objectql test           → 120 files / 1959 tests passed
pnpm --filter @objectstack/metadata-protocol test  →  43 files /  402 tests passed
pnpm --filter @objectstack/rest test               →  49 files /  737 tests passed
pnpm --filter @objectstack/runtime test            →  97 files / 1428 tests passed
pnpm check:query-options-erasure                   → holds(test surface 267,at the ceiling;基线未改)
node scripts/check-nul-bytes.mjs                   → OK(5593 files,no raw control bytes)

其中 objectql 的 120 files / 1959 tests 包含 #5610 自己的
engine-readonly-strict-writes.test.ts 原样通过
,与本 PR 新增的叠加语义用例同批跑绿。

packages/spec 本 PR 未改动(分支相对 main 的 delta 只有 objectql /
metadata-protocol / changeset);构建过程中被 gen:schema 顺手重锚的
authorable-surface.base.json 已还原,不随本 PR 漂移。

升级影响

普通(非历史)导入若把遗留单号列映射到 autonumber 字段,该值现在会被丢弃并改由序列
发号,同时在响应的 droppedFields 里上报、在服务端日志里留下一条带补救办法的 warn
要保留原始单号,请把导入标记为历史导入(treat_as_historicalpreserveAudit),
这与 #3493 为只读业务字段确立的划分一致。

范围之外

packages/specautonumber builder 是否应当直接注入 readonly: true(issue 建议
方向的后一种)属 spec 座位的独立议题,本 PR 按认领约束未触碰,已单独记录为观察类
finding #5628。两者不冲突:即便 spec 层将来注入了该标记,本 PR 的引擎侧防线仍然是必要的
—— 它同时覆盖了 insert 端(spec 标记只会让入口剥离生效,而入口对 sys_ / managedBy
平台对象是豁免的)以及绕过入口直接调用 engine.insert 的路径。


🤖 Generated with Claude Code

https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx

claude added 2 commits August 5, 2026 20:33
… path (#5503)

A non-system REST caller could POST an explicit record number (bypassing the
sequence) and PATCH an existing one (forging a business identifier). The engine
already documented that "the runtime owns the value, not the client" — and both
record validators exempt a `required` autonumber because of it — but nothing on
the write path enforced it. Author-declared `readonly: true` fields were already
stripped (#2948 / #3043); `autonumber` carries no such flag, so the strip loop
walked straight past it. Same defect family as #4447 (`created_at`).

- UPDATE: `stripReadonlyFields` now reads two sources of "read-only" at equal
  rank — the author-declared flag and a runtime-owned field TYPE
  (`isRuntimeOwnedField`, today exactly `autonumber`). Single-id and `multi`
  bulk updates share this one strip site, so both are covered.
- INSERT: a narrower `stripRuntimeOwnedFields` runs in the engine before
  `applyAutonumbers`. It deliberately does NOT take over the author-declared
  `readonly` insert strip, which stays at the DataProtocol ingress per #3413 /
  #3043 (a create may legitimately seed read-only columns, and trusted internal
  writers call `engine.insert` directly).

Stripping in the engine, before dispatch, is what makes this driver-agnostic:
a driver advertising `supports.autonumber` receives a row with no caller value,
so its persistent sequence always wins — no driver changed. The tests assert
the driver-facing payload rather than patching a driver.

Exemptions keep their existing semantics: `isSystem` writes skip the pass, a
`preserveAudit` historical import may still reinstate legacy record numbers
(#3493 whitelist — `autonumber` is an author-declared business field), and only
CALLER-supplied keys are candidates, so hook stamps survive.

The strip is silent, so it is reported: the insert path's `onFieldsDropped`
(#3407) is wired at the new strip site as its standing note required, reusing
the existing `readonly` reason code rather than forking the spec vocabulary.
`createManyData` and `insertManyData` forward the listener too; the latter keeps
row precision by attributing each dropped name to the rows that supplied it.

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

Request Review

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling and removed size/l 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/metadata-protocol, @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/metadata-protocol, @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/metadata-protocol, @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)
  • content/docs/releases/v9.mdx (via @objectstack/metadata-protocol)

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
…4918 ratchet)

`check:query-options-erasure` reported the test surface growing 267 -> 269: the
#5503 test file passed `{ where: { id } } as any` to `findOne` and `{} as any`
to `find`. Neither is deliberately off-contract — both values are plain
`EngineQueryOptions` that the signatures already infer — so the gate's FIRST
remedy applies (drop the assertion), not the `as unknown as EngineQueryOptions`
spelling reserved for tests whose subject IS off-contract input.

The baseline is untouched: the count returns to its 267 ceiling on its own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx
@os-zhuang
os-zhuang marked this pull request as ready for review August 5, 2026 22:36
@os-zhuang
os-zhuang enabled auto-merge August 5, 2026 22:36
claude added 2 commits August 5, 2026 23:23
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/xl tests tooling

Projects

None yet

2 participants