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?". A workflow can be perfectly valid YAML and still broadcast unsimulated sends with no gas ceiling off an unauthenticated webhook. Lint holds every workflow against a policy: the 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 honest tiers, so a fresh project is advised rather than blocked:

  • A knob you wrote into policy: denies. You asked for the guarantee — 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 — head-fired money is flagged; 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 — 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 funding safety net: if the relayer runs low on gas, sends stall silently until an operator tops it up. Lint is static (CI never touches the chain), so this checks the configuration posture — the live balance-vs-need comparison is rflow doctor's relayer.<name>.funding.<network> check and rflow relayers funding-plan. Monitor-only: 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.

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

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

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.

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"

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.xyz/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.