一份仍然写着 accepted 的旧 ADR
2026 年 10 月,北辰工作室整理《群岛纪元》的决策目录。ADR-005 的状态仍然是 accepted,标题是“匹配服务独占匹配票据”。这份记录写于 2024 年,复审日期是 2026 年 6 月,已经过去四个月。
更麻烦的是它的依赖:ADR-005 依赖 ADR-002 定义的“世界分片身份模型”,而 ADR-002 在 2025 年被 ADR-011 取代。也就是说,一份仍然有效的决策,建立在一个已经失效的前提上。
没有人故意忽略。ADR 写完之后,团队就进入了下一次迭代;状态字段只在创建时被填写,之后没有任何机制提醒谁去复审。记录还在,决策的生命周期却停在了“采纳”那一刻。
2.2 节 已经讲过 ADR 的写法:背景、备选、决定、后果、退出条件与状态。本节不重复模板,只讨论模板写完之后的事:决策如何记录成图、何时评审、何时复审、失效后如何替代。2.3 节 的风险台账是评审输入,本节关注评审之后的状态流转。
决策不是一次性事件,而是生命周期
一份决策至少要经过六个状态:
proposed ──> in-review ──> accepted ──> superseded
│ │
└──> rejected └──> expired
| 状态 | 含义 | 必须回答 |
|---|---|---|
| proposed | 有人提出,尚未评审 | 要解决什么问题 |
| in-review | 正在收集证据与反对意见 | 谁在评审,何时截止 |
| accepted | 已采纳,正在执行 | 复审日期、退出条件、负责人 |
| rejected | 明确不采纳 | 否决理由,避免重复提议 |
| superseded | 被新决策替代 | 替代者是谁,迁移是否完成 |
| expired | 前提消失,自然失效 | 哪个前提消失了 |
关键约束是:只有 accepted 的决策可以取代旧决策。一个还在评审中的提议可以“计划取代”,但不能把旧决策提前标成失效,否则执行层会同时收到两套指令。
决策图:取代、依赖、冲突
决策之间不是孤立文档,而是一张有向图:
| 关系 | 含义 | 检查 |
|---|---|---|
| supersedes | 新决策替代旧决策 | 旧决策状态必须是 superseded,替代者必须是 accepted |
| depends-on | 本决策的前提是另一份决策 | 依赖对象不能是 rejected、superseded 或 expired |
| conflicts-with | 两份决策不能同时成立 | 关系必须对称,且不能同时为 accepted |
例如:
- ADR-005 依赖 ADR-002;
- ADR-011 取代 ADR-002;
- ADR-012 与 ADR-006 在“结算幂等键由谁生成”上冲突。
图一旦画出来,前面那份旧 ADR 的问题就暴露了:ADR-005 是 accepted,但它依赖的 ADR-002 已经 superseded。这不是文档瑕疵,而是执行风险:团队可能仍在按一个失效前提做容量规划。
评审看什么:假设、证据、退出条件
评审常被理解成“看方案是否合理”。更可执行的评审清单是三项:
- 假设:这个决定依赖哪些尚未验证的事实?例如“跨地域延迟稳定低于 100ms”。
- 证据:假设现在有哪些数据支撑?压测、线上观测、事故复盘,还是只有直觉。
- 退出条件:什么信号出现时,这个决定必须重新评估?条件必须可观测、有阈值、有持续时间。
方案本身反而排在第四位。方案可以改,前提错了,方案再漂亮也没有意义。
评审的产出不是“通过/不通过”两个词,而是四类结论:采纳、附条件采纳、退回补充证据、拒绝。附条件采纳必须把条件写进决策记录,否则条件会在会后消失。
复审触发:时间、信号与依赖失效
复审不能只靠日历。至少有三类触发:
| 触发 | 例子 | 动作 |
|---|---|---|
| 时间 | 复审日期到达 | 负责人召集复审,更新状态 |
| 信号 | 匹配 p99 连续一周超 2 秒 | 检查退出条件,决定是否替代 |
| 依赖失效 | 依赖的 ADR 被取代或过期 | 立即标记为待复审,不等到日期 |
第三类最容易被忽略。依赖失效不需要等复审日期,因为前提已经不存在了。代码里的 ValidateAll 会把“active 决策依赖 dead 决策”直接报错,目的就是逼团队在合并前处理,而不是等下一次架构会。
复审还要看“决策是否仍然成立”,而不是“当初的决定是否聪明”。复盘不是追责,是确认前提、成本和收益是否还和当初一致。
失效处理:标记、替代与迁移清单
决策失效不等于工作结束。至少要完成五件事:
- 把旧决策状态改为 superseded 或 expired,并写明原因。
- 指定替代者;如果暂时没有替代者,标记为“无替代失效”,并列出风险。
- 找出所有引用旧决策的文档、配置和代码注释,更新或加注。
- 检查依赖旧决策的其他决策,把它们标记为待复审。
- 在发布说明或架构月报里记录这次失效,避免执行层继续按旧结论工作。
只改状态字段,不改下游引用,等于把过期结论留在了系统里。
代码:决策图与三类检查
// file: internal/decisionlog/decisionlog.go
// tier: L1
// verify: go test ./internal/decisionlog/
package decisionlog
import (
"fmt"
"sort"
"strings"
"time"
)
// Status is the lifecycle position of a decision, not its technical outcome.
type Status string
const (
Proposed Status = "proposed"
InReview Status = "in-review"
Accepted Status = "accepted"
Rejected Status = "rejected"
Superseded Status = "superseded"
Expired Status = "expired"
)
var statuses = map[Status]bool{
Proposed: true, InReview: true, Accepted: true,
Rejected: true, Superseded: true, Expired: true,
}
// Decision is one node in the decision graph. Supersedes, DependsOn, and
// ConflictsWith point at other decision IDs.
type Decision struct {
ID string
Title string
Status Status
Owner string
ReviewBy time.Time
ExitConditions []string
Supersedes []string
DependsOn []string
ConflictsWith []string
}
func active(s Status) bool {
return s == Proposed || s == InReview || s == Accepted
}
func dead(s Status) bool {
return s == Rejected || s == Superseded || s == Expired
}
// Validate checks one node. Active decisions must carry a review date and
// at least one exit condition; otherwise they cannot be re-evaluated.
func (d Decision) Validate() []error {
var errs []error
for _, f := range []struct{ name, value string }{
{"id", d.ID}, {"title", d.Title}, {"owner", d.Owner},
} {
if strings.TrimSpace(f.value) == "" {
errs = append(errs, fmt.Errorf("decision %q: empty %s", d.ID, f.name))
}
}
if !statuses[d.Status] {
errs = append(errs, fmt.Errorf("decision %q: unknown status %q", d.ID, d.Status))
}
if active(d.Status) {
if d.ReviewBy.IsZero() {
errs = append(errs, fmt.Errorf("decision %q: active without review date", d.ID))
}
if len(d.ExitConditions) == 0 {
errs = append(errs, fmt.Errorf("decision %q: active without exit conditions", d.ID))
}
}
if contains(d.Supersedes, d.ID) || contains(d.DependsOn, d.ID) || contains(d.ConflictsWith, d.ID) {
errs = append(errs, fmt.Errorf("decision %q: references itself", d.ID))
}
return errs
}
// SupersedeCycle reports the first cycle in the replacement graph.
func SupersedeCycle(ds []Decision) []error {
edges := make(map[string][]string, len(ds))
for _, d := range ds {
edges[d.ID] = append(edges[d.ID], d.Supersedes...)
}
state := make(map[string]int, len(ds)) // 1 visiting, 2 done
var visit func(string) bool
visit = func(id string) bool {
switch state[id] {
case 1:
return true
case 2:
return false
}
state[id] = 1
for _, next := range edges[id] {
if visit(next) {
return true
}
}
state[id] = 2
return false
}
for _, d := range ds {
if state[d.ID] == 0 && visit(d.ID) {
return []error{fmt.Errorf("supersede cycle involving %q", d.ID)}
}
}
return nil
}
// ValidateAll checks references, replacement consistency, conflicts, and
// dependencies. It returns every error; callers decide how to report them.
func ValidateAll(ds []Decision) []error {
var errs []error
byID := make(map[string]Decision, len(ds))
for _, d := range ds {
errs = append(errs, d.Validate()...)
if _, ok := byID[d.ID]; ok {
errs = append(errs, fmt.Errorf("decision %q: duplicate id", d.ID))
}
byID[d.ID] = d
}
supersededBy := make(map[string]bool)
for _, d := range ds {
for _, ref := range d.Supersedes {
target, ok := byID[ref]
if !ok {
errs = append(errs, fmt.Errorf("decision %q: supersedes unknown %q", d.ID, ref))
continue
}
if d.Status != Accepted {
errs = append(errs, fmt.Errorf("decision %q: only accepted decisions may supersede", d.ID))
}
if target.Status != Superseded {
errs = append(errs, fmt.Errorf("decision %q: supersedes %q which is %s", d.ID, ref, target.Status))
continue
}
supersededBy[ref] = true
}
for _, ref := range d.DependsOn {
target, ok := byID[ref]
if !ok {
errs = append(errs, fmt.Errorf("decision %q: depends on unknown %q", d.ID, ref))
continue
}
if active(d.Status) && dead(target.Status) {
errs = append(errs, fmt.Errorf("decision %q: depends on %s decision %q", d.ID, target.Status, ref))
}
}
for _, ref := range d.ConflictsWith {
target, ok := byID[ref]
if !ok {
errs = append(errs, fmt.Errorf("decision %q: conflicts with unknown %q", d.ID, ref))
continue
}
if !contains(target.ConflictsWith, d.ID) {
errs = append(errs, fmt.Errorf("decision %q: conflict with %q is not symmetric", d.ID, ref))
}
if d.Status == Accepted && target.Status == Accepted {
errs = append(errs, fmt.Errorf("decisions %q and %q are both accepted and conflict", d.ID, ref))
}
}
}
for _, d := range ds {
if d.Status == Superseded && !supersededBy[d.ID] {
errs = append(errs, fmt.Errorf("decision %q: superseded without accepted replacement", d.ID))
}
}
errs = append(errs, SupersedeCycle(ds)...)
return errs
}
// Due returns active decisions inside the review window, earliest first.
func Due(ds []Decision, now time.Time, window time.Duration) []Decision {
cutoff := now.Add(window)
var out []Decision
for _, d := range ds {
if !active(d.Status) || d.ReviewBy.IsZero() || d.ReviewBy.After(cutoff) {
continue
}
out = append(out, d)
}
sort.SliceStable(out, func(i, j int) bool {
if !out[i].ReviewBy.Equal(out[j].ReviewBy) {
return out[i].ReviewBy.Before(out[j].ReviewBy)
}
return out[i].ID < out[j].ID
})
return out
}
func contains(list []string, want string) bool {
for _, s := range list {
if s == want {
return true
}
}
return false
}
Validate 检查单个节点,SupersedeCycle 检查取代关系是否有环,ValidateAll 检查引用、替代一致性、冲突对称性和依赖有效性,Due 返回复审窗口内到期的 active 决策。Due 只提醒,不修改状态;状态变更仍然需要人来做决定。
测试:取代环、冲突不对称、到期提醒
表驱动测试分成四组:六组单节点用例、三组取代环用例、九组图一致性用例和四组到期提醒用例。重点用例:
| 用例 | 期望 |
|---|---|
| active 决策缺复审日期 | 报错 |
| active 决策缺退出条件 | 报错 |
| 取代关系形成环 | 报错 |
| 非 accepted 决策试图取代旧决策 | 报错 |
| 冲突关系不对称 | 报错 |
| 两份 accepted 决策互相冲突 | 报错 |
| active 决策依赖已失效决策 | 报错 |
| 到期提醒按日期、再按 ID 排序 | 固定顺序 |
本机只跑本包:
$ cd .local/checks/gsarch-lab
$ go test -count=1 ./internal/decisionlog/
ok gsarchlab/internal/decisionlog 0.723s
同时跑了 gofmt -l internal/decisionlog(无输出)和 go vet ./internal/decisionlog/(退出码 0)。
常见错误与边界
第一,只记录决定,不记录状态流转。ADR 目录里全是 accepted,等于没有生命周期。
第二,用 proposed 的决策取代旧决策。执行层会看到两份互相矛盾的指令。
第三,依赖失效不处理。旧决策仍然 active,但它成立的前提已经不存在。
第四,冲突关系只写一边。单向冲突在图上看起来像普通依赖,评审时会被漏掉。
第五,复审只看日历,不看信号。退出条件写了却没人观测,等于没写。
第六,失效只改状态。下游文档、配置和依赖决策不更新,过期结论会继续传播。
小结
决策是有生命周期的图节点:提议、评审、采纳、复审、失效与替代。取代关系不能成环,冲突必须对称,active 决策不能依赖已失效决策,复审既看日期也看信号。internal/decisionlog 把这些规则变成可执行检查,并把到期提醒单独输出,避免“记录还在、决策已经过期”。
本节与 2.2 的分工是:2.2 教怎么写一份 ADR,本节管 ADR 写完之后的评审、复审与失效。下一章回到三款产品,用前十二章的视图、契约和决策方法,给出各自的架构基线。
阅读导航:上一节:12.2 时序图、状态机与契约表 · 下一节:13.1 三款产品的架构基线 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。