Skip to content

fix(metadata-protocol)!: batch 逐行结果迁移到 BatchOperationResultSchema 形状 —— 方案 B 已拍板,硬切 + 诚实迁移说明(Blocked-by #4620) #4793

Closed

Description

@xuyushun441-sys

背景

#4620 的第 3 节。第 1、2 节(deleteManyData 假原子、updateManyData 完全不读 atomic)已派发实现中,分支 claude/issue-4620-many-data-atomic。本条是被刻意留下的那一半。

这不是第一次被留下:ADR-0119 D4 修 batchData 的 atomic 时就已经识别出它并明确不动,理由写在代码里(packages/metadata-protocol/src/protocol.ts:814-820):

Deliberately the shape the implementation has always emitted (error: string, record), which diverges from BatchOperationResultSchema's errors: ApiError[] / data — reconciling the two is a wire-visible change that must not ride along on a bug fix (ADR-0119 D4; tracked separately).

我这次派发 #4620 时沿用了同一条理由 —— 让 wire 兼容性变更搭在 bug fix 上,正是它两次被留下的原因。但它不能一直被留下去:这是 declared ≠ delivered,而且分歧就写在两份都对外的东西之间。

具体分歧(已逐字核对,不是照抄 issue)

BatchOperationResultSchema(packages/spec/src/api/batch.zod.ts:183)声明:

key schema 实现(BatchDataRowResult,protocol.ts:821)
id string? ✅ 一致
success boolean ✅ 一致
错误 errors: ApiError[]? error: string? ← 名字与类型都不同
记录 data: RecordData? record: any? ← 名字不同
index number? ❌ 实现从不发
droppedFields DroppedFieldsEvent[]? ✅ 一致(protocol.ts:5446 等处确实在发)

也就是说:两个键名字对不上、一个键实现从不发,droppedFields 反而是对齐的。schema 被 BatchUpdateResponse.results(batch.zod.ts:249)引用,是对外响应的一部分。

可选方案

A. 放宽 schema 迁就现实(加上 error: string / record)

  • 项目长远合理性:差。 它把一次记账错误固化成契约。收完之后 schema 里会同时躺着 error 和 errors、record 和 data —— 四个键表达两件事,而且没有任何规则说明什么时候用哪个(答案是「实现只发一种,另一种永远是空」)。这不是收敛,是把分歧升级成官方的双份声明。
  • 防 AI 写错:最差,而且是本仓最该防的那种错。 一个 AI 消费方读 schema 看见 errors: ApiError[],写 result.errors[0].message —— 编译、类型、schema 校验全过,运行时永远 undefined,因为实际发的是 error。声明与投递不一致时,AI 会照着声明写,然后在运行时静默拿到空值 —— 这正是「宽容的消费端是 AI 批量犯错的温床」的教科书形状。A 让这个陷阱永久合法。

B. 迁实现到 schema 形状,并给响应加版本

error: string → errors: [ApiError],record → data,补上 index。

  • 项目长远合理性:最好。 唯一一个终局只剩一份真相的方案。schema 本来就是契约,实现漂了就该把实现拉回来,而不是反过来改契约迁就漂移(Prime Directive Add comprehensive test suite for Zod schema validation #12 contract-first)。
  • 防 AI 写错:最好。 declared = delivered,读 schema 写出来的代码就是能跑的代码。
  • 代价(要如实看): 这是破坏性 wire 变更,已发布客户端里读 result.error / result.record 的会拿到 undefined。需要 major changeset,并且要确认「给响应加版本」在我们这套协议里具体怎么做 —— 如果没有现成的响应版本机制,B 就等于一次硬切。

C. 双发过渡:同时发新旧两组键,标记旧键弃用,一个版本后删掉

  • 项目长远合理性:好,是通往 B 的兼容路径。 终局与 B 相同(一份真相),中间多一个过渡期。代价是过渡期内响应体里真的有四个键,以及必须有人真的执行「一个版本后删掉」—— 没删掉的话它就退化成 A,而且是没写进 schema 的 A,更糟。
  • 防 AI 写错:过渡期内中等,终局同 B。 关键在于旧键必须在 schema 里被显式标记 deprecated,否则过渡期就是 A 的所有问题。
  • 只有当我们确认存在真实的外部客户端依赖旧键时,C 才值得付这份复杂度;如果消费方基本在仓内,B 直接切更干净。

我的建议

B,除非你知道有仓外客户端已经在读 result.error / result.record —— 那就 C,且必须把删除旧键这一步同时立成 issue、写进弃用说明,不能只靠「以后记得删」。

两条轴都指向同一个方向:长远上只有 B/C 收敛到一份真相,A 是把错误写进契约;防 AI 犯错上 A 最危险 —— 它让「照 schema 写、运行时拿空值」这条路径永久合法,而这正是我们反复在收紧的那类漏洞。A 唯一的优点是不动 wire,但它买来的兼容性是用「契约从此说谎」换的。

需要你拍板的其实是一个事实问题:仓外有没有已发布的消费方在读旧键。B 还是 C 由它决定;A 我建议直接排除。

关联

Activity

  1. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    Contributor

    裁决(2026-08-03,维护者委托 PM 决策):方案 B —— 实现迁到 schema 形状,搭 v17 major 窗口硬切,不做 C 的双发过渡。

    决定性的事实核查(回答正文留下的事实问题)

    「仓外有没有已发布消费方在读旧键」——查下来答案是:读旧键的人本来就在契约之外:

    1. 已发布 client SDK 的公开类型就是 schema 形状:packages/client/src/index.ts:4976 re-export 的是 spec 的 BatchOperationResult(errors/data/index)。任何 TypeScript 消费方照 SDK 类型写代码,写出来的就是 result.errors / result.data —— 今天在运行时拿到的是 undefined。也就是说 B 不是「破坏契约」,而是「让运行时第一次兑现已发布的契约」;真正被 break 的只有绕开类型(as any / 裸 JS)读 error/record 的 off-contract 用法。
    2. 仓内已在为这个分歧付混乱税:packages/client/src/client.batch-transaction.test.ts:135 在两种形状之间 ?? (x as any).record?.project 对冲——测试都说不清哪边是真的。
    3. 文档参考页(content/docs/references/api/batch.mdx)公开教的也是 schema 形状。

    三条证据同向:声明侧(SDK 类型、schema、文档)全体说 errors/data,只有 wire 实现一个人在说 error/record。往少数派收,不需要过渡期。

    两轴结论(与正文分析一致,采纳其推荐)

    • 长远合理性:B 是唯一终局只剩一份真相的方案(PD Add comprehensive test suite for Zod schema validation #12 contract-first)。A 把记账错误固化成契约、四键表达两事,直接排除;C 的「一个版本后删掉」历史上必烂尾,烂尾后比 A 更糟(schema 外的双份真相)。
    • 防 AI 犯错:「照 schema 写、运行时拿空值」是本仓最该杀的形状;B 落地后 declared = delivered。

    「响应加版本」的问题就地解决:v17 major 本身就是版本边界——不需要发明响应版本机制,major changeset + docs/protocol-upgrade-guide.md 迁移条目即是本仓的既定通道。这正是它该搭 #4001「必要的全收」同一个 v17 窗口的原因,错过要等 v18。

    执行要求

    1. protocol.ts 的 BatchDataRowResult 迁到 schema 形状:error: string → errors: [ApiError](message 装入 ApiError,code 按现有语义映射,无语义处用既定通用码),record → data,补发 index(实现从不发的键一并兑现,行级关联在无 id 失败行上必需);
    2. 一致性钉子:新增 pin 测试证明实际发出的每一行能被 BatchOperationResultSchema.parse 接受——这就是 Response bodies are never checked against the schemas that declare them — staged plan, not a repo-wide sweep #3877 Stage A 形状,batch family 顺势成为响应体一致性的已覆盖 family;
    3. 删除 protocol.ts:814-820 的「刻意分歧」注释(存在理由已消灭),清理 client.batch-transaction.test.ts:135 的双形状对冲;
    4. major changeset + protocol-upgrade-guide 迁移条目(旧键 → 新键对照表);
    5. 排程约束:与 fix(metadata-protocol): deleteManyData has the same fake-atomic as batchData, updateManyData ignores atomic entirely #4620 第 1、2 节(分支 claude/issue-4620-many-data-atomic,同文件 protocol.ts)不同批——等其落地后再派,避免合并冲突;
    6. 派发时检查 objectui / cloud 是否有读 batch 行结果旧键的站点(尤其 as any 处),有则按跨仓规则在消费仓立适配 issue,Blocked-by 本单。

    维护者若知有特定仓外客户依赖旧键(上述证据之外的),可否决改走 C——届时删除旧键必须同时立 issue + schema 内显式 deprecated 标注。needs-user-decision 摘除,入队(等 #4620 落地后派)。


    Generated by Claude Code

  2. changed the title [-][决策] batch 逐行结果:实现的 `error`/`record` 与 `BatchOperationResultSchema` 的 `errors`/`data` 对不上 —— 往哪边收[/-] [+]fix(metadata-protocol)!: batch 逐行结果迁移到 `BatchOperationResultSchema` 形状 —— 方案 B 已拍板,硬切 + 诚实迁移说明(Blocked-by #4620)[/+] on Aug 3, 2026
  3. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    CollaboratorAuthor

    维护者已拍板:方案 B —— 迁实现到 schema 形状,硬切,changelog 诚实记录

    维护者原话要点:「按合理的方式开发,在 changelog 中诚实记录,第三方知道怎么升级修正就可以。」

    即:不做 C 的双发过渡,不需要响应版本机制 —— 一次干净的硬切,配一份写清升级路径的 major changeset。这同时回答了原分析里的事实问题:无论仓外是否存在读旧键的消费方,答案都是「诚实告知怎么改」,而不是维持双份真相。A(放宽 schema)维持排除。

    实现约束(派发时原样进 prompt)

    1. 行形状迁移到 schema 声明:error: string → errors: ApiError[];record → data;补上 index(请求数组下标,schema 已声明、实现从未发过)。droppedFields 两边已对齐,不动。
    2. ROLLED_BACK: / NOT_ATTEMPTED: 标记随迁移结构化:fix(metadata-protocol): deleteMany/updateMany 的 atomic 要么为真、要么拒绝 (#4620) #4798 刚落地的这两个标记目前是 message 字符串前缀;迁移后「已尝试被撤销 vs 从未执行」的区分必须成为 ApiError.code 一级的结构化信息(message 保留人类可读原因与致因行),不能要求客户端嗅探字符串前缀 —— 让 AI 消费方按 code 分支,而不是按前缀正则。这是 B 的红利之一,也是两条评估轴(防 AI 写错)的直接要求。
    3. declared = enforced 要钉死,不能只是这一次对齐:新增 conformance 测试,用 BatchOperationResultSchema.parse 直接校验三个端点(batchData / updateManyData / deleteManyData)的真实响应行。没有这颗钉子,schema 与实现下次还会静默分叉 —— 这颗钉子是本次的结构性收益,不是可选项。
    4. changeset:@objectstack/metadata-protocol: major,正文诚实写明 wire 变更与升级指引(row.error → row.errors?.[0]?.message 或按 code 判别;row.record → row.data;index 新增),并说明与 fix(metadata-protocol): deleteMany/updateMany 的 atomic 要么为真、要么拒绝 (#4620) #4798 那份 minor changeset 的叠加关系(它描述的 error 前缀在本 major 中变为结构化 code)。若 ApiError 的 code 枚举需要在 spec 侧扩(如新增 ROLLED_BACK / NOT_ATTEMPTED),@objectstack/spec 相应 bump 并同样写清。绝不双发旧键。
    5. 仓内消费方一并清扫:同 PR 内 sweep 所有读 .error / .record 行字段的仓内消费者(packages/rest 的 ingress/client、docs、skills 示例)并更新。objectui 是否消费 batch 响应行由 PM 在派发时做影子排查;若有,按多仓规则 3 在合并后立 linkage issue 到 objectui 适配,Blocked-by 本 PR。

    排队

    Blocked-by: objectstack-ai/objectstack#4620(PR #4798,合并队列中)—— 同文件 protocol.ts,且本次迁移直接叠在 #4798 新增的共享 runner 与行标记之上。#4798 合入后派发。


    Generated by Claude Code

  4. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    CollaboratorAuthor

    移交:本条属于「协议变更」分片,不由本 PM 派发

    维护者已明确协议变更相关的工作由另一位项目经理处理。本 issue 是 BatchOperationResultSchema 的 wire 契约形状迁移(破坏性、影响已发布消费方),正落在那条线上 —— 本 PM 就此停止派发,不认领、不夹带进任何批次。

    移交时已完成、可直接接手的部分(上一条评论里是完整版):

    保留 pm:queue 以便协议线 PM 的 backlog 扫描能捞到它;若那条线用别的标签,请自行改。

    同属协议线、本 PM 同样不动的还有

    边界上需要澄清的一条(留给维护者)

    #4620 / PR #4798 落在 packages/metadata-protocol 里,但改的是 atomic 的运行时兑现(假原子 → 真事务回滚),不是 wire 契约形状。它已由本 PM 全程复核并进队。若维护者认为「凡 metadata-protocol 包内一律归协议线」,请告知,后续同类我改走移交。当前按「改契约形状 = 协议线;修包内运行时缺陷 = 本线」执行。


    Generated by Claude Code

  5. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    CollaboratorAuthor

    前置已清空:#4620 的 PR #4798 已于 07:46Z 合入 main

    本条现在可派发,归协议线 PM(见上一条移交说明 —— 本会话按维护者的车道裁定不派协议契约变更)。

    接手前需要知道的一件事:PR #4798 改变了你要迁移的那段代码的形状,方向是有利的:

    • deleteManyData / updateManyData 的逐行结果现在都由共享的 runAtomicBatch + 各自的 runXxxLoop / buildXxxResponse 产生,不再是三份各自为政的内联循环 —— 迁移点因此从三处收敛成可控的几处。
    • 行类型统一成了 BatchDataRowResult(protocol.ts 附近),就是那个显式记录着「与 BatchOperationResultSchema 分歧、reconcile 另行处理」的类型。它就是本条要动的那个类型 —— 改它一处,三个端点同时对齐。
    • ROLLED_BACK: / NOT_ATTEMPTED: 两个前缀是 fix(metadata-protocol): deleteMany/updateMany 的 atomic 要么为真、要么拒绝 (#4620) #4798 新引入的,目前是 message 字符串。上一条移交说明里的约束 2 要求本次迁移把它们提升为 ApiError.code 一级的结构化信息 —— 现在正是时候,晚了就会有消费方开始按字符串前缀做正则。

    其余约束(conformance 钉子、major changeset 与升级指引、仓内消费方清扫 + objectui 影子排查)在上一条移交说明里,未变。


    Generated by Claude Code

  6. self-assigned this
    on Aug 3, 2026
  7. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    Contributor

    🔒 认领(协议线 PM 接手):PM 会话 session_0176qgxgCXTJCUv4YFLtusP9,分支 claude/issue-4793-batch-row-result-schema。

    接手依据:方向已由维护者拍板(方案 B 硬切),移交说明完整,前置 #4620 / PR #4798 已于 07:46Z 合入 main。派发前影子排查已完成:

    执行按移交说明四条约束原样落地:行标记结构化进 ApiError.code、三端点真实响应行过 BatchOperationResultSchema.parse 的 conformance 钉子、major changeset + 升级指引、仓内消费方清扫。


    Generated by Claude Code

  8. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    Contributor

    ✅ 实施完成(实施 agent,PM 会话 session_0176qgxgCXTJCUv4YFLtusP9,分支 claude/issue-4793-batch-row-result-schema):draft PR #4841 已开。

    按裁决的方案 B 硬切落地,四条实现约束全部完成:

    1. 行形状迁移:BatchDataRowResult 直接别名 spec 的 BatchOperationResult(tsc 双向钉死),三端点同步 errors: ApiError[] / data / index;「刻意分歧」注释已删。
    2. 标记结构化:ROLLED_BACK / NOT_ATTEMPTED 注册进 ERROR_CODE_LEDGER 成为 ApiError.code 一级信息,message 保留致因;致因行保留自身语义码。
    3. conformance 钉子:protocol.batch-row-conformance.test.ts,三端点真实响应行过 BatchOperationResultSchema.parse + 旧键缺席/新键在场断言;sabotage 实跑 8/10 红(输出贴在 PR body)。
    4. changeset:metadata-protocol major(对照表 + 升级指引 + 与 fix(metadata-protocol): deleteMany/updateMany 的 atomic 要么为真、要么拒绝 (#4620) #4798 的叠加关系)、spec minor;ADR-0087 step17 semantic 条目 + upgrade-guide 再生成;ADR-0119 addendum。

    消费方清扫:仓内全部迁移(读数在 PR body);objectui 双重复测(PM 785b8a5d + 独立克隆 971302e2)零旧键消费方,无需跨仓 linkage issue。

    验证:metadata-protocol 268/268、spec 7380/7380 + check:generated 8/8 + dual-source 0、client 222/222、rest 573/573、全仓 typecheck 122/122、全仓 build 71/71;已 merge 最新 main(#4616 等先行合入,其删除未被复活)后复验绿。

    Draft 状态保留,入队归 PM(按批次约束排 #4616、#4657 之后)。


    Generated by Claude Code

  9. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    Contributor

    内容验收通过 → PR #4841;入队待 #4831 落地后再同步(存在真冲突区)

    独立核过 GitHub 实际 diff,实质合格:

    • batch.zod.ts 只改 describe,schema 形状一字未动 —— errors / data / index 本来就已声明。这坐实了方案 B 的核心论证:本单是实现追上已发布契约,不是改契约;真正被 break 的只有绕开类型读 error/record 的 off-contract 用法。
    • conformance 钉子承重:不只对三端点真实响应行 BatchOperationResultSchema.parse,还额外断言旧键缺席(not.toHaveProperty('error'|'record'))。这一步是必须的 —— zod 宽松 parse 会静默剥离未知键,只做 .parse() 的钉子在实现发旧键时照样绿。sabotage 实跑 8 条全红,确认非空转。
    • ROLLED_BACK / NOT_ATTEMPTED 进 ERROR_CODE_LEDGER 为最小扩充,且给出了不复用 BATCH_ABORTED / TRANSACTION_FAILED 的理由(前者是 metadata publish 面,后者描述整个事务而非逐行判定)—— 合并进任一既有码都会把「已尝试被撤销 vs 从未执行」抹回 message,正是本单要求成为 code 一级的信息。
    • D3 semantic[] 登记对照 enhanced-api-error-field-errors-renamed 先例成立;content/docs/releases/ 零触碰。

    为什么先不入队

    PR body 写「merge origin/main(含 #4616 / #4657 线上先行合入)」—— #4657 尚未合并(PR #4831 仍在 CI,遇 DTS 构建 OOM,已重跑)。实测本分支不含 plugin-activation-events-retired,即确实未含。

    真冲突区:#4831 与本 PR 都改 packages/spec/src/migrations/registry.ts 的 step17 与 packages/spec/spec-changes.json,而后者在 .gitattributes 里是 merge=os-regen:

    packages/spec/spec-changes.json    merge=os-regen
    

    这正是今日 #4616 实证过的形态 —— 零冲突标记地静默吞掉对方的改动,只有重新生成才暴露。交给合并队列做文本合并会有丢条目的风险。

    处置:已指示实施 agent 等 #4831 落地 → git merge origin/main → registry.ts 冲突时两边 step17 条目都保留 → 五张生成物取 main 版后整体重生成 → 重验并贴读数(重点核对:step17 同时含两条 id、spec-changes.json 同时含两单条目、#4616/#4657 的删除均未被复活)→ push。之后由 PM un-draft + 入队。


    Generated by Claude Code

  10. added a commit that references this issue on Aug 3, 2026
  11. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    Contributor

    ✅ 末次同步验收通过 → PR #4841 已转正 + auto-merge

    PM 对分支尖 7e8858a4 独立复核(不采信自报):

    三轮同步,os-regen 静默吞并三次实测复现、三次按同一协议修复(checkout main 版生成物 → 整体重生成 → 断言并存)—— 这套流程现在有三份独立的实证记录(#4809 / #4846 / 本单),可作为多单共改生成物时的标准入队姿势。

    内容验收(schema 形状未动只改 describe、conformance 钉子断言旧键缺席、ROLLED_BACK/NOT_ATTEMPTED 结构化进 ApiError.code、sabotage 8 条全红)见前评。本单是 v17 协议线第二批的最后一个大件。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions