Approvals — human-in-the-loop sends
Some transactions should not fire without a human saying so — big treasury
moves, emergency responses, anything above a threshold. approval: on a
send_transaction step parks the run with
the transaction fully prepared and simulated, notifies the approvers, and
only broadcasts after an explicit yes.
workflows:
treasury-sweep:
trigger:
cron: { expression: "0 9 * * 1" }
steps:
- id: sweep
send_transaction:
network: ethereum
relayer: treasury
contract: USDC
function: "transfer(address,uint256)"
args: ["${{ constants.cold_storage }}", "${{ steps.balance.output }}"]
approval:
via: [telegram: ops, cli]
timeout: 4h
on_timeout: fail # fail (default) | proceed (warned)
message: "sweeping ${{ format_units(steps.balance.output, 6) }} USDC to cold storage"
recheck: "${{ steps.balance.output > wei('1000', 6) }}"The approval block
| Field | Default | Description |
|---|---|---|
via | ✅ required | Approval routes, non-empty: telegram: <channel> (a notifications channel) and/or cli |
timeout | How long to wait for a decision, e.g. 1h | |
on_timeout | fail | What an undecided timeout does: fail | proceed |
message | Template shown to the approver — the decoded call + simulation result are always included |
Approval policies — N-of-M quorums
For two-person review, name the people and the quorum instead of a route
list. Approvers are identity objects: each route maps a channel to the
member's address, and the address doubles as the attribution key. Channels
keep holding credentials (via_channel points at a
notifications channel); approvers hold addresses.
approvers:
alice:
cli: { token: ops-alice } # named api token (rflow token create)
telegram: { chat_id: "12345678", via_channel: ops } # her PRIVATE chat with the bot
bob:
sms: { to: "+447700900123", via_channel: ops-sms } # over the twilio channel's account
carol:
cli: { token: ops-carol }
approver_groups:
ops: [alice, bob, carol]
approval_policies:
two-of-ops:
group: ops
required: 2 # validated: 1 <= required <= group size (groups max 25)
timeout: 4h # hard 30-day ceiling; absent = wait the full ceiling
remind_every: 1h # re-notify undecided members (journaled)
on_timeout: fail # NEVER manufactures a decision; `proceed` is warned
on_reject: fail # first rejection VETOES (default); `wait` = fail only
# once `required` approvals become impossible
escalate: ops # notify-only when the quorum expires undecidedReference it from the send — a bare policy, or amount tiers (ordered,
first when: match wins, a trailing no-when tier is the default, no match
= no gate; value is the prepared send's native value in wei):
approval:
policy: two-of-ops
# or
approval:
- when: "${{ value > wei('50', 18) }}"
policy: two-of-ops
- policy: one-of-opsSemantics that make the quorum safe:
- The snapshot is pinned at park time. Members,
required, timeouts and routes are frozen onto the approval row — a later config edit never changes a pending quorum. - One member counts once. Decisions are journaled per member with a unique key; a duplicate command, replayed link, or the same person on two channels cannot double-count.
- Members with a
telegram:/sms:route get their own private notice (CLI-only members decide fromrflow approvals lswith their token) — a DM / SMS over the channel's credential, carrying a signed one-time approval link (whenconfig.server.public_urlis set) plus the CLI one-liners. The link is a bearer credential bound to one member of one approval: single-use, expires with the quorum, GET only renders the confirm page (scanners cannot spend it), the decision is a POST. - CLI decisions must prove membership:
rflow approve <id> --token <api-token>(orRFLOW_API_TOKEN) — the token's name maps toapprovers.<member>.cli.token.cli:$USERis never accepted for quorums. - Everything is journaled: per-member decisions (member, route, reason,
time) in
rflow.approval_decisions, anoperator_auditrow per decision, and the full trail inrflow runs show/GET /api/runs/{id}. Retention keeps everything by default; when an operator-configured window prunes a decided approval (or its whole run), the decision/link rows cascade away with it — theoperator_audittrail survives pruning by design.
Safe proposal mode
send_transaction.propose_to_safe: PROPOSES the prepared tx to a
Safe via the Safe Transaction Service instead of
broadcasting — the Safe's own owner threshold then governs execution in
Safe{Wallet}. An integration, not custody: rflow holds a revocable
delegate key that can only propose, never execute.
send_transaction:
network: ethereum
relayer: treasury # unused in this mode (no broadcast)
contract: USDC
function: "transfer(address,uint256)"
args: ["${{ constants.cold_storage }}", "${{ steps.balance.output }}"]
approval:
policy: two-of-ops # rflow's gate = proposal hygiene
propose_to_safe:
safe: "0xYourSafe..."
delegate_key: ${SAFE_DELEGATE_KEY}
service_url: https://api.safe.global/tx-service/eth
api_key: ${SAFE_API_KEY} # optional; anonymous is rate-limitedEvery gate still runs first (permissions, simulation, gas caps, recheck,
rflow approval); the step then signs the safeTxHash (v1.3.0+ domain) with
the delegate key and posts the proposal — output carries safe_tx_hash and
the nonce. Register the delegate once with the service (an owner signs the
Delegate message). Know the edges: a proposal cannot be un-proposed (an
rflow approval timeout upstream simply means nothing is proposed), and the
Safe queue is nonce-ordered — concurrent proposers race for the next nonce.
The lifecycle
- Prior steps have already run. The gate is on this transaction, not the workflow — reads, HTTP enrichment and guard steps complete first, so the approver sees final values.
- The transaction is prepared and simulated. Simulation,
assert_simand gas caps all run before anyone is asked — a send that would revert never bothers a human. - The run parks durably (
waiting_approval): the approval row (rflow.approvals), its expiry and the prepared tx summary are journaled in the same transaction that parks the step. A crash or restart while parked loses nothing; a recovering executor adopts the pending row. - Approvers are notified through every
via:route — the notice carries the workflow, run and step ids, yourmessage, the decoded call summary and the exactrflow approve/rejectone-liners. Notification failures are logged, never fatal: the gate is the database row, so the CLI always works even when every channel is down. - A decision settles it — decisions are single-shot (
WHERE status = 'pending'), so a double-approve or a race against the expiry poll changes nothing and reports what actually happened.
| Decision | Effect |
|---|---|
| approved | The send re-runs recheck: and re-simulates against current state, then broadcasts under the same idempotency key. Conditions moved while the human decided → the send is dropped, not fired stale |
| rejected | The step fails (kind dropped, the reason journaled); on_failure applies |
| expired | on_timeout decides: fail — the step fails with kind expired, nothing broadcasts. proceed — see below |
Deciding: CLI
rflow approvals ls # pending approvals (--all includes decided/expired)
rflow approve <id> [--yes] # <id> is the approval id or the run id
rflow reject <id> --reason "gas too high today"rflow approve prints the prepared transaction and asks for confirmation
(--yes skips it). Since steps are strictly sequential, a run has at most one
pending approval — the run id works as the <id>. Pending approvals are also
visible in rflow runs show <run> and the run-trace viewer.
An AI agent operating the project via MCP can see pending approvals in the journal but has no approve/reject tool — deciding a money gate stays with humans and the CLI, deliberately.
on_timeout: proceed — the sharp edge
Semantics worth knowing
- Webhook callers see 429 while their workflow's run is parked at a gate — the caller's retry loop redelivers naturally once the run settles.
- Dry-run sessions auto-proceed: in
rflow replay/rflow testthe gate does not park — the journal carries anapproval.requiredmarker so you still see where a human would have been asked. on_reorg:steps may not contain approval gates (validated) — reorg responses must stay fast and unattended.- The pagerduty/opsgenie channels classify approval requests as
info/P5— a request for a decision, not an incident.