Composite triggers — all / any
A composite trigger fires a workflow from several on-chain events instead of one.
trigger.all— fire one run when every listed event has matched within a sliding correlation window (within).trigger.any— fire when any listed event matches (first match).
Each matched event is exposed to the workflow steps as events.<id>.* (its decoded
args, tx_hash, block_number, address, …).
all — correlate two related events
workflows:
deposit-then-transfer:
trigger:
all:
within: 10m # sliding correlation window (REQUIRED for `all`)
events:
- id: deposit
event:
contract: Vault
name: Deposit
network: ethereum
- id: transfer
event:
contract: USDC
name: Transfer
network: ethereum
# cross-event correlation: a LATER event's where may reference
# events.<earlier-id>.args
where: "${{ event.args.to == events.deposit.args.user }}"
steps:
- notify:
channel: ops
message: "deposit ${{ events.deposit.args.amount }} then transfer ${{ events.transfer.tx_hash }}"Semantics:
- The first listed event opens a correlation; every later event attaches to
the correlation it satisfies.
${{ event.args.* }}in awhere:is this event;events.<earlier-id>.argsare the already-matched earlier events. - A later event is consumed by at most one correlation — the oldest open one
it satisfies. A single on-chain event never fans out into multiple runs (so one
settlement transfer can never trigger two payouts). If a cross-event
where:is not fully discriminating (e.g. two deposits by the same user), the oldest window wins and the others keep waiting for their own later event. - When a correlation holds every listed event, the run is claimed exactly once (a durable dedup key over the matched set — a re-delivered completing event never fires a second run).
- Partial matches accumulate in a durable table (
rflow.composite_matches) and are swept once theirwithinwindow passes — an incomplete set never fires.
within is required for all and must be a positive duration (a zero window
is rejected). Cross-event where: references must point at an earlier event in the
list (validation enforces this), and a composite where: may reference only
event.* and events.<earlier-id>.* — any other root (constants, secrets,
trigger, state, …) is undefined at match time and is rejected by validation.
Ordering assumption (honest): only the first-listed event opens a correlation,
so all assumes the starter is observed no later than the events that correlate to
it. Events on different contracts (or networks) are delivered by independent head
callbacks, so a later event emitted in the same block as its starter but at a lower
log index can arrive first; with no correlation open yet it is dropped, and that set
never fires. Order the events: list so the event most likely to be seen first (or the
one others correlate back to) is the starter, or keep the correlated events on the
same contract (same-contract logs are delivered in on-chain order). Buffering
out-of-order arrivals is intentionally out of scope — doing it exactly-once would need
a durable consumed-identity tombstone (the arbitrary-CEP join this feature excludes).
A single-event all is just an event: trigger — rflow validate advises you to
model a "react to a follow-up event" flow as an event: trigger plus a
wait_for: step (the run then correlates with the full
run context).
any — fire on the first of several events
trigger:
any:
events:
- id: paused
event: { contract: Vault, name: Paused, network: ethereum }
- id: guardian
event: { contract: Guardian, name: EmergencyStop, network: ethereum }
steps:
- notify: { channel: pager, message: "emergency: ${{ events.paused.tx_hash or events.guardian.tx_hash }}" }any fires immediately on the first matching event (deduped by that event's log
identity). within is accepted but does not gate any.
Match-time scope + reorgs
Composite events match at chain head — the same default an event: trigger uses
for an event with no confirmations: of its own. Exactly-once completion does not
depend on the indexing depth: it rides the durable rflow.composite_matches
accumulator, the composite:{workflow}:{hash} dedup key, and the sliding within
window (a re-delivered completing event re-derives the same key and never fires a
second run). The reorg caveat is the same as a head-firing event: trigger: a reorg
can revert a matched event, so a matched-but-not-completed correlation may linger
until its window expires. Keep composite money workflows to values you are comfortable
acting on at head, or gate the payout behind a send_transaction wait_for: confirmed.
A composite where: sees only event.* (this event) and events.<earlier-id>.*
(correlated earlier events) — the same self-contained match-time discipline as
wait_for. Addresses are case-normalized, so
event.args.to == events.deposit.args.user compares correctly regardless of casing.
Testing composites
rflow test <wf> --fixture <file> rehearses a composite path by supplying the
matched events directly:
{ "events": {
"deposit": { "args": { "user": "0xabc", "amount": "1000000" } },
"transfer": { "args": { "to": "0xabc", "value": "500000" } }
} }Replay limit (honest): the fixture asserts the correlated set directly — the
sliding within window and the cross-event where: matching are not re-run in
rflow test. Use it to exercise the workflow's steps over a known-correlated set; the
correlation state machine itself is covered by rflow's DB-gated tests.