Skip to content

feat(scripts): durability 闸门新增「读接缝编造空值」规则 —— #4728/#4825/#5108 这一族终于有闸门了 (#5186) - #5986

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-5186-durability-read-seam-gate
Aug 6, 2026
Merged

feat(scripts): durability 闸门新增「读接缝编造空值」规则 —— #4728/#4825/#5108 这一族终于有闸门了 (#5186)#5986
hotlong merged 1 commit into
mainfrom
claude/issue-5186-durability-read-seam-gate

Conversation

@hotlong

@hotlong hotlong commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #5186

按维护者 2026-08-06 10:44Z 的「裁 3——收窄先行」落地。

前提复核(先证再做)

三条事实全部在 origin/main 上核过,issue 的判断成立:

issue 说 origin/main 实况
词表清一色写/DDL 专名 DURABILITY_CRITICAL_CALLEES 现有 13 项(issue 写的 9 项之后又长了 4 个),syncSchema / initObjects / writeRecord / dropPromotedDraftRow / saveMetaItem / persistAuditTrailRow …,无一读语义
闸门判据是「日志级别」 analyzeSourceFileloud/quiet/rethrow/propagates,读接缝的 catch { return [] } 在这四个维度上全是空的
#5108 修完的形状可直接当正面 fixture DatabaseLoader 五处读现在都走 rethrowUnlessTableUnprovisioned(error),nextEventSeqif (isMissingTableError(error)) return 1; throw error;

这一版做了什么

同一个脚本、同一个 CI step 里加了第二条规则,与第一条不共享词表、不共享 baseline、不共享判决:

try 块里有一次(IDataDriverfind/findOne/count,或同文件薄封装)
catch 一行日志都没有
+ 某条路径 return 了一个空值/零值([] / false / null / undefined / {} / 空字符串 / 0 / 1)
+ 这条路径没有按错误类型区分过
→ 红

判据的关键不在「调了什么」,而在「没读到,却编了一个答案出来」。

豁免就是 #4825 / #5108 修完留下的形状:isMissingTableError(packages/metadata/src/errors.ts)。行内写或经 rethrowUnlessTableUnprovisioned 这类「重抛式 guard」都认,两种极性都认:

catch (e) { if (isMissingTableError(e)) return []; throw e; }   // 认
catch (e) { if (!isMissingTableError(e)) throw e; return []; }  // 也认
catch (e) { this.rethrowUnlessTableUnprovisioned(e); return []; } // 也认(会跟进 guard 体)

豁免是声明的、且仍要结构证明:

扫描面收窄到 packages/metadata / packages/metadata-protocol / packages/objectql。收窄不只是成本考虑:find/findOne/count 是彻底通用的名字,是 SCOPE 让它们意味着「存储缝」而不是「任意数据读取」

度量与 baseline

扫描面内 64 处读接缝,3 处命中,全部进新的 shrink-only scripts/durability-read-invention.baseline.json(本 PR 是闸门单,文件面 scripts/ + .changeset/,不改被扫的包):

条目 verdict 说明
protocol.tslistCommits unfixed-degradation commit store 读不到答成 [],JSDoc 自己写着这是设计 → 已立 #5980
engine.tsseedAutonumber unfixed-degradation #4825 那一半的活体:读失败从 0 起号,与既有行撞号,零日志 → 已立 #5979
engine.tsreferenceExists reviewed-legitimate null 不是编造:签名是 Promise< boolean or null >,JSDoc 明写「false 只在探测跑成功且没找到时,null 是根本没跑成」,故障是经返回值交给调用方的。规则读语法不读返回类型,故记录复核结论而不是放宽规则

baseline 的 key 是 file::enclosing function,不是 file::callee——callee 永远是 find,而 protocol.ts 九千行、engine.ts 五千行,文件级许可的盲区大到能藏下一个 #5108。这与 FAILURE_PROPAGATION_SITES 的粒度取法同源。

反向验证(方向事先预判为「红」,结果符合)

#4825 + #5108 的修复全部还原(去掉 5 处 guard 调用、把 nextEventSeq 改回 catch { return 1 }),闸门在它们当年修的那 6 处里红了 5 处:

✗ 5 read seam(s) invent an empty answer for a read that failed, and tell nobody
  database-loader.ts:307  (in nextEventSeq())
  database-loader.ts:805  (in loadMany())
  database-loader.ts:827  (in exists())
  database-loader.ts:866  (in stat())
  database-loader.ts:893  (in list())

第 6 处 load() 看不见,这是如实记录的局限,不是漏测:它返回的是 { data: null, loadTime: … } —— 空答案被包在信封里,而规则读的是被 return 的表达式。放宽成「对象字面量里有一个空值属性就判」会把三个包里每一个 result envelope 都按其中一个属性判掉,那个误报率是会让闸门被关掉的量级(而被关掉的闸门比没有闸门更糟,因为它还报成功)。信封形状的空答案需要另一条判据(声明的返回类型),不是这一条的松版。脚本头部把这条与另外两条局限一起写在「Honest limitations」里。

baseline 的 shrink-only 也双向验过:删掉一条 → 红(该 seam 重新报出);加一条对不上的 → 红(stale entry 报出)。

为什么放在同一个脚本里

「独立规则」按裁决是指不走 DURABILITY_CRITICAL_CALLEES 词表(判据独立),不是指独立文件。放在同一脚本换来三件事:

  1. 零 CI 接线:check:durability-log-level 本来就是「self-test && audit」,两条规则都被覆盖,package.jsonlint.yml 一行不动(lint.yml 属 spec-tooling 座位在飞面,本单 ⛔ 不碰);
  2. 共享一次 AST pass 与 walkSameTick / loggerLevel / indexFunctionBodies 等工具;
  3. 「catch 里有没有日志」这个问题被抽成了 collectLoggedLevels() 给两条规则共用——两份拷贝就是两套「这个 catch 说过话」的事实来源,会照着 @objectstack/metadata/errors 存在要防的那种方式漂移。

两条规则的报告、baseline、staleness 检查各自独立,任一条红不遮蔽另一条。

自检

脚本自带 fixture 从 35 → 61 例(原 35 例全部保持绿,验证 collectResponse 抽取无回归),新增 26 例全部来自仓库真实代码而非想象:通过样例是 #4825 / #5108 修完的代码,命中样例是它们修之前的形状。一个已经复发三次的族,闸门必须双向钉在这三次实例上,否则第四次照样绿灯过。

关于 changeset

本 PR 只动 scripts/(根 package 是 private: true,scripts/ 不进任何发布产物),不发布任何东西,故按 pr-automation.yml 明示的路线 2 走 skip-changeset 标签,而不是写一个不指名任何包的空 changeset(路线 3 是 last resort,#4898 那次静默停发的原因)。

越界说明


🤖 Generated with Claude Code

https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3


Generated by Claude Code

`check:durability-log-level` graded ONE axis — how loud is the catch — which
is structurally unable to see the other half of the #4632 family. A read seam
does not fail by logging too quietly; it fails by `catch { return []; }`: no
log to grade, and an answer invented for a read that never happened. The
vocabulary model comes apart there too, because a read's callee is
`find`/`findOne`/`count`, names too generic to declare repo-wide.

The same shape has recurred three times in one package (#4728 -> #4825 ->
#5108), every one found by a human and none by the gate.

Second rule, in the same script and the same CI step, sharing no vocabulary,
no baseline and no verdict with the first:

  try block performs a READ (IDataDriver find/findOne/count, or a same-file
  wrapper over one) + catch logs NOTHING + some path returns an empty/zero
  value ([] / false / null / undefined / {} / '' / 0 / 1) that was NOT
  reached by discriminating the error TYPE  ->  red.

The exemption is the shape #4825 and #5108 left behind: `isMissingTableError`
(packages/metadata/src/errors.ts), inline or through a rethrowing guard such
as `rethrowUnlessTableUnprovisioned`, with polarity handled both ways. It is
declared, never inferred, and still structurally proved: a hand-rolled
`e.code === '42P01'` does not exempt, and a discrimination that returns the
empty value on both branches does not either.

Scan scope is narrowed to packages/metadata, packages/metadata-protocol and
packages/objectql per the maintainer's 2026-08-06 ruling; the narrowing is
what makes the generic read names mean "a storage seam" at all.

Measured: 64 read seams in scope, 3 findings, all baselined in the new
shrink-only scripts/durability-read-invention.baseline.json - two real
degradations tracked as #5980 and #5979, one reviewed as legitimate (a
declared tri-state `null`). Reverse-verified: reverting the #4825 and #5108
fixes turns the rule red on 5 of their 6 seams; the sixth returns an envelope
rather than a bare empty value and is documented as a limitation.

No CI wiring change: `check:durability-log-level` already runs the self-test
and the audit, and both now cover both rules.

Fixes #5186

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3
@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 6, 2026 1:57pm

Request Review

@hotlong hotlong added skip-changeset PR has no user-facing published change; bypasses the changeset gate size/xl and removed size/xl labels Aug 6, 2026 — with Claude
@hotlong
hotlong marked this pull request as ready for review August 6, 2026 14:11
@hotlong
hotlong added this pull request to the merge queue Aug 6, 2026
Merged via the queue into main with commit d436b17 Aug 6, 2026
30 of 31 checks passed
@hotlong
hotlong deleted the claude/issue-5186-durability-read-seam-gate branch August 6, 2026 14:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/xl skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

check:durability-log-level 结构性看不见「读接缝把故障答成空值」这一类 —— #4825 / #5108 全家都在闸门盲区里

2 participants