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 ActionsLint 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 lintprints the finding, its fix and a docs link, and exits 0. --strict(orpolicy.strict: true) promotes every warning to deny. Teams typically shipstrict: trueinside aprodprofile'spolicy:.
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: falseA 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: trueSuppressions (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:
| stage | what it checks | when it is skipped |
|---|---|---|
ci.validate | strict collect-all validation (rflow validate) | never |
ci.lint | this page's safety lint (--strict promotes) | never |
ci.schema | a 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.plan | config plan vs the latest stored version | DB unreachable / no stored version |
ci.tests | every tests/<workflow>[.case].json fixture as a dry-run test | no fixtures / DB unreachable |
ci.preflight | live RPC/database/channel connectivity | unless --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 blockrflow 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.