三个 +1 不算评审
2026 年 8 月,order API 要给 POST /api/v1/orders 增加一个必填字段。三个月后回看,没人说得清是谁拍的板:聊天记录里只有一句“业务同意了”,评审记录里只有三个 +1。字段已经上线,两个外部调用方被迫连夜改造。决策权要回答三个问题:谁决策(D)、谁执行(E)、谁验收(V),外加谁必须被咨询(C)。RACI 也能表达这件事,但它把“执行”和“负责”混在 R 里,又允许一列出现多个 A,落到跨团队时经常变成“人人有责”。本节用 D/E/V/C:每个决策类恰好一个 D 和一个 V,且验收必须由 D 以外的人完成。9.1 已经画清团队、模块和沟通通道,本节回答另一个问题:通道有了,事情谁说了算;ADR 的写法在 2.2 已经给过模板,这里只关心决策权怎么分配、契约怎么验收、评审记录怎么变成可检查的数据。
决策权矩阵
决策权跟随决策范围,不跟随职级。业务规则的 D 是 DOMAIN,平台的 D 是 PLATFORM,跨切面的 D 是 ARCH;任何范围的 V 都必须独立于 D。矩阵只写角色,不写人名;角色稳定,人会轮换。
| 决策类 | 范围 | D 决策 | E 执行 | V 验收 | C 咨询 |
|---|---|---|---|---|---|
| DC-ORDER-LOCAL | BUSINESS | DOMAIN | DOMAIN | DOMAIN | 无 |
| DC-DATA-OWNERSHIP | CROSS_CUTTING | ARCH | PLATFORM、DOMAIN | SECURITY | DOMAIN、PLATFORM |
| DC-PLATFORM-INTERFACE | PLATFORM | PLATFORM | PLATFORM | SRE | DOMAIN |
| DC-SECURITY-EXCEPTION | CROSS_CUTTING | ARCH | SECURITY、PLATFORM | SECURITY | DOMAIN、PLATFORM |
这张表有两条硬约束。第一,平台不能替业务决定业务规则:DC-ORDER-LOCAL 的 D 只能是 DOMAIN,平台可以执行、可以被咨询,但不能签字。第二,业务也不能单方面改平台接口:DC-PLATFORM-INTERFACE 的 D 是 PLATFORM,但 C 必须有 DOMAIN;没有业务方签字的平台契约,验收时会被真实调用方打回。跨切面决策最容易变成“架构组说了算”,所以矩阵要求 D 是 ARCH、C 必须包含 DOMAIN 和 PLATFORM:咨询不是投票,D 仍然只有一个,但记录里必须留下咨询证据。
平台与业务团队的契约
| 条款 | 写法 | 谁负责 |
|---|---|---|
| 接口与版本 | v2;破坏性变更升主版本 | 平台 |
| SLI | 可用性 99.9%,P99 80ms,30 天窗口 | 平台 |
| 变更通知 | 破坏性变更提前 30 天 | 平台 |
| 消费方验收 | 业务团队跑契约测试并签字 | 业务 |
| 退出与升级 | 旧版本下线日期、故障升级联系人 | 双方 |
契约校验器检查团队存在、SLI 范围、破坏性变更的通知天数与主版本号。它不测真实延迟,那是监控和契约测试的事;它只保证契约在纸面上可执行。比如版本还是 v1 却标记破坏性变更,或者只给七天通知,评审阶段就应该被拦下。
把规则写成校验器
decision 包把决策权矩阵、平台契约和评审记录放在同一个账本里。Validate 是纯函数:不读当前时间、不读文件,输入相同,输出相同。这样测试可以稳定复现,CI 也不会因为跨天而变红。
// file: internal/decision/decision.go
// tier: L1
// verify: go test ./internal/decision/
package decision
import (
"fmt"
"slices"
"strconv"
"strings"
)
type Role string
const (
Arch Role = "ARCH"
Platform Role = "PLATFORM"
Domain Role = "DOMAIN"
Security Role = "SECURITY"
SRE Role = "SRE"
)
type Scope string
const (
Business Scope = "BUSINESS"
PlatformScope Scope = "PLATFORM"
CrossCutting Scope = "CROSS_CUTTING"
)
type Person struct {
ID string
Team string
Roles []Role
}
// Class 是一类决策,不是某一次决策。D 决策、E 执行、V 验收、C 必须被咨询。
type Class struct {
ID string
Scope Scope
Decide Role
Execute []Role
Verify Role
Consult []Role
}
// Contract 是平台与业务团队之间的可验收承诺,不是服务目录里的介绍。
type Contract struct {
ID string
Provider string
Consumer string
Version string
Breaking bool
NoticeDays int
AvailabilityPct float64
P99Millis int
}
type Verdict string
const (
Approved Verdict = "APPROVED"
ApprovedWithException Verdict = "APPROVED_WITH_EXCEPTION"
)
type Approval struct {
Reviewer string
Verdict Verdict
Evidence string
}
type Review struct {
ID string
ClassID string
Author string
Approvals []Approval
}
type Ledger struct {
Classes []Class
People []Person
Contracts []Contract
Reviews []Review
}
type Finding struct {
Code string
Subject string
Detail string
}
// Validate 检查决策权矩阵、平台契约和评审记录。它是纯函数:
// 同样的账本永远得到同样的违规,不依赖运行时间和外部系统。
func (l Ledger) Validate() []Finding {
var out []Finding
seen := make(map[string]bool, 16)
add := func(code, subject, detail string) {
key := code + "\x00" + subject
if seen[key] {
return
}
seen[key] = true
out = append(out, Finding{Code: code, Subject: subject, Detail: detail})
}
people := make(map[string]Person, len(l.People))
teams := make(map[string]bool, len(l.People))
for _, p := range l.People {
people[p.ID] = p
teams[p.Team] = true
}
classes := make(map[string]Class, len(l.Classes))
for _, c := range l.Classes {
classes[c.ID] = c
switch c.Scope {
case Business:
if c.Decide != Domain {
add("DECISION_SCOPE_CONFLICT", c.ID, "business decision must be DOMAIN")
}
case PlatformScope:
if c.Decide != Platform {
add("DECISION_SCOPE_CONFLICT", c.ID, "platform decision must be PLATFORM")
}
if !includes(c.Consult, Domain) {
add("MISSING_CONSULTATION", c.ID, "platform contract needs a DOMAIN consumer")
}
case CrossCutting:
if c.Decide != Arch {
add("DECISION_SCOPE_CONFLICT", c.ID, "cross-cutting decision must be ARCH")
}
if !includes(c.Consult, Domain) || !includes(c.Consult, Platform) {
add("MISSING_CONSULTATION", c.ID, "cross-cutting decision needs DOMAIN and PLATFORM")
}
}
if len(c.Execute) == 0 || c.Verify == "" {
add("INCOMPLETE_CLASS", c.ID, "executor and verifier are required")
}
}
for _, ct := range l.Contracts {
if !teams[ct.Provider] || !teams[ct.Consumer] {
add("UNKNOWN_CONTRACT_TEAM", ct.ID, ct.Provider+" -> "+ct.Consumer)
}
if ct.Provider == ct.Consumer {
add("SELF_CONTRACT", ct.ID, ct.Provider)
}
if ct.AvailabilityPct <= 0 || ct.AvailabilityPct > 100 || ct.P99Millis <= 0 {
add("SLI_OUT_OF_RANGE", ct.ID, fmt.Sprintf("availability=%.2f p99=%d", ct.AvailabilityPct, ct.P99Millis))
}
if ct.Breaking && (ct.NoticeDays < 30 || major(ct.Version) < 2) {
add("BREAKING_CHANGE_UNSAFE", ct.ID, fmt.Sprintf("notice=%dd version=%s", ct.NoticeDays, ct.Version))
}
}
for _, r := range l.Reviews {
class, ok := classes[r.ClassID]
if !ok {
add("UNKNOWN_CLASS", r.ID, r.ClassID)
continue
}
decider, verifier, approved := "", "", false
for _, a := range r.Approvals {
if a.Reviewer == r.Author {
add("SELF_APPROVAL", r.ID, a.Reviewer)
continue
}
if _, ok := people[a.Reviewer]; !ok {
add("UNKNOWN_PERSON", r.ID, a.Reviewer)
continue
}
if a.Evidence == "" {
add("MISSING_EVIDENCE", r.ID, a.Reviewer)
}
approved = true
if decider == "" && holds(people, a.Reviewer, class.Decide) {
decider = a.Reviewer
}
if a.Reviewer != decider && verifier == "" && holds(people, a.Reviewer, class.Verify) {
verifier = a.Reviewer
}
}
if !approved || decider == "" {
add("NO_EFFECTIVE_DECISION", r.ID, "no approver holds "+string(class.Decide))
}
if verifier == "" {
add("MISSING_VERIFIER_APPROVAL", r.ID, string(class.Verify)+" never signed")
}
}
return out
}
func includes(values []Role, want Role) bool {
return slices.Contains(values, want)
}
func holds(people map[string]Person, id string, role Role) bool {
p, ok := people[id]
return ok && slices.Contains(p.Roles, role)
}
func major(version string) int {
n, err := strconv.Atoi(strings.TrimPrefix(version, "v"))
if err != nil {
return 0
}
return n
}
代码里 V 的选择有一个细节:验收人必须是持有 V 角色、且不是 D 的人。如果只有 D 一个人签字,报 MISSING_VERIFIER_APPROVAL 而不是“通过”。这比在矩阵里写一句“V 不得与 D 相同”更硬,因为矩阵管角色,记录管人。
测试:五个变异
基线是四类决策、七个人、两份契约、三条评审记录,零违规。五个变异分别模拟决策越权、平台契约缺业务咨询、作者自审、验收人缺席、破坏性变更通知不足。
// file: internal/decision/decision_test.go
// tier: L1
// verify: go test ./internal/decision/
package decision
import "testing"
func baseline() Ledger {
return Ledger{
Classes: []Class{
{ID: "DC-ORDER-LOCAL", Scope: Business, Decide: Domain, Execute: []Role{Domain}, Verify: Domain},
{ID: "DC-DATA-OWNERSHIP", Scope: CrossCutting, Decide: Arch, Execute: []Role{Platform, Domain}, Verify: Security, Consult: []Role{Domain, Platform}},
{ID: "DC-PLATFORM-INTERFACE", Scope: PlatformScope, Decide: Platform, Execute: []Role{Platform}, Verify: SRE, Consult: []Role{Domain}},
{ID: "DC-SECURITY-EXCEPTION", Scope: CrossCutting, Decide: Arch, Execute: []Role{Security, Platform}, Verify: Security, Consult: []Role{Domain, Platform}},
},
People: []Person{
{ID: "alice", Team: "team-order", Roles: []Role{Domain}},
{ID: "bob", Team: "team-order", Roles: []Role{Domain}},
{ID: "carol", Team: "team-catalog", Roles: []Role{Domain}},
{ID: "grace", Team: "team-platform", Roles: []Role{Platform}},
{ID: "heidi", Team: "team-arch", Roles: []Role{Arch}},
{ID: "judy", Team: "team-sre", Roles: []Role{SRE}},
{ID: "karl", Team: "team-security", Roles: []Role{Security}},
},
Contracts: []Contract{
{ID: "CT-ORDER-EVENTS", Provider: "team-order", Consumer: "team-catalog", Version: "v2", Breaking: true, NoticeDays: 45, AvailabilityPct: 99.95, P99Millis: 120},
{ID: "CT-IDEMPOTENCY", Provider: "team-platform", Consumer: "team-order", Version: "v2", AvailabilityPct: 99.9, P99Millis: 80},
},
Reviews: []Review{
{ID: "R-1001", ClassID: "DC-DATA-OWNERSHIP", Author: "alice", Approvals: []Approval{{"heidi", Approved, "ADR-021"}, {"karl", Approved, "security-88"}, {"grace", Approved, "signoff"}}},
{ID: "R-1002", ClassID: "DC-PLATFORM-INTERFACE", Author: "alice", Approvals: []Approval{{"grace", Approved, "contract-v2"}, {"judy", Approved, "slo-check"}, {"carol", Approved, "consumer-review"}}},
{ID: "R-1003", ClassID: "DC-ORDER-LOCAL", Author: "bob", Approvals: []Approval{{"alice", Approved, "state-machine-test"}, {"carol", Approved, "second-review"}}},
},
}
}
func TestBaselineReport(t *testing.T) {
l := baseline()
findings := l.Validate()
t.Logf("classes=%d people=%d contracts=%d reviews=%d findings=%d",
len(l.Classes), len(l.People), len(l.Contracts), len(l.Reviews), len(findings))
if len(findings) != 0 {
t.Fatalf("baseline should be clean, got %v", findings)
}
}
func TestValidateCatchesPowerDrift(t *testing.T) {
cases := []struct {
name string
mutate func(*Ledger)
code string
subject string
}{
{"business decided by platform", func(l *Ledger) {
l.Classes[0].Decide = Platform
}, "DECISION_SCOPE_CONFLICT", "DC-ORDER-LOCAL"},
{"platform without consumer consultation", func(l *Ledger) {
l.Classes[2].Consult = nil
}, "MISSING_CONSULTATION", "DC-PLATFORM-INTERFACE"},
{"author approves own decision", func(l *Ledger) {
l.Reviews[2].Approvals = append(l.Reviews[2].Approvals, Approval{"bob", Approved, "self"})
}, "SELF_APPROVAL", "R-1003"},
{"verifier never signed", func(l *Ledger) {
l.Reviews[1].Approvals = removeApproval(l.Reviews[1].Approvals, "judy")
}, "MISSING_VERIFIER_APPROVAL", "R-1002"},
{"breaking change with short notice", func(l *Ledger) {
l.Contracts[0].NoticeDays = 7
}, "BREAKING_CHANGE_UNSAFE", "CT-ORDER-EVENTS"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
l := baseline()
tc.mutate(&l)
if !has(l.Validate(), tc.code, tc.subject) {
t.Fatalf("want %s on %q, got %v", tc.code, tc.subject, l.Validate())
}
})
}
}
func removeApproval(values []Approval, reviewer string) []Approval {
out := values[:0]
for _, v := range values {
if v.Reviewer != reviewer {
out = append(out, v)
}
}
return out
}
func has(findings []Finding, code, subject string) bool {
for _, f := range findings {
if f.Code == code && f.Subject == subject {
return true
}
}
return false
}
实测输出
$ go test ./internal/decision/ -run TestBaselineReport -v
=== RUN TestBaselineReport
decision_test.go:37: classes=4 people=7 contracts=2 reviews=3 findings=0
--- PASS: TestBaselineReport (0.00s)
PASS
ok archlab/internal/decision 0.454s
基线零违规;完整测试的五个变异全部被抓住,gofmt -l 无输出,go vet ./... 无告警。这些数字也能当治理指标:决策类增加而评审记录没增加,说明新决策没有留痕;契约增加而 C 列没变,说明平台在单方面扩接口。
常见错误与边界
第一,把 D 给职位最高的人。D 应该给承担后果、能在故障群里做取舍的角色;职位高但不值班的人签字,等于把责任转嫁给执行者。第二,矩阵里写人名。人一换矩阵就失效,应该写角色,人名只出现在评审记录里。
第三,D 和 V 是同一个人。自己决策、自己验收,记录再完整也只是形式;校验器会要求 V 由另一人担任。第四,契约只写“尽力而为”。没有 SLI、版本规则和通知期,契约无法验收,故障时只能争论“当初说没说”。
边界也要说清楚:跨公司接口、开源社区贡献者和法务合规签字不适用这套矩阵;紧急故障可以事后补记录,但补记录时必须保留当时的决策依据,不能把事后解释写成事前审批。
小结
决策权的落地物不是一张组织图,而是三样可检查的东西:一张按范围分配 D/E/V/C 的矩阵、一份带 SLI 和变更规则的平台契约、以及每条决策的评审记录。arch-lab 的 internal/decision 把三者放进同一个账本,用纯函数校验越权、缺咨询、自审、缺验收和破坏性变更通知。基线零违规、五个变异全部被抓住。本节只固定决策权归谁和单条记录是否完整;9.3 把单条记录扩展成带时间维度的最小治理流程,例外、复审和指标都在那里展开。
阅读导航:上一节:9.1 康威定律与团队拓扑 · 下一节:9.3 架构治理的最小流程 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。