The approval gate - how "humans keep the final say" works as a state machine
The engineering behind Rezee's agent approval gate - the agent_tasks state machine, why steps are append-only rows, the conditional UPDATE that makes double-approval impossible, and what happens when the runtime dies mid-task.
May 13, 2026 · 11 min read · Kash Gohil
Rezee is one workspace for the whole product development lifecycle - code, planning, CI/CD, chat, and docs - where agents work as teammates with humans keeping the final say. Every vendor building agents says that last part. This post is about what it means when you have to make it true structurally: not a confirmation dialog, not a policy document, but a state machine where the irreversible transition cannot happen without a human row in the database.
The short version: a delegated task is two tables and five states, the gate is a conditional UPDATE (the same one-query trick that runs our CI queue), and the interesting engineering is all in the failure modes.
What is a delegated task, structurally?
Two tables. The task is the spine; the steps are an append-only stream:
CREATE TABLE agent_tasks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id uuid NOT NULL REFERENCES agents(id),
workspace_id uuid NOT NULL REFERENCES workspaces(id),
origin text NOT NULL, -- issue key, chat mention, manual
state task_state NOT NULL DEFAULT 'running',
summary text NOT NULL,
last_heartbeat_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE agent_task_steps (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
task_id uuid NOT NULL REFERENCES agent_tasks(id) ON DELETE CASCADE,
kind text NOT NULL, -- 'tool_call' | 'message' | 'proposal' | 'gate'
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);Steps are never updated, only inserted. That single property does a lot of quiet work: the stream a human watches is exactly the audit trail, there is no "current step" pointer to corrupt, and a crashed runtime can't leave a half-written step - a row is either committed or it never happened.
It also makes watching a task trivially cheap. The delegated-task view reads steps the same way our CI log viewer reads log lines: poll for rows newer than the last one you have, keyed on (created_at, id). Append-only means the query is a pure keyset scan with no invalidation to reason about - a step you've rendered can never change behind you, so the client never re-fetches history. The whole live-stream UX is WHERE task_id = $1 AND created_at > $2 ORDER BY created_at on an index - triggered by the workspace event stream announcing "task has new steps," with the keyset query as the fetch. Push decides when; the append-only table decides what.
How does the state machine work?
Five states, six transitions, one of which is the entire product promise:
The transition table, with the guard on each:
| from | event | guard | to |
|---|---|---|---|
running |
agent proposes an irreversible action | proposal step inserted in same transaction | awaiting_review |
awaiting_review |
human approves | conditional UPDATE (below); approval references the proposal step id | approved |
awaiting_review |
human rejects | same conditional UPDATE shape | failed |
approved |
server executes the gated action | executor, not the agent, performs it | running |
running |
no work left | all steps committed | done |
running |
error, or heartbeat lost | reaper (below) | failed |
Two details matter more than the diagram suggests. First, the agent never executes its own proposal. The runtime proposes; after approval, makima's executor performs the merge (or publish, or deploy) server-side and appends the resulting step. An agent whose runtime is compromised or confused can propose anything it likes - proposals are inert rows. Second, awaiting_review is a hard pause. The API rejects step appends for a task in that state, so the thing the human is reviewing can't grow new steps under their cursor. What you approve is what was there when you read it.
The propose transition is one transaction, and the ordering inside it is what makes the pause airtight:
BEGIN;
UPDATE agent_tasks SET state = 'awaiting_review', updated_at = now()
WHERE id = $1 AND state = 'running'; -- guard: only a running task can propose
-- 0 rows updated → the task was reaped or already paused; abort, no step
INSERT INTO agent_task_steps (task_id, kind, payload)
VALUES ($1, 'proposal', $2);
COMMIT;State flips first, in the same transaction as the proposal row. There is no instant where a proposal exists on a still-running task (where the runtime could keep appending after it), and no instant where a task is paused with its proposal missing. The step-append endpoint enforces the pause with the same shape - its insert is guarded by AND state = 'running' on the task row, under FOR SHARE, so a concurrent propose and append serialize instead of interleaving.
What stops a double-approve?
The same thing that stops two CI runners claiming one job - a conditional UPDATE where the WHERE clause is the state machine:
const updated = await db
.update(agentTasks)
.set({ state: "approved", updatedAt: new Date() })
.where(and(
eq(agentTasks.id, taskId),
eq(agentTasks.state, "awaiting_review"),
))
.returning({ id: agentTasks.id });
if (updated.length === 0) return status(409, "not awaiting review");Two reviewers click Approve in the same instant: one flips awaiting_review → approved, the other matches zero rows and gets a 409. Approve races Reject: same shape, one winner, and the loser's click reports honestly instead of silently double-firing. We benchmarked this exact pattern to 700,000 contended attempts without a double-win, so the gate inherits a claim we've already tested rather than a new mechanism we hope works.
The approval also carries what it approved: the gate step records the proposal step's id and a hash of its payload. If a rendering bug ever showed a reviewer a different diff than the proposal contained, the executor would refuse the mismatch. Approvals bind to bytes, not to screens.
Hashing JSON has a wrinkle worth knowing about. You can't hash "the JSON the agent sent" - Postgres jsonb normalizes on write (deduplicates keys, discards whitespace, reorders object keys), so the stored payload is not byte-identical to the wire payload. The hash is therefore computed server-side over the stored form (sha256(payload::text) of the committed row) at proposal time, and the executor recomputes it from the same row at execution time. Both sides hash the single canonical representation Postgres already enforces, which sidesteps the entire canonical-JSON problem instead of solving it. What the hash actually buys, given that steps are append-only and can't be edited through any API: a tamper-evidence check against the paths the API doesn't control - a bug that executes the wrong step id, or a manual row edit in the database. Cheap insurance against the mistakes you haven't imagined yet.
What happens when the runtime dies mid-task?
The runtime (beam - a worker service shaped like our CI runner) holds no authoritative state, so the failure analysis reduces to a handful of cases:
- Crash between steps. The task sits in
runningwith a silent stream. Beam heartbeats its active tasks; a reaper flips tasks with two missed beats tofailed- the same conditional-UPDATE-pointed-backward we described (and then built) for stale CI claims. Everything already streamed survives, because it was in Postgres, not in the process. - Crash mid-tool-call. The step row for a tool call is written after the call returns, so a crash leaves either a completed call with its row, or no row - and the tool calls that mutate rezee itself (comment, open PR, transition issue) go through the same API as everyone else, where each carries an idempotency key derived from
(task_id, step_index). A restarted attempt that retries the call is deduplicated server-side rather than double-commenting. - Crash while
awaiting_review. Nothing is lost and nothing is urgent - the task isn't waiting on the runtime, it's waiting on a person. Approval works even if the runtime that proposed it no longer exists; the executor is makima, not beam. - The human never shows up. A task can sit in
awaiting_reviewindefinitely; that's a feature, not a leak. It appears in the workspace's review queue like an unreviewed PR would - pressure comes from visibility, not timeouts. We chose not to auto-expire approvals; an auto-rejected task at 3am helps no one.
The reaper and the idempotency guard deserve their exact shapes, because both are places where hand-waving hides bugs. The reaper is a cron-shaped query using the same conditional-UPDATE guard as everything else - it can race a task's own completion and lose harmlessly, because state = 'running' is in its WHERE clause:
UPDATE agent_tasks
SET state = 'failed', updated_at = now()
WHERE state = 'running'
AND last_heartbeat_at < now() - interval '90 seconds' -- 3 missed 30s beats
RETURNING id;Idempotency for mutating tool calls is a unique index, not a convention. Beam derives the key from (task_id, step_index) - deterministic across retries of the same plan step - and makima's mutating endpoints record it:
CREATE TABLE idempotency_keys (
key text PRIMARY KEY, -- 'task:{id}:step:{n}'
response jsonb NOT NULL, -- replayed to retries verbatim
created_at timestamptz NOT NULL DEFAULT now()
);A retried open_pr after a crash hits the primary-key conflict, gets the stored response back, and the workspace sees exactly one PR. The key insight is where the dedupe lives: server-side, at the API makima already owns, so it protects against runtime retries, network-layer retries, and the double-fire bugs nobody has written yet - the same reasoning as putting the claim guard in the database rather than in runner discipline.
What we deliberately did not build: resume-from-crash. A task whose runtime died goes to failed with its stream intact, and a human re-delegates if they want a retry. Resumption means replaying a partially-executed plan against a workspace that may have moved - the cost/risk curve says fail loudly and let the human decide, at least until real usage argues otherwise.
Watch the gate work
A compressed version of the M9 acceptance test - "delegate fix the failing lint, watch it open a PR, approve the merge." Try all three endings: approve, reject, and killing the runtime mid-task:
This is an interactive demo - it needs JavaScript. A delegated task streams its steps (tool calls, messages), pauses at a merge proposal in awaiting_review, and finishes only when a human approves. Rejecting keeps the work but never lands it; killing the runtime shows the reaper marking the task failed while the streamed steps survive.
What doesn't the gate cover?
Honesty about the boundary, because a gate you overtrust is worse than no gate:
- It gates actions, not judgment. The reviewer sees exactly what will execute, but a plausible-looking wrong fix still needs a human actually reading the diff. The gate buys the opportunity for review, not the review itself.
- Reversible actions flow freely. Comments, draft docs, issue transitions, branch pushes - an agent can do these without approval, by design. The blast radius of the ungated set is bounded by being undoable inside rezee, but "undoable" and "harmless" aren't identical; a wrong comment is still noise a teammate reads.
- The gate is downstream of scopes. A token that shouldn't reach production repos at all is a scoped-token problem; the gate assumes the agent is somewhere it's allowed to be and asks whether this particular step may land.
FAQ
Why not just require PR review instead of building a task gate?
PR review covers merges, and for code-shaped work the gate and PR review compose - the agent's PR is reviewable like anyone's. But agents also publish docs, close issues in bulk, and trigger deploys, and those surfaces have no native review ritual. One gate mechanism across every surface beats re-inventing review per surface, and the delegated-task view gives the reviewer the whole trail - what the agent tried, not just the final diff.
Why are steps append-only instead of a status field per step?
Because the stream is simultaneously the live UI, the audit log, and the crash-recovery record, and append-only is the only shape that serves all three without coordination. A mutable step table needs locking and "who updated this" bookkeeping; an insert-only table gets ordering from the primary key and immutability for free - the same reasoning as our CI log lines.
Why not a workflow engine like Temporal instead of two tables?
Durable-execution engines earn their operational weight when workflows are long-horizon, deeply branched, and need automatic replay - none of which describes "open a PR and wait for a human." Our durable state is two tables with five states; the pause primitive is a status column; the retry story is deliberately "fail and re-delegate." Adopting an engine would replace a mechanism we can benchmark with one we'd have to trust, and put the product's defining promise - the gate - behind a third-party abstraction. If agent tasks grow real DAGs and week-long horizons, this trade-off gets revisited; the tables would become the engine's source-of-truth projection rather than being thrown away.
What counts as irreversible enough to gate?
Merge to a protected branch, publishing a doc, triggering a deploy, and deleting anything. The list is a server-side allowlist, not agent self-assessment - an agent doesn't get to decide its action was harmless. Everything else is reversible inside the workspace and flows without a pause.