两次评审,两个相反的结论
「回声」在一个月内评审了两次模型切换。第一次会上,平台架构师与安全都同意,结论是「先灰度 10%」;第二次会上,评测负责人没到场,结论却变成「全量上线」。两次都没有正式记录,只有群聊里两条互相矛盾的消息。三周后质量回退,没人能说清当时依据的是哪份评测、谁投了赞成票。
评审委员会真正要解决的,不是「有没有开会」,而是「结论能不能被重算」。只要决策记录里留下议题类型、到会席位、每一票与批准条件,下一个接手的人就能独立复现结论;否则委员会只是另一场没有留痕的讨论。
七个席位,各有明确的否决理由
席位按职责划分,不按部门划分。同一个人可以兼任,但一个席位不能因为「没空」而永久缺席,否则该席位形同虚设。
| 席位 | 关注点 | 典型否决理由 |
|---|---|---|
platform-architect | 契约兼容、复用边界、版本策略 | 变更破坏现有契约,或绕过版本发布 |
security | 提示注入、工具权限、数据出域 | 未做威胁建模,或工具权限超出最小集合 |
compliance | 数据分级、留存删除、对外披露 | 数据分级不清,或留存期限无法满足 |
finance | 预算总额、成本归因、内部计价 | 超出总额,或成本无法落到团队 |
sre | 容量、SLO、值班与告警 | 没有容量计划,或告警无人接收 |
business-rep | 业务连续性、迁移窗口、调用方影响 | 迁移窗口不可执行,或调用方未通知 |
eval-owner | 评测集版本、污染检查、回归门禁 | 没有基线,或评测样本已污染 |
七类议题,各自固定规则
议题类型决定到会人数、否决席、最低同意票与复查窗口。规则写在代码常量里,不靠会议主持人临场解释。
| 议题 | 必到席位 | 否决席 | 最低同意 | 复查窗口 |
|---|---|---|---|---|
新能力 new-capability | 架构、安全、业务、评测 | 架构、安全 | 3 | 90 天 |
主版本 major-version | 架构、SRE、业务 | 架构 | 2 | 90 天 |
跨租户配额 cross-tenant-quota | 架构、财务、业务 | 财务 | 2 | 30 天 |
模型切换 model-change | 架构、安全、评测、财务 | 安全、评测 | 3 | 30 天 |
能力退役 retirement | 架构、业务、SRE | 业务 | 2 | 90 天 |
事故复盘 incident-postmortem | SRE、安全、业务 | SRE | 2 | 30 天 |
例外 exception | 架构、安全、合规 | 安全、合规 | 3 | 14 天 |
议题路由还指定议程负责人:新能力与主版本由平台架构师排期,配额由财务,模型切换由评测负责人,退役与事故由 SRE,例外由平台架构师。负责人只负责把材料凑齐,不因此多一票。
判定顺序:流会、否决、弃权、票数
票数不能直接比较,必须先过三道门。顺序如下:
- 必到席位中有任何缺席,结论是
deferred,原因是流会。邮件补签不算到会,因为补签无法确认当事人看过材料。 - 任一否决席投反对,结论是
rejected。否决席不需要多数支持,一票即可。 - 任一否决席投弃权,结论是
deferred。弃权不是同意,沉默不能被解释为放行。 - 同意票达到最低票数且多于反对票,结论是
approved;否则按反对票是否不少于同意票,判为rejected或deferred。
这里有两个容易争议的取舍。第一,否决席弃权判 deferred 而不是 rejected,因为它表达的是「尚未确认」,不是「反对」,应当允许补充材料后再议。第二,普通席位弃权不计入分母,最低同意票是绝对票数,不是出席人数的比例;这样 3 票门槛不会因为多来几个人而被稀释。
决策记录必须能重算结论
一份可复查的记录至少包含十个字段:记录号、议题类型、能力、决策日期、主持席位、每一票、结论、理由、附加条件、复查日期。记录号固定为 RB-年份-三位序号,便于在事故复盘与审计中引用。
校验规则不是「字段齐全」这么简单,而是四条硬约束:
- 主持人必须是该议题的必到席位。主持人不在必到名单里,说明这场会开错了。
outcome必须能由ballots重算出来。记录写「批准」、票数却只够流会,校验直接报outcome-mismatch。- 退役与例外议题批准时必须至少有一条附加条件。退役没有迁移窗口、例外没有补偿措施,等于无条件放行。
- 批准的记录必须写
reviewAt,且晚于决策日期、不超过议题复查窗口。复查日期随议题类型自动计算,避免手填一个「一年后再说」。
把路由与判定写成确定性模块
review.ts 提供四组能力:议题路由、投票判定、记录校验、默认复查日。路由返回席位、否决权、票数门槛与窗口;判定严格按流会、否决、弃权、票数的顺序返回结论;记录校验会重新跑一遍判定,把 outcome 与票数对不上的记录挑出来。
// file: src/gov/review.ts
// tier: L2
// verify: node --test test/gov/org.test.ts
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
export function isIsoDate(value: string): boolean {
if (!ISO_DATE.test(value)) return false;
const parsed = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
}
export function addDays(date: string, days: number): string {
const parsed = new Date(`${date}T00:00:00Z`);
parsed.setUTCDate(parsed.getUTCDate() + days);
return parsed.toISOString().slice(0, 10);
}
export function daysBetween(from: string, to: string): number {
const start = new Date(`${from}T00:00:00Z`).getTime();
const end = new Date(`${to}T00:00:00Z`).getTime();
return Math.round((end - start) / 86_400_000);
}
/** 评审委员会只有这七个席位;席位是职责,不是部门名称。 */
export type BoardRole =
| "platform-architect" | "security" | "compliance" | "finance"
| "sre" | "business-rep" | "eval-owner";
export const BOARD_ROLES: readonly BoardRole[] = [
"platform-architect", "security", "compliance", "finance",
"sre", "business-rep", "eval-owner",
];
export type TopicKind =
| "new-capability" | "major-version" | "cross-tenant-quota" | "model-change"
| "retirement" | "incident-postmortem" | "exception";
export interface TopicRule {
/** 到会人数不足即流会,不允许用邮件补签替代。 */
requiredRoles: readonly BoardRole[];
/** 否决席:任一人反对即拒绝;弃权视为尚未同意。 */
vetoRoles: readonly BoardRole[];
minApprovals: number;
/** 批准后多久必须复查,天。 */
reviewWindowDays: number;
agendaOwner: BoardRole;
}
export const TOPIC_RULES: Record<TopicKind, TopicRule> = {
"new-capability": { requiredRoles: ["platform-architect", "security", "business-rep", "eval-owner"],
vetoRoles: ["platform-architect", "security"], minApprovals: 3, reviewWindowDays: 90,
agendaOwner: "platform-architect" },
"major-version": { requiredRoles: ["platform-architect", "sre", "business-rep"],
vetoRoles: ["platform-architect"], minApprovals: 2, reviewWindowDays: 90,
agendaOwner: "platform-architect" },
"cross-tenant-quota": { requiredRoles: ["platform-architect", "finance", "business-rep"],
vetoRoles: ["finance"], minApprovals: 2, reviewWindowDays: 30, agendaOwner: "finance" },
"model-change": { requiredRoles: ["platform-architect", "security", "eval-owner", "finance"],
vetoRoles: ["security", "eval-owner"], minApprovals: 3, reviewWindowDays: 30,
agendaOwner: "eval-owner" },
retirement: { requiredRoles: ["platform-architect", "business-rep", "sre"],
vetoRoles: ["business-rep"], minApprovals: 2, reviewWindowDays: 90, agendaOwner: "sre" },
"incident-postmortem": { requiredRoles: ["sre", "security", "business-rep"],
vetoRoles: ["sre"], minApprovals: 2, reviewWindowDays: 30, agendaOwner: "sre" },
exception: { requiredRoles: ["platform-architect", "security", "compliance"],
vetoRoles: ["security", "compliance"], minApprovals: 3, reviewWindowDays: 14,
agendaOwner: "platform-architect" },
};
export type Vote = "approve" | "reject" | "abstain" | "absent";
export type BallotBox = Partial<Record<BoardRole, Vote>>;
export type ReviewOutcome = "approved" | "rejected" | "deferred";
export interface ReviewResult {
topic: TopicKind;
outcome: ReviewOutcome;
approvals: number;
rejects: number;
abstentions: number;
missingRoles: readonly BoardRole[];
vetoRejects: readonly BoardRole[];
vetoPending: readonly BoardRole[];
reasons: readonly string[];
}
export interface AgendaRoute {
topic: TopicKind;
rule: TopicRule;
quorum: number;
decisionRule: string;
}
/** 议题先路由到固定席位与固定规则,避免每次开会临时决定谁有否决权。 */
export function routeTopic(topic: TopicKind): AgendaRoute {
const rule = TOPIC_RULES[topic];
return {
topic, rule, quorum: rule.requiredRoles.length,
decisionRule: `至少 ${rule.minApprovals} 票同意且同意票多于反对票;`
+ `否决席 ${rule.vetoRoles.join(" / ")} 弃权视为未通过`,
};
}
/**
* 判定顺序:先看流会,再看否决,再看否决席是否表态,最后看票数。
* 流会与弃权都返回 deferred,只有明确反对或票数劣势才返回 rejected。
*/
export function decideReview(topic: TopicKind, ballots: BallotBox): ReviewResult {
const rule = TOPIC_RULES[topic];
const missingRoles = rule.requiredRoles.filter((role) => (ballots[role] ?? "absent") === "absent");
const vetoRejects = rule.vetoRoles.filter((role) => ballots[role] === "reject");
const vetoPending = rule.vetoRoles.filter((role) => ballots[role] === "abstain");
const count = (vote: Vote): number => BOARD_ROLES.filter((role) => ballots[role] === vote).length;
const result = (outcome: ReviewOutcome, reason: string): ReviewResult => ({
topic, outcome, approvals: count("approve"), rejects: count("reject"),
abstentions: count("abstain"), missingRoles, vetoRejects, vetoPending, reasons: [reason],
});
if (missingRoles.length > 0) return result("deferred", `quorum missing: ${missingRoles.join(", ")}`);
if (vetoRejects.length > 0) return result("rejected", `vetoed by ${vetoRejects.join(", ")}`);
if (vetoPending.length > 0) {
return result("deferred", `veto seat has not approved: ${vetoPending.join(", ")}`);
}
const approvals = count("approve");
const rejects = count("reject");
if (approvals >= rule.minApprovals && approvals > rejects) {
return result("approved", `${approvals} approvals, threshold ${rule.minApprovals}`);
}
return result(rejects >= approvals ? "rejected" : "deferred",
`${approvals} approvals against ${rejects} rejects, threshold ${rule.minApprovals}`);
}
export interface DecisionRecord {
/** 固定格式 RB-年份-三位序号,例如 RB-2026-011。 */
id: string;
topic: TopicKind;
capability: string;
decidedAt: string;
chair: BoardRole;
ballots: BallotBox;
outcome: ReviewOutcome;
rationale: string;
/** 批准附带条件;退役与例外议题必须至少写一条。 */
conditions: readonly string[];
/** 复查日期;批准的记录必填。 */
reviewAt?: string;
}
export type DecisionIssueCode =
| "invalid-id" | "unknown-topic" | "empty-capability" | "invalid-date"
| "chair-not-required" | "missing-rationale" | "outcome-mismatch"
| "missing-conditions" | "missing-review-date" | "review-not-after-decision"
| "review-too-late";
export interface DecisionIssue {
id: string;
code: DecisionIssueCode;
field: string;
detail: string;
}
const RECORD_ID = /^RB-\d{4}-\d{3}$/;
/** 决策记录不是会议纪要:outcome 必须能由 ballots 重算出来。 */
export function validateDecision(record: DecisionRecord): DecisionIssue[] {
const issues: DecisionIssue[] = [];
const add = (code: DecisionIssueCode, field: string, detail: string): void => {
issues.push({ id: record.id, code, field, detail });
};
const rule = TOPIC_RULES[record.topic];
if (!RECORD_ID.test(record.id)) add("invalid-id", "id", "expected RB-YYYY-NNN");
if (rule === undefined) {
add("unknown-topic", "topic", `unknown ${record.topic}`);
return issues;
}
if (record.capability.trim() === "") add("empty-capability", "capability", "empty");
if (!isIsoDate(record.decidedAt)) add("invalid-date", "decidedAt", record.decidedAt);
if (!rule.requiredRoles.includes(record.chair)) add("chair-not-required", "chair", record.chair);
if (record.rationale.trim() === "") add("missing-rationale", "rationale", "empty");
const recomputed = decideReview(record.topic, record.ballots);
if (recomputed.outcome !== record.outcome) {
add("outcome-mismatch", "outcome", `ballots give ${recomputed.outcome}`);
}
if (record.outcome !== "approved") return issues;
if ((record.topic === "retirement" || record.topic === "exception")
&& record.conditions.length === 0) {
add("missing-conditions", "conditions", `${record.topic} approval needs a condition`);
}
if (record.reviewAt === undefined) {
add("missing-review-date", "reviewAt", "approved decisions must set reviewAt");
} else if (!isIsoDate(record.reviewAt)) {
add("invalid-date", "reviewAt", record.reviewAt);
} else if (isIsoDate(record.decidedAt)) {
const gap = daysBetween(record.decidedAt, record.reviewAt);
if (gap <= 0) add("review-not-after-decision", "reviewAt", record.reviewAt);
else if (gap > rule.reviewWindowDays) {
add("review-too-late", "reviewAt", `${gap} days exceeds ${rule.reviewWindowDays}`);
}
}
return issues;
}
/** 按议题规则计算默认复查日,避免每份记录手填一个随意日期。 */
export function nextReviewDate(topic: TopicKind, decidedAt: string): string {
return addDays(decidedAt, TOPIC_RULES[topic].reviewWindowDays);
}
/** 批量校验;同一记录号出现两次会让复查队列静默丢一条。 */
export function validateDecisions(records: readonly DecisionRecord[]): DecisionIssue[] {
const issues: DecisionIssue[] = [];
const seen = new Set<string>();
for (const record of records) {
if (seen.has(record.id)) issues.push({
id: record.id, code: "invalid-id", field: "id", detail: "duplicate decision id",
});
seen.add(record.id);
issues.push(...validateDecision(record));
}
return issues;
}
代码里的判定函数只依赖 TOPIC_RULES 与 ballots,不读会议纪要,也不读组织架构。这样同一份票型在任何时候重算都得到同一个结论,决策记录才具备审计价值。nextReviewDate 把窗口计算收进代码,记录里只存日期结果,不存「三个月后」这类无法比较的描述。
示例样本上的运行结果
示例样本用五组票型覆盖五种结论路径。以下是示例样本上的运行结果。
| 议题 | 票型 | 结论 |
|---|---|---|
| 新能力 | 四个必到席位全部同意 | approved,4 票同意,门槛 3 |
| 模型切换 | 3 票同意,评测席弃权 | deferred,否决席未表态 |
| 能力退役 | 三个必到席位全部同意 | approved,3 票同意,门槛 2 |
| 跨租户配额 | 财务反对,其余同意 | rejected,被财务否决 |
| 例外 | 安全反对,其余同意 | rejected,被安全否决 |
五组票型得到 2 个批准、1 个流会、2 个否决。注意模型切换的 3 票已经达到最低票数,但因为评测席弃权,结论仍是 deferred;这正是「否决席弃权不算同意」规则的直接体现。
记录校验用四条记录覆盖三种问题:一条完全合法;一条把结论写成 deferred,但票数实际是 approved;一条退役记录批准却没有附加条件;一条退役记录把复查日期写到 208 天后,超过 90 天窗口。四条记录返回 3 个问题,分别对应 outcome-mismatch、missing-conditions、review-too-late。
如果按规则计算默认复查日,11 月 5 日批准的例外议题应在 11 月 19 日复查;退役议题应在 90 天后,即次年 2 月 3 日复查。日期由代码计算,团队只需要决定是否接受这个节奏。
复查不是走过场
批准之后的复查要回答三个问题:附加条件是否完成、指标是否仍在阈值内、当初的假设是否还成立。复查结果写回同一条记录,不另开一份文档,避免「决策记录」与「复查记录」各说各话。
连续两次复查超期的议题,自动回到委员会议程,按原议题类型重新投票。这样做不是为了惩罚,而是因为超过两个窗口仍未复查,说明当初的风险判断已经失去时效。
小结
评审委员会由七个席位组成,每个席位对应一类关注点与否决理由。七类议题各有固定的必到席位、否决席、最低同意票与复查窗口;判定顺序是流会、否决、弃权、票数,否决席弃权一律判 deferred。
决策记录的价值在于可重算:outcome 必须由 ballots 推出,主持席位必须在必到名单内,退役与例外批准必须带条件,批准必须写复查日期且不超过窗口。示例样本的五组票型得到 2 个批准、1 个流会、2 个否决;四条记录中三条被校验拒绝。
阅读导航:上一节:11.1 谁拥有 AI 能力:平台团队与业务团队 · 下一节:11.3 培训、文档与知识传递 。
常见错误与边界
- 只记结论,不记票型。结论无法重算,下一次评审只能重新争论。
- 允许邮件补签替代到会。补签无法证明当事人看过材料,流会门槛失效。
- 把否决席弃权当同意。沉默被解释为放行,风险最高的环节反而最容易被绕过。
- 所有议题用同一套规则。配额变更与模型切换的风险结构不同,统一规则会让该严的不严、该快的不快。
- 主持人不在必到席位。会议开得再完整,也不具备该议题的决策资格。
- 批准退役不写迁移窗口。旧版本下线时才发现调用方仍在,附加条件形同虚设。
- 复查日期写成「三个月后」。无法比较、无法排序,也无法自动提醒。
- 决策记录与复查记录分开维护。两份文档一旦不一致,审计时无法判断哪份有效。
- 只用同意票比例判定。多来几个旁观者就能稀释门槛,最低票数必须是绝对数。
- 把委员会当成审批瓶颈。规则固定的目的是减少临场争论,不是让所有小事都排队。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。