Split a Refund Into Preview, Approval, and Commit
A refund is an irreversible action, and the agent doesn't perform it itself. The agent loop may prepare the calculation (preview), the user confirms the exact calculation (approval), and a separate service re-checks the whole business policy and executes an idempotent transaction (commit). Three steps - three levels of trust.
Preview is a tool: it checks ownership and policy and creates a record in the trusted store.
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 };
}And commit is a separate endpoint that the model doesn't call at all.
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 });
}Look at what happens in commit: it locks the preview and the order, verifies the actor, the validity, the order version, re-checks the policy, and executes the payment with an idempotency key. None of these values comes from the model.
"The user agreed" is not a tool argument. The confirmation is tied to a specific preview id, actor, amount, validity, and order version. Changing any of these requires a new preview, not a new "yes."
The refund is split and protected. But not every case should the agent decide itself - sometimes the right outcome is a handoff to an operator, and you must pass proven context.
Who executes the refund commit?