Repository navigation
publishPackageDrafts answers success:false for a publish that refused nothing, and leaves no trace of that exit #10462
Description
Activity
分诊落卡(
needs-user-decision+domain:engine):契约形状提案,按人工地板交维护者。以下分析按业务视角写。一句话问题
管理员对一个套件点"发布"时,如果本来就没有待发布的草稿,系统回答"发布失败"——和真正被安全闸拒绝并整体回滚时的回答一模一样,且不留任何日志。AI 工具据此向用户报"发布被拒绝并整体回滚",再花两轮去修本来就正确的数据(cloud#1488 实测,消费端已在 cloud#1492 打了补丁)。
前提(re-check)
git log --oneline -5 -- packages/metadata-protocol/src/protocol.ts— 确认publishPackageDrafts结尾的success: failed.length === 0 && published.length > 0仍在。- cloud 侧补丁靠
failed.length > 0区分,该不变式("凡失败必有 failed[] 条目")今天无测试钉住。
选项 × 真实代价
选项 做什么 客户可感知代价 A 响应加第一类区分(如 noop或reason字段)+ no-op 路径补一行日志公开契约加一个键(一次性);老消费者不受影响。业务上 = 收银台明确告诉你"你没买东西",而不是"交易被拒" B 只用测试钉住现状不变式("凡失败必有 failed[]"),消费者靠 failed.length判别契约面不动,但规则是隐性的——每个新消费者(多半是 AI 写的)都要重新发现它,cloud#1488 的误报剧本会重演。业务上 = 规则只在老店员脑子里 C no-op 改回 success:true(成功发布了 0 项)语义翻转,现有把 success 读作"确实发布了东西"的调用方转为误报另一个方向 四轴(业务立场)
<!-- os-decision-facets -->- 长远合理性:A 缩小歧义面——一个响应一个含义,契约收紧方向;B 让隐性契约继续增生。
- 实际业务拉动:已有真实事故(cloud#1488:用户看到假"回滚"警报 + 两轮无效自动修复),非投机能力面。
- 防 AI 犯错:A 响亮直白,AI 消费端无需逆向推断;B 下一个 AI 消费者大概率复刻同一误读(已实测发生一次)。
- 创业不扩散:加一个判别字段是一次性小义务,换掉的是每个消费端各自的推断逻辑;不是能力扩张。
推荐 A;回退 B。
置信缺口:本分析看不见 repo 之外是否还有依赖现状success语义的消费者(未测)。裁后执行(你不用管)
回一个字母即可。A → 入队
domain:engine,按条款②契约复审档派发(响应契约变化),cloud 消费端补丁随后收敛到新字段;B → 降为 test-only pin 卡直接入队;C → 需另评估现有调用方,默认不推荐。
Generated by Claude Code
Marker re-post (sanitizer ate the code-span spelling in the analysis comment above — read-back caught it): the machine-findable facet-block marker for this card, in surviving spelling —
<!-- os-decision-facets -->
The four-facet block itself is in the analysis comment above (四轴(业务立场) section). Recommendation A, fallback B, one-letter reply suffices.
Generated by Claude Code
Maintainer ruling (2026-08-21, live PM chat — recorded by the triage seat)
Verbatim (untranslated): 「其他接受」 — accepting the triage recommendation presented for this card in the decision discussion: A + pin (the "C-lite" composite): add a first-class discriminant (
reason/ no-op field) to thepublishPackageDraftsresponse, add the missing no-op audit/log line so that exit is no longer traceless, and pin the invariant "every non-success return carries at least onefailed[]entry" with a test so existing consumers' discrimination stays reliable during the transition.State:
needs-user-decision→pm:queue(domain:engine), dispatchable.Dispatch notes: response-envelope widening ⇒ Clause-②: yes, contract-review tier applies. Follow-up owed to the consumer side once merged: cloud's
failed.length > 0discrimination (cloud#1492) should converge on the new discriminant — the accepting seat files that card in cloud per the cross-repo rule.
Generated by Claude Code
- added a commit that references this issue
on Aug 23, 2026
What
publishPackageDrafts(packages/metadata-protocol/src/protocol.ts) ends withso a publish that had nothing to promote — no pending drafts for the requested
packageId— resolves{ "success": false, "publishedCount": 0, "failedCount": 0, "published": [], "failed": [] }That is the same
success:falsea genuine ADR-0067 D2 refusal answers with. One boolean is carrying two facts ("nothing was refused" and "nothing landed") and the caller cannot tell them apart fromsuccessalone.Why it matters
The no-op path is also the only
publishPackageDraftsexit that leaves no trace at all. Both refusal returns (the pre-flight block and the Phase-1 unwind) writerecordMetadataAuditrefusal rows; this one writes nothing. So a caller that readssuccess:falseas a refusal reports a rollback, and an operator who then goes looking for the rollback in the audit trail and the server log finds neither.Measured on the cloud side (objectstack-ai/cloud#1488, fixed consumer-side in objectstack-ai/cloud#1492): the AI Studio publish tools graded that shape as a rollback and told users "发布被拒绝并整体回滚" over turns whose metadata was entirely
state='active', then burned two automatic repair rounds re-authoring artifacts that were already correct. The cloud fix discriminates onfailed.length > 0— which works only because today both refusal returns happen to build a non-emptyfailed[]. That is an invariant no test states and nothing pins.Suggested shape (not a ruling)
Make the answer say which of the two it is, rather than making every caller infer it from
failed[]:publishedCount === 0 && failedCount === 0given a first-class name), orreason/ discriminant on the envelope, orfailed[]entry") with a test, so consumers may rely on it.Any of the three is fine; the thing worth deciding is that the distinction becomes part of the contract instead of a shape consumers reverse-engineer. Worth pairing with a log line on the no-op path so the exit is not invisible.
Filed from cloud#1488 by the dev agent that fixed the consumer half. No assignee.