Files
iomgaa 4f8812fa82 docs(design): 落成边界、续跑、公共 API 形状与停止语义四份决策
第 ② 阶段需求对齐与第 ③ 阶段架构的产出,代码尚未开始。

design/0001 定边界判据:三道测试(时机 / 信息 / 性质)全过才在界内,
外加「只认接缝、不认接缝后面是什么」与不夺走下游实验因子的排除条款。
design/0002 定步级续跑:不承诺原子性,承诺绝不静默丢失与不替工具猜幂等性;
先写意图再执行、结果 ID 预分配、重放策略由工具声明且默认绝不重放。
design/0003 定公共 API 形状:单一入口两个动词、五个接缝、三个伪接缝的排除理由、
分层与九条依赖规则。design/0004 定停止判定顺序、十个停止原因取值与步记录字段表。
0003 与 0004 需过 CLAUDE.md §2 人类门,已由项目负责人确认,状态转为已接受。

explanation/scope.md 与 explanation/architecture.md 是这四份决策的常青回写,
分层与模块边界的权威在 architecture.md,将来由 import-linter 契约机器断言。
migrations/ 下 dissect 是唯一的硬迁移验收,govdoc-saas 只做设计级对齐。

三道闸都过了:14 agent 对抗辩论定骨架,两轮硕士生阅读报的 30 余条已修完,
Codex 对抗审查抓出的两条致命问题(提交型完成被误判成环境故障、
崩溃恢复漏一个状态)已修,修完的形状还没送 Codex 复审。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:48:33 -04:00

350 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# research-wiki 是什么,各个目录收什么
这里是本项目除代码之外的绝大部分文档。这一份说明它怎么组织、你要写的东西该放哪。
两个例外留在仓库根目录,因为它们要在克隆仓库的第一眼就被看到:`README.md`(项目概览、消费者与
当前进度)和 `CLAUDE.md`(协作约定)。除这两份之外,新写的文档都进 `research-wiki/`
先说结论,赶时间的话看完这张表就够:
| 目录 | 收什么 | 会不会被改写 |
|---|---|---|
| `explanation/` | 为什么这样设计 | 会 |
| `migrations/` | 每个下游项目迁走什么、迁完算不算数 | 会 |
| `reference/` | 查得到的事实:日志字段、遥测口径 | 会 |
| `guides/` | 怎么做某件事:发布、排障、本地环境 | 会 |
| `design/` | 动工前的方案与权衡,写完冻结 | **不会** |
| `scratch/` | 一次性草稿,由人在每轮工作会话结束前清理 | 不适用 |
`reference/`(仓库根目录那个,不是本目录下的)不在表里:那是参考资料,不是本项目的文档,
理由见 `../CLAUDE.md` §0。
下面解释这个划分是怎么来的,以及为什么值得遵守。
---
## 1. 两层,判据是「发现它不对了,你会改它还是留着它」
前五个目录分成两层,界线是这一句:
> **如果这份文档和事实对不上了,你会去把它改对,还是原样留着?**
会去改对的是**常青层**`explanation/``migrations/``reference/``guides/`)。它的职责是描述
当前的真实情况,一旦不符就是在说谎,必须修。「常青」(evergreen)是文档领域的常用说法,
指一份需要长期保持有效的文档,与之相对的是写完就归档的一次性文档。
原样留着的是**记录层**`design/`)。它记录的是某个时刻我们知道什么、决定了什么。就算后来
证明当初判断错了也不改——因为它的价值正在于保存「当初的判断是什么」。写完就冻结。
**为什么判据是这一句,而不是「描述现在还是描述过去」。** 后者听起来更直观,但切不动一类
很常见的文档:一个外部工具的固有行为(比如某个命令的输出会被缓冲),它既是现在的事实
也是当时的事实,按时间根本分不开。而用上面这句一问就清楚了——如果那个工具升级后行为变了,
你当然会去把文档改对,所以它属于常青层。
同理,一次设计决策做完的当天,它的理由既是「当时」也是「现在」,时间判据同样失效。但如果
三个月后这个决策被推翻,你不会回去改那份 design doc,而是新写一份——所以它属于记录层。
### 为什么必须分开
因为两层的维护规则是互相排斥的,混在一起没法同时成立:
- 常青层的规则是「改代码时必须同步改它」。
- 记录层的规则是「写完就冻结」。一份三个月后被人改过的决策记录,已经没法回答
「我们当初为什么这么定」了——它变成了「我们现在觉得当初应该这么定」,而这两件事在
排查历史问题时差别很大。
同一个目录挂不上这两条规则。不分开的实际后果,CHSAnalyzer 的上一版演示过:因为没人区分
哪份该更新、哪份不该更新,结果是全都不更新。那一版的 `API.md` 落后代码 210 个提交,
`CLAUDE.md` 落后 676 个提交,而两份文档当时都写着要保持同步。
### 两层的写法不一样,写成一样就说明有一层写错了
同一件事在两层各出现一次是正常的:分层是什么、接缝有哪些,`design/` 里定过一遍,
`explanation/` 里还要描述一遍。**重复的是事实,不重复的是理由。**
| | `design/` | `explanation/` |
|---|---|---|
| 回答什么 | 当初为什么这么定 | 现在到底是什么样 |
| 时间 | 有时间性:当时的处境、比较过哪几个、否决了什么 | 无时间性,只描述现在 |
| 主语 | 我们、这次决策 | 系统里的东西:这一层、这个接缝、这条规矩 |
| 备选方案 | 必须写全,含否决理由 | 不写——备选方案是历史 |
| 理由 | 完整论证 | 就地一两句,展开指回 `design/` |
| 冲突时 | 冻结,可能已经过时 | **以它为准** |
**记录层写成法条就走音了。** ADR 实践里这个反模式有个名字叫「Blueprint or Policy in
Disguise」——本该是一份记录活动及其结果的日志,写着写着变成了菜谱或者法条那种命令式、
权威式的口吻。design doc 该像一份设计讨论的笔记:叙述、有处境、有取舍。
**常青层写成自述也走音了。** 主语一旦变成「这份文档」「这一节」「这里」,动词一旦变成
写作动作(不复述、列出来、说清楚、免得读者),语气就错了,**哪怕那句话本身有道理**。
改法是把主语换回系统。判据在语气,不在内容——那种句子往往真的带着信息,按「删掉之后信息
有没有少」来判会把它留下来,而它照样读着别扭。
**常青文档里的决策索引只索引、不复述理由。** 复述会漂移:旧文档冻结着,索引里那句转述
却跟着人的记忆变,几个月后两处就对不上,而机器查不出来。
## 2. 常青层:四类,各自要有更新触发点
前三类的划分借自 Google 的工程文档实践,以及 Diátaxis——一个把文档按「读者此刻想干什么」
分成教程 / 操作指南 / 参考 / 说明四类的文档组织框架,读作「迪亚塔克西斯」。两者对这几类的
切法基本一致。第四类 `migrations/` 是本项目自己加的,理由在它自己那一节。
本项目**故意没有「教程」那一类**。教程是带零基础的人走完一遍完整流程的入门材料,它的成本
很高(每次流程变动都要重走一遍验证),而本项目目前的协作者都已经在项目里了,没有真正的
新手入门场景。等真的需要时再建 `tutorial/`
划分的意义在于**一份文档不要同时追求两个目标**——参考手册里插一段设计动机,查参数的人会
被打断;设计说明里塞满字段表格,想理解全局的人会被淹没。
### `explanation/` —— 为什么是这样
回答「能讲讲 X 吗」。分层为什么这么切、某个约束为什么存在、某个坑背后的机制是什么。
这类文档允许有观点,也应该写清楚考虑过哪些替代方案。
本项目最重要的一份常青文档 `explanation/architecture.md` 会在这里,它说明分层、依赖方向和
抽象接缝。它的触发点是第 1 档(由 `pyproject.toml` 的 import-linter 契约断言,文档和代码
对不上 `make lint` 直接失败)。
**这份文档会先于代码存在。** 第 ③ 阶段的正题就是把架构理清楚,而理清楚的产物只能是文档;
如果要求文档必须落后于代码,那这个阶段就没有产物,只能并进实现阶段,变成「边写边想」。
代价是那段时间它没有机器兜底——契约要等 `src/` 落地才写得出来。对策有两条:文档开头放一段
醒目的状态说明,写清楚它描述的是目标而不是现状;实现时**在同一个提交里**把守着这块代码的
那条契约加上,不是同一个 PR,是同一个提交,因为提交是能被单独回退的最小单位。
### `migrations/` —— 每个下游迁什么、迁完算不算数
一个下游项目一份。内容是删除清单(它那边哪些代码由本库继任)、组件映射(旧的哪个类对应
新的哪个接缝)、以及验收口径(迁完之后跑什么算通过)。
**这一类单独设,而不是塞进 `guides/`**,因为它的读者和用途都不一样。`guides/` 的读者手上
有一件确定的活要干,看完就照做;`migrations/` 的读者在判断「本库现在够不够用」——它同时是
本库的验收标准和边界证据。本项目的核心验收标准就是能不能搬回 dissect、能不能替代掉
GovDoc-SaaS 的 `docagent-core/`,把这件事藏在操作指南里会让它看起来像可选的工序。
触发点是第 2 档:改公共 API 时同一个提交里改它。它不设长度上限——删除清单本来就该写全,
砍长度只会让它变得不可信。
**真正的验收不是这份文档,是把下游迁过来跑它的测试。** 文档只是清单和审计记录;一份写着
「已完成迁移」而没有人真跑过下游测试的迁移文档,说明的只是我们相信自己做完了。
### `reference/` —— 查得到的事实
回答「X 的取值是什么」。日志字段契约、遥测口径。特点是读者已经知道自己要找什么,只是来
核对,所以它要准确、完整、好检索,不需要循循善诱。
**有一类看起来该收在这里、但故意没有收的事实**:公共类型的字段、不变量与枚举取值。它们的
权威是代码本身,不另写一份文档复述——那份文档不重复代码的内容太少,而它腐烂的速度和代码
一样快。哪类事实的权威在哪,`../CLAUDE.md` §0 的表格是总索引。
### `guides/` —— 怎么做某件事
回答「我要做 X,步骤是什么」。发布流程、本地环境搭建、排障。读者手上有活要干,所以只给
能达成目的的路径,不展开讲原理——原理放 `explanation/`,需要时链过去。
发布流程是这一类里最重要的一份,因为它的每一步都是欠账换来的:PolyGateway 有两个版本
完成了版本号 bump 与 CHANGELOG 却从未上传,registry 长期停在旧版,下游 `pip install` 拿不到
任何修复且无人发现。
### 每一类都必须写明更新触发点
**「更新触发点」指的是:什么事情发生时,这份文档一定会被改。** 这是常青文档不腐烂的唯一
可靠机制。没有触发点的常青文档,靠的就是「大家记得更新」,而这件事在 CHSAnalyzer 上一版
已经失败过一次。
触发点的强度分三档,能用强的就不用弱的:
1. **机器断言**(最强)。文档说的和代码不符,CI 直接失败。例如架构文档由 import-linter 契约
断言分层,日志字段契约由结构化日志的测试断言。
2. **同 PR 同改**(次强)。改某块代码时,改文档是同一个提交的一部分。这是 Google 的做法,
好处是不依赖任何人事后记得。
3. **定期复审**(最弱)。**每三个月**看一眼,并在文档头部更新「最后复审」日期。周期必须写死,
否则读者拿到那个日期也算不出文档过没过期,这一档就等于没有。三个月这个值取自 Google 的
做法,本身没有特别的道理,重点是它是个确定的数。只在前两档都做不到时才用这一档。
外部工具的固有行为(例如某个命令的缓冲方式)也属于常青层,它的触发点通常是第 2 档:
我们依赖它的那条规则改了、或者那个工具升级后行为变了,就在同一个提交里改这份文档。
**写一份新的常青文档时,要在文档开头写明它属于哪一档、触发点具体是什么。** 写在开头而不是
集中在一张索引表里,是因为索引表本身也会腐烂——它会漏掉新加的文档,而写在文档自己头上的
东西,改这份文档的人一定会看到。
如果三档都想不出来,那说明这份文档不该写成常青的——要么它其实是记录层的内容,要么它不该存在。
### 第一节必须从一个看得见的东西起步,词表不许挡在正文前面
这条是 CHSAnalyzer 踩出来的,它量了自己五份 `explanation/` 文档,规律很干净:好读的三份都
从一个具体的东西开场(一个临床问题、一张处理线的图、一个「命令跑完了没有输出」的现象),
难读的两份都从一个抽象的定位开场(「队列入口那层代码非常薄」、整节在讲「我和另一份文档的
分界线在哪」)。**而且和长度无关**:五百多行那份没人说难,六百行那份读不动。差别不在长短,
在第一页给了读者什么。
所以定两条:
**一、第一节必须从一个读者已经能看见的东西起步**——一个真实的问题、一张图、一个会出错的
场景。让读者先站稳,再开始学词。**不要用「本文和某某文档怎么分工」当第一节**:那种内容
对已经读过别的文档、正在纠结该翻哪份的人有用,对第一次打开的人是纯负担,把它放到词表后面去。
**二、词表不许挡在正文前面。** 具体是两件事:正文开始前不要列一串「这些词请先去别处看」;
词表本身如果超过十条,要在开头说清「哪几个现在就得记住、其余读到再回来查」。
**还要写明假设了什么背景知识**,而不只是「要先读哪几份文档」。读者读不懂的时候,得能判断是
自己缺背景还是文档写得烂——不写清楚,他只会怪自己。
这条没有机器能查,靠 `../CLAUDE.md` §3 那轮「硕士生阅读」评审时专门看一眼第一节。
**评审时要额外问一句:这份文档假设的读者是谁,项目负责人算不算在内。** CHSAnalyzer 那边
四轮评审都是以「相关领域的硕士生」的角色做的,它们能读懂那份并发文档,而项目负责人读不懂——
这说明评审的读者假设定窄了,而文档自己也没把这个假设写出来。
## 3. 记录层:`design/`
一份 design doc 记录一次决策:当时的处境、做了什么选择、否决了什么、代价是什么。
**必须在动工前写。** 不是因为流程要求,而是因为事后补写的方案文档会被已经知道的结果污染:
你会不自觉地把当初没想清楚的地方写得很笃定,把真正纠结过的备选方案一笔带过。那样写出来的
东西读起来像是一路顺理成章,也就失去了它唯一的用途——让后来的人看清当初在什么信息条件下
做的判断。
**命名**`NNNN-短标题.md`,四位编号递增,例如 `0001-xxx.md`。用递增编号而不是日期前缀,
是为了让 `supersedes: 0001` 这样的引用能指向一个短而稳定的名字;日期前缀在文档之间互相引用时
又长又难记。
**目录为什么叫 `design/` 而不是 `adr/`。** 机制继承自 ADR 传统(见下一段),但这里装的不只是
架构决策——接缝取舍、公共类型的形状、验收口径的定法都放这里,而 `adr/` 这个名字会让人以为
只收架构类的东西。`design doc` 是 Google 工程实践里的叫法,覆盖面更宽。
**`design/` 是中间产物,不是最终产物——这一条最容易漏,而且漏了不会有任何提示。** 后续开发
对着的是**常青层**:写代码的人读 `explanation/`,不会为了写一行代码去翻 `design/`。所以一份
design doc 冻结的时候,它的结论必须已经落到两处之一:
- **代码**(含它的测试),或者
- **常青文档**——如果代码还轮不到写。
**两处都没有,这份 design doc 就是死的**:它自己说决策已接受,而权威处(`../CLAUDE.md` §0
那张表)还写着老样子甚至写着「还没定」,于是照权威处读,这件事至今没定。**这不是「以后补」,
是当场就已经错了**——两层直接矛盾,而 §0 明令冲突时禁止自行调和。
**代码还没到写的时候,常青文档照样能写。** 常青层的职责是描述当前的真实情况,而「这个机制的
形状已经定了、代码还不存在」本身就是一种真实情况——写清楚形状,再写明它还没有代码、缺口在哪,
就够了。
**冻结**:写完不改。决策变了就新写一份,旧的原样留着。这是从 ADRArchitecture Decision
Record,架构决策记录,一种把每次架构决策单独存成一份不可修改文件的做法)里保留下来的
唯一一条机制。成本很低,但记录层的价值全靠它——只有旧文档还在,你才能看出决策是怎么演变的。
### 新文档和旧文档之间是什么关系,在头部写清楚
一份新的 design doc 很少是凭空长出来的,它多半跟已有的某几份有关系。**只有「整份作废」
一种关系是不够用的**——大多数时候动的只是旧文档里的一小块,而那份文档的其余部分还在生效。
所以关系词有五个,每个都写成 `**关系词** 目标 + 具体到哪一节`
| 关系词 | 什么时候用 | 必须同时写清楚 |
|---|---|---|
| **取代** | 推翻旧文档的某个结论 | 取代的是哪一节;那份文档的其余部分是不是仍然有效 |
| **补充** | 沿着旧文档的某条决策继续往下定 | 补充的是哪几条决策 |
| **回答** | 填掉旧文档明写「还没定」的坑 | 回答的是哪一节留的坑 |
| **触及** | 本文的结论要回写进哪份常青文档 | 回写到哪一节;**不回写这份 design doc 就是死的** |
| **不取代任何文件** | 填的是一块从来没人填过的空白 | 为什么这块空白到现在才填 |
「取代」必须点名其余部分仍然有效,否则读者会以为整份旧文档作废了,连那些还在生效的决策
一起丢掉。
**旧文档不回标。** 被取代的那份不加任何指向新文档的痕迹——加了就是改内容,而记录层的价值
全在于它没被改过。读者靠常青文档里的**决策索引**找到当前有效的那份,而不是靠在 `design/`
目录里翻。所以决策索引是常青文档的必备一节,它只索引不复述理由。
代价说清楚:直接跳进某一份旧 design doc 的人,有可能读到一个已经被取代的结论,而那份文档
里没有任何东西提示他。**这是接受了的风险**,换来的是记录层真的不可变。降低风险的办法是让
常青文档成为入口——任何一个问题,先在常青层找到答案,再顺着决策索引跳进 design。
### 状态字段
头部写 `**日期** YYYY-MM-DD · **状态** X`。状态只有两个取值。
**待确认**——这份 design doc 定的东西按 `../CLAUDE.md` §2 要过人类门。**这种文档必须在正文
最前面写明:在它被确认之前,什么不许做。** 只写「待确认」是不够的,那让读者无从判断这份
文档算不算数;写清楚被阻塞的是哪件具体的事,这个状态才有操作意义。
**已接受**——确认之后改成 `已接受(YYYY-MM-DD 项目负责人确认)`,并在正文留一句说明它
原来是待确认、为什么要过门。**改状态字段不算破坏冻结规则**,因为决策内容一个字没动。
不需要过门的 design doc 写完直接就是「已接受」。
**和 `explanation/architecture.md` 的分工**(这两份最容易搞混):改一次架构,两份都要动,但写的
东西不同。design doc 写「我们当时面对什么问题、比较了哪几个方案、为什么选了这个、放弃了什么」,
写完冻结。`architecture.md` 写「现在的分层长什么样、每层的职责和依赖方向是什么」,它永远只描述
当前状态,上一版的样子不在里面。简单说:想知道**为什么变成今天这样**去翻 `design/`,想知道
**今天到底是什么样**去读 `architecture.md`
**什么算需要写 design doc**:改公共类型的字段、改 Protocol 签名、改主循环的停止语义、改分层与
接缝,以及任何「选错了要花很大代价才能改回来」的决定。判断标准是**能不能写出「否决的方案」**——
如果这件事只有一种做法,那它不是决策,不用写。
**只有事实、没有决策的东西不属于这里。** 比如「conda run 的输出会被缓冲两层」,它不是我们选的,
是 conda 本来就这样。这类内容属于 `explanation/`,因为它描述的是一个不会变的机制,而不是我们
某个时刻的选择。
**`reference/agent-core.md` 不属于这里,也不属于任何一层。** 它是别人写的架构提案,不是我们的
决策记录——放进 `design/` 会让后来的人以为它被接受了。它留在仓库根目录的 `reference/` 里,
和五个参考仓库同级,理由见 `../CLAUDE.md` §0。
## 4. `scratch/` —— 一次性文档
计划、草稿、调研笔记。进 git(方便协作时互相看见),但**由项目负责人在每轮工作会话结束前
手动清理**。
两点说明,因为这条规则完全靠人执行,含糊就等于没有:
- **「每轮工作会话」**指一次连续的开发工作从开始到告一段落,通常就是和 AI 协作者的一次对话。
不是按 PR 算,也不是按天算。
- **清理的是人,不是 AI。** AI 协作者不要自动删 `scratch/` 下的任何东西——哪份草稿已经没用了、
哪份还要接着写,只有正在做这件事的人知道。
这里必须把风险讲明白:**这条规则没有任何机器兜底。** CHSAnalyzer 上一版的 `plans/` 目录攒到
49,222 行、2,757 个复选框其中 87% 永远没有被勾上,靠的也是同一句「记得定期清理」。之所以还是
这么定,是因为自动删除别人正在用的草稿风险更大。选择这条路就是接受了这个风险。
## 5. 复述规则:论证可以复述,参数不许复述
文档写作里有两个常被混为一谈的目标,本项目明确区分它们:
- **DRY(每个事实只写一次)**——这是**代码**的原则。用在文档上会伤害可读性,因为人类读者
希望在一份文档里把事情读懂,而不是在几份文档之间反复跳转。
- **权威来源(每个事实有唯一权威版本)**——这是**文档**的原则。它规定的是冲突时信谁,
**并不禁止复述**
Google 在他们的工程实践里对这一点说得很直接:「这常常导致一些信息的重复,但这种重复是有目的的:
为了清晰。」
所以本项目的规则是:
> **论证可以复述,参数不许复述。**
区别在于会不会漂移。同一个道理在两处各讲一遍,两处不会互相矛盾——最多其中一处写得不如另一处好,
而读者少跳转一次是实打实的收益。但同一个数字写在两处,迟早有一处被改、另一处没改。
「参数」指的是**当前生效的取值**:阈值数字、路径、文件名、类型名、枚举取值、命令行的具体形状。
这些只在权威来源里出现一次,别处引用它。哪些事实的权威来源在哪,见 `../CLAUDE.md` §0 的表格。
**历史数字不算参数**,不受这条约束。比如「CHSAnalyzer 上一版的 `API.md` 落后代码 210 个提交」,
这个 210 是已经发生的事实,不会再变,也就不会漂移;它是论证的一部分,哪里需要就可以在哪里写。
会漂移的只有「当前生效」的那类值——因为它们将来会被改。
**代价是:** 允许复述论证,就等于接受了一种新的漂移——两处的**道理**打架。比如一处写「为了
可复现所以整份冻结」,另一处写「为了省内存所以只留引用」,两处都没有数字错误,但结论已经
矛盾了。这类冲突机器查不出来,只能靠 `../CLAUDE.md` §3 那轮独立评审。这是接受重复必须付的账。
## 6. 文档质量为什么不进 CI
CHSAnalyzer 曾经有过一个 `check_doc_consistency.py`,用正则断言文档之间的一致性,已经删除。
本项目不再造一个。
原因是它能查的(文件行数、目录树标注对不对)用一条 shell 命令就能看,而它查不了的
(这段话读不读得懂、这个决策有没有写理由)才是文档真正的腐烂形态。
这不是工程没做到位,而是原理上做不到。Google 在同一件事上的自我评价是:「测试可以自动化,
而文档自动化的方案往往是缺失的」,以及「文档必然是主观的;文档的质量不由作者衡量,而由读者
衡量,而且往往是异步地衡量」。质量的度量发生在读者脑子里,而且延迟发生,CI 拿不到这个信号。
所以文档质量走 `../CLAUDE.md` §3 的独立评审:一个没参与过讨论的 subagent,只拿到改后的文档,
纯靠文档读懂。先问它读的时候发生了什么,再问三类具体问题——哪句话读不懂、哪个决策只写了
结论没写理由、同一个参数在两处取值不同。