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
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
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 — no restart needed.
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 |
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.