fix(objectql): autonumber 是运行时拥有的字段,写路径不再接受调用者提交的单号 (#5503) - #5627
Merged
Conversation
… 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
…onumber-readonly-strip
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 14 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
This was referenced Aug 5, 2026
…onumber-readonly-strip
…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
marked this pull request as ready for review
August 5, 2026 22:36
os-zhuang
enabled auto-merge
August 5, 2026 22:36
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
#5126) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5503
前提核验(先证伪,再动手)
在
origin/maine900015上逐条核实,issue 的前提成立,且是用可执行的复现测试证明的,不是读代码得出的印象:先写下
packages/objectql/src/engine-autonumber-runtime-owned.test.ts,在未改动的源码上跑一遍:三条现象与 issue 描述逐字对应:POST 显式值原样落库、PATCH 改写落库、SQL 驱动
(
supports.autonumber)路径同样采纳显式值。另外 8 条一开始就是绿的 —— 它们断言的是
isSystem/preserveAudit/ 钩子写入这些豁免语义,改动前后都必须绿,这也是这次改动"只收窄客户端、不动豁免"的边界证明。
缺陷
autonumber的值一直被声明为运行时所有 ——applyAutonumbers的注释写着"the runtime owns the value, not the client",两个记录校验器也正因如此在 insert 与
update 上都豁免了它的
required检查。缺的是另一半:没有任何一层写路径阻止客户端自己填这个值。
与已修复的 #4447(
created_at可被普通 PATCH 伪造)是同一缺陷族。区别在于:声明了readonly: true的字段早已被 #2948 / #3043 保护,而autonumber身上根本没有这个标记,
stripReadonlyFields的if (!def?.readonly) continue;从它旁边直接走过去了。修法:在引擎/校验层视为隐含 readonly,insert 与 update 同权
stripReadonlyFields的判定从"作者声明的readonly: true"扩展为"作者声明的 或 运行时拥有的字段类型"(新增
isRuntimeOwnedField,当前集合恰好只有
autonumber)。单行更新与multi批量更新共用这一个剥离点,两条路径同时被覆盖。stripRuntimeOwnedFields,只剥离运行时拥有的字段。它刻意不接管作者声明的
readonly在 insert 上的语义:那条防线按 feat(automation): update_record/create_record 步骤对被静默剥离的写入字段挂 warning(#3407) #3413 / 安全/设计:静态 readonly 的 INSERT 豁免让审批/状态字段可在创建时被直接播种(比 #3003 少一步) #3043 的设计留在 DataProtocol 入口,因为 create 确实可能合法写入只读列,而直接调用
engine.insert的可信内部写入者(身份预置、元数据仓库、事件游标)必须不受影响。单号没有这种两可性 —— 谁都不该在 create 时自带单号。剥离跑在校验之前:一个调用者
本就无权提交的值,也不该被对象的校验规则审判。
为什么这天然与驱动无关:剥离发生在引擎里、派发给任何驱动之前。声明
supports.autonumber === true的 SQL 驱动拿到的行里根本没有这个键,它的持久序列必然胜出 —— 没有任何驱动改动一行代码。测试直接断言递交给
driver.create的负载:豁免语义原样保留,与 update 侧既有白名单完全一致:
isSystem写入整体跳过;preserveAudit(#3493)的历史导入仍可写入原始单号(把遗留系统的历史单号迁移进来正是该白名单存在的业务场景,而
autonumber是作者声明的业务字段,system !== true,恰好落在
isPreservableUnderAudit允许的范围内);只有调用者提交的键才是候选,因此beforeInsert/beforeUpdate钩子算出的值不受影响。与 #5610(
strictReadonlyWrites,#5126)的叠加本 PR 在途期间 main 合入了 #5610,两者在
engine.ts文本冲突。冲突体本身只是insert()的文档注释,但语义叠加是实打实的,按两个 PR 的意图合并而非取一边:
两条注释各自带着同一个待决条件,而本 PR 恰恰就是触发条件的那次改动:
successwhen written fields are silently stripped — no observability for dropped writes (split from #3356) #3407:「若 insert 将来出现静默剥离,必须在剥离点接上监听器」;strictReadonlyWrites在此惰性,仅因为 insert 不剥离任何东西。insert 一旦有了剥离,两个成员就在那个剥离点一起接上」。
于是叠加后的语义:
strictReadonlyWrites: trueonFieldsDropped上报 + 提交readonly字段同权onFieldsDropped上报 + 提交ERR_READONLY_FIELD_REJECTEDstripReadonlyFields→reportDroppedFields→assertNoStrictDrops这条 [决策] readonly 剥离的 strict/reject 模式落在哪一层 —— B(WriteObservabilityOptions)推荐,A/C/D 各有代价(#4903 后续) #5126 已铺好的接缝,叠加自然成立。已用测试钉住,而不是假定。
ReadonlyFieldRejectedError,且监听器不触发 ——DroppedFieldsEvent的契约是「已丢弃且写入已完成」,被拒绝的写入并未完成。这是 [决策] readonly 剥离的 strict/reject 模式落在哪一层 —— B(WriteObservabilityOptions)推荐,A/C/D 各有代价(#4903 后续) #5126 自己写下的设计要点,照搬。
接缝处没有新增任何策略。 #5126 明确写着 strict「不引入第二套策略,它只是把既有策略
报出来」,且「剥离拿不走的字段也不会被拒绝」。照此逐字适用即可回答 PM 点名的那个可能分叉
(strict 下 autonumber 是否豁免历史导入):
isSystem与preserveAudit的值根本不会进入剥离分支,因此 strict 下依旧被接受 —— 这是既有规则的推论,不是新裁决,故未按
needs_decision 上报。已各自钉了测试。
ReadonlyFieldRejectedError新增可选的operation(默认'update',#5126 的 UPDATE文案逐字节不变,其测试对
code/fields/drops/message的断言全部原样通过)。动词与补救办法确实因操作而异:INSERT 的拒绝必然关于运行时拥有的值,其合法写入者是
isSystem与历史导入
preserveAudit,而readonlyWhen在 create 上根本锁不住任何东西。顺带钉住一个此前没人面对过的角落:
insertMany的部分成功模式剔除坏行,而 strict 是拒绝整笔写入。两者同时传入时以拒绝为准 —— 错误契约写的是「什么都没写」,降级成「其余
行保留了」会让这句话变成假话。这条现在是决定,不是意外。
静默剥离必须被上报,而不是被吞掉
createManyData与insertManyData也补上了监听器转发。后者保持逐行精度:引擎事件是整批的并集(监听器签名没有行下标),但剥离只会移除行自身提交过的键,因此可以
准确归属回具体行 —— 导入器优先走的正是
insertManyData这条部分成功路径。全仓 pin 扫描(逐条判定,非批量重写)
按规则的消费半径扫(objectql / metadata-protocol / rest / runtime / qa / cli /
lint / examples / driver-*),命中三处既有断言,均属系统路径豁免语义,按要求原样保留:
packages/objectql/src/validation/record-validator.test.ts:30accepts an explicitly-provided autonumber valuevalidateRecord不负责写路径所有权;剥离跑在它之前,所以还能带着值走到这里的只剩豁免写入者,这条断言现在恰好钉的是"豁免写入者不会反被校验器拒掉"。已就地加注说明,避免被误读成"客户端可以提交单号"。driver-sql的sql-driver-autonumber.test.tsrespects caller-provided values and never overrides themdriver-*)。此处的 "caller" 是引擎而非 HTTP 客户端;改动后只有豁免写入者的行会带着值到达create(),这条钉的是该豁免的驱动半边。driver-sqlite-wasm的同名用例未发现任何"显式 autonumber 值被采纳"的客户端伪造语义断言需要翻转 ——
protocol-data.test.ts早已断言 clone 会丢弃 autonumber 让其重新派生,方向一致。反向验证(方向是先预测、后执行的)
预测方向:红。这是一个谓词式守卫,不喂计数、也不存在
??链首为规范键的倒挂情形。把两处限肢摘掉(
stripReadonlyFields的判定退回只看def.readonly,引擎 insert 剥离关闭)后重跑:
13 条红,与预测一致。同批中保持绿的三类,都是有理由保持绿的:
isSystem/preserveAudit/ 钩子写入的豁免断言(本就与限肢无关)、stripRuntimeOwnedFields的单元测试(只关掉了它在引擎里的调用点,函数本身没动)、以及
record-validator.test.ts全部(
validateRecord的 autonumber 豁免不受剥离影响 —— 它在两个方向上都绿,这一点如实记录,不硬凑成"改动前红")。
多行更新那条曾经可能因为什么都没匹配到而空绿:谓词用了内存驱动不认识的
$ne,0 行命中也能让"单号没变"通过。已改成两行共享的标量谓词,并补上"两行的
name都被改写"与"同一次写入的合法部分确实落库"的断言,让它只能因为逻辑正确而绿。
验证(合并 #5610 之后重跑)
其中 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_historical→preserveAudit),这与 #3493 为只读业务字段确立的划分一致。
范围之外
packages/spec的autonumberbuilder 是否应当直接注入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