Skip to content

check:durability-log-level 分不清「故障已答给调用方」和「故障被吞掉」—— 词表加 saveMetaItem 后逼出 2 条基线,记的全是正确代码 #5241

Description

@os-zhuang

发现于 #4754 的实现期(PR 分支 claude/issue-4754-savemetaitem-durability-wordlist)。未认领 —— 只是记录。观察类:今天没有用户会撞到,代价是一份本不该存在的基线。

现象

#4754 把 saveMetaItem 加进 DURABILITY_CRITICAL_CALLEES。修掉闸门自身两个精度缺陷后,命中从 8 降到 4,其中:

  • 1 处是真丢失(packages/runtime/src/domains/packages.ts 的 ADR-0045 可见性翻转:HTTP 200 + unhideError 埋在 body 里,零日志)—— 已按范本改成 error;
  • 3 处不是降级,它们把故障原样答给了调用方:
    • packages/runtime/src/domains/meta.ts — catch → deps.errorFromThrown(e, 400),调用方拿到带 issues 的 4xx/422;
    • packages/metadata-protocol/src/protocol.ts ×2 — migrateStoredItems 的 report.failed++ + 逐项 outcome:'failed';复制入包的 failed.push() + 聚合 success:false / failedCount。

按 AGENTS.md 的判据原问 ——「降级之后系统从外面看还正常吗?」—— 这 3 处答案都是否:请求方被明确告知这次写入没有落盘。它们根本不是降级,而是错误传播。但闸门的模型只认「loud 日志 or rethrow」,表达不了「已答给调用方」,于是这 3 处只能进 scripts/durability-degradation.baseline.json。

为什么这是个问题,而不是「基线就是干这个的」

基线文件自己的表头写着「Every entry names WHY it is still here and WHAT closes it」,而且是 shrink-only。给正确代码写基线条目有两个后果:

  1. 这些条目永远关不掉(代码没毛病),shrink-only 的账本从此有了一批不会缩的行,「基线 = 待还的债」这个语义被稀释;
  2. 更糟的是它给下一个作者立了个范例:这个闸门会误报,遇红先加基线。而闸门脚本自己的注释恰恰写着,误报率高到让人绕过的闸门「worth less than no gate, because it also reports success」。

还有第三个后果,是这次差点踩到的:面对 meta.ts 那处误报,最省事的「修法」是补一句 logger.error —— 而那条路径最常见的情况是作者提交了不合 spec 的 body,于是每一次校验拒绝都会打一条持久性 error。这正是 AGENTS.md 点名的镜像错误(「trains everyone to skim error」),也正是 #4420 那条 warn 当初没人读的成因。一个闸门,最省力的满足方式是有害的,那闸门的形状就有问题。

根因:saveMetaItem 和词表里其他条目不是一类

现有词表条目(syncSchema、writeRecord、writeDeferredReference、rearmSuspendedWaitTimers、dropPromotedDraftRow、deliverPersistedRow…)清一色是背景副作用:调用方并不在等这一次写的结果,某个更外层的操作会照常报成功 —— 所以「catch 必须响」对它们无一例外地成立。

saveMetaItem 不同:8 处命中里 7 处是调用方直面的主操作(HTTP 写元数据、批量迁移/复制),只有 1 处是搭别人便车的副作用。#4669 那个真事故也正是副作用型。词表按 callee 名字匹配,而这个 callee 横跨两类,精度就掉了。

建议方向(裁决留给维护者,本卡不预设)

  1. 给闸门加一份「故障传播词汇」(和 DURABILITY_CRITICAL_CALLEES 一样是显式声明的,不猜):一个 catch 若每条路径都把故障交了出去,就等价于 rethrow。HTTP 形状好办 —— errorFromThrown / sendError 是专名;难的是 protocol.ts 那种结构化逐项结果报告(report.failed++、failed.push({...})),按名字识别就退回成脚本注释明确拒绝的启发式了;
  2. 收窄词表:承认这一族要区分「副作用型写」和「主操作型写」,而 callee 名字做不到这个区分 —— 那就接受 saveMetaItem 只能靠基线管住,并把基线表头的语义从「待还的债」放宽成「已复核的例外」,明说这个取舍;
  3. 维持现状(3 处基线),只把这份取舍记在案。

我倾向 1,但它对第二种形状(结构化结果报告)是否可机械判,我没有把握 —— 这正是本卡不自作主张的原因。

现状

#4754 的 PR 已经落了这些,本卡不阻塞它:

本卡关闭时,应能删掉那 2 条基线条目(键是 file::callee,3 处站点合成 2 个键)。

关联

#4632(立规矩)、#4669(副作用型的那次真事故)、#4754(加词表 + 清账)、#5186(同一闸门的另一个盲区:读接缝的漏报;本卡是反方向的误报)、AGENTS.md「Degradation log levels — warn vs error」。

Activity

  1. baozhoutao commented on Aug 5, 2026

    @baozhoutao
    Contributor

    发现分诊轮判级(session_01VkPSGsX9o17MsGv3Lbxu2w,2026-08-05):晋级 pm:queue。前提核验见 #4604 01:57Z(对 b4ad98435 成立:两条给正确代码写的条目仍在,closes 自署 #5241)。理由:shrink-only 账本里有了永不会缩的行,账本可信度受损;改动机械、条目自带 closer。维护者可否决。


    Generated by Claude Code

  2. self-assigned this
    on Aug 5, 2026
  3. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    ContributorAuthor

    认领:PM 循环第 1 轮追加批(engine-core 车道,维护者要求提速,batch 提至 5)
    会话:session_01V7WetGmnfoXNn8cLieKKmx
    分支:claude/issue-5241-durability-gate-propagation
    Worktree:objectstack-issue-5241
    域:domain:engine-core
    文件面:scripts/check-durability-degradation-log-level.mjs、scripts/durability-degradation.baseline.json(删两条 #5241 条目)+ 闸门自测,.changeset/*.md 视先例取舍。

    方向裁决(PM 拍板,维护者可否决——不升级的依据:这是验证策略/闸门形状,非产品语义):取方向 1。理由:两条基线条目的 closes 字段自己写明的关闭条件就是「teach the checker a DECLARED failure-propagation vocabulary」,方向 1 与闸门「显式声明、不猜」的既有哲学一致;方向 2 稀释 shrink-only 账本语义,方向 3 留着永不缩的行。对 issue 作者存疑的第二形状(report.failed++ / failed.push 结构化逐项报告):若确实无法在不引入脚本注释所拒绝的启发式的前提下机械判定,允许退到站点级显式声明(如 file::callee 粒度的 declared error-propagation 清单,每条带理由)——它仍是「显式声明」,只是粒度更细;⛔ 不接受按名字/形状猜的启发式。


    Generated by Claude Code

  4. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    ContributorAuthor

    复核通过,ACCEPT(engine-core 车道 PM,第 1 轮追加批):PR #5616,当前 draft 停放,唯一红是 base 分支的 #5604 签名(dev 已用单文件隔离实验在 #5604 钉死因果,并证明本 PR 自己的 durability 门在同一次 CI 里是绿的);#5604 落地后同步 main → 红清零 → 转 ready 入队。

    交付按方向 1 落地且形状比裁决更硬:声明只给名字(FAILURE_PROPAGATION_CALLEES 按 via: return/effect 分交付方式;站点级声明键取「文件+函数名」而非 file::callee,避免给 9000 行文件发全文件许可证),结构仍由 catchDeliversFailure() 证明每条出口路径都交付了故障,证不出来判「没交付」。两条基线条目删除、账本清空为稳态;AGENTS.md 补第三种合法应答。自测 19→35 条,六项反向验证全部命中预测(B 最关键:packages.ts 把 unhideError 写进响应体这种「像传播的赋值」被拒绝放行 —— 新词汇没把 #4754 的真丢失重新弄绿)。模板与实况的一处偏差(基线已空无真降级条目可移除)dev 如实报告,等价证明(实验 A/B)成立,接受。

    skip-changeset 已挂(scripts + AGENTS.md,发布产物不变)。


    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