Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

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

FieldDefaultDescription
via✅ requiredApproval routes, non-empty: telegram: <channel> (a notifications channel) and/or cli
timeoutHow long to wait for a decision, e.g. 1h
on_timeoutfailWhat an undecided timeout does: fail | proceed
messageTemplate 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 undecided

Reference 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-ops

Semantics 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 from rflow approvals ls with their token) — a DM / SMS over the channel's credential, carrying a signed one-time approval link (when config.server.public_url is 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> (or RFLOW_API_TOKEN) — the token's name maps to approvers.<member>.cli.token. cli:$USER is never accepted for quorums.
  • Everything is journaled: per-member decisions (member, route, reason, time) in rflow.approval_decisions, an operator_audit row per decision, and the full trail in rflow 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 — the operator_audit trail 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-limited

Every 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

  1. 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.
  2. The transaction is prepared and simulated. Simulation, assert_sim and gas caps all run before anyone is asked — a send that would revert never bothers a human.
  3. 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.
  4. Approvers are notified through every via: route — the notice carries the workflow, run and step ids, your message, the decoded call summary and the exact rflow approve/reject one-liners. Notification failures are logged, never fatal: the gate is the database row, so the CLI always works even when every channel is down.
  5. 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.
DecisionEffect
approvedThe 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
rejectedThe step fails (kind dropped, the reason journaled); on_failure applies
expiredon_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 test the gate does not park — the journal carries an approval.required marker 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.