一个已经下了三次的单
「回声」的站内下单咨询链最后一步是 action.createOrder。某天用户点了一次「帮我下单」,页面超时,客户端自动重试,编排器又重试了一次。三次请求都通过了权限检查,因为用户确实有 order: create 权限;结果创建了三张订单,仓库开始拣货,用户才发现。
权限检查回答的是「这个人能不能下单」,授权要回答的是「这一次、这个商品、这个金额、这个幂等键,是否只执行一次」。前者是静态能力,后者是一次性承诺。跨能力编排把这两件事混在一起,就会在重试、双击和超时补偿时制造重复副作用。
本节不重复卷一的密钥与注入基础,只讨论外部动作跨节点落地时的授权边界。三能力顺序编排的示例样本仍是 4,240 → 3,786 微元、1,200 → 1,269ms;授权闸门不改变模型价目表,它改变的是「动作能不能执行」。
授权不是权限
| 概念 | 回答的问题 | 生效范围 | 失败后果 |
|---|---|---|---|
| 权限 | 这个主体能不能做这类动作 | 一段时间、一类资源 | 拒绝访问 |
| 授权 | 这一次动作是否被批准 | 一个主体、一个资源、一次 | 拒绝执行 |
| 幂等 | 同一个业务动作重复到达怎么办 | 一个幂等键 | 返回原结果或冲突 |
| 确认 | 谁为这次不可逆动作负责 | 一次请求指纹 | 挂起等待人工 |
四者缺一不可。只有权限,重试会重复下单;只有授权没有幂等,超时后的补偿仍可能二次执行;只有幂等没有确认,模型可以在用户没看清金额时直接退款。
四道闸门
第一道是主体策略:租户、主体、允许的动作、金额上限、资源前缀必须同时匹配。第二道是令牌绑定:令牌只能用于它签发的那个动作与资源,不能跨动作复用。第三道是幂等:同一个键、同一个请求指纹返回原决策;同一个键、不同指纹视为冲突。第四道是人工确认:不可逆动作或达到金额阈值的动作,必须由非发起人批准。
示例里金额阈值取 100,000 微元,确认窗口取 10 分钟。两个数都是示例值,真实项目要按业务金额分布和审批时效标定。
谁批准,批准什么
确认不能只记一个「同意」。它必须绑定四件事:批准人、批准时间、请求指纹与确认渠道。批准人不能是发起人,否则等于自己给自己签字;请求指纹必须与当前动作一致,否则批准可能被挪用到另一笔退款;批准时间必须落在窗口内,太早的批准可能对应已经变化的金额。
代码里把确认渠道分成 human 与 policy 两类。前者是人工审批,后者是策略自动批准,例如小额退款走规则引擎。两者都产生确认记录,区别只在审计口径;policy 不等于「不需要确认」。
一次授权,一次动作
一次性令牌绑定 tenantId + subjectId + action + resourceId。第一次执行后令牌标记为已用;同一个令牌换一个动作或换一个资源,直接返回 token-mismatch;同一个令牌换一个新幂等键再次请求,返回 token-used。
幂等重放是唯一允许「再来一次」的路径:同一个幂等键、同一个请求指纹、同一个动作,返回第一次的决策 id,令牌不会被再次消费。它和令牌复用的区别是,重放没有产生新的执行意图,复用则是在旧授权上发起新动作。
幂等键放在哪里
幂等键由服务端生成或校验,不能由模型自由填写。模型可以建议「这是一次下单」,但幂等键应当来自业务请求 id、用户会话与动作类型的稳定组合。把模型输出直接当幂等键,会让同一业务动作在模型改写措辞后变成两个键。
请求指纹覆盖动作、资源、金额与关键参数。幂等判断只比较键与指纹,不比较自然语言描述;描述变了但金额、商品、收货人没变,仍应视为同一个动作。反过来,金额从 99 微元变成 199 微元,即使键相同也必须判冲突。
实现
下面的模块把主体策略、一次性令牌、幂等记录与人工确认放在一个判定函数里。它不调用支付或订单服务,只回答「这次动作是否被批准、令牌是否被消费」。
// file: src/orch/action-authz.ts
// tier: L1
// verify: node --test test/orch/permission.test.ts
import { AiError } from "../core/errors.ts";
/** 外部动作只列出有副作用的三类;读操作不走这套授权。 */
export type ActionKind = "order.create" | "message.send" | "refund.issue";
export interface ActionRequest {
requestId: string;
tenantId: string;
subjectId: string;
action: ActionKind;
resourceId: string;
amountMicros: number;
irreversible: boolean;
idempotencyKey: string;
payloadFingerprint: string;
requestedAt: number;
}
/** 主体策略:某人在某租户内能做哪些动作、金额上限与资源前缀。 */
export interface ActionPolicy {
tenantId: string;
subjectId: string;
actions: readonly ActionKind[];
maxAmountMicros: number;
resourcePrefixes: readonly string[];
}
/** 一次性令牌绑定主体、动作与资源,不能跨动作复用。 */
export interface OneTimeToken {
tokenId: string;
tenantId: string;
subjectId: string;
action: ActionKind;
resourceId: string;
expiresAt: number;
used: boolean;
}
export interface Confirmation {
approverId: string;
approvedAt: number;
requestFingerprint: string;
channel: "human" | "policy";
}
export interface IdempotencyRecord {
key: string;
fingerprint: string;
action: ActionKind;
status: "executed" | "rejected";
decisionId: string;
}
export interface AuthorizationState {
tokens: Map<string, OneTimeToken>;
idempotency: Map<string, IdempotencyRecord>;
}
export type AuthzReason =
| "ok"
| "unknown-subject"
| "action-not-granted"
| "resource-out-of-scope"
| "amount-exceeded"
| "token-missing"
| "token-mismatch"
| "token-used"
| "token-expired"
| "idempotency-required"
| "idempotent-replay"
| "idempotency-conflict"
| "confirmation-required"
| "self-approval"
| "confirmation-fingerprint"
| "confirmation-future"
| "confirmation-stale";
export interface AuthorizationDecision {
allowed: boolean;
reason: AuthzReason;
requiresConfirmation: boolean;
replay: boolean;
tokenConsumed: boolean;
decisionId: string;
}
/** 达到或超过这个金额的动作必须人工确认;不可逆动作无条件确认。 */
export const CONFIRMATION_THRESHOLD_MICROS = 100_000;
export const CONFIRMATION_WINDOW_MS = 10 * 60_000;
export function createAuthorizationState(): AuthorizationState {
return { tokens: new Map(), idempotency: new Map() };
}
function fail(message: string): never {
throw new AiError("invalid-request", `action-authz: ${message}`);
}
function nonEmpty(value: string, field: string): string {
const trimmed = value.trim();
if (trimmed === "") fail(`${field} 不能为空`);
return trimmed;
}
function decision(
request: ActionRequest,
allowed: boolean,
reason: AuthzReason,
extras: Partial<Pick<AuthorizationDecision, "requiresConfirmation" | "replay" | "tokenConsumed">> = {},
): AuthorizationDecision {
return {
allowed,
reason,
requiresConfirmation: extras.requiresConfirmation ?? false,
replay: extras.replay ?? false,
tokenConsumed: extras.tokenConsumed ?? false,
decisionId: `authz:${request.requestId}:${request.idempotencyKey}`,
};
}
/**
* 外部动作的授权判定。顺序是:主体策略、令牌绑定、幂等重放、人工确认、一次性消费。
* 任何一步失败都返回拒绝,不抛异常;抛异常只留给结构错误。
*/
export function authorizeAction(
request: ActionRequest,
token: OneTimeToken | null,
policy: ActionPolicy,
state: AuthorizationState,
confirmation: Confirmation | null = null,
now: number = request.requestedAt,
confirmationWindowMs = CONFIRMATION_WINDOW_MS,
): AuthorizationDecision {
nonEmpty(request.requestId, "requestId");
nonEmpty(request.subjectId, "subjectId");
nonEmpty(request.payloadFingerprint, "payloadFingerprint");
if (!Number.isFinite(request.amountMicros) || request.amountMicros < 0) {
fail("amountMicros 必须是 >= 0 的有限数");
}
if (!Number.isFinite(now) || !Number.isFinite(confirmationWindowMs) || confirmationWindowMs < 0) {
fail("时间参数非法");
}
if (request.idempotencyKey.trim() === "") {
return decision(request, false, "idempotency-required");
}
if (policy.tenantId !== request.tenantId || policy.subjectId !== request.subjectId) {
return decision(request, false, "unknown-subject");
}
if (!policy.actions.includes(request.action)) {
return decision(request, false, "action-not-granted");
}
if (!policy.resourcePrefixes.some((prefix) => request.resourceId.startsWith(prefix))) {
return decision(request, false, "resource-out-of-scope");
}
if (request.amountMicros > policy.maxAmountMicros) {
return decision(request, false, "amount-exceeded");
}
if (token === null) return decision(request, false, "token-missing");
if (
token.tenantId !== request.tenantId ||
token.subjectId !== request.subjectId ||
token.action !== request.action ||
token.resourceId !== request.resourceId
) {
return decision(request, false, "token-mismatch");
}
if (token.expiresAt <= now) return decision(request, false, "token-expired");
const existing = state.idempotency.get(request.idempotencyKey);
if (existing !== undefined) {
if (existing.fingerprint === request.payloadFingerprint && existing.action === request.action) {
return {
allowed: true,
reason: "idempotent-replay",
requiresConfirmation: false,
replay: true,
tokenConsumed: false,
decisionId: existing.decisionId,
};
}
return decision(request, false, "idempotency-conflict");
}
if (token.used) return decision(request, false, "token-used");
const needsConfirmation =
request.irreversible || request.amountMicros >= CONFIRMATION_THRESHOLD_MICROS;
if (needsConfirmation) {
if (confirmation === null) {
return decision(request, false, "confirmation-required", { requiresConfirmation: true });
}
if (confirmation.approverId === request.subjectId) {
return decision(request, false, "self-approval");
}
if (confirmation.requestFingerprint !== request.payloadFingerprint) {
return decision(request, false, "confirmation-fingerprint");
}
if (confirmation.approvedAt > now) {
return decision(request, false, "confirmation-future");
}
if (now - confirmation.approvedAt > confirmationWindowMs) {
return decision(request, false, "confirmation-stale");
}
}
token.used = true;
state.idempotency.set(request.idempotencyKey, {
key: request.idempotencyKey,
fingerprint: request.payloadFingerprint,
action: request.action,
status: "executed",
decisionId: `authz:${request.requestId}:${request.idempotencyKey}`,
});
return decision(request, true, "ok", { tokenConsumed: true });
}
authorizeAction 的检查顺序是固定的:主体策略、令牌绑定、幂等重放、人工确认、一次性消费。把幂等放在令牌消费之前,重放才能返回原决策;把确认放在消费之前,被拒绝的请求才不会白白烧掉令牌。
示例样本上的运行结果
把上面十二种请求输入判定函数,结果如下。单位是示例样本上的运行结果,不是生产数据。
| 场景 | 结果 | 令牌是否消费 |
|---|---|---|
| 首次发消息 | ok | 是 |
| 同一令牌换新幂等键 | token-used | 否 |
| 同键同指纹重放 | idempotent-replay | 否 |
| 同键不同指纹 | idempotency-conflict | 否 |
| 120,000 微元退款,无确认 | confirmation-required | 否 |
| 退款由发起人自己批准 | self-approval | 否 |
| 退款由他人批准且指纹一致 | ok | 是 |
| 金额 600,000 超过上限 | amount-exceeded | 否 |
| 幂等键为空 | idempotency-required | 否 |
| 令牌动作不匹配 | token-mismatch | 否 |
| 令牌已过期 | token-expired | 否 |
| 资源不在前缀内 | resource-out-of-scope | 否 |
关键的三个「否」:确认缺失、自己批准、指纹不符,都不会消费令牌。用户补一次合规确认后,同一个令牌仍然可用;如果第一次就消费了令牌,补确认反而要重新走一遍授权。
编排层怎么接
模型只输出动作提议,例如「创建订单,商品 A,数量 1」。服务端根据会话与权限上下文构造 ActionRequest,生成或读取幂等键,再签发一次性令牌。令牌不进入提示词,也不返回给模型;模型永远看不到可复用的凭证。
执行顺序应当是:授权判定 → 人工确认(如需要)→ 消费令牌 → 调用外部服务 → 把执行结果写入幂等记录。外部服务失败时,幂等记录标记为未完成,重试沿用同一个键;不能让模型重新生成一个键再来一次。
对于不可逆动作,确认节点应当阻塞在编排图里,而不是发一条异步通知就算完成。异步确认可以用于低风险动作,但它必须让动作停在「待执行」状态,不能先执行后补签。
与成功率口径的关系
三能力顺序编排的示例样本里,主场景成功率 99.86%,高失败变体 98.12%,每次成功交付 4,139 微元。这些数字统计的是模型链路是否产出可用结果,不包含授权拒绝。授权拒绝不是失败交付,它是一次正确的拦截;把它混进成功率,会让团队误以为模型变差。
反过来,重复下单也不是模型失败,而是授权边界失效。两类问题要分开计数:链路失败看节点重试与降级,动作失败看授权拒绝、幂等冲突与确认超时。
小结
外部动作的授权不是「有没有权限」的二次确认,而是四道独立闸门:主体策略、一次性令牌、幂等键与人工确认。示例样本里,首次动作消费令牌,重放不消费,令牌复用被拒绝;不可逆退款在缺少确认、自己批准或指纹不符时都不会消费令牌。
这套判定不改变三能力编排的 3,786 微元与 1,269ms 基线,但它决定了这条链路落地时会不会创建三张订单。
阅读导航:上一节:9.1 跨能力的权限传播 · 下一节:9.3 注入在多能力间的传播 。
常见错误与边界
- 把权限当授权。有
order: create不等于这一次下单已经被批准。 - 令牌可以跨动作复用。令牌必须绑定主体、动作与资源。
- 幂等键由模型生成。模型改写措辞就会产生新键,重复动作仍会落地。
- 幂等只比较自然语言描述。应比较动作、资源、金额等结构化指纹。
- 不可逆动作缺少人工确认。退款、删除、发布都需要明确批准人。
- 允许发起人自己批准。审批必须满足职责分离。
- 确认不绑定请求指纹。批准可能被挪用到另一笔金额不同的动作。
- 确认窗口无限长。金额与商品可能已经变化,过期确认必须失效。
- 被拒绝的请求也消费令牌。补确认时应复用原令牌,而不是重新授权。
- 把授权拒绝计入链路成功率。拦截与模型失败是两类指标。
- 先执行外部动作再补确认。确认必须发生在副作用之前。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。