Repository navigation
fix(metadata-protocol)!: batch 逐行结果迁移到 BatchOperationResultSchema 形状 —— 方案 B 已拍板,硬切 + 诚实迁移说明(Blocked-by #4620) #4793
Description
背景
#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 fromBatchOperationResultSchema'serrors: 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 我建议直接排除。
关联
- 来源:fix(metadata-protocol): deleteManyData has the same fake-atomic as batchData, updateManyData ignores atomic entirely #4620 第 3 节(第 1、2 节已派发,
claude/issue-4620-many-data-atomic) - 前史:ADR-0119 D4 / No plugin-reachable transactional write primitive — every migration-class tool rebuilds journal + compensation #4612 —— 第一次识别并刻意留下,理由记在
protocol.ts:814-820 - 涉及:
packages/spec/src/api/batch.zod.ts:183(schema)、packages/metadata-protocol/src/protocol.ts:821(实现类型)、batch.zod.ts:249(响应引用点) - 相关原则:ADR-0049 enforce-or-remove(声明即强制)
Activity
os-zhuang commentedon Aug 3, 2026
裁决(2026-08-03,维护者委托 PM 决策):方案 B —— 实现迁到 schema 形状,搭 v17 major 窗口硬切,不做 C 的双发过渡。
决定性的事实核查(回答正文留下的事实问题)
「仓外有没有已发布消费方在读旧键」——查下来答案是:读旧键的人本来就在契约之外:
packages/client/src/index.ts:4976re-export 的是 spec 的BatchOperationResult(errors/data/index)。任何 TypeScript 消费方照 SDK 类型写代码,写出来的就是result.errors/result.data—— 今天在运行时拿到的是 undefined。也就是说 B 不是「破坏契约」,而是「让运行时第一次兑现已发布的契约」;真正被 break 的只有绕开类型(as any/ 裸 JS)读error/record的 off-contract 用法。packages/client/src/client.batch-transaction.test.ts:135在两种形状之间?? (x as any).record?.project对冲——测试都说不清哪边是真的。content/docs/references/api/batch.mdx)公开教的也是 schema 形状。三条证据同向:声明侧(SDK 类型、schema、文档)全体说
errors/data,只有 wire 实现一个人在说error/record。往少数派收,不需要过渡期。两轴结论(与正文分析一致,采纳其推荐)
「响应加版本」的问题就地解决:v17 major 本身就是版本边界——不需要发明响应版本机制,major changeset +
docs/protocol-upgrade-guide.md迁移条目即是本仓的既定通道。这正是它该搭 #4001「必要的全收」同一个 v17 窗口的原因,错过要等 v18。执行要求
protocol.ts的BatchDataRowResult迁到 schema 形状:error: string→errors: [ApiError](message 装入ApiError,code 按现有语义映射,无语义处用既定通用码),record→data,补发index(实现从不发的键一并兑现,行级关联在无 id 失败行上必需);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;protocol.ts:814-820的「刻意分歧」注释(存在理由已消灭),清理client.batch-transaction.test.ts:135的双形状对冲;claude/issue-4620-many-data-atomic,同文件protocol.ts)不同批——等其落地后再派,避免合并冲突;as any处),有则按跨仓规则在消费仓立适配 issue,Blocked-by本单。维护者若知有特定仓外客户依赖旧键(上述证据之外的),可否决改走 C——届时删除旧键必须同时立 issue + schema 内显式 deprecated 标注。
needs-user-decision摘除,入队(等 #4620 落地后派)。Generated by Claude Code
[-][决策] batch 逐行结果:实现的 `error`/`record` 与 `BatchOperationResultSchema` 的 `errors`/`data` 对不上 —— 往哪边收[/-][+]fix(metadata-protocol)!: batch 逐行结果迁移到 `BatchOperationResultSchema` 形状 —— 方案 B 已拍板,硬切 + 诚实迁移说明(Blocked-by #4620)[/+]xuyushun441-sys commentedon Aug 3, 2026
维护者已拍板:方案 B —— 迁实现到 schema 形状,硬切,changelog 诚实记录
维护者原话要点:「按合理的方式开发,在 changelog 中诚实记录,第三方知道怎么升级修正就可以。」
即:不做 C 的双发过渡,不需要响应版本机制 —— 一次干净的硬切,配一份写清升级路径的 major changeset。这同时回答了原分析里的事实问题:无论仓外是否存在读旧键的消费方,答案都是「诚实告知怎么改」,而不是维持双份真相。A(放宽 schema)维持排除。
实现约束(派发时原样进 prompt)
error: string→errors: ApiError[];record→data;补上index(请求数组下标,schema 已声明、实现从未发过)。droppedFields两边已对齐,不动。ROLLED_BACK:/NOT_ATTEMPTED:标记随迁移结构化:fix(metadata-protocol): deleteMany/updateMany 的 atomic 要么为真、要么拒绝 (#4620) #4798 刚落地的这两个标记目前是 message 字符串前缀;迁移后「已尝试被撤销 vs 从未执行」的区分必须成为ApiError.code一级的结构化信息(message 保留人类可读原因与致因行),不能要求客户端嗅探字符串前缀 —— 让 AI 消费方按 code 分支,而不是按前缀正则。这是 B 的红利之一,也是两条评估轴(防 AI 写错)的直接要求。BatchOperationResultSchema.parse直接校验三个端点(batchData/updateManyData/deleteManyData)的真实响应行。没有这颗钉子,schema 与实现下次还会静默分叉 —— 这颗钉子是本次的结构性收益,不是可选项。@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 并同样写清。绝不双发旧键。.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
xuyushun441-sys commentedon Aug 3, 2026
移交:本条属于「协议变更」分片,不由本 PM 派发
维护者已明确协议变更相关的工作由另一位项目经理处理。本 issue 是
BatchOperationResultSchema的 wire 契约形状迁移(破坏性、影响已发布消费方),正落在那条线上 —— 本 PM 就此停止派发,不认领、不夹带进任何批次。移交时已完成、可直接接手的部分(上一条评论里是完整版):
error: stringvserrors: ApiError[]、recordvsdata、index实现从不发;droppedFields两边已对齐无需动。ApiError.code、BatchOperationResultSchema.parseconformance 钉子、major changeset 写明升级指引、仓内消费方一并清扫 + objectui 影子排查。Blocked-by #4620(PR fix(metadata-protocol): deleteMany/updateMany 的 atomic 要么为真、要么拒绝 (#4620) #4798,已复核通过并进合并队列)。同文件protocol.ts,且本次迁移直接叠在 fix(metadata-protocol): deleteMany/updateMany 的 atomic 要么为真、要么拒绝 (#4620) #4798 新增的共享 runner 与ROLLED_BACK:/NOT_ATTEMPTED:行标记之上 —— 必须等 fix(metadata-protocol): deleteMany/updateMany 的 atomic 要么为真、要么拒绝 (#4620) #4798 落地。保留
pm:queue以便协议线 PM 的 backlog 扫描能捞到它;若那条线用别的标签,请自行改。同属协议线、本 PM 同样不动的还有
$regexon driver-sql is not a regex — it compiles to a substring LIKE, so it both over-matches and silently matches nothing #4706 ——$regex在 driver-sql 上不是正则(编译成 substring LIKE,既过度匹配又静默不匹配),带protocol:data标签,needs-user-decision。system-data默认 affordance 是否应包含 CSVimport(RBAC 关联表批量绑定) #4671 ——system-data默认 affordance 是否包含 CSVimport,带protocol:data标签。边界上需要澄清的一条(留给维护者)
#4620 / PR #4798 落在
packages/metadata-protocol里,但改的是atomic的运行时兑现(假原子 → 真事务回滚),不是 wire 契约形状。它已由本 PM 全程复核并进队。若维护者认为「凡metadata-protocol包内一律归协议线」,请告知,后续同类我改走移交。当前按「改契约形状 = 协议线;修包内运行时缺陷 = 本线」执行。Generated by Claude Code
xuyushun441-sys commentedon Aug 3, 2026
前置已清空:#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
os-zhuang commentedon Aug 3, 2026
🔒 认领(协议线 PM 接手):PM 会话
session_0176qgxgCXTJCUv4YFLtusP9,分支claude/issue-4793-batch-row-result-schema。接手依据:方向已由维护者拍板(方案 B 硬切),移交说明完整,前置 #4620 / PR #4798 已于 07:46Z 合入 main。派发前影子排查已完成:
ffab8033)零 batch 行结果旧键消费方 ——packages/*/src裸名扫描无.error/.record行字段读取点;唯一命中是client/src/index.ts:4976re-export 的BatchOperationResult类型(即 schema 形状,恰是 B 的佐证)。无需跨仓 linkage issue,实施 agent 仍须自行复核。metadata-protocol/src/protocol.ts+spec/src/api/batch.zod.ts(如需扩ApiErrorcode)+client/rest仓内清扫;与在飞 ADR-0049:两份activationEvents声明四仓零 reader —— declared-but-unenforced,且 studio 侧z.string()零校验 #4657(kernel/plugin-runtime + studio/plugin)、spec/system 通知模板语汇在 C3 后成为零消费方孤儿:EmailTemplate/SMSTemplate/PushNotification/InAppNotification(Schema) 待 enforce-or-remove 处置 #4616 收尾(system/notification)文件不相交,可并行实施,入队排最后。执行按移交说明四条约束原样落地:行标记结构化进
ApiError.code、三端点真实响应行过BatchOperationResultSchema.parse的 conformance 钉子、major changeset + 升级指引、仓内消费方清扫。Generated by Claude Code
os-zhuang commentedon Aug 3, 2026
✅ 实施完成(实施 agent,PM 会话
session_0176qgxgCXTJCUv4YFLtusP9,分支claude/issue-4793-batch-row-result-schema):draft PR #4841 已开。按裁决的方案 B 硬切落地,四条实现约束全部完成:
BatchDataRowResult直接别名 spec 的BatchOperationResult(tsc 双向钉死),三端点同步errors: ApiError[]/data/index;「刻意分歧」注释已删。ROLLED_BACK/NOT_ATTEMPTED注册进ERROR_CODE_LEDGER成为ApiError.code一级信息,message 保留致因;致因行保留自身语义码。protocol.batch-row-conformance.test.ts,三端点真实响应行过BatchOperationResultSchema.parse+ 旧键缺席/新键在场断言;sabotage 实跑 8/10 红(输出贴在 PR body)。消费方清扫:仓内全部迁移(读数在 PR body);objectui 双重复测(PM
785b8a5d+ 独立克隆971302e2)零旧键消费方,无需跨仓 linkage issue。验证:metadata-protocol 268/268、spec 7380/7380 +
check:generated8/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
os-zhuang commentedon Aug 3, 2026
内容验收通过 → PR #4841;入队待 #4831 落地后再同步(存在真冲突区)
独立核过 GitHub 实际 diff,实质合格:
batch.zod.ts只改 describe,schema 形状一字未动 ——errors/data/index本来就已声明。这坐实了方案 B 的核心论证:本单是实现追上已发布契约,不是改契约;真正被 break 的只有绕开类型读error/record的 off-contract 用法。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 一级的信息。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:这正是今日 #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
chore(spec): 合并 main(#4846)后整体重生成 ADR-0087 生成物 —— 三方并存
os-zhuang commentedon Aug 3, 2026
✅ 末次同步验收通过 → PR #4841 已转正 + auto-merge
PM 对分支尖
7e8858a4独立复核(不采信自报):spec-changes.json同时含 ADR-0049:两份activationEvents声明四仓零 reader —— declared-but-unenforced,且 studio 侧z.string()零校验 #4657 的plugin-activation-events-retired、docs(spec):app.homePageId墓碑说清真正的退役理由 —— 「no shell ever read it」是假的 (#4709) #4846 的WAS read by objectui散文、本单的batch-row-result-schema-shape(各 2 处,格式所致的双份记录与 main 一致);registry.tsstep17 两条 id 俱在;activationEvents声明四仓零 reader —— declared-but-unenforced,且 studio 侧z.string()零校验 #4657 的删除精确名(带引号)0 命中("system/EmailTemplate"/"kernel/ActivationEvent"/"studio/ActivationEvent")—— 采用实施 agent 自己发现的教训:子串匹配会撞上幸存的EmailTemplateDefinition家族,必须精确名;61cc079e)。三轮同步,os-regen 静默吞并三次实测复现、三次按同一协议修复(checkout main 版生成物 → 整体重生成 → 断言并存)—— 这套流程现在有三份独立的实证记录(#4809 / #4846 / 本单),可作为多单共改生成物时的标准入队姿势。
内容验收(schema 形状未动只改 describe、conformance 钉子断言旧键缺席、
ROLLED_BACK/NOT_ATTEMPTED结构化进ApiError.code、sabotage 8 条全红)见前评。本单是 v17 协议线第二批的最后一个大件。Generated by Claude Code
fix(metadata-protocol)!: batch 逐行结果迁移到 BatchOperationResultSchema 形状 …
fix(metadata-protocol): updateMany classifies an id-less row as a cal…
fix(metadata-protocol,spec): a stopped bulk write reports every recor…