《互联网产品的 AI 架构·平台化与治理》11.2 评审委员会与决策记录

评审委员会的价值不在开会,而在可重算的决策记录。本节定义七个席位的职责、七类议题的到会人数与否决席,给出「流会、否决、弃权、票数」的判定顺序,并用代码把 outcome 从 ballots 重算出来。示例样本上五个议题得到批准、流会与否决三种结果,四条记录中三条因结果不一致、缺条件或复查超期被拒绝。

两次评审,两个相反的结论

「回声」在一个月内评审了两次模型切换。第一次会上,平台架构师与安全都同意,结论是「先灰度 10%」;第二次会上,评测负责人没到场,结论却变成「全量上线」。两次都没有正式记录,只有群聊里两条互相矛盾的消息。三周后质量回退,没人能说清当时依据的是哪份评测、谁投了赞成票。

评审委员会真正要解决的,不是「有没有开会」,而是「结论能不能被重算」。只要决策记录里留下议题类型、到会席位、每一票与批准条件,下一个接手的人就能独立复现结论;否则委员会只是另一场没有留痕的讨论。

七个席位,各有明确的否决理由

席位按职责划分,不按部门划分。同一个人可以兼任,但一个席位不能因为「没空」而永久缺席,否则该席位形同虚设。

席位关注点典型否决理由
platform-architect契约兼容、复用边界、版本策略变更破坏现有契约,或绕过版本发布
security提示注入、工具权限、数据出域未做威胁建模,或工具权限超出最小集合
compliance数据分级、留存删除、对外披露数据分级不清,或留存期限无法满足
finance预算总额、成本归因、内部计价超出总额,或成本无法落到团队
sre容量、SLO、值班与告警没有容量计划,或告警无人接收
business-rep业务连续性、迁移窗口、调用方影响迁移窗口不可执行,或调用方未通知
eval-owner评测集版本、污染检查、回归门禁没有基线,或评测样本已污染

七类议题,各自固定规则

议题类型决定到会人数、否决席、最低同意票与复查窗口。规则写在代码常量里,不靠会议主持人临场解释。

议题必到席位否决席最低同意复查窗口
新能力 new-capability架构、安全、业务、评测架构、安全390 天
主版本 major-version架构、SRE、业务架构290 天
跨租户配额 cross-tenant-quota架构、财务、业务财务230 天
模型切换 model-change架构、安全、评测、财务安全、评测330 天
能力退役 retirement架构、业务、SRE业务290 天
事故复盘 incident-postmortemSRE、安全、业务SRE230 天
例外 exception架构、安全、合规安全、合规314 天

议题路由还指定议程负责人:新能力与主版本由平台架构师排期,配额由财务,模型切换由评测负责人,退役与事故由 SRE,例外由平台架构师。负责人只负责把材料凑齐,不因此多一票。

判定顺序:流会、否决、弃权、票数

票数不能直接比较,必须先过三道门。顺序如下:

  1. 必到席位中有任何缺席,结论是 deferred,原因是流会。邮件补签不算到会,因为补签无法确认当事人看过材料。
  2. 任一否决席投反对,结论是 rejected。否决席不需要多数支持,一票即可。
  3. 任一否决席投弃权,结论是 deferred。弃权不是同意,沉默不能被解释为放行。
  4. 同意票达到最低票数且多于反对票,结论是 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 培训、文档与知识传递 。

常见错误与边界

  • 只记结论,不记票型。结论无法重算,下一次评审只能重新争论。
  • 允许邮件补签替代到会。补签无法证明当事人看过材料,流会门槛失效。
  • 把否决席弃权当同意。沉默被解释为放行,风险最高的环节反而最容易被绕过。
  • 所有议题用同一套规则。配额变更与模型切换的风险结构不同,统一规则会让该严的不严、该快的不快。
  • 主持人不在必到席位。会议开得再完整,也不具备该议题的决策资格。
  • 批准退役不写迁移窗口。旧版本下线时才发现调用方仍在,附加条件形同虚设。
  • 复查日期写成「三个月后」。无法比较、无法排序,也无法自动提醒。
  • 决策记录与复查记录分开维护。两份文档一旦不一致,审计时无法判断哪份有效。
  • 只用同意票比例判定。多来几个旁观者就能稀释门槛,最低票数必须是绝对数。
  • 把委员会当成审批瓶颈。规则固定的目的是减少临场争论,不是让所有小事都排队。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查