《互联网产品的 AI 架构·多能力编排与任务闭环》9.2 外部动作的授权边界

外部动作一旦落地就不可逆:下单、发消息、退款不能只靠模型「觉得可以」。本节把授权拆成主体策略、一次性令牌、幂等键与人工确认四道闸门,说明谁批准、一次授权为何只能对应一次动作,并给出可运行的 TypeScript 判定模块与十二种场景的示例运行结果。

一个已经下了三次的单

「回声」的站内下单咨询链最后一步是 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 不等于这一次下单已经被批准。
  • 令牌可以跨动作复用。令牌必须绑定主体、动作与资源。
  • 幂等键由模型生成。模型改写措辞就会产生新键,重复动作仍会落地。
  • 幂等只比较自然语言描述。应比较动作、资源、金额等结构化指纹。
  • 不可逆动作缺少人工确认。退款、删除、发布都需要明确批准人。
  • 允许发起人自己批准。审批必须满足职责分离。
  • 确认不绑定请求指纹。批准可能被挪用到另一笔金额不同的动作。
  • 确认窗口无限长。金额与商品可能已经变化,过期确认必须失效。
  • 被拒绝的请求也消费令牌。补确认时应复用原令牌,而不是重新授权。
  • 把授权拒绝计入链路成功率。拦截与模型失败是两类指标。
  • 先执行外部动作再补确认。确认必须发生在副作用之前。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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