Approval Gates
An approval gate pauses a workflow run until a human approves or rejects it. This enables human-in-the-loop workflows like deploy pipelines where production deploys require sign-off.
How it works
- The handler calls
ctx.approval()with a prompt message - The run transitions to
AwaitingApproval - The worker releases the run and moves on to other work
- A human calls
POST /api/v1/runs/:id/approveorPOST /api/v1/runs/:id/reject - On approval, the run resumes. Under
ExecutionMode::Workersit is requeued toPending: a worker picks it up, replays completed steps from cache, skips the approved gate, and continues execution. UnderExecutionMode::Local(the default) the API process resumes it the same way itself - On rejection, the run transitions to
Failed
If the gate carries an SLA deadline and nobody answers in time, step 4 is performed by the server instead of a human – see SLA timers below.
Example
//! Deploy workflow with human approval gate before production.
use std::time::Duration;
use ironflow_engine::config::{
ApprovalConfig, Assignee, EscalationPolicy, NotificationTarget, ShellConfig,
};
use ironflow_engine::context::WorkflowContext;
use ironflow_engine::handler::{HandlerFuture, WorkflowHandler};
/// Deploy pipeline that requires human approval before shipping to production.
///
/// 1. **build** -- compile the project
/// 2. **test** -- run the test suite
/// 3. **deploy-staging** -- deploy to staging environment
/// 4. **approval gate** -- pause and wait for human approval
/// 5. **deploy-production** -- resumes after approval via step replay
///
/// After approval (POST /api/v1/runs/:id/approve), the engine
/// re-executes the handler: completed steps return cached output,
/// the approved gate is skipped, and execution continues with
/// deploy-production.
///
/// If the approval is rejected, the run transitions to `Failed`.
/// If cancelled, the run transitions to `Cancelled`.
///
/// The gate carries a one-hour SLA. If nobody answers in time, the escalation
/// chain runs one policy per expiry: the first hour pings a webhook and keeps
/// waiting, the second gives up and fails the run. The deadline lives in the
/// database, so it survives an API or worker restart.
pub struct DeployApproval;
impl WorkflowHandler for DeployApproval {
fn name(&self) -> &str {
"deploy-approval"
}
fn description(&self) -> &str {
"Deploy pipeline with human approval gate before production. \
Demonstrates ctx.approval() for human-in-the-loop workflows."
}
fn source_code(&self) -> Option<&str> {
Some(include_str!("deploy_approval.rs"))
}
fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
Box::pin(async move {
// Step 1: Build
ctx.shell(
"build",
ShellConfig::new("echo 'Compiling...' && sleep 0.2 && echo 'Build OK'"),
)
.await?;
// Step 2: Test
ctx.shell(
"test",
ShellConfig::new("echo 'Running tests...' && sleep 0.3 && echo '87 tests passed'"),
)
.await?;
// Step 3: Deploy to staging
ctx.shell(
"deploy-staging",
ShellConfig::new(
"echo 'Deploying to staging...' && sleep 0.2 && echo 'Staging live'",
),
)
.await?;
// Step 4: Human approval gate
// The run pauses here and transitions to AwaitingApproval.
// A human must call POST /api/v1/runs/:id/approve to continue,
// or POST /api/v1/runs/:id/reject to fail the run.
ctx.approval(
"prod-approval",
ApprovalConfig::new("Staging looks good. Deploy to production?")
.assigned_to(Assignee::group("release-managers"))
.with_deadline(Duration::from_secs(3600))
.on_timeout(EscalationPolicy::Chain(vec![
// After 1 h without an answer: warn, keep waiting.
EscalationPolicy::Notify(vec![NotificationTarget::Webhook {
url: "https://example.com/hooks/deploy-sla".to_string(),
}]),
// After 2 h: give up rather than ship unreviewed.
EscalationPolicy::AutoReject,
])),
)
.await?;
// Step 5: Deploy to production (only reached after approval)
ctx.shell(
"deploy-production",
ShellConfig::new(
"echo 'Deploying to production...' && sleep 0.3 && echo 'Production live'",
),
)
.await?;
Ok(())
})
}
}
Configuration
use std::time::Duration;
ApprovalConfig::new("Deploy to production?")
.assigned_to(Assignee::group("release-managers")) // Who is expected to answer
.with_deadline(Duration::from_secs(3600)) // SLA: one hour to answer
.on_timeout(EscalationPolicy::AutoReject) // What happens when it expires
assigned_to takes an Assignee – Assignee::user("alice") or
Assignee::group("release-managers"). It drives notification routing and audit,
and it decides who may resolve the gate:
- an admin resolves any gate;
- a gate assigned to a user is resolved by that user, admin or not, and by whoever holds an active delegation from them;
- a gate assigned to a group, or to nobody, is admin-only.
The assignee is matched to the caller by user ID, so an API key resolves its owner’s gates whatever the key is named.
Everything past the message is optional. Without a deadline, the run waits indefinitely.
SLA timers
with_deadline (or with_deadline_secs) arms a timer on the approval step. The
deadline is stored in the database next to the step, not in memory, so it
survives an API or worker restart: a fresh process picks the expired gate up on
its next pass. The API server checks for expired gates every 30 seconds.
The timer is cleared the moment the gate resolves – approved, rejected, or escalated – so a gate is never escalated after a human answered it. A deadline fires at most once, even with several API instances running.
with_timeout_seconds is the legacy spelling: it is now enforced, as a
deadline with an implicit AutoReject policy. Setting both keeps the explicit
with_deadline.
Escalation policies
on_timeout takes an EscalationPolicy. Without one, an expired deadline
auto-rejects.
| Policy | What it does when the deadline fires |
|---|---|
AutoApprove | Completes the gate with approved_by: "system:timeout" and resumes the run: in the server under ExecutionMode::Local, by requeuing it for a worker under ExecutionMode::Workers (execution mode). |
AutoReject | Fails the step and the run with approval timeout. The default. |
Notify(targets) | Posts the escalation event to each target, leaves the gate open, restarts the timer. |
Escalate(Assignee) | Reassigns the gate to another user or group, leaves it open, restarts the timer. |
Chain(policies) | Applies one policy per expiry, in order. |
Notify and Escalate do not resolve the gate: on their own, they fire again at
every expiry until a human answers. Each firing writes an audit entry, so the
loop is visible rather than silent. Wrap them in a Chain to advance one policy
per expiry instead:
use std::time::Duration;
ApprovalConfig::new("Deploy to production?")
.with_deadline(Duration::from_secs(3600))
.on_timeout(EscalationPolicy::Chain(vec![
// After 1 h: ping the on-call channel, keep waiting.
EscalationPolicy::Notify(vec![NotificationTarget::Slack {
webhook_url: slack_webhook_url,
channel: "#deploys".to_string(),
}]),
// After 2 h: give up.
EscalationPolicy::AutoReject,
]))
Once a chain runs out, the gate stays open with no timer and a warning is logged – it is never silently auto-rejected.
NotificationTarget is delivered as a plain HTTP POST, with the engine’s
shared retry and backoff: Webhook { url } posts the escalation event as JSON,
Slack { webhook_url, channel } posts a message to a Slack incoming webhook.
A dead endpoint is logged and never blocks the timer reset.
Seeing the remaining time
The countdown surfaces in three places:
- the API:
approval_seconds_remainingandapproval_assigneeon every step ofGET /api/v1/runs/:id(clamped at 0,nullwithout a deadline); - the dashboard: a countdown badge on the gate in the run’s step list;
- the CLI: the
SLAcolumn ofironflow run steps <id>, yellow in the last tenth of the window and red once expired.
Every escalation is also recorded in the audit log as an approval_escalated
event carrying the stage, the policy, what it did, and why it fired.
Delegation and absence
An approval gate assigned to one person stops every run behind it the moment that person is away. A delegation hands their approval power to a colleague for a bounded window, without making anyone an admin and without reassigning the gates one by one.
A delegation records who grants it, who receives it, the window it is valid for, and an optional glob on the workflow name:
# Alice hands her deploy approvals to Bob for a week.
ironflow-cli delegation create <bob-user-id> \
--until 2026-10-01T00:00:00Z \
--workflow 'deploy-*'
# Everything Alice granted, plus everything she received (20 per page).
ironflow-cli delegation list --page 1 --per-page 20
# Back early.
ironflow-cli delegation delete <delegation-id>
The same three endpoints back the CLI:
| Endpoint | What it does |
|---|---|
POST /api/v1/approval-delegations | Grant a delegation. The delegator is always the caller. |
GET /api/v1/approval-delegations | List the active delegations you granted or received, paginated with page and per_page (default 20, max 100). An admin sees them all and may filter with from_user_id and to_user_id. |
DELETE /api/v1/approval-delegations/{id} | Revoke one. Only the delegator or an admin may. |
What a delegation covers
Only a gate assigned to an individual can be delegated:
ApprovalConfig::new("Deploy to production?")
.assigned_to(Assignee::user("alice")) // Bob can answer this through a delegation.
ApprovalConfig::new("Deploy to production?")
.assigned_to(Assignee::group("release-managers")) // Admin-only; no single delegator.
A gate assigned to a group, or with no assignee at all, stays admin-only: there is no single person whose power could have been handed over.
The workflow_filter glob narrows a delegation to part of the catalogue.
"deploy-*" covers deploy-prod but not cleanup; omitting it covers every
workflow. A pattern that does not parse matches nothing, so a corrupted row can
never widen someone’s reach.
Several delegations can be active at once – one per colleague, one per workflow family, or from several delegators to the same person. The newest one that matches both the gate’s assignee and the run’s workflow wins.
Expiry
The window is half-open: a delegation is live at valid_from and already over
at valid_until. There is no cleanup job. Expired and not-yet-started rows are
filtered out every time delegations are read, so they can neither be listed nor
used to approve – they are still reachable by ID, which is what makes an
expired delegation revocable.
Audit
A delegated decision names both people. The approval_granted (or
approval_rejected) audit entry reads:
{
"type": "approval_granted",
"run_id": "01932f...",
"approved_by": "bob (delegated from alice)",
"at": "2026-09-22T10:15:00Z"
}
An admin, or the assignee resolving their own gate, is recorded under their own name alone.
Requiring several approvers
A gate can require more than one approval, and restrict who may vote, depending
on the run itself: a small payment needs one approver, a large one needs two
people from finance. The handler decides in plain Rust, from its typed input and
the outputs of earlier steps, and passes the result to requiring:
use ironflow_engine::config::{ApprovalConfig, Approvers};
let payment: Payment = ctx.input().await?;
let approvers = match payment.amount {
a if a > 100_000 => Approvers::at_least(3)
.from_groups(["finance", "board"])
.because("amount > 100k"),
a if a > 10_000 => Approvers::at_least(2)
.from_groups(["finance"])
.because("amount > 10k"),
_ => Approvers::any(),
};
ctx.approval(
"payment-gate",
ApprovalConfig::new("Release the payment?").requiring(approvers),
).await?;
| Builder | Meaning |
|---|---|
Approvers::any() | One approval, from anyone allowed to answer the gate |
Approvers::at_least(n) | n distinct approvals (required_approvers) |
.from_groups([..]) | Only members of these groups may vote (approver_groups) |
.because("..") | Audit label shown on the dashboard and in the events (reason), never evaluated |
A typo in a field name or a comparison does not compile, and the compiler checks
every branch of the match. A gate without requiring behaves as a single
approval gate.
The approvers are stored on the step as an ApprovalRequirement (reason,
required_approvers, approver_groups) when the gate opens. That record is the
source of truth from then on: replaying or resuming the run never recomputes
it, even if the handler would now compute other approvers. GET /api/v1/runs/:id exposes it on the step as approval_requirement, with the
votes cast so far in approvals and the count needed in approvals_required;
the dashboard shows it as an n/m approvals badge whose tooltip gives the
reason.
Approvers::at_least(0) and a blank group name panic, so a broken gate fails
when the workflow runs the builder, not when a human votes. A JSON config with
required_approvers: 0 is rejected on deserialization.
Voting
- One vote per user. Votes are counted by user ID: an API key votes as its
owner, and the same user approving twice gets
409 Conflict. - An admin’s approval is one vote. Admins may always vote, even on a gate restricted to groups, but they do not override the count.
- A rejection vetoes. One rejection from anyone allowed to vote fails the run, even after partial approvals.
- Until the count is reached,
POST /approvereturns200with the run stillawaiting_approval, the gate keeps its SLA timer, and the CLI printsApproval recorded; more approvals are required.
An EscalationPolicy::AutoApprove still resolves the gate outright, whatever
the required count.
Approver groups
When the approvers list approver_groups, only members of at least one of
those groups (and admins) may vote. The gate’s assignee and approval
delegations are not consulted. A listed group without members leaves the gate
to admins.
Group membership is managed by admins:
# Put alice in finance and legal (replaces her current groups).
ironflow user set-groups <alice-id> --group finance --group legal
# Show her groups.
ironflow user groups <alice-id>
# Remove her from every group.
ironflow user set-groups <alice-id>
The same operations are available as GET and PUT /api/v1/users/:id/groups.
Group names are 1 to 64 characters from [A-Za-z0-9_.-], at most 50 per user.
Audit events
approval_requestedis published when the gate opens and carries the recordedrequirement.approval_grantedis published for every vote, with thestep_id,approvals_received,approvals_requiredand therequirement. The gate resolves whenapprovals_received >= approvals_required.approval_rejectedcarries thestep_idand therequirement.
{
"type": "approval_granted",
"run_id": "01932f...",
"step_id": "01932f...",
"approved_by": "alice",
"approvals_received": 1,
"approvals_required": 2,
"requirement": {
"reason": "amount > 10k",
"required_approvers": 2,
"approver_groups": ["finance"]
},
"at": "2026-09-24T10:15:00Z"
}
Step replay
After an approval, the engine re-executes the handler from the beginning: in the API process under ExecutionMode::Local, on the worker that picks up the requeued run under ExecutionMode::Workers (see execution mode). Completed steps return their cached output immediately – they do not re-run. The approved gate is skipped, and execution resumes with the next step.