TALDYN
‹ The Playbook Library
SCH-02 · SCHEMATIC

Approval Gates: Engineering Human-in-the-Loop Agent Workflows

"A human approves it" is a state machine, an idempotency contract, and an audit log — or it's a liability. The engineering behind approval gates that hold up under retries, race conditions, and auditors.

JUL 2 · 20265 min readEngineeringGovernanceArchitecture

Every serious agent workflow eventually reaches an action that shouldn't happen without a human: send the contract, issue the refund, change the record that matters. The design intent is easy to state — "a human approves it." The engineering is where deployments quietly go wrong, because an approval gate is a distributed-systems problem wearing a UX costume: requests retry, reviewers double-click, workers crash mid-execution, and the audit trail has to survive all of it.

01The action ledger: one table, all the guarantees

The core of a trustworthy gate is a ledger of proposed actions with an explicit state machine: proposed → pending_review → approved | rejected | expired, and approved → executing → executed | failed. Two properties are non-negotiable: an idempotency key so the same proposal can't be created twice by an agent retry, and single-transition updates so two reviewers (or two workers) can't both act on the same row.

sqlschema.sql — the action ledger
CREATE TABLE actions (
  id              bigserial PRIMARY KEY,
  idempotency_key text NOT NULL UNIQUE,   -- hash of (workflow, target, payload)
  workflow        text NOT NULL,          -- 'refund', 'contract_send', ...
  risk_tier       text NOT NULL CHECK (risk_tier IN ('auto','queue','approve')),
  payload         jsonb NOT NULL,         -- exactly what will be executed
  reasoning       text NOT NULL,          -- the agent's stated why
  inputs_snapshot jsonb NOT NULL,         -- what the agent saw when proposing
  status          text NOT NULL DEFAULT 'proposed'
                  CHECK (status IN ('proposed','pending_review','approved',
                                    'rejected','expired','executing',
                                    'executed','failed')),
  proposed_by     text NOT NULL,          -- agent run id
  decided_by      text,                   -- human identity, never a service acct
  decided_at      timestamptz,
  decision_note   text,
  expires_at      timestamptz NOT NULL,   -- stale approvals are dangerous
  executed_at     timestamptz,
  result          jsonb,
  created_at      timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX actions_review_queue_idx
  ON actions (status, expires_at) WHERE status = 'pending_review';

02Transitions as compare-and-swap

Every state change must be a conditional update that names the state it expects to leave. If the UPDATE matches zero rows, someone else got there first — and that's a normal outcome to handle, not an error to log and ignore. This one discipline eliminates the double-approve and the double-execute, the two bugs that end approval systems' credibility.

typescriptgate.ts — race-safe approve and execute transitions
export async function approve(
  db: Db, actionId: number, reviewer: string, note?: string
): Promise<"approved" | "conflict"> {
  const res = await db.query(
    "UPDATE actions SET status = 'approved', decided_by = $2,\n" +
    "       decided_at = now(), decision_note = $3\n" +
    " WHERE id = $1 AND status = 'pending_review' AND expires_at > now()\n" +
    " RETURNING id",
    [actionId, reviewer, note ?? null]
  );
  return res.rowCount === 1 ? "approved" : "conflict";
}

// Worker: claim exactly one approved action, execute, record the outcome.
export async function executeNext(db: Db, runEffect: EffectRunner) {
  const claim = await db.query(
    "UPDATE actions SET status = 'executing'\n" +
    " WHERE id = (\n" +
    "   SELECT id FROM actions WHERE status = 'approved'\n" +
    "   ORDER BY decided_at\n" +
    "   FOR UPDATE SKIP LOCKED LIMIT 1\n" +
    " ) RETURNING id, workflow, payload"
  );
  if (claim.rowCount === 0) return;             // queue empty — normal
  const action = claim.rows[0];
  try {
    const result = await runEffect(action.workflow, action.payload);
    await db.query(
      "UPDATE actions SET status = 'executed', executed_at = now(),\n" +
      "       result = $2 WHERE id = $1 AND status = 'executing'",
      [action.id, result]
    );
  } catch (err) {
    await db.query(
      "UPDATE actions SET status = 'failed', result = $2\n" +
      " WHERE id = $1 AND status = 'executing'",
      [action.id, { error: String(err) }]
    );
  }
}

03The effect itself must be idempotent

The ledger guarantees you execute an approved action once — from the ledger's point of view. The outside world is less tidy: the worker can crash after the payment API succeeded but before the row updated, and a retry would pay twice. The fix is to push the idempotency key through to the effect: most serious APIs (payments, messaging, provisioning) accept an idempotency key precisely for this. Pass the ledger row's key; the provider deduplicates on their side, and a crash-retry becomes a safe no-op.

For internal effects without idempotency support, make the effect itself a compare-and-swap — "set status to sent where status = ready" — or record an effect-side receipt the worker checks before acting. The principle is uniform: at every layer, an action carries an identity, and every layer refuses to perform the same identity twice.

04Timeouts, escalation, and the expiry rule

A pending approval is a liability with a clock on it. The payload was built from an inputs_snapshot; the longer it waits, the staler that snapshot gets. Every gate needs expires_at, a sweeper that marks expired rows (which re-propose from fresh inputs if still relevant), and an escalation path — pending past N hours pings the reviewer, past M it moves up the chain. An approval that arrives after the world changed is not an approval; it's a misfire with a signature on it.

05What the reviewer must see

The review UI is a security control, and its content is dictated by the question the reviewer must answer: exactly what will happen if I click yes? That means rendering the payload (the actual email text, the actual amount, the actual recipient), the agent's reasoning, the inputs snapshot behind it, and this action's history for the same target. A reviewer approving a summary of the action is approving a different action.

checklist
  • The rendered approval shows the literal payload to be executed, not a paraphrase.
  • Approve/reject requires a named human identity — service accounts can't sit in decided_by.
  • Every transition writes to the ledger; the audit trail is the system of record, not a side log.
  • Expiry and escalation are tested the way you'd test a fire alarm: on a schedule, on purpose.
  • The kill switch (pause all execution, keep proposals flowing) has been exercised in production.

OPERATOR NOTE — If the audit answer to "who approved this and what did they see?" takes longer than one query, the gate is decorative.

TRANSMIT

Put this architecture to work.

A 30-minute strategy call with an operator — we'll map your first deployment path, not send a deck.