Doctor & explain — pre-deploy diagnosis and PR review
Two commands cover "is this safe to boot?":
rflow doctor— live diagnosis against the real world (DB, RPCs, relayer balances, channels) plus static posture guidance, each check with a severity and a fix hint. Safe to gate CI and deploys on.rflow explain— static narrative: what a workflow does, what sends money, and what stands between trigger and broadcast. Built for PR review.
Run doctor before every deployment; paste explain --risk into every PR that
touches a money workflow.
rflow doctor — the operational suite
rflow doctor [--path <dir>] [--profile <name>] [--deep] [--json]The quick readiness table (Docker, Foundry, Postgres, Node, the rflow.yaml
validate summary; see Scaffolding)
prints first. In a project the deep suite follows automatically (--deep
only makes it explicit; outside a project only the tool checks run). With
--profile, doctor diagnoses the merged config rflow start --profile <name>
would boot.
deep checks
check | severity | status | detail
config.valid | error | pass | valid - 1 workflow(s), 1 warning(s)
contract.Token | error | pass | abi parses (2 event(s), 9 function(s)); 1 static address(es) checksum-valid
db.connect | error | pass | connected
db.schema | info | pass | rflow schema present (migrations applied)
relayer.treasury.mapping.ethereum | warning | FAIL | no rflow.relayers row for chain 1
rpc.ethereum | error | pass | chain id 1 matches
signer.raw | warning | pass | mnemonic resolves (24 words) - dev-only; use a managed signer in production
approval.treasury-sweep-approval.sweep | warning | pass | gates the broadcast via cli, timeout 1h -> fail
coverage.treasury-sweep-approval | info | FAIL | money workflow without: rate_limit, circuit_breaker, permissions.max_value_per_tx
How to fix:
- relayer.treasury.mapping.ethereum [warning]: `rflow start` creates the wallet on first boot (or `rflow relayers sync`)
- coverage.treasury-sweep-approval [info]: each is optional - but they are the blast-radius caps when something upstream goes wrong
doctor: ok - 7 check(s) passed, 1 warning(s), 0 advice, 1 info (warnings/advice never block)The check catalogue
Check ids are stable. Key CI logic off them, not off titles or details.
| check id | severity | what it verifies |
|---|---|---|
config.valid | error | the config (post-profile-merge) passes strict validation |
config.profile.<name> | error / warning | a profiles.<name> overlay applies cleanly and the merged config validates: error for the --profile you selected, warning for other declared profiles checked in passing |
contract.<name> | error | the ABI file parses; every static address is EIP-55 checksum-valid |
db.connect | error | Postgres reachable via config.db_connection (bounded SELECT 1) |
db.schema | info | the rflow schema and its migration sentinel exist (a fresh project passes this only after the first rflow start) |
rpc.<network>[.N] | error | every RPC url (primary and every fallback) answers eth_chainId matching the declared chain_id (at runtime a wrong-chain endpoint is permanently evicted). ${VAR} placeholders resolve before probing; an unresolved variable is its own clear failure |
rpc.<network>.finalized | advice | the node answers a finalized-tag block query; probed only when a workflow uses finalized (event confirmations: finalized or send wait_for: finalized) |
rpc.<network>.archive | advice | best-effort archive probe (eth_getBalance at block 0x1), probed only when a workflow backfills history; passing it is not proof of full archive depth |
relayer.<name>.mapping.<network> | warning | an rflow.relayers row exists for the chain (rflow start creates the wallet on first boot) |
relayer.<name>.import | warning | a declared import: { id } has been adopted (a mapping row exists: the boot derived-address check passed); rflow relayers verify-imports re-checks against the live signer |
relayer.<name>.balance.<network> | warning | the relayer's native balance is above 0.01 ether; below that it likely cannot pay for gas |
relayer.<name>.funding.<network> | warning | the balance covers the funding-plan estimate; only checked when a money workflow targets the relayer and the balance/gas price could be read |
relayer.<name>.queue.<network> | advice | no in-flight (queued/in-mempool) relayer transaction looks stuck (a long-pending nonce blocks every send behind it); remediate with rflow relayers pending / cancel / replace |
signer.raw / signer.private_keys | warning | the mnemonic / key env vars actually resolve (raw mnemonics also get a "dev-only" nudge) |
signer.<cloud-provider> | info | config-shape only for aws_kms, aws_secret_manager, gcp_secret_manager, privy, turnkey, pkcs11, fireblocks: no side-effect-free probe exists; rflow start verifies them |
channel.<name> | warning | delivery probe per sender: Telegram getMe, Slack/Discord webhook-host TCP, PagerDuty/Opsgenie/Twilio API reachability |
command.<workflow>.<step> | error | every command: step's executable resolves (covers steps, on_reorg and finally) |
webhook.<workflow> | warning / advice | webhook auth posture: no auth on a workflow that queues transactions is a warning (anyone who reaches the port can fire it); no auth on a monitor-only workflow is advice |
approval.<workflow>.<step> | warning | approval-gate posture: on_timeout: proceed fails the check because an undecided approval broadcasts once the timeout elapses (covers steps and finally; validation rejects an approval in on_reorg) |
reorg.<workflow> | advice | head-fired triggers feeding sends / ignored confirmations (validation's send-safety warnings, grouped per workflow) |
history.<workflow> | advice | start_block: earliest with no bounded end_block: the trigger backfills every historical event before tailing live |
cursor.<workflow>.<network> | warning | a persisted trigger cursor more than 1,000 blocks behind the chain head: a long (re)backfill or a stalled indexer |
coverage.<workflow> | info | a money-moving workflow lacking any of rate_limit, circuit_breaker, permissions.max_value_per_tx (optional, but they are the blast-radius caps) |
Severities and the exit rule
- error — broken as configured; boot would misbehave. The only severity
that gates:
rflow doctorexits non-zero iff an error-severity check fails. - warning — operationally risky (unfunded relayer, unauthenticated money
webhook,
on_timeout: proceed), never blocks. - advice — posture guidance (reorg exposure, backfill volume, missing
finalizedsupport), never blocks. - info — context worth knowing (schema not yet migrated, coverage notes), never blocks.
Skipped checks (e.g. a balance fetch against an unreachable node) report as
skipped, never as failures.
Gating CI and deploys
--json prints exactly one machine-readable object on stdout (logs go to
stderr), with the same exit rule:
{
"version": 1,
"quick": [ { "name": "docker", "status": "ok", "detail": "...", "hint": null } ],
"checks": [ { "id": "rpc.ethereum", "title": "rpc ethereum",
"severity": "error", "status": "pass",
"detail": "chain id 1 matches", "hint": null } ],
"summary": { "passed": 7, "failed": 2, "skipped": 0,
"errors": 0, "warnings": 1, "advice": 0, "info": 1 },
"ok": true
}ok is true unless an error-severity check failed. The shape is versioned
(version: 1) and pinned by a golden test. A GitHub Actions gate:
- name: doctor gate
run: |
rflow doctor --path ./ops --profile prod --json > doctor.json
jq '.summary' doctor.json # visibility in the job log
# optional stricter policy: also refuse warnings
- name: no-warnings policy
run: jq -e '.summary.warnings == 0' doctor.jsonThe first step fails on its own exit code when an error-severity check fails,
so no parsing is required. Key any custom policy off summary counts or stable
check ids.
rflow validate --preflight runs the connectivity subset of the same checks
but keeps its stricter historical rule: any failed check fails preflight,
regardless of severity. It resolves ${VAR} placeholders in rpc urls exactly
like the deep suite; a missing variable fails with a clear "missing env var"
message.
rflow explain — the plain-English narrative
rflow explain [<workflow>] [--risk] [--json] [--path <dir>] [--profile <name>]Fully static: no network, no database. It reads rflow.yaml raw, so
${VAR} references stay visible and secret values never print. When the raw
view cannot parse (an env placeholder in a numeric field like
chain_id: ${CHAIN_ID}), explain substitutes env values to parse, then
restores every string field to its ${VAR} form before rendering. With no
argument, one summary block per workflow:
workflow 'treasury-sweep-approval' [money_moving]
trigger: fires on cron schedule '0 * * * *'
steps: balance (read) -> sweep (send_transaction)The risk label is the template-registry vocabulary:
monitor_only (no sends), prepares_tx (a command: step with tx-shaped
input keys, a heuristic), money_moving (any send_transaction).
simulate: steps are listed as read-only:
they cannot send money (no relayer handle exists in their path), so a workflow
whose only chain interaction is a simulate: step keeps the monitor_only
label.
With a workflow name, the full breakdown: TRIGGER (kind, schedule/event,
conditions, confirmations), STEPS (in order, with if: conditions and
dependencies), WHAT SENDS MONEY, WHAT BLOCKS MONEY (simulation,
assert_sim, recheck, valid_for, approval gates, gas caps and the
absolute gas.hard_max_price ceiling, permissions/budgets, rate limit,
circuit breaker, concurrency), ON FAILURE,
ON REORG, REPLAY / DRY-RUN (sends stop pre-broadcast; waits settle
unless --with-waits) and EXTERNAL SYSTEMS (HTTP hosts, channels, local
commands).
--risk — the PR-review view
--risk prints only the money sections plus a reviewer checklist:
REVIEWER CHECKLIST
[x] pre-flight simulation on every send
[x] simulation assertions (assert_sim)
[x] recheck immediately before broadcast
[x] human approval gate
[ ] send expiry (valid_for)
[x] gas price / cost caps
[ ] absolute gas ceiling (gas.hard_max_price)
[ ] per-tx spend cap (permissions.max_value_per_tx)
[ ] relayer allowlist (permissions.relayers)
[ ] rate limit
[ ] circuit breaker
[ ] reorg compensation steps (on_reorg)
verdict: 5 of 12 reviewer safeguards configuredEnough context to approve or reject a production change without opening the
YAML. Review pattern: require rflow explain <workflow> --risk output in the
PR description for any diff touching a workflow with sends, and treat a
decreasing verdict count as a question to answer in review. --json emits
the same data structured and deterministic for diffing between branches.
Honesty note
Every explain render ends with the same footer:
explain describes the CONFIGURATION, not runtime guarantees — it is not formal verification and not a security audit.
explain tells you what rflow was asked to do. Whether the RPC lies, the
contract behaves, or the approval channel pages the right human is what
rflow doctor, staging replays
(Backtesting) and unattended-ops alerting
are for.