state_set, list_add & list_remove
Three small steps that give workflows durable memory: a Postgres-backed
key/value store (state_set) and runtime mutation of your
lists: watchlists
(list_add / list_remove). Plus the previous_run context root for
comparing against the last run.
state_set
rflow_version: 1
name: oracle-watch
config:
port: 3940
db_connection: ${DATABASE_URL}
networks:
- name: ethereum
chain_id: 1
rpc: ${ETH_RPC}
contracts:
Oracle:
abi: ./abis/chainlink-aggregator.json
addresses:
ethereum: "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419"
notifications:
channels:
ops:
console: {}
workflows:
price-move-alert:
trigger:
interval:
every: 1m
steps:
- id: read-price
read: { contract: Oracle, network: ethereum, function: "latestAnswer()" }
- id: alert-on-move
if: "${{ state['oracle.last_price'] is defined and mul_div(steps.read-price.output, 100, state['oracle.last_price']) > 105 }}"
notify: { channel: ops, message: "price moved >5% since the last tick" }
- id: remember
state_set:
key: "oracle.last_price"
value: "${{ steps.read-price.output }}"| Field | Description |
|---|---|
key | The state key (may be an expression) |
value | The value (may be an expression; numbers stay exact) |
Values persist in rflow.state and are readable from any expression as
state.<key> (or state['dotted.key']). State written earlier in the same
run is visible to the next step's expressions.
list_add / list_remove
rflow_version: 1
name: swap-guard
config:
port: 3940
db_connection: ${DATABASE_URL}
networks:
- name: ethereum
chain_id: 1
rpc: ${ETH_RPC}
contracts:
UniV3Factory:
abi: ./abis/univ3-factory.json
network: ethereum
address: "0x1F98431c8aD98523631AE4a59f267346ea31F984"
AnyPool:
abi: ./abis/univ3-pool.json
factory:
contract: UniV3Factory
event: PoolCreated
network: ethereum
lists:
blocked: { seed: [] }
workflows:
auto-block:
trigger:
event: { contract: AnyPool, name: Swap, network: ethereum }
steps:
- id: block-trader
if: "${{ trigger.args.amount0 > wei('1000000', 18) }}"
list_add:
list: blocked
value: "${{ trigger.args.sender }}"| Field | Description |
|---|---|
list | A list declared under lists: (validated) |
value | The member (may be an expression; addresses are stored lowercased) |
The classic factory pattern: one workflow watches PoolCreated and
list_adds each child; other workflows filter with
${{ trigger.address in lists.pools }}.
What a list is at runtime
lists.<name> in expressions is the union of three provenances (visible
with rflow list show):
| Provenance | Written by | Removed by |
|---|---|---|
seed | rflow.yaml | editing rflow.yaml (list_remove/CLI cannot remove seeds) |
manual | rflow list add | rflow list remove / list_remove: |
workflow | list_add: steps | list_remove: / CLI |
source | lists.<name>.source refresh | the next refresh (atomically replaced) |
A mutation is visible to the very next where: evaluation without a restart.
previous_run — compare against last time
Every step expression can read the workflow's last succeeded run:
| Path | Description |
|---|---|
previous_run.id / previous_run.status | The run (status is always succeeded) |
previous_run.steps.<id>.output | That run's journaled step output |
previous_run.steps.<id>.status | That step's terminal status |
rflow_version: 1
name: rate-watch
config:
port: 3940
db_connection: ${DATABASE_URL}
notifications:
channels:
ops:
console: {}
workflows:
rate-change-alert:
trigger:
interval:
every: 5m
steps:
- id: fetch
http_call: { url: "https://api.example.com/rate" }
- id: only-on-change
if: "${{ previous_run.steps.fetch.output.body.rate != steps.fetch.output.body.rate }}"
notify: { channel: ops, message: "rate changed: ${{ steps.fetch.output.body.rate }}" } On the workflow's first run, previous_run is undefined. Strict evaluation
makes an unguarded reference a step error (never a silent false). Guard it:
if: "${{ previous_run is not defined or previous_run.steps.fetch.output.body.rate != steps.fetch.output.body.rate }}"Dry-run behaviour
In rflow replay / rflow test sessions, state_set /
list_add / list_remove skip their writes and journal would_set /
would_mutate outputs instead: reads see live state, writes never land, and
there is nothing to clean up after a rehearsal.