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 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 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 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,
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: 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.
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:
| 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.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 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.