Human-in-the-loop approval workflows for AI-generated side effects

When assistants send email, charge cards, or mutate records, treat model output as a proposal—not an action. State machines, idempotency, audit trails, and timeout policies for production AI backends.

Autor: Matheus Palma8 Min. Lesezeit
Software engineeringArtificial intelligenceBackendAPI designSecurityTypeScript

A product manager asks for a copilot that “just sends the follow-up email” after a support conversation. You wire the model to your mail provider, add a cheery system prompt, and ship. Two weeks later, finance notices duplicate invoices: the model drafted a refund note, the user clicked send twice during a retry storm, and a background job reprocessed the same transcript after a deploy. None of these failures look like classic bugs—they are concurrency, idempotency, and authorization problems wearing an LLM costume.

Whenever an AI feature can change money, data, or obligations outside your own database, you need a human-in-the-loop (HITL) path that is as rigorous as any payment or compliance workflow. This article describes how to model those paths so they stay correct under retries, auditable, and safe when humans are slow or absent. The patterns come up repeatedly when helping teams move from demos to production-ready integrations where the model proposes and humans (or policy engines) dispose.

Why “ask the model to be careful” is not a control

Prompt engineering reduces obvious mistakes; it does not create authorization boundaries. A model has no stable concept of “this user may transfer up to $500” or “this tenant cannot email external domains.” It emits text (or structured tool calls) that your application must interpret.

A HITL workflow is therefore not a UX nicety—it is where you:

  • Bind proposed actions to authenticated principals, scopes, and limits.
  • Serialize side effects so duplicate proposals do not double-execute.
  • Record who approved what, when, and under which policy version.
  • Recover from partial failure (provider timeouts after send, stuck approvals, operator overrides).

Skipping this layer is how “AI features” become incident generators.

Separate proposal, approval, and execution

Think in three explicit phases:

1. Proposal (model or rules engine)

The system produces a draft artifact: structured JSON, a row in action_proposals, or an event in an outbox. It must include everything needed to execute later without re-querying the model—recipient, amount, idempotency key, correlation id, and a hash of the inputs that justified the action.

Why: If execution happens hours later, you do not want to re-run inference to “reconstruct” intent. Re-inference is non-deterministic, expensive, and may diverge from what the human saw.

2. Approval (human or automated policy)

A human or a rules service transitions the proposal from pending_review to approved or rejected. Approvals should reference a stable proposal id, not raw prompt text.

Why: Text is not a primary key. Two operators approving “the same” draft from different UI rows is a classic double-execution bug.

3. Execution (idempotent workers)

Only the execution phase talks to SMTP, payment rails, CRM APIs, or your own write models. It should accept an idempotency key issued at proposal creation (or approval) so retries and duplicate deliveries collapse to one external effect.

Why: Networks and queues retry. Your correctness story must survive at-least-once delivery.

State machines beat ad hoc booleans

Represent each proposal as a finite state machine with explicit transitions, for example:

  • pending_reviewapprovedexecutingcompleted
  • Terminal states: rejected, expired, failed_permanent

Guard each transition:

  • pending_reviewapproved: caller holds reviewer role; proposal not expired; tenant still entitled to the action.
  • approvedexecuting: single-flight lock or conditional update so two workers cannot both execute.
  • executingcompleted: external provider acknowledges success with a durable id stored on the row.

This is the same discipline you would apply to subscription billing or KYC review—AI does not relax the rules, it only increases the volume of proposals.

Expiry and operator load

Pending proposals should expire. Stale approvals are dangerous: prices, inventory, and compliance context change. Default expiries (for example 24–72 hours depending on domain) plus visible countdowns in the UI reduce “sleepy approval” incidents.

When consulting on workflow-heavy products, teams often underestimate operator throughput: if proposals arrive faster than humans can review, you need backpressure (rate limits on proposal creation), triage queues, or automated pre-approval for low-risk classes—not an ever-growing pending pile.

Automated policy vs human review

Not every action needs eyeballs. Many systems use tiered gates:

  • Auto-approve when structured checks pass (amount under threshold, recipient in allowlist, duplicate hash not seen in 24h).
  • Human required for cross-tenant data, financial movement, or legal templates.
  • Always deny for blocked intents regardless of model confidence.

Document which policy version approved execution. When regulators or customers ask “why did this email go out?”, you answer with machine-readable evidence, not chat logs alone.

Audit trails and least privilege

Minimum viable audit fields:

  • Proposal id, tenant id, actor who triggered proposal generation (user or system job).
  • Model and prompt template version (not necessarily full prompts if they contain PII—store references or redacted hashes).
  • Reviewer identity, timestamp, and optional comment.
  • Execution outcome, external provider ids, and idempotency key.

Access to full prompts and PII-bearing context should follow least privilege—customer support tooling is a common exfiltration path once AI features centralize sensitive text.

Practical example: proposal store and idempotent executor

The following example sketches a Node.js / TypeScript service using an SQL-ish repository interface. It is illustrative: swap in your ORM, transactional outbox, or workflow engine as needed.

import { createHash, randomUUID } from "crypto";

export type ProposalStatus =
  | "pending_review"
  | "approved"
  | "rejected"
  | "executing"
  | "completed"
  | "failed_permanent"
  | "expired";

export type EmailAction = {
  kind: "send_email";
  to: string[];
  subject: string;
  bodyText: string;
};

export type ActionProposal = {
  id: string;
  tenantId: string;
  requestedByUserId: string;
  idempotencyKey: string;
  inputFingerprint: string;
  action: EmailAction;
  status: ProposalStatus;
  policyVersion: string;
  reviewerUserId?: string;
  reviewedAt?: Date;
  executedAt?: Date;
  externalMessageId?: string;
};

export interface ProposalRepository {
  insertProposal(p: ActionProposal): Promise<void>;
  /** Returns null if no row updated (wrong status or version). */
  tryTransition(
    id: string,
    from: ProposalStatus,
    to: ProposalStatus,
    patch?: Partial<ActionProposal>,
  ): Promise<ActionProposal | null>;
  findById(id: string): Promise<ActionProposal | null>;
}

export function fingerprintAction(action: EmailAction, tenantId: string): string {
  const canonical = JSON.stringify({ tenantId, action });
  return createHash("sha256").update(canonical).digest("hex");
}

export async function createEmailProposal(
  repo: ProposalRepository,
  params: {
    tenantId: string;
    requestedByUserId: string;
    clientSuppliedIdempotencyKey?: string;
    action: EmailAction;
    policyVersion: string;
  },
): Promise<ActionProposal> {
  const idempotencyKey = params.clientSuppliedIdempotencyKey ?? randomUUID();
  const inputFingerprint = fingerprintAction(params.action, params.tenantId);

  const proposal: ActionProposal = {
    id: randomUUID(),
    tenantId: params.tenantId,
    requestedByUserId: params.requestedByUserId,
    idempotencyKey,
    inputFingerprint,
    action: params.action,
    status: "pending_review",
    policyVersion: params.policyVersion,
  };

  await repo.insertProposal(proposal);
  return proposal;
}

export async function approveProposal(
  repo: ProposalRepository,
  proposalId: string,
  reviewerUserId: string,
): Promise<ActionProposal | null> {
  return repo.tryTransition(proposalId, "pending_review", "approved", {
    reviewerUserId,
    reviewedAt: new Date(),
  });
}

export async function executeApprovedEmailOnce(
  repo: ProposalRepository,
  sendEmail: (p: ActionProposal) => Promise<{ messageId: string }>,
  proposalId: string,
): Promise<"completed" | "noop" | "failed"> {
  const locked = await repo.tryTransition(proposalId, "approved", "executing", {});
  if (!locked) {
    const current = await repo.findById(proposalId);
    if (current?.status === "completed") return "noop";
    return "failed";
  }

  try {
    const proposal = (await repo.findById(proposalId))!;
    const { messageId } = await sendEmail(proposal);
    await repo.tryTransition(proposalId, "executing", "completed", {
      executedAt: new Date(),
      externalMessageId: messageId,
    });
    return "completed";
  } catch (err) {
    await repo.tryTransition(proposalId, "executing", "failed_permanent", {});
    return "failed";
  }
}

Key properties this layout encodes:

  • Approval and execution are different transitions; only executeApprovedEmailOnce calls the mail provider.
  • tryTransition should be implemented as a single conditional UPDATE with WHERE status = :expected and RETURNING * so concurrency is safe at the database layer.
  • idempotencyKey is available if you prefer to dedupe at insert time instead of only at execution.

In real deployments you would enqueue execution after approval (outbox pattern), propagate OpenTelemetry trace ids across proposal and execution spans, and attach dead-letter handling for permanent failures.

The important invariant is unchanged whether you use a minimal table or a full workflow engine: only execution touches the outside world, and it does so at most once per approved proposal under realistic retry semantics.

Common mistakes and pitfalls

  • Re-running the model at execution time instead of persisting the approved payload. You get drift, higher cost, and disputes when behavior changes.
  • Storing only natural language as the executable spec. Parse into structured actions early; reject proposals that do not validate against a schema.
  • Missing expiry on pending items, leading to actions that are legally or commercially stale.
  • Treating “user clicked approve in the UI” as synchronous success without handling double-clicks and tab duplication—use disable-after-submit plus server-side single-flight.
  • Omitting tenant isolation in review queues so operators see cross-customer drafts—this is both a privacy defect and a source of wrong approvals.
  • Logging full prompts in plain text in centralized logging systems without retention policy alignment.

Conclusion

Human-in-the-loop workflows are how you keep AI-generated behavior inside the same safety bar as the rest of your product. Model output should create durable proposals; humans or policy services approve; workers execute with idempotency and clear audit history. That separation is what lets you scale traffic, survive retries, and still explain outcomes to customers and regulators.

Treat LLM output as a proposal, never as an immediate command to external systems. Prefer explicit state machines, idempotent execution, and expiry so concurrency and retries do not create duplicate real-world effects. Record policy and template versions with approvals so post-incident review is factual rather than anecdotal, and tier automation vs human review deliberately—backpressure proposal creation when human capacity is the bottleneck.

If you are designing similar controls for side-effecting assistants or migrating a pilot into a regulated environment, the engineering is mostly state, identity, and observability—areas where experienced backend support can save months of rework. More background on related skills and engagement options is on the about page; for a direct conversation about architecture or implementation, use contact.

Newsletter abonnieren

E-Mail erhalten, wenn neue Artikel erscheinen. Kein Spam — nur neue Beiträge von diesem Blog.

Über Resend. Abmeldung in jeder E-Mail möglich.