Глава 18

Разделите возврат на preview, approval и commit

Возврат денег - необратимое действие, и агент не выполняет его сам. Agent loop может подготовить расчёт (preview), пользователь подтверждает точный расчёт (approval), а отдельный service повторно проверяет всю бизнес-политику и выполняет идемпотентную транзакцию (commit). Три шага - три уровня доверия.

Preview - это tool: он проверяет владение и политику и создаёт запись в доверенном хранилище.

TypeScript
type RefundPreview = {
  id: string;
  actorId: string;
  orderId: string;
  orderVersion: number;
  amountMinor: number;
  currency: string;
  reasonCode: string;
  expiresAt: string;
  status: "pending";
};

async function refundPreview(raw: unknown, context: TrustedToolContext) {
  const input = refundPreviewInput.parse(raw);
  const order = await requireOwnedOrder(input.order_id, context);
  const decision = await refundPolicy.evaluate(order, input.reason_code);

  if (!decision.allowed) return { eligible: false, reason: decision.publicReason };
  const preview = await previews.createFromDecision(order, decision, context.actorId);
  return { eligible: true, preview_id: preview.id };
}

А commit - это отдельный endpoint, который модель не вызывает вовсе.

TypeScript
export async function POST(request: Request) {
  const actor = await requireSession(request);
  const body = approvalInput.parse(await request.json());

  const result = await db.transaction(async function commit(tx) {
    const preview = await tx.refundPreview.lock(body.preview_id);
    requireSameActor(preview, actor.id);
    requirePendingAndFresh(preview, new Date());

    const order = await tx.order.lock(preview.orderId);
    requireVersion(order, preview.orderVersion);
    await refundPolicy.revalidate(order, preview);

    const refund = await payments.refund({
      orderId: order.id,
      amountMinor: preview.amountMinor,
      currency: preview.currency,
      idempotencyKey: `refund:${preview.id}`
    });

    await tx.refundPreview.markCommitted(preview.id, refund.id);
    return refund;
  });

  return Response.json({ status: "completed", refund_id: result.id });
}

Посмотрите, что происходит в commit: он блокирует preview и заказ, сверяет actor, срок, версию заказа, ещё раз проверяет политику и выполняет платёж с idempotency key. Ни одно из этих значений не приходит от модели.

"Пользователь согласился" не является аргументом tool. Подтверждение связано с конкретным preview id, actor, суммой, сроком и версией заказа. Изменение любого из этих значений требует нового preview, а не нового "да".

Возврат разделён и защищён. Но не всякий случай агент должен решать сам - иногда правильный исход это передача оператору, и передавать нужно доказанный контекст.

Проверка знаний

Кто выполняет commit возврата денег?

Ссылки