ON THIS PAGE (11 SECTIONS)
- ✓Treat every AI-proposed action as data sitting in a draft state, not as code that executes — a database status column, not a prompt, should gate real actions.
- ✓Define an explicit allowed-transitions map so no code path can move an item from draft straight to executed without passing through approval.
- ✓Re-validate preconditions right before execution — the world can change between when a human approves a draft and when it actually runs.
By 2026, most product teams have shipped at least one AI agent that's supposed to take a real action — send an email, submit an application, move a budget, message a customer — and the uncomfortable question is always the same one: what actually stops it from doing the wrong thing confidently? The answer isn't a smarter model. It's not relying on the model to know when to ask for help in the first place. Put a real state machine between "the AI drafted this" and "this actually happened," with a mandatory approval state that only a human, or an explicitly authorized and audited code path, can advance past.
Fully autonomous agents fail in production for mundane reasons: they read a webpage that changed since their last check, misinterpret an ambiguous field, or work from a snapshot of the world that's gone stale by the time they act on it. None of that requires a better model to fix. It requires a pipeline that doesn't let the model's output become a real-world side effect without a checkpoint in between.
Modeling Actions as Data, Not Code
The core idea is to represent every proposed action as a row in a table, not as a function call. The AI's job ends the moment it writes that row — it never calls the send, apply, or execute function directly. Everything past that point is handled by ordinary application code operating on ordinary database state.
| 1 | CREATE TABLE actions ( |
| 2 | id BIGSERIAL PRIMARY KEY, |
| 3 | type TEXT NOT NULL, |
| 4 | payload JSONB NOT NULL, |
| 5 | status TEXT NOT NULL DEFAULT 'draft' |
| 6 | CHECK (status IN ('draft', 'pending_review', 'approved', 'executing', 'done', 'rejected', 'expired')), |
| 7 | created_by TEXT NOT NULL, |
| 8 | approved_by TEXT, |
| 9 | version INT NOT NULL DEFAULT 1, |
| 10 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), |
| 11 | executed_at TIMESTAMPTZ |
| 12 | ); |
The Allowed-Transitions Map
The dangerous version of this pattern is having "update the status column" scattered across the codebase, where any handler in any file could, through a bug, jump a row straight from draft to done. The fix is a single transition function with a static allow-list of valid (from, to) pairs, and a rule baked directly into that function: an actor of type agent can never be the one who moves something into approved.
| 1 | const ALLOWED_TRANSITIONS = { |
| 2 | draft: ['pending_review'], |
| 3 | pending_review: ['approved', 'rejected'], |
| 4 | approved: ['executing', 'expired'], |
| 5 | executing: ['done', 'approved'], // 'approved' = retry after a failed execution |
| 6 | done: [], |
| 7 | rejected: [], |
| 8 | expired: [] |
| 9 | }; |
| 10 | |
| 11 | async function transitionAction(db, actionId, toStatus, { actorType, actorId }) { |
| 12 | if (toStatus === 'approved' && actorType !== 'human') { |
| 13 | throw new Error('Only a human actor can approve an action.'); |
| 14 | } |
| 15 | |
| 16 | const { rows } = await db.query('SELECT status, version FROM actions WHERE id = $1', [actionId]); |
| 17 | const current = rows[0]; |
| 18 | if (!current) throw new Error('Action not found.'); |
| 19 | |
| 20 | const allowed = ALLOWED_TRANSITIONS[current.status] || []; |
| 21 | if (!allowed.includes(toStatus)) { |
| 22 | throw new Error(`Cannot move action ${actionId} from ${current.status} to ${toStatus}.`); |
| 23 | } |
| 24 | |
| 25 | const result = await db.query( |
| 26 | `UPDATE actions SET status = $1, version = version + 1, |
| 27 | approved_by = CASE WHEN $1 = 'approved' THEN $2 ELSE approved_by END |
| 28 | WHERE id = $3 AND version = $4`, |
| 29 | [toStatus, actorId, actionId, current.version] |
| 30 | ); |
| 31 | |
| 32 | if (result.rowCount === 0) { |
| 33 | throw new Error('Action was modified concurrently. Reload and try again.'); |
| 34 | } |
| 35 | } |
Optimistic Locking to Stop Double-Approval
Two reviewers can open the same draft and both click approve within seconds of each other. The `version` column and the `WHERE ... AND version = $4` clause above turn that into a harmless no-op for the second click — the update simply affects zero rows, and the caller sees an explicit "was modified concurrently" error instead of silently double-processing the action.
Decoupling Approval From Execution
Approval and execution should live in separate transactions and separate failure domains. A worker polls for rows in the approved state, claims them with `FOR UPDATE SKIP LOCKED` so multiple workers don't grab the same row, and only then performs the actual side effect.
| 1 | async function runExecutionWorker(db) { |
| 2 | const { rows } = await db.query( |
| 3 | `SELECT * FROM actions WHERE status = 'approved' ORDER BY created_at LIMIT 10 FOR UPDATE SKIP LOCKED` |
| 4 | ); |
| 5 | |
| 6 | for (const action of rows) { |
| 7 | await transitionAction(db, action.id, 'executing', { actorType: 'system', actorId: 'executor' }); |
| 8 | |
| 9 | const stillValid = await revalidatePreconditions(action); |
| 10 | if (!stillValid) { |
| 11 | await transitionAction(db, action.id, 'expired', { actorType: 'system', actorId: 'executor' }); |
| 12 | continue; |
| 13 | } |
| 14 | |
| 15 | try { |
| 16 | await performSideEffect(action); |
| 17 | await transitionAction(db, action.id, 'done', { actorType: 'system', actorId: 'executor' }); |
| 18 | } catch (err) { |
| 19 | await transitionAction(db, action.id, 'approved', { actorType: 'system', actorId: 'executor' }); |
| 20 | logger.error(`Execution failed for action ${action.id}, requeued:`, err); |
| 21 | } |
| 22 | } |
| 23 | } |
Re-Validating Right Before Execution
Time passes between when a human approves a draft and when a worker actually executes it — sometimes seconds, sometimes hours. A job listing can close, a price can change, a recipient can unsubscribe. `revalidatePreconditions` exists specifically to catch that gap: the execution worker checks the current state of the world against what the draft assumed, and moves the action to expired or rejected rather than blindly trusting a snapshot that may no longer be true.
Guarding the Gate Against the Model Itself
The single most important property of this design is that the model's output can never write to the status column directly, not even indirectly through a tool call. Only the `transitionAction` function, gated by the `actorType` check, can move state forward — which means even a successful prompt injection against the drafting step can, at worst, produce a bad draft sitting in `pending_review`. It cannot forge an approval. That's the same discipline as least-privilege MCP servers: the model proposes, code decides.
Wrong vs Right: Letting the Model Grade Its Own Confidence
A tempting shortcut is to have the model attach a confidence score to its own draft and auto-approve anything above a threshold — "the model was 95% sure, so skip the human." This defeats the entire architecture, because a model's stated confidence isn't grounded in anything external; it's another piece of text the same model generated, with the same blind spots as the draft it's supposedly grading. The `ALLOWED_TRANSITIONS` map earlier in this piece is deliberately unconditional about this: nothing in the transition function accepts a confidence value as a reason to skip the `human` actor check, no matter how it's framed upstream.
| 1 | // Wrong: the model's own score decides whether a human sees it at all |
| 2 | // if (draft.aiConfidence > 0.95) { |
| 3 | // await transitionAction(db, draft.id, 'approved', { actorType: 'agent', actorId: 'drafting-model' }); |
| 4 | // } |
| 5 | |
| 6 | // Right: confidence can prioritize the review queue, never bypass it |
| 7 | async function enqueueForReview(db, draft) { |
| 8 | await transitionAction(db, draft.id, 'pending_review', { actorType: 'system', actorId: 'drafting-pipeline' }); |
| 9 | await reviewQueue.add('review', { actionId: draft.id, priority: draft.aiConfidence < 0.5 ? 'high' : 'normal' }); |
| 10 | } |
Fighting Reviewer Fatigue Without Weakening the Gate
The gate has a human failure mode too: a reviewer who has approved forty similar-looking drafts in a row starts clicking approve on the forty-first without really reading it. The fix isn't to lower the bar — it's to reduce how much the reviewer has to hold in their head per item. Group similar drafts together, surface a diff against the most recently approved draft of the same type instead of the raw payload, and for genuinely high-volume, low-stakes categories, consider a sampled audit model: a person still reviews and approves every single item before it moves forward, but a separate, smaller sample gets a deeper second look specifically to catch drift in what "normal" looks like before it becomes systematic.
Partial Failure Mid-Execution
The execution worker's `catch` block above requeues a failed action back to `approved` for retry, which is safe for side effects that are naturally idempotent — but many aren't. If `performSideEffect` sends an email and then the process crashes before the `done` transition commits, a naive retry sends the email twice. The fix is the same idempotency-key pattern used elsewhere in this pipeline: record an execution attempt with a unique key before performing the side effect, and have the side-effect function itself check that key first so a retry after a partial failure can detect "this already went out" and skip straight to marking the action done.
| 1 | CREATE TABLE execution_attempts ( |
| 2 | action_id BIGINT NOT NULL REFERENCES actions(id), |
| 3 | attempt_key TEXT NOT NULL UNIQUE, |
| 4 | side_effect_sent BOOLEAN NOT NULL DEFAULT false, |
| 5 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() |
| 6 | ); |
What This Looks Like at Vasiva
In production AI workflows, drafts can be generated quickly, but consequential sends must still require explicit human approval — there's no code path that skips that gate, agent-authored or otherwise. It's the same "AI drafts, a person decides" rule we apply to our own review queues, just enforced at the database level instead of left as a policy statement.
If you're building an AI feature that's supposed to take real actions on someone's behalf, this is the architecture question worth answering before the first line of the integration is written. That's exactly the kind of problem our AI Integration work is for — reach out if you want help designing the gates.
References & Prior Art
Panneer
Systems EngineerPanneer works on workflow state machines and approval logic behind Vasiva's AI systems — gating execution until explicit human sign-off.
