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

Safety lint & the CI gate

rflow validate answers "is this config correct?"; rflow lint answers "is it safe to run in production?". Lint holds every workflow against a policy: recommended defaults out of the box, tunable via an optional top-level policy: block. rflow ci wraps lint, validate and every other unattended check into one command with one exit code.

rflow lint                      # recommended policy - warnings advise, exit 0
rflow lint --strict             # warnings fail - the CI posture
rflow lint --profile prod       # lint the prod-merged view (and its policy:)
rflow ci                        # the full gate
rflow ci --profile prod --json  # machine report for GitHub Actions

Lint is fully static: no database, no network, and usually no .env needed (it prefers the raw ${VAR} view of rflow.yaml; only a placeholder in a typed non-string field like chain_id forces the substituted view and needs that var set), so it runs first in any pipeline.

The severity model

Three tiers; a fresh project is advised, not blocked:

  • A knob you wrote into policy: denies. You asked for the guarantee, so its absence is a failure (exit 1).
  • A recommended default warns. rflow lint prints the finding, its fix and a docs link, and exits 0.
  • --strict (or policy.strict: true) promotes every warning to deny. Teams typically ship strict: true inside a prod profile's policy:.

Every finding carries a stable rule id, the key used by suppressions and the anchors below.

Rules

send-simulation-off

A send_transaction sets simulate: false. Sends are pre-flight simulated by default; opting out means a reverting call still broadcasts (and pays gas), and assert_sim / approval previews are skipped. Knob: require_simulation (default true).

send-no-gas-cap

A send has no gas ceiling: no step gas.max_price / gas.max_cost and its relayer has no policy.max_gas_price. Without one, a gas spike broadcasts at any price. Knob: require_gas_caps (default true).

send-low-confirmations

A workflow that sends transactions triggers from an on-chain event below the policy's confirmation depth (run_on: unconfirmed counts as depth 0, and composite all:/any: triggers match at head). A reorg can orphan the trigger after money moved. Knob: min_confirmations_for_sends (default 1, which flags head-fired money; 0 disables). Notify-only workflows are never flagged: alert at head, pay at depth.

send-approval-timeout-proceeds

An approval gate sets on_timeout: proceed, so an unanswered approval broadcasts once the timeout elapses. Knob: allow_approval_timeout_proceed (default false).

send-approval-no-recheck

An approval-gated send has no recheck: expression. Approval already re-simulates before broadcast, but only a recheck re-verifies your business condition after the human delay. Knob: require_recheck_with_approval (default false; enable where conditions go stale while a human decides).

send-no-spend-guard

A money-moving workflow has neither an attached budget nor permissions.max_value_per_tx, so nothing bounds cumulative outflow when something upstream goes wrong. Knob: require_spend_guard_for_sends (default false). See Budgets.

send-no-test-fixture

A money-moving workflow has no tests/<workflow>*.json dry-run fixture (the convention rflow ci runs). Knob: require_test_fixtures_for_sends (default false).

relayer-budget-required

A send uses a relayer listed in require_budget_for_relayers, but no attached budget covers that relayer. This knob only exists explicitly, so a violation always fails, and a typo'd relayer name in the policy is itself flagged (policy-invalid) rather than silently protecting nothing.

catch-up-sends

A money-moving cron/interval workflow sets catch_up: true: after downtime, every missed slot fires: a burst of sends against conditions that may no longer hold. Knob: allow_catch_up_for_sends (default false).

relayer-underfunded

A money-moving send targets a relayer on a network with no automatic_top_up: if the relayer runs low on gas, sends stall silently until an operator tops it up. Lint checks the configuration posture only; the live balance-vs-need comparison is rflow doctor's relayer.<name>.funding.<network> check and rflow relayers funding-plan. Warns by default, denies under --strict.

webhook-no-auth

A webhook trigger accepts unauthenticated requests. An open route is remote control of whatever the workflow does, money or not, so this fires on every webhook without auth: hmac. Knob: require_webhook_auth (default true).

webhook-send-no-idempotency

A webhook whose workflow sends has no idempotency_key. Webhook delivery is at-least-once: a caller retry (timeout, 5xx, network blip) becomes a second money-moving run. Knob: require_webhook_idempotency_for_sends (default true).

server-unauthenticated

The project moves money and rflow's port binds publicly (the zero-config default is 0.0.0.0) with no config.server.auth: the run viewer and /api/* are open to anyone who can reach the port. Satisfied by an auth token or a loopback config.server.bind. Knob: require_server_auth (default true; never fires on monitoring-only projects). See Self-hosting.

policy-invalid

The policy: block itself is inconsistent: a suppression naming an unknown rule or workflow, an empty reason, or require_budget_for_relayers listing a relayer that does not exist. Always denies, and cannot be suppressed.

policy-unused-suppression

A policy.suppress entry matched no finding. Stale suppressions rot; remove it (or fix its scope).

The policy: block

Every knob is optional; absent knobs use the recommended default. Written knobs deny on violation.

rflow_version: 1
name: treasury-ops
 
config:
  port: 3940
  db_connection: ${DATABASE_URL}
 
networks:
  - name: ethereum
    chain_id: 1
    rpc: ${ETH_RPC}
 
signer:
  raw:
    mnemonic: ${RAW_DANGEROUS_MNEMONIC}   # DEV ONLY
 
relayers:
  treasury:
    networks: [ethereum]
    speed: FAST
 
policy: 
  strict: false                            # true = every warning fails 
  require_simulation: true
  require_gas_caps: true
  min_confirmations_for_sends: 2
  allow_approval_timeout_proceed: false
  require_recheck_with_approval: false
  require_spend_guard_for_sends: false
  require_budget_for_relayers: [treasury]  # violations always fail 
  allow_catch_up_for_sends: false
  require_webhook_auth: true
  require_webhook_idempotency_for_sends: true
  require_server_auth: true
  require_test_fixtures_for_sends: false
 
notifications:
  channels:
    ops:
      console: {}
 
workflows:
  heartbeat:
    trigger:
      cron:
        expression: "0 8 * * *"
    steps:
      - id: ping
        notify:
          channel: ops
          message: "rflow is alive"

A profile can carry its own policy:; it replaces the top-level block wholesale when --profile is passed, so prod states its complete posture:

rflow_version: 1
name: treasury-ops
 
config:
  port: 3940
  db_connection: ${DATABASE_URL}
 
notifications:
  channels:
    ops:
      console: {}
 
workflows:
  heartbeat:
    trigger:
      cron:
        expression: "0 8 * * *"
    steps:
      - id: ping
        notify:
          channel: ops
          message: "rflow is alive"
 
profiles: 
  prod: 
    policy: 
      strict: true
      min_confirmations_for_sends: 12
      require_spend_guard_for_sends: true
      require_test_fixtures_for_sends: true

Suppressions (with a required reason)

Silence a specific finding under policy.suppress. reason is mandatory (the file does not parse without it), and every suppression is echoed in the report with its reason, so the justification stays visible in CI logs. Optional workflow: and step: narrow the scope; an entry that matches nothing is flagged as policy-unused-suppression.

rflow_version: 1
name: liquidation-keeper
 
config:
  port: 3940
  db_connection: ${DATABASE_URL}
 
networks:
  - name: ethereum
    chain_id: 1
    rpc: ${ETH_RPC}
 
signer:
  raw:
    mnemonic: ${RAW_DANGEROUS_MNEMONIC}   # DEV ONLY
 
relayers:
  keeper:
    networks: [ethereum]
    speed: FAST
 
contracts:
  Vault:
    abi: ./abis/vault.json
    addresses:
      ethereum: "0x1111111111111111111111111111111111111111"
 
policy: 
  suppress: 
    - rule: server-unauthenticated
      reason: "port is firewalled to the ops VPN"
    - rule: send-no-gas-cap
      workflow: liquidate
      step: fire
      reason: "liquidation protection must land at any gas price"
 
workflows:
  liquidate:
    trigger:
      event:
        contract: Vault
        name: Liquidatable
        network: ethereum
        confirmations: 2
        run_on: confirmed
    steps:
      - id: fire
        send_transaction:
          network: ethereum
          relayer: keeper
          contract: Vault
          function: "liquidate(address)"
          args: ["${{ trigger.args.account }}"]

rflow ci

One command, one exit code: everything that fits an unattended pipeline, in order. Stages that need something the environment does not have (a database, fixtures, a committed schema copy) are skipped and say why, never silently passed:

stagewhat it checkswhen it is skipped
ci.validatestrict collect-all validation (rflow validate)never
ci.lintthis page's safety lint (--strict promotes)never
ci.schemaa committed rflow.schema.json matches this CLI (rflow schema > rflow.schema.json)no schema copy in the project
ci.templates.rflow/template-lock.yaml entries resolve in the registry (version drift is visible, non-gating)no lock file
ci.planconfig plan vs the latest stored versionDB unreachable / no stored version
ci.testsevery tests/<workflow>[.case].json fixture as a dry-run testno fixtures / DB unreachable
ci.preflightlive RPC/database/channel connectivityunless --preflight is passed

Exit rule: non-zero iff a gating stage fails (ci.templates drift is informational). --json prints a versioned machine report on pure stdout; diagnostics and the final error go to stderr, so a pipeline can parse stdout and gate on the exit code.

Test fixtures convention

Put a fixture for a workflow's own trigger kind at tests/<workflow>.json (more cases: tests/<workflow>.big-amount.json). rflow ci runs each as rflow test <workflow> --fixture <file>, a dry-run against the real config where sends simulate but never broadcast. The send-no-test-fixture rule can require one per money-moving workflow.

GitHub Actions example

name: rflow-ci
on: [pull_request]
 
jobs:
  gate:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env: { POSTGRES_PASSWORD: rflow, POSTGRES_DB: rflow }
        ports: ["5448:5432"]
    env:
      DATABASE_URL: postgres://postgres:rflow@localhost:5448/rflow
      ETH_RPC: ${{ secrets.ETH_RPC }}
    steps:
      - uses: actions/checkout@v4
      - name: Install rflow
        run: curl -fsSL https://rflow.sh/install.sh | bash
      - name: CI gate
        run: rflow ci --profile prod --json > rflow-ci.json
      - name: Upload report
        if: always()
        uses: actions/upload-artifact@v4
        with: { name: rflow-ci-report, path: rflow-ci.json }

The gate step fails on its own exit code; the JSON artifact carries every stage row and lint finding (with fix + docs link) for the PR annotation of your choice, e.g. jq -r '.lint.findings[] | "::warning::\(.rule): \(.message)"' rflow-ci.json.

Where lint sits among the other checks

  • rflow validate — correctness. Unknown keys, broken references, malformed values. Hard errors block rflow start.
  • rflow lint — safety posture. Static, policy-driven, suppressable.
  • rflow doctor — operational health of a RUNNING setup (balances, cursors, stuck work). Needs the environment.
  • rflow plan — what a deploy would change, against the journal's live state. Needs the database.

rflow ci sequences the unattended subset of all four.