读完之后,团队真正需要的是“还欠什么”
四卷读完,arch-lab 的架构评审会上有人问:我们到底覆盖了什么,下一步补哪一块?如果回答是一串文章标题,评审就无法判断优先级;如果回答是“都覆盖了”,那多半是把“写过”当成了“做到”。
收口篇不列书单,而是把能力当成资产登记:每一项能力属于哪一卷、由谁负责、成熟到哪一级、证据是什么、下一步做什么、依赖谁。登记表可以被程序校验,于是“覆盖”从形容词变成一份能拒绝过度声明的数据。三卷之间的技术衔接已在卷三收口 里做过一次,这一节把范围扩到四卷,并补上治理 maturity 与证据新鲜度。
四卷是一条依赖链,不是四个并列主题
依赖方向大体是从左到右:没有稳定边界,性能预算无处挂载;没有一致性保障,治理只能统计不可信的数字。但依赖不是严格串行,卷四的门禁会反过来暴露前三卷的缺口,例如 SLO 门禁发现某个服务的错误预算从未被度量,这就是一条要回填的依赖。
把依赖写成图还有第二个好处:能查环。团队常写出“发布门禁依赖金丝雀,金丝雀依赖自动回滚,自动回滚又依赖发布门禁”这样的环,口头评审很难发现,程序一次深度优先搜索就能指出路径。
成熟度必须由证据兑现
| 等级 | 含义 | 最低证据 | 允许的对外表述 |
|---|---|---|---|
| L0 临时 | 靠人记得步骤 | 无 | 只能称“有做法” |
| L1 成文 | 有 runbook 或 ADR | 文档 | 可称“已定义” |
| L2 自动化 | 流水线强制执行 | 测试或流水线 | 可称“已落地” |
| L3 持续验证 | 证据按节奏刷新 | 演练或遥测,且未过期 | 可称“持续有效” |
“文档不能证明自动化”是这张表最重要的一条:一份写得很好的发布规范,不能支撑“自动回滚已落地”的声明。同理,一次测试通过不能支撑“持续验证”,后者必须拿出带日期的演练或遥测证据,并且日期落在允许的证据年龄内。矩阵校验器把这三条都写成错误而不是警告,因为治理声明一旦被放宽,后面所有统计都会失真。
覆盖矩阵要能拒绝,而不只是能展示
校验器检查四类问题。结构类:ID 唯一、卷号在 1 到 4、章号在 1 到 13、标题与负责人非空。证据类:L1 以上必须有证据类型与引用,L2 以上不能只靠文档,L3 必须是演练或遥测且未过期。路线类:L2 以上必须有下一步动作,依赖必须存在且不能自指。覆盖类:四卷都必须至少有一项能力,依赖图不能有环。
环检测使用白、灰、黑三色深度优先搜索:灰色表示当前递归栈上的节点,再次遇到灰色即发现环,并输出从路径到重复节点的完整链条。未知依赖在环检测里跳过,因为结构校验已经报过,跳过可以避免一个坏引用掩盖真正的环。
可运行实现
// file: internal/evolve/maturity.go
// tier: L1
// verify: go test ./internal/evolve/ -count=1 -run TestMatrix
package evolve
import (
"fmt"
"sort"
"strings"
"time"
)
// Maturity is what is operated, not what has been written about.
type Maturity int
const (
MaturityAdHoc Maturity = 0 // people remember the steps
MaturityDocumented Maturity = 1 // a runbook or ADR exists
MaturityAutomated Maturity = 2 // a pipeline enforces it
MaturityContinuouslyVerified Maturity = 3 // evidence is refreshed on a cadence
)
var maturityNames = [...]string{"L0-ad-hoc", "L1-documented", "L2-automated", "L3-continuous"}
func (m Maturity) String() string {
if m >= 0 && int(m) < len(maturityNames) {
return maturityNames[m]
}
return fmt.Sprintf("invalid(%d)", m)
}
// EvidenceKind says what backs a claim; a document cannot prove automation.
type EvidenceKind string
const (
EvidenceDoc EvidenceKind = "doc"
EvidenceTest EvidenceKind = "test"
EvidenceDrill EvidenceKind = "drill"
EvidenceTelemetry EvidenceKind = "telemetry"
)
var evidenceKinds = map[EvidenceKind]bool{
EvidenceDoc: true, EvidenceTest: true, EvidenceDrill: true, EvidenceTelemetry: true,
}
// Capability is one row of the four-volume coverage matrix.
type Capability struct {
ID, Title, Owner string
Volume, Chapter int
Maturity Maturity
Evidence EvidenceKind
EvidenceRef string
VerifiedAt time.Time
NextAction string
DependsOn []string
}
// CoverageMatrix is the roadmap as data: coverage, maturity, owner, next step.
type CoverageMatrix struct {
AsOf time.Time
MaxEvidenceAge time.Duration
Capabilities []Capability
}
// Validate is strict: maturity without matching evidence is an error.
func (m CoverageMatrix) Validate() []error {
var errs []error
fail := func(format string, args ...any) {
errs = append(errs, fmt.Errorf(format, args...))
}
if m.AsOf.IsZero() {
fail("as-of date is required")
}
if m.MaxEvidenceAge <= 0 {
fail("max evidence age must be positive")
}
if len(m.Capabilities) == 0 {
fail("matrix has no capabilities")
return errs
}
seen, volumes := map[string]bool{}, map[int]bool{}
byID := map[string]Capability{}
// Index IDs first so a forward reference is not read as a missing capability.
for _, c := range m.Capabilities {
byID[c.ID] = c
}
for i, c := range m.Capabilities {
where := fmt.Sprintf("capability[%d] %s", i, c.ID)
if strings.TrimSpace(c.ID) == "" {
fail("%s: id is empty", where)
} else if seen[c.ID] {
fail("%s: duplicate id %q", where, c.ID)
}
seen[c.ID] = true
if c.Volume < 1 || c.Volume > 4 {
fail("%s: volume %d outside 1..4", where, c.Volume)
} else {
volumes[c.Volume] = true
}
if c.Chapter < 1 || c.Chapter > 13 {
fail("%s: chapter %d outside 1..13", where, c.Chapter)
}
if strings.TrimSpace(c.Title) == "" {
fail("%s: title is empty", where)
}
if strings.TrimSpace(c.Owner) == "" {
fail("%s: owner is empty", where)
}
if c.Maturity < MaturityAdHoc || c.Maturity > MaturityContinuouslyVerified {
fail("%s: maturity %d outside 0..3", where, c.Maturity)
}
if c.Evidence != "" && !evidenceKinds[c.Evidence] {
fail("%s: unknown evidence kind %q", where, c.Evidence)
}
if c.Maturity >= MaturityDocumented && c.Evidence == "" {
fail("%s: %s needs an evidence kind", where, c.Maturity)
}
if c.Maturity >= MaturityDocumented && strings.TrimSpace(c.EvidenceRef) == "" {
fail("%s: %s needs an evidence reference", where, c.Maturity)
}
if c.Maturity >= MaturityAutomated && c.Evidence == EvidenceDoc {
fail("%s: %s cannot rest on a document", where, c.Maturity)
}
if c.Maturity >= MaturityAutomated && strings.TrimSpace(c.NextAction) == "" {
fail("%s: %s needs a next action", where, c.Maturity)
}
if c.Maturity == MaturityContinuouslyVerified {
if c.Evidence != EvidenceDrill && c.Evidence != EvidenceTelemetry {
fail("%s: continuous claim needs drill or telemetry evidence", where)
}
if c.VerifiedAt.IsZero() || m.AsOf.Sub(c.VerifiedAt) > m.MaxEvidenceAge {
fail("%s: evidence is stale or undated", where)
}
}
for _, dep := range c.DependsOn {
if dep == c.ID {
fail("%s: depends on itself", where)
} else if _, ok := byID[dep]; !ok {
fail("%s: unknown dependency %q", where, dep)
}
}
}
for v := 1; v <= 4; v++ {
if !volumes[v] {
fail("volume %d has no capability", v)
}
}
return append(errs, m.cycleErrors(byID)...)
}
// cycleErrors finds cycles; unknown dependencies are skipped here.
func (m CoverageMatrix) cycleErrors(byID map[string]Capability) []error {
const white, gray, black = 0, 1, 2
color := map[string]int{}
var errs []error
var visit func(id string, path []string)
visit = func(id string, path []string) {
switch color[id] {
case gray:
errs = append(errs, fmt.Errorf("dependency cycle: %s", strings.Join(append(path, id), " -> ")))
return
case black:
return
}
color[id] = gray
deps := append([]string(nil), byID[id].DependsOn...)
sort.Strings(deps)
for _, dep := range deps {
if _, ok := byID[dep]; !ok {
continue
}
visit(dep, append(path, id))
}
color[id] = black
}
ids := make([]string, 0, len(byID))
for id := range byID {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
visit(id, nil)
}
return errs
}
// VolumeScore is one row of the maturity summary.
type VolumeScore struct {
Volume int
Count int
Average float64
}
// Scores averages maturity per volume; an uncovered volume still shows up.
func (m CoverageMatrix) Scores() []VolumeScore {
sum, count := map[int]int{}, map[int]int{}
for _, c := range m.Capabilities {
sum[c.Volume] += int(c.Maturity)
count[c.Volume]++
}
out := make([]VolumeScore, 0, 4)
for v := 1; v <= 4; v++ {
score := VolumeScore{Volume: v, Count: count[v]}
if count[v] > 0 {
score.Average = float64(sum[v]) / float64(count[v])
}
out = append(out, score)
}
return out
}
// Overall is the mean maturity across all capabilities.
func (m CoverageMatrix) Overall() float64 {
if len(m.Capabilities) == 0 {
return 0
}
total := 0
for _, c := range m.Capabilities {
total += int(c.Maturity)
}
return float64(total) / float64(len(m.Capabilities))
}
示例矩阵为四卷各登记两项能力,其中两项达到 L3、四项达到 L2、一项 L1,依赖构成一条从左到右的链。测试同时验证评分与拒绝路径:
// file: internal/evolve/maturity_test.go
// tier: L1
// verify: go test ./internal/evolve/ -count=1 -run TestMatrix
func sampleMatrix() CoverageMatrix {
verified := time.Date(2026, 10, 20, 9, 0, 0, 0, time.UTC)
reconciled := time.Date(2026, 10, 27, 9, 0, 0, 0, time.UTC)
return CoverageMatrix{
AsOf: time.Date(2026, 11, 2, 10, 20, 0, 0, time.UTC),
MaxEvidenceAge: 90 * 24 * time.Hour,
Capabilities: []Capability{
{ID: "boundary-modular-monolith", Volume: 1, Chapter: 4, Title: "模块边界与拆分评分卡", Owner: "架构组",
Maturity: MaturityAutomated, Evidence: EvidenceTest, EvidenceRef: "go test ./internal/platform/split",
NextAction: "把评分卡接入季度评审"},
{ID: "latency-budget", Volume: 2, Chapter: 1, Title: "尾延迟预算", Owner: "性能组",
Maturity: MaturityAutomated, Evidence: EvidenceTest, EvidenceRef: "go test ./internal/platform/slo",
NextAction: "补齐跨服务预算", DependsOn: []string{"boundary-modular-monolith"}},
{ID: "outbox-delivery", Volume: 3, Chapter: 8, Title: "事务性 outbox 投递", Owner: "订单组",
Maturity: MaturityContinuouslyVerified, Evidence: EvidenceDrill, EvidenceRef: "drill-2026-10-20",
VerifiedAt: verified, NextAction: "每季度重跑", DependsOn: []string{"latency-budget"}},
{ID: "slo-gate", Volume: 4, Chapter: 7, Title: "错误预算发布门禁", Owner: "SRE",
Maturity: MaturityContinuouslyVerified, Evidence: EvidenceTelemetry, EvidenceRef: "grafana/order-slo",
VerifiedAt: time.Date(2026, 11, 1, 9, 0, 0, 0, time.UTC), NextAction: "覆盖库存服务",
DependsOn: []string{"outbox-delivery"}},
{ID: "adr-review", Volume: 1, Chapter: 2, Title: "ADR 评审", Owner: "架构组",
Maturity: MaturityDocumented, Evidence: EvidenceDoc, EvidenceRef: "adr-014",
NextAction: "把评审清单自动化"},
{ID: "canary-release", Volume: 4, Chapter: 9, Title: "金丝雀发布", Owner: "发布组",
Maturity: MaturityAutomated, Evidence: EvidenceTest, EvidenceRef: "go test ./internal/platform/release",
NextAction: "接入自动回滚", DependsOn: []string{"slo-gate"}},
{ID: "rate-limit", Volume: 2, Chapter: 6, Title: "限流与隔离", Owner: "网关组",
Maturity: MaturityAutomated, Evidence: EvidenceTest, EvidenceRef: "go test ./internal/platform/gateway",
NextAction: "补租户配额", DependsOn: []string{"latency-budget"}},
{ID: "reconciliation", Volume: 3, Chapter: 9, Title: "实时对账", Owner: "订单组",
Maturity: MaturityContinuouslyVerified, Evidence: EvidenceDrill, EvidenceRef: "drill-2026-10-27",
VerifiedAt: reconciled, NextAction: "改为每日自动差异", DependsOn: []string{"outbox-delivery"}},
},
}
}
func TestMatrixAcceptsCoveredRoadmap(t *testing.T) {
m := sampleMatrix()
if errs := m.Validate(); len(errs) != 0 {
t.Fatalf("valid matrix rejected: %v", errs)
}
scores := m.Scores()
want := []float64{1.5, 2.0, 3.0, 2.5}
for i, s := range scores {
if s.Count != 2 || math.Abs(s.Average-want[i]) > 0.001 {
t.Fatalf("volume %d = %+v, want count 2 average %.2f", s.Volume, s, want[i])
}
}
if got := m.Overall(); math.Abs(got-2.25) > 0.001 {
t.Fatalf("overall = %.2f, want 2.25", got)
}
}
func TestMatrixRejectsOverclaimedMaturity(t *testing.T) {
m := sampleMatrix()
c := &m.Capabilities[0]
c.Maturity = MaturityContinuouslyVerified
c.Evidence = EvidenceDoc
assertMatrixError(t, m, "cannot rest on a document")
assertMatrixError(t, m, "continuous claim needs drill or telemetry evidence")
}
func assertMatrixError(t *testing.T, m CoverageMatrix, want string) {
t.Helper()
for _, err := range m.Validate() {
if strings.Contains(err.Error(), want) {
return
}
}
t.Fatalf("missing error %q in %v", want, m.Validate())
}
本机实测
环境为 macOS darwin/arm64、Go 1.26.0。完整测试套件共 23 个用例,下面是其中四个用例的原始输出:
$ go test ./internal/evolve/ -count=1 -v -run TestMatrix
=== RUN TestMatrixAcceptsCoveredRoadmap
--- PASS: TestMatrixAcceptsCoveredRoadmap (0.00s)
=== RUN TestMatrixRejectsStaleContinuousClaim
--- PASS: TestMatrixRejectsStaleContinuousClaim (0.00s)
=== RUN TestMatrixRejectsOverclaimedMaturity
--- PASS: TestMatrixRejectsOverclaimedMaturity (0.00s)
=== RUN TestMatrixRejectsMissingNextAction
--- PASS: TestMatrixRejectsMissingNextAction (0.00s)
PASS
ok archlab/internal/evolve 0.239s
示例矩阵四卷平均成熟度分别为 1.5、2.0、3.0、2.5,总体 2.25;每一项都低于最高级,说明路线图仍有明确缺口,而不是一张“全部 L3”的漂亮报表。过度声明用例把第一项从 L2 改成 L3 却仍用文档作证据,校验器同时报出“不能只靠文档”和“持续声明需要演练或遥测”,两条都保留,避免修掉一条就以为问题消失。过期证据用例把日期推到 120 天前,超过了 90 天的证据年龄上限,同样被拒绝。
这些数字来自固定输入的单元测试,不代表任何真实组织的治理水平。真实使用时,AsOf、证据年龄和每项能力的证据引用需要按季度人工复核,校验器只负责让“写了什么”和“证据是什么”对不上时无法悄悄通过。
常见错误与边界
第一,把篇数当成熟度。文章数量说明覆盖了主题,不说明能力被运营。矩阵登记的是能力,不是目录条目;一篇讲金丝雀的文章不能自动让“金丝雀发布”达到 L2。
第二,只登记不写下一步。没有 NextAction 的 L2 以上能力会停在原地,半年后证据过期才发现。校验器强制要求下一步动作,把路线图从静态清单变成待办队列。
第三,依赖图不查环。人工评审容易漏掉三方互相依赖,白灰黑三色搜索能给出完整环路径。发现环之后要拆依赖,而不是把校验规则关掉。
第四,证据一次性写死。L3 的关键是新鲜度,VerifiedAt 加上 MaxEvidenceAge 才能表达“持续”。只写“已演练”而不写日期,等同于没有证据。
小结
四卷的关系不是四个并列主题,而是一条从边界、性能、一致性到治理的依赖链。把能力登记成数据之后,覆盖、成熟度、负责人、证据和下一步都可以被程序检查,“还欠什么”也从会议争论变成一次校验输出。
这套矩阵的价值不在评分高低,而在拒绝:拒绝没有证据的成熟度、拒绝只靠文档的自动化、拒绝过期仍宣称持续、拒绝缺卷和依赖环。治理成熟的标志不是所有项都到 L3,而是团队愿意让不合格的声明失败,并按 NextAction 一项项补齐。
至此四卷收口。架构、性能、一致性与治理最终都要回到同一件事:让系统在变化中仍然可解释、可验证、可回退。
阅读导航:上一节:13.2 一次带 SLO 门禁与混沌演练的发布 · 下一节:回到目录 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。