Build Logs15 January 20268 min read

Human-in-the-Loop AI Architecture: State Machines Instead of Trusting Agent Judgment

How to design human-in-the-loop AI systems with explicit state machines, approval gates, and audit trails instead of trusting a model's own judgment.

PanneerSystems Engineer
FIGURE 1.0
EDITORIAL REFERENCE
Engineering team collaborating around screen conducting human-in-the-loop review of automated outputs
CONTEXT:Engineering team collaborating around screen conducting human-in-the-loop review of automated outputs
REFERENCE:Annie Spratt
ON THIS PAGE (11 SECTIONS)
EXECUTIVE BRIEFING · KEY TAKEAWAYS
  • ✓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.

sql
1CREATE 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.

javascript
1const 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 
11async 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.

javascript
1async 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.

javascript
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
7async 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.

sql
1CREATE 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.

ACADEMIC & INDUSTRY CITATIONS

References & Prior Art

REF 01Formal Methods

Statecharts: A Visual Formalism for Complex Systems

David Harel • Science of Computer Programming 8(3) (1987)

Source
REF 02Technical Monograph

Human-in-the-Loop Machine Learning: Active Learning and Operational State Control

Robert (Munro) Monarch • Manning Publications (2021)

Source
VERIFIED CONTRIBUTOR

Panneer

Systems Engineer

Panneer works on workflow state machines and approval logic behind Vasiva's AI systems — gating execution until explicit human sign-off.

CURATED ARCHIVE

Continue Reading

Contextually relevant engineering logs and production architecture notes.

SYSTEM ADVISORY

Have an AI workflow or complex system worth exploring?

Work directly with the senior engineers who designed and shipped these systems. Zero vendor lock-in, deterministic architectures, and first-party audit integrity.

Primary Domain:ai workflows
Evaluate the opportunity