Skip to content

Effects

Everything else in an app ledger keeps state inside the ledger. Effects let a finalized transition trigger an action outside it — call an ERP or webhook, publish to Kafka, store an object, pin to IPFS, submit a Cardano payment — without breaking determinism.

You’ll learn: how an effect travels from emit to a recorded outcome, what its identity and result policy mean, how to run an executor, and what a result does and does not prove.

A state machine never performs the action. It emits a record describing it.

Emission happens inside deterministic apply(), is identical on every member, and is committed transitively through a count-bound effectsRoot leaf into the state root. A separate effect runtime, outside consensus, executes finalized effects. When the effect asks for it, the runtime reports the outcome back as an ordinary sequenced message.

The guarantee is exactly-once incorporation, at-least-once execution. The external action may run more than once, so every executor and every receiver must be idempotent under the supplied idempotency identity.

Step through one webhook.post effect. The “What if” scenarios show the NONE result policy, a receiver that answers 4xx or 5xx, and an executor that crashes after the POST.

Life of an effectIllustrative data

What you're seeing: an order approval that calls a webhook, with the default app-final gate and a CHAIN result. Try the “What if” scenarios, or switch to your receiver’s view.

  1. Emit Inside apply(), the state machine calls effects.emit(...) with type webhook.post, a scope, ResultPolicy.CHAIN, and an expiry of 1,000 blocks. Nothing is sent. The emitter returns EffectId(chain, height, ordinal), the effect’s position in history: the ordinal counts emissions in the block. The scope is not part of the identity.
  2. Finalize The block becomes final like any other. Every member stores the same effect record, and the block’s effects root, written under ~fx/root/120, commits it in the state root. The effect is provable from now on. What a transition emits is consensus data.
  3. Pick up The effect runtime is off by default; you enable it on one node. It reads finalized records, checks the gate (app-final is eligible at once), and never dispatches an effect within 2 blocks of its expiry. Its progress, such as PENDING, RETRY, DONE, or PARKED, is node-local and never part of a root.
  4. Deliver The webhook.post executor POSTs the payload to the URL in its own configuration; a URL inside the payload is refused unless allow-payload-url is set. The Idempotency-Key header is the hex Blake2b-256 hash of the effect id, the same on every attempt. X-Effect-Id carries orders-chain/120/0.
  5. Answer The receiver answers 2xx. The executor records the effect as done and confirmed, with the Location header, or HTTP 200, as the external reference. A 4xx means FAILED with no retry; a 5xx or a network error is retried.
  6. Report For a CHAIN effect, the executor node signs a ~fx/result message with its member key and submits it like any other message. It repeats this every 60 seconds until the ledger closes the effect. effects.result.signers can limit which members may report.
  7. Incorporate In the block that contains it, the framework accepts the first valid result before the state machine runs, records the outcome, and calls onEffectResult on every member. A duplicate, late, or unknown result is a no-op, so a result can never stall the ledger. A result is a member’s attestation, not proof of what the receiver did.

What if: Result policy NONE

Without .result(ResultPolicy.CHAIN), the policy is the default, NONE.

  1. Emit without a result The intent does not call .result(...), so its policy is the default ResultPolicy.NONE. A NONE effect cannot take an expiry, and its outcome never returns to the ledger.
  2. Finalize The block becomes final like any other. Every member stores the same effect record, and the block’s effects root, written under ~fx/root/120, commits it in the state root. The effect is provable from now on. What a transition emits is consensus data.
  3. Pick up The effect runtime is off by default; you enable it on one node. It reads finalized records, checks the gate (app-final is eligible at once), and never dispatches an effect within 2 blocks of its expiry. Its progress, such as PENDING, RETRY, DONE, or PARKED, is node-local and never part of a root.
  4. Deliver The webhook.post executor POSTs the payload to the URL in its own configuration; a URL inside the payload is refused unless allow-payload-url is set. The Idempotency-Key header is the hex Blake2b-256 hash of the effect id, the same on every attempt. X-Effect-Id carries orders-chain/120/0.
  5. Done locally The receiver answers 2xx and the executor marks the effect done. That is the end: no ~fx/result, no onEffectResult, no expiry. Only this node’s REST status and metrics show the outcome. A NONE effect that fails for good is PARKED for an operator.

What if: Receiver answers 4xx

A definitive “no” from the target is recorded as FAILED.

  1. Emit Inside apply(), the state machine calls effects.emit(...) with type webhook.post, a scope, ResultPolicy.CHAIN, and an expiry of 1,000 blocks. Nothing is sent. The emitter returns EffectId(chain, height, ordinal), the effect’s position in history: the ordinal counts emissions in the block. The scope is not part of the identity.
  2. Finalize The block becomes final like any other. Every member stores the same effect record, and the block’s effects root, written under ~fx/root/120, commits it in the state root. The effect is provable from now on. What a transition emits is consensus data.
  3. Pick up The effect runtime is off by default; you enable it on one node. It reads finalized records, checks the gate (app-final is eligible at once), and never dispatches an effect within 2 blocks of its expiry. Its progress, such as PENDING, RETRY, DONE, or PARKED, is node-local and never part of a root.
  4. Deliver The webhook.post executor POSTs the payload to the URL in its own configuration; a URL inside the payload is refused unless allow-payload-url is set. The Idempotency-Key header is the hex Blake2b-256 hash of the effect id, the same on every attempt. X-Effect-Id carries orders-chain/120/0.
  5. Rejected The receiver answers 400. Retrying the same bytes cannot succeed, so the executor does not retry. For a CHAIN effect it records FAILED with the reason HTTP 400.
  6. Report For a CHAIN effect, the executor node signs a ~fx/result message with its member key and submits it like any other message. It repeats this every 60 seconds until the ledger closes the effect. effects.result.signers can limit which members may report.
  7. Incorporate FAILED Every member calls onEffectResult with outcome FAILED: the target answered no. The state machine decides what that means for the order.

What if: Receiver answers 5xx

Retries are local and bounded; the expiry closes the effect deterministically.

  1. Emit Inside apply(), the state machine calls effects.emit(...) with type webhook.post, a scope, ResultPolicy.CHAIN, and an expiry of 1,000 blocks. Nothing is sent. The emitter returns EffectId(chain, height, ordinal), the effect’s position in history: the ordinal counts emissions in the block. The scope is not part of the identity.
  2. Finalize The block becomes final like any other. Every member stores the same effect record, and the block’s effects root, written under ~fx/root/120, commits it in the state root. The effect is provable from now on. What a transition emits is consensus data.
  3. Pick up The effect runtime is off by default; you enable it on one node. It reads finalized records, checks the gate (app-final is eligible at once), and never dispatches an effect within 2 blocks of its expiry. Its progress, such as PENDING, RETRY, DONE, or PARKED, is node-local and never part of a root.
  4. Deliver The webhook.post executor POSTs the payload to the URL in its own configuration; a URL inside the payload is refused unless allow-payload-url is set. The Idempotency-Key header is the hex Blake2b-256 hash of the effect id, the same on every attempt. X-Effect-Id carries orders-chain/120/0.
  5. Retry The receiver answers 503. The executor retries after 2 seconds, doubling the wait up to 5 minutes. After 8 attempts the effect is PARKED, and an operator can requeue or cancel it. It is never dispatched within 2 blocks of its expiry.
  6. Expire No result arrives before height 1120, the emission height plus the expiry. At that height every member closes the effect as EXPIRED, without any message, and calls onEffectResult. EXPIRED means “nobody answered in time”, which is different from FAILED.

What if: Executor crashes after the POST

Execution is at least once, so the receiver must deduplicate.

  1. Emit Inside apply(), the state machine calls effects.emit(...) with type webhook.post, a scope, ResultPolicy.CHAIN, and an expiry of 1,000 blocks. Nothing is sent. The emitter returns EffectId(chain, height, ordinal), the effect’s position in history: the ordinal counts emissions in the block. The scope is not part of the identity.
  2. Finalize The block becomes final like any other. Every member stores the same effect record, and the block’s effects root, written under ~fx/root/120, commits it in the state root. The effect is provable from now on. What a transition emits is consensus data.
  3. Pick up The effect runtime is off by default; you enable it on one node. It reads finalized records, checks the gate (app-final is eligible at once), and never dispatches an effect within 2 blocks of its expiry. Its progress, such as PENDING, RETRY, DONE, or PARKED, is node-local and never part of a root.
  4. Deliver The webhook.post executor POSTs the payload to the URL in its own configuration; a URL inside the payload is refused unless allow-payload-url is set. The Idempotency-Key header is the hex Blake2b-256 hash of the effect id, the same on every attempt. X-Effect-Id carries orders-chain/120/0.
  5. Crash The receiver does the work and answers 200, but the executor node stops before it records the outcome. Its local status still says the effect needs to run.
  6. Deliver again After the restart, the executor POSTs the same payload again with the same Idempotency-Key. The receiver finds the key, does nothing new, and answers 200 again.
  7. Report For a CHAIN effect, the executor node signs a ~fx/result message with its member key and submits it like any other message. It repeats this every 60 seconds until the ledger closes the effect. effects.result.signers can limit which members may report.
  8. Incorporate In the block that contains it, the framework accepts the first valid result before the state machine runs, records the outcome, and calls onEffectResult on every member. A duplicate, late, or unknown result is a no-op, so a result can never stall the ledger. A result is a member’s attestation, not proof of what the receiver did.
  • deterministic, on every member
  • app ledger members
  • node-local execution
  • your system
  • final
  • failed or refused

An effect’s identity is EffectId(chain, height, ordinal): the block that emitted it and its position among that block’s emissions. Every member derives the same id, across retries, restarts, and executor failover. The scope you pass to .scope(...) is an application handle and the executor’s ordering key; it is not part of the identity.

Executors hand external systems a fixed-width key derived from that id: the Blake2b-256 hash of "yano-fx-v1", the chain id, the height, and the ordinal. The built-in webhook sends it as the Idempotency-Key header. A receiver must store the key with the work it does and answer a repeated key without doing the work again.

ResultPolicy decides whether the outcome comes back into the ledger. There are two values, and the default is NONE.

PolicyWhat happens to the outcomeExpiryonEffectResult
NONE (default)Recorded only on the executing node; visible through REST and metrics.Not allowedNever called
CHAINRe-enters as a member-signed ~fx/result message and is incorporated exactly once.MandatoryCalled with CONFIRMED, FAILED, CANCELLED, or EXPIRED

Choose CHAIN whenever the application must react to the outcome or prove it later.

Effects are off by default, and these are consensus parameters — every member must set identical values, or the state root diverges exactly as it would with a mismatched state machine.

yano.app-chain.effects.enabled: true
yano.app-chain.effects.max-per-block: 256 # effects one block may emit
yano.app-chain.effects.max-payload-bytes: 16384 # per-effect payload cap
yano.app-chain.effects.max-expiry-blocks: 100000
yano.app-chain.effects.result-window-blocks: 100000 # results incorporable within this window
yano.app-chain.effects.outcome-commitment: per-effect # per-effect | per-block
yano.app-chain.effects.default-gate: app-final # app-final | l1-anchored | zk-settled

Enabling effects reserves the ~fx/ key prefix in the state trie — a state machine may not write keys starting with ~fx/. The reservation holds from genesis regardless of the flag, so effects can be switched on later without colliding with historical state.

public class OrderStateMachine implements AppStateMachine {
@Override public String id() { return "orders"; }
@Override
public void apply(AppBlockExecutionContext context, AppStateWriter writer, AppEffectEmitter effects) {
for (AppMessage m : context.messages()) {
Order o = decode(m.getBody());
writer.put(key(o.id()), o.toBytes()); // ordinary state
if (o.isApproved()) {
effects.emit(EffectIntent.of("webhook.post", o.fulfilmentJson())
.scope("orders/" + o.id()) // application idempotency scope
.result(ResultPolicy.CHAIN) // the outcome returns on-chain
.gate(FinalityGate.CHAIN_DEFAULT)
.expiryBlocks(1000) // deterministic timeout
.sourceMessageId(m.getMessageId())
.build());
}
}
}
// Called deterministically when a CHAIN effect's outcome is incorporated.
@Override
public void onEffectResult(AppBlockExecutionContext context, EffectResult result,
AppStateWriter writer, AppEffectEmitter effects) {
if (!result.scope().startsWith("orders/")) return;
String id = result.scope().substring("orders/".length());
writer.put(fulfilledKey(id), result.externalRef());
}
}

The emitter records intent and performs no I/O. Everything forbidden in apply() — wall clock, randomness, network — stays forbidden. Leave out .result(ResultPolicy.CHAIN) and the effect is NONE: onEffectResult is never called for it.

A gate decides when an emitted effect becomes eligible to execute.

GateEligible whenNotes
app-finalThe block is committed.The chain is append-only after finality, so emission is already irrevocable.
l1-anchoredThe effect’s height is covered by an anchor confirmed in an L1 block at least l1.stability-depth blocks below this node’s L1 tip.The emission is provable against Cardano before you act. An anchor that L1 rolls back stops counting.
zk-settledCovered by an accepted validity proof.Reserved for the ZK settlement roadmap; waits until expiry on non-ZK chains.

l1-anchored requires anchoring to be enabled and l1.stability-depth to be above 0; a chain whose effects.default-gate is l1-anchored without a stability depth fails to start. effects.gate.anchor-margin-blocks (node-local, default 0) holds effects that many blocks further below the stable anchored height. Each node judges depth from its own L1 view, and in metadata mode only the anchor leader observes anchors, so run l1-anchored executors on that node.

Every CHAIN effect must provably close, because a result arriving after the result window is a deterministic no-op. Passing expiryBlocks(0), or no expiry at all, sets it to the smaller of effects.max-expiry-blocks and effects.result-window-blocks. An expiry larger than the result window is rejected.

When the expiry height passes with no incorporated result, the effect deterministically becomes EXPIRED and is delivered to onEffectResult. That is the “nobody answered in time” escape hatch, and it is distinct from FAILED, which means “the target answered no”. An operator’s cancel closes an open CHAIN effect as CANCELLED; it cannot unsend an action already taken. The executor does not dispatch an effect within two blocks of its expiry, so the expiry can close it cleanly.

The result path, and what a result actually proves

Section titled “The result path, and what a result actually proves”

Executed CHAIN outcomes re-enter as member-signed ~fx/result messages, sequenced like anything else. The executor node resubmits a result until the ledger closes the effect. The interpreter is fail-closed and first-result-wins: duplicate, late, malformed, unknown, or out-of-window results are deterministic no-ops. A result can never stall the chain.

A result is a member attestation, not an independently verified fact. Followers check the signature and membership, not the external world. Narrow who may attest:

# Only these member keys' ~fx/result messages are accepted. Default: any member.
yano.app-chain.effects.result.signers: "<hex pubkey of the executor node>"

For L1-visible facts such as a payment landing, prefer verifying through an L1 observer over trusting an attestation. k-of-n result attestation for high-value effects is designed but not yet shipped.

The effect runtime is off by default on every node. Everywhere it is off, effects are still emitted and provable; they wait.

yano.app-chain.effects.executor.enabled: true # this node executes effects
yano.app-chain.effects.executor.types: "" # empty = all; or "webhook.post,kafka.publish"
yano.app-chain.effects.executor.max-attempts: 8 # then PARKED for operator review
  • Run it on one node, or split effect types across nodes with disjoint effects.executor.types. There is no cross-node mutual exclusion: two nodes enabled for the same type both attempt it, and idempotency absorbs the duplicate.
  • Failures retry with backoff, then park. A parked effect never blocks the others; an operator can requeue or cancel it through the privileged effects REST surface.
  • A late-enabled executor does not fire history. Open effects that predate it are quarantined until an operator requeues them.
  • External workers can execute instead, through yano.app-chain.effects.external.enabled: true. A worker claims effects with POST …/effects/claim and reports with POST …/effects/{height}/{ordinal}/report; the node signs the ~fx/result. Claims are wall-clock leases, and late reports are fenced to the lease holder. Leases and fencing are node-local bookkeeping on the member the worker talks to, not consensus state.

The user guide’s effects section covers every setting, the REST surface, and writing a custom executor.

KindWhat it doesAvailability
webhook.post executor and finalized webhook sinkHTTP delivery of an authorized action or a finalized block stream.Bundled
kafka.publish executor and Kafka sinkAcknowledged effects and finalized blocks to Kafka topics.First-party optional plugin
object.put executorImmutable or versioned writes to tested S3-compatible stores.First-party optional plugin
ipfs.pin executorReconciled pin-only effects through a configured Kubo RPC.First-party optional plugin
cardano.payment executorCardano payments from the effect system. A preview, tightly capped-wallet profile: use no material funds until the transaction-safety work tracked as FX-002 is complete.First-party optional plugin

All of these capabilities, and the effect runtime itself, are preview in the current release catalog. An optional connector needs its exact release-matched bundle installed in plugins/ on every applicable node, plus the external service. See Optional connectors.

The bundled effects demo needs no broker, object store, or credentials. It uses the stock effects-chain, whose approvals machine emits a demo.webhook effect when an item is approved; a simulated external worker claims it and reports success.

Terminal window
./yano.sh appchain cluster start 3
./yano.sh appchain cluster effect demo "order 42 approved"

Expected output, with your heights and hashes:

Effect emitted effects-chain height=<h> ordinal=<n>
Effect type demo.webhook
Executor external demo worker on node 0
Delivery CONFIRMED
External reference demo-worker://confirmed/<effect id>
Attempts <n>
Proof AVAILABLE (<hash prefix>...)
Captured payload {"event":"demo.effect.requested","itemId":"demo-…","message":"order 42 approved"}

Next: Tutorial 6 — webhook effects runs a real webhook.post against a local receiver, and covers when to use a finalized-block sink instead.