从一张表到一份可校验的 schema
卷三 11.3 已经说明能力目录为什么需要七个字段:能力名、版本、契约、SLO、成本、所有者与变更流程。那一节解决的是「目录该记什么」,这一节解决「记下来的东西如何被机器拒绝」。
差别很具体。七个字段写在文档里,仍然要靠人检查;写成 schema 之后,一条缺提供方、契约引用对不上、依赖成环或退役日期早于依赖的条目,会在提交时直接失败。多团队共享能力时,后者才是目录真正起作用的方式。
本节在 11.3 的基础上把字段扩到十一个:标识、提供方、版本、契约、默认档位、可覆盖参数、失败语义、计费单位、SLA、下游依赖、退役日期。新增的四个字段不是装饰,它们分别回答「谁能改」「失败怎么办」「按什么单位记账」「什么时候必须迁走」。成本不再作为条目字段,而是由计费单位与调用量在计费系统里结算;这样同一能力可以按次、按输入 token 或按输出 token 计价,不会把价格写死在目录里。卷三 11.3 的示例里三能力成本合计 4,240 微元、P95 合计 1,200ms;本节保留这两个合计作为对照,但不把单价塞回条目。
十一个字段分别约束什么
| 字段 | 形态 | 校验点 | 缺失或写错后的表现 |
|---|---|---|---|
| id | content.summarize | 小写点分段,至少两段 | 调用方按文档猜名字,指标无法按能力聚合 |
| provider | 团队标识 | 非空 | 事故、升级与退役找不到责任团队 |
| version | 语义化版本 | 可解析、同版本唯一 | 范围解析与回滚没有锚点 |
| contract | ref / inputs / outputs / cacheKey | ref 必须等于 contract:id@version;缓存键必须是输入子集 | 同一版本被换契约,调用方运行时才报错 |
| defaultTier | frontier / standard / economy | 枚举内 | 路由落到一个没人评审过的隐式默认 |
| overridable | 参数名、允许值、责任归属 | 允许值非空、不重复、责任归属合法 | 调用方不知道改了参数谁负责质量与成本 |
| failure | retry-safe / retry-idempotent / no-retry / human-escalation | 枚举内 | 重试与转人工策略无法从目录推导 |
| billingUnit | 按次 / 按千输入 token / 按千输出 token | 枚举内 | 配额与分摊使用互不兼容的单位 |
| sla | 可用率、P95、统计窗口 | 可用率在 (0,1],P95 为正,窗口写明分母 | 两个 SLA 数字口径不同却被放在一张表里比较 |
| dependsOn | 能力标识列表 | 存在、非自依赖、无环、不晚于被依赖方退役 | 退役顺序算不出来,迁移当天才发现依赖仍在 |
| retireAt | ISO 日期或 none | 真实日期 | 下游无法排期,能力会无声地「一直可用」 |
契约字段值得单独说。它不是一个文档链接,而是三个可比对的部分:ref 锁定版本,inputs 与 outputs 定义边界,cacheKey 规定调用方做缓存时必须覆盖哪些字段。cacheKey 必须是 inputs 的子集,否则缓存键里会出现能力根本没有承诺的字段,命中率看似很高,实际把不同输入混在一起。
SLA 的窗口也不是注释。availability 的分母必须写清楚:是「该版本的全部调用」,还是「排除调用方输入错误后的调用」。分母不同,99.9% 与 99.9% 可能差出一个数量级。目录不接受一个没有窗口的可用率。
缺字段的后果要能被复述
只告诉团队「必须填」没有用,团队会复制一条最像的旧条目。有效做法是给每个校验码配一句后果,让提交失败时同时看到「为什么」。
- 缺
provider:事故发生时找不到能改配置的人,升级审批也没有发起者。 - 缺
contract.inputs:调用方只能照文档猜字段,错了要等到运行时。 contract.ref与id@version不一致:版本锁定的语义被破坏,回滚也无法复现。cacheKey含非输入字段:缓存会把不同租户或不同提示版本的结果混用。- 缺
failure:调用方各自决定重试,动作节点可能被重复执行。 - 缺
billingUnit:租户总额用微元、团队份额用次数,配额无法相加。 - 缺
retireAt:下游永远不知道什么时候必须迁走,旧版本会无限期滞留。 - 依赖成环:没有任何安全的发布或退役顺序,两个团队互相等待。
这八条不是理论风险。在示例目录里,一条 chat.reply@9.9.9 同时写错档位、失败语义、SLA 与退役日期,校验一次返回六个问题;如果只在发布后人工检查,通常只会发现第一个。
把校验写成确定性模块
catalog.ts 提供五组能力:最小字段检查、单条目 schema 校验、整目录校验、ISO 日期判断与后果表。整目录校验在单条目之外补三类规则:同一 id@version 不能重复;依赖必须存在且不能指向自己;依赖图不能成环,调用方也不能比它依赖的能力更晚退役。
// file: src/gov/catalog.ts
// tier: L2
// verify: node --test test/gov/catalog.test.ts
import { compareSemver, parseSemver } from "../orch/platform.ts";
export type QualityTier = "frontier" | "standard" | "economy";
export type FailureMode = "retry-safe" | "retry-idempotent" | "no-retry" | "human-escalation";
export type BillingUnit =
| "micros-per-call"
| "micros-per-1k-input-tokens"
| "micros-per-1k-output-tokens";
export type Responsibility = "provider" | "consumer" | "shared";
export interface Sla {
/** Success rate in (0, 1]; the denominator is written into window. */
availability: number;
p95LatencyMs: number;
/** Human-readable denominator, for example "per version per rolling 28d". */
window: string;
}
export interface OverridableParam {
name: string;
allowed: readonly (string | number)[];
responsibility: Responsibility;
}
export interface CapabilityContract {
/** Must equal contract:<id>@<version>; a version can never swap contracts. */
ref: string;
inputs: readonly string[];
outputs: readonly string[];
/** Fields that must appear in a consumer cache key. */
cacheKey: readonly string[];
}
export interface CapabilityEntry {
id: string;
provider: string;
version: string;
contract: CapabilityContract;
defaultTier: QualityTier;
overridable: readonly OverridableParam[];
failure: FailureMode;
billingUnit: BillingUnit;
sla: Sla;
dependsOn: readonly string[];
/** "none" or an ISO date (YYYY-MM-DD). */
retireAt: string;
}
export type EntryIssueCode =
| "invalid-id"
| "invalid-version"
| "missing-provider"
| "contract-ref-mismatch"
| "empty-contract"
| "cache-key-not-input"
| "invalid-tier"
| "invalid-override"
| "duplicate-override"
| "invalid-failure"
| "invalid-billing-unit"
| "invalid-sla"
| "invalid-retirement-date"
| "self-dependency"
| "unknown-dependency"
| "dependency-cycle"
| "dependency-retired"
| "duplicate-version";
export interface EntryIssue {
id: string;
version: string;
code: EntryIssueCode;
detail: string;
}
export const MINIMUM_FIELDS = [
"id",
"provider",
"version",
"contract",
"defaultTier",
"overridable",
"failure",
"billingUnit",
"sla",
"dependsOn",
"retireAt",
] as const;
const ID = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/;
const DATE = /^\d{4}-\d{2}-\d{2}$/;
const TIERS: readonly QualityTier[] = ["frontier", "standard", "economy"];
const FAILURES: readonly FailureMode[] = [
"retry-safe",
"retry-idempotent",
"no-retry",
"human-escalation",
];
const BILLING: readonly BillingUnit[] = [
"micros-per-call",
"micros-per-1k-input-tokens",
"micros-per-1k-output-tokens",
];
const RESPONSIBILITIES: readonly Responsibility[] = ["provider", "consumer", "shared"];
export function isIsoDate(value: string): boolean {
if (!DATE.test(value)) return false;
const parsed = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
}
/** Report fields a caller forgot, before type-level validation runs. */
export function missingFields(raw: Record<string, unknown>): string[] {
const missing: string[] = [];
for (const field of MINIMUM_FIELDS) {
const value = raw[field];
if (value === undefined || value === null) {
missing.push(field);
} else if (typeof value === "string" && value.trim() === "") {
missing.push(field);
}
}
return missing;
}
/** One entry's own schema. Cross-entry rules live in validateCatalog. */
export function validateEntry(entry: CapabilityEntry): EntryIssue[] {
const issues: EntryIssue[] = [];
const add = (code: EntryIssueCode, detail: string): void => {
issues.push({ id: entry.id, version: entry.version, code, detail });
};
if (!ID.test(entry.id)) add("invalid-id", `bad capability id ${entry.id}`);
if (parseSemver(entry.version) === null) add("invalid-version", `bad version ${entry.version}`);
if (entry.provider.trim() === "") add("missing-provider", "provider is empty");
if (entry.contract.ref !== `contract:${entry.id}@${entry.version}`) {
add("contract-ref-mismatch", `expected contract:${entry.id}@${entry.version}`);
}
if (entry.contract.inputs.length === 0 || entry.contract.outputs.length === 0) {
add("empty-contract", "inputs and outputs are required");
}
const cacheKey = entry.contract.cacheKey;
const unknownKey = cacheKey.find((field) => !entry.contract.inputs.includes(field));
if (unknownKey !== undefined || new Set(cacheKey).size !== cacheKey.length) {
add("cache-key-not-input", `cache key ${unknownKey ?? "has duplicates"} is not an input`);
}
if (!TIERS.includes(entry.defaultTier)) add("invalid-tier", `unknown tier ${entry.defaultTier}`);
const names = new Set<string>();
for (const param of entry.overridable) {
if (param.name.trim() === "" || param.allowed.length === 0
|| !RESPONSIBILITIES.includes(param.responsibility)) {
add("invalid-override", `bad override ${param.name}`);
}
if (names.has(param.name)) add("duplicate-override", `duplicate override ${param.name}`);
names.add(param.name);
}
if (!FAILURES.includes(entry.failure)) add("invalid-failure", `unknown failure ${entry.failure}`);
if (!BILLING.includes(entry.billingUnit)) {
add("invalid-billing-unit", `unknown billing unit ${entry.billingUnit}`);
}
if (!(entry.sla.availability > 0 && entry.sla.availability <= 1)) {
add("invalid-sla", `availability ${entry.sla.availability}`);
}
if (!(Number.isFinite(entry.sla.p95LatencyMs) && entry.sla.p95LatencyMs > 0)) {
add("invalid-sla", `p95 ${entry.sla.p95LatencyMs}`);
}
if (entry.sla.window.trim() === "") add("invalid-sla", "SLA window has no denominator");
if (entry.retireAt !== "none" && !isIsoDate(entry.retireAt)) {
add("invalid-retirement-date", `bad retirement date ${entry.retireAt}`);
}
return issues;
}
function latest(entries: readonly CapabilityEntry[], id: string): CapabilityEntry | undefined {
return entries
.filter((entry) => entry.id === id && parseSemver(entry.version) !== null)
.sort((a, b) => compareSemver(b.version, a.version))[0];
}
/** Validate the whole catalog, including dependencies and duplicate versions. */
export function validateCatalog(entries: readonly CapabilityEntry[]): EntryIssue[] {
const issues: EntryIssue[] = [];
const ids = new Set(entries.map((entry) => entry.id));
const counts = new Map<string, number>();
for (const entry of entries) {
issues.push(...validateEntry(entry));
const key = `${entry.id}@${entry.version}`;
counts.set(key, (counts.get(key) ?? 0) + 1);
}
for (const [key, count] of counts) {
if (count > 1) {
const at = key.lastIndexOf("@");
issues.push({
id: key.slice(0, at),
version: key.slice(at + 1),
code: "duplicate-version",
detail: `${count} entries for ${key}`,
});
}
}
for (const entry of entries) {
for (const dep of entry.dependsOn) {
if (dep === entry.id) {
issues.push({ id: entry.id, version: entry.version, code: "self-dependency", detail: dep });
continue;
}
if (!ID.test(dep) || !ids.has(dep)) {
issues.push({
id: entry.id, version: entry.version, code: "unknown-dependency", detail: dep,
});
continue;
}
const target = latest(entries, dep);
if (target !== undefined && isIsoDate(target.retireAt)
&& (entry.retireAt === "none" || entry.retireAt > target.retireAt)) {
issues.push({
id: entry.id, version: entry.version, code: "dependency-retired",
detail: `${dep} retires at ${target.retireAt}`,
});
}
}
}
const graph = new Map<string, string[]>();
for (const entry of entries) {
graph.set(entry.id, entry.dependsOn.filter((dep) => ids.has(dep) && dep !== entry.id));
}
const state = new Map<string, 1 | 2>();
const reported = new Set<string>();
const visit = (id: string): void => {
state.set(id, 1);
for (const dep of graph.get(id) ?? []) {
if (state.get(dep) === 1 && !reported.has(id)) {
const entry = entries.find((item) => item.id === id)!;
issues.push({
id, version: entry.version, code: "dependency-cycle", detail: `cycle via ${dep}`,
});
reported.add(id);
} else if (state.get(dep) === undefined) {
visit(dep);
}
}
state.set(id, 2);
};
for (const entry of entries) {
if (state.get(entry.id) === undefined) visit(entry.id);
}
return issues;
}
/** Why a missing field hurts, used by the catalog review checklist. */
export const FIELD_CONSEQUENCES: Record<EntryIssueCode, string> = {
"invalid-id": "callers cannot address the capability or join metrics by name",
"invalid-version": "range resolution and rollback have no anchor",
"missing-provider": "no team owns incidents, upgrades or retirement",
"contract-ref-mismatch": "a version may silently change its payload",
"empty-contract": "callers guess fields and only fail at runtime",
"cache-key-not-input": "cache hits mix tenants or reuse stale inputs",
"invalid-tier": "routing falls back to an implicit default nobody reviewed",
"invalid-override": "an allowed value cannot be explained or audited",
"duplicate-override": "two owners claim the same parameter",
"invalid-failure": "retry and escalation policy cannot be derived",
"invalid-billing-unit": "quota and chargeback use incompatible units",
"invalid-sla": "capacity planning compares numbers with different denominators",
"invalid-retirement-date": "downstream plans cannot schedule a migration",
"self-dependency": "the dependency graph never reaches a leaf",
"unknown-dependency": "retirement order cannot be computed",
"dependency-cycle": "no safe retirement or rollout order exists",
"dependency-retired": "the caller outlives a capability it still needs",
"duplicate-version": "two payloads claim one immutable version",
};
代码里有两个刻意的取舍。第一,missingFields 把空数组视为「显式声明没有」,只有字段不存在、为 null 或空白字符串才算缺失;dependsOn: [] 是一条合法声明,不应该被当成漏填。第二,依赖退役检查只比较 ISO 日期字符串,不引入时区换算;目录日期是日历日,不是时刻,比较字符串已经足够且更稳定。
后果表用 Record<EntryIssueCode, string> 声明,新增一个校验码却忘记写后果时,TypeScript 会在编译期报错。这比在评审会上提醒「记得补文档」可靠。
示例样本上的运行结果
示例目录包含回声的五个能力:content.summarize、moderation.classify、chat.reply、support.assist、search.rerank,依赖关系为摘要依赖审核、回复依赖摘要与审核、客服辅助依赖回复。以下是示例样本上的运行结果。
| 操作 | 结果 |
|---|---|
| 五条完整条目 | 0 个问题 |
| 只填 id、provider、version | 缺 8 个字段:contract、defaultTier、overridable、failure、billingUnit、sla、dependsOn、retireAt |
| 三条问题条目 | 11 个问题:search 4 个(版本、提供方、契约引用、空契约)、chat 6 个(档位、失败语义、SLA 三处、退役日期)、自依赖 1 个 |
重复 id@version | 1 个 duplicate-version |
| 两个能力互相依赖 | 1 个 dependency-cycle |
| 调用方退役晚于被依赖能力 | 1 个 dependency-retired |
五条完整条目里,chat.reply 声明了 99.9% 可用率、420ms P95、按次计费与 retry-idempotent 失败语义;search.rerank 只有输入输出与缓存键,没有可覆盖参数。两者都能通过校验,因为「没有可覆盖参数」是显式声明,不是缺字段。
三条问题条目共 11 个问题,说明校验的价值不是找到一个错,而是一次把同一份提交里的错全部列出来。团队修完一轮即可重新提交,不用在「发布—报错—再发布」之间来回。
目录条目的三条硬规则
第一条,版本与契约绑定。contract:content.summarize@3.1.0 只能对应 3.1.0 的输入输出;字段变化必须发新版本,不能原地改契约。
第二条,依赖必须可排序。dependsOn 不只是展示关系,它决定发布与退役顺序。调用方的 retireAt 不能晚于它依赖的能力,否则迁移计划会在最后一步失败。
第三条,责任写进参数。可覆盖参数不是「调用方随便调」,每个允许值都带一个责任归属:provider 表示提供方仍对 SLA 负责,consumer 表示覆盖后的质量与成本由调用方承担,shared 表示双方按约定分摊。没有责任归属的覆盖,就是一次没有签字的变更。
小结
卷三 11.3 的七个字段回答「目录记什么」,本节把它们落成可校验 schema,并补上默认档位、可覆盖参数、失败语义、计费单位、下游依赖与退役日期。十一个字段分别对应标识、责任、兼容、边界、路由、变更权、失败策略、计价口径、容量承诺、发布顺序与生命周期。
校验模块在示例样本上对五条完整条目返回 0 个问题,对三条问题条目一次返回 11 个问题;只填三个字段的条目会被指出缺 8 项。目录只有能拒绝不合格条目,才配叫治理工具;否则它只是一张更长的 README。
阅读导航:上一节:1.3 平台与业务的职责边界 · 下一节:2.2 提供方与调用方的契约 。
常见错误与边界
- 把目录条目写成介绍卡。没有版本、契约与提供方,任何自动校验都无从谈起。
- 把
cacheKey当注释。缓存键不覆盖租户与提示版本,命中率越高,串数据越隐蔽。 - 用浮点金额记录价格。计费单位与调用量相乘会出现精度问题,统一用整数微元结算。
- 允许任意参数覆盖。没有允许值列表和责任归属,覆盖就是绕过提供方的 SLA。
- 把
dependsOn当展示关系。它决定发布与退役顺序,必须参与环检测。 - 调用方退役日期晚于依赖。迁移计划会在最后一步发现依赖已经下线。
- 只校验单条目,不校验整目录。重复版本、依赖环与退役顺序都跨条目。
- 把空数组当成漏填。
dependsOn: []与「没有这个字段」是两种语义。 - 一次只报一个错。团队会在发布与修字段之间来回多轮,成本远高于一次列全。
- 目录校验通过就认为能力可共享。SLA、配额与调用方契约仍要单独评审。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。