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.
The rule that makes it safe
Section titled “The rule that makes it safe”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.
Life of an effect
Section titled “Life of an effect”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.
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.
- Emit Inside
apply(), the state machine callseffects.emit(...)with typewebhook.post, a scope,ResultPolicy.CHAIN, and an expiry of 1,000 blocks. Nothing is sent. The emitter returnsEffectId(chain, height, ordinal), the effect’s position in history: the ordinal counts emissions in the block. The scope is not part of the identity.Nothing reaches you yet. The effect is only a record in a block that is not final. - 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.Still nothing. The authorization is now final and provable. - Pick up The effect runtime is off by default; you enable it on one node. It reads finalized records, checks the gate (
app-finalis 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.The executor node has the effect queued for you. - Deliver The
webhook.postexecutor POSTs the payload to the URL in its own configuration; a URL inside the payload is refused unlessallow-payload-urlis set. TheIdempotency-Keyheader is the hex Blake2b-256 hash of the effect id, the same on every attempt.X-Effect-Idcarriesorders-chain/120/0.You receive a POST. Store theIdempotency-Keywith the work you do. - Answer The receiver answers 2xx. The executor records the effect as done and confirmed, with the
Locationheader, orHTTP 200, as the external reference. A 4xx means FAILED with no retry; a 5xx or a network error is retried.Answer 2xx only after you have durably done the work. - Report For a
CHAINeffect, the executor node signs a~fx/resultmessage 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.signerscan limit which members may report.Your answer is on its way back into the ledger. - Incorporate In the block that contains it, the framework accepts the first valid result before the state machine runs, records the outcome, and calls
onEffectResulton 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.The ledger now records CONFIRMED and the reference you returned.
What if: Result policy NONE
Without .result(ResultPolicy.CHAIN), the policy is the default, NONE.
- Emit without a result The intent does not call
.result(...), so its policy is the defaultResultPolicy.NONE. A NONE effect cannot take an expiry, and its outcome never returns to the ledger.Nothing reaches you yet. The effect is only a record in a block that is not final. - 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.Still nothing. The authorization is now final and provable. - Pick up The effect runtime is off by default; you enable it on one node. It reads finalized records, checks the gate (
app-finalis 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.The executor node has the effect queued for you. - Deliver The
webhook.postexecutor POSTs the payload to the URL in its own configuration; a URL inside the payload is refused unlessallow-payload-urlis set. TheIdempotency-Keyheader is the hex Blake2b-256 hash of the effect id, the same on every attempt.X-Effect-Idcarriesorders-chain/120/0.You receive a POST. Store theIdempotency-Keywith the work you do. - Done locally The receiver answers 2xx and the executor marks the effect done. That is the end: no
~fx/result, noonEffectResult, 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.You answered 2xx. The ledger never learns the result.
What if: Receiver answers 4xx
A definitive “no” from the target is recorded as FAILED.
- Emit Inside
apply(), the state machine callseffects.emit(...)with typewebhook.post, a scope,ResultPolicy.CHAIN, and an expiry of 1,000 blocks. Nothing is sent. The emitter returnsEffectId(chain, height, ordinal), the effect’s position in history: the ordinal counts emissions in the block. The scope is not part of the identity.Nothing reaches you yet. The effect is only a record in a block that is not final. - 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.Still nothing. The authorization is now final and provable. - Pick up The effect runtime is off by default; you enable it on one node. It reads finalized records, checks the gate (
app-finalis 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.The executor node has the effect queued for you. - Deliver The
webhook.postexecutor POSTs the payload to the URL in its own configuration; a URL inside the payload is refused unlessallow-payload-urlis set. TheIdempotency-Keyheader is the hex Blake2b-256 hash of the effect id, the same on every attempt.X-Effect-Idcarriesorders-chain/120/0.You receive a POST. Store theIdempotency-Keywith the work you do. - Rejected The receiver answers 400. Retrying the same bytes cannot succeed, so the executor does not retry. For a
CHAINeffect it records FAILED with the reasonHTTP 400.You rejected the request. You will not get it again. - Report For a
CHAINeffect, the executor node signs a~fx/resultmessage 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.signerscan limit which members may report.Your answer is on its way back into the ledger. - Incorporate FAILED Every member calls
onEffectResultwith outcome FAILED: the target answered no. The state machine decides what that means for the order.The ledger records that you said no.
What if: Receiver answers 5xx
Retries are local and bounded; the expiry closes the effect deterministically.
- Emit Inside
apply(), the state machine callseffects.emit(...)with typewebhook.post, a scope,ResultPolicy.CHAIN, and an expiry of 1,000 blocks. Nothing is sent. The emitter returnsEffectId(chain, height, ordinal), the effect’s position in history: the ordinal counts emissions in the block. The scope is not part of the identity.Nothing reaches you yet. The effect is only a record in a block that is not final. - 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.Still nothing. The authorization is now final and provable. - Pick up The effect runtime is off by default; you enable it on one node. It reads finalized records, checks the gate (
app-finalis 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.The executor node has the effect queued for you. - Deliver The
webhook.postexecutor POSTs the payload to the URL in its own configuration; a URL inside the payload is refused unlessallow-payload-urlis set. TheIdempotency-Keyheader is the hex Blake2b-256 hash of the effect id, the same on every attempt.X-Effect-Idcarriesorders-chain/120/0.You receive a POST. Store theIdempotency-Keywith the work you do. - 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.You keep receiving the same POST, with the same key.
- 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.The ledger stopped waiting for you.
What if: Executor crashes after the POST
Execution is at least once, so the receiver must deduplicate.
- Emit Inside
apply(), the state machine callseffects.emit(...)with typewebhook.post, a scope,ResultPolicy.CHAIN, and an expiry of 1,000 blocks. Nothing is sent. The emitter returnsEffectId(chain, height, ordinal), the effect’s position in history: the ordinal counts emissions in the block. The scope is not part of the identity.Nothing reaches you yet. The effect is only a record in a block that is not final. - 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.Still nothing. The authorization is now final and provable. - Pick up The effect runtime is off by default; you enable it on one node. It reads finalized records, checks the gate (
app-finalis 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.The executor node has the effect queued for you. - Deliver The
webhook.postexecutor POSTs the payload to the URL in its own configuration; a URL inside the payload is refused unlessallow-payload-urlis set. TheIdempotency-Keyheader is the hex Blake2b-256 hash of the effect id, the same on every attempt.X-Effect-Idcarriesorders-chain/120/0.You receive a POST. Store theIdempotency-Keywith the work you do. - 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.You did the work and answered 200. The executor never heard it.
- 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.The same key arrives twice. Answer 200 without doing the work again. - Report For a
CHAINeffect, the executor node signs a~fx/resultmessage 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.signerscan limit which members may report.Your answer is on its way back into the ledger. - Incorporate In the block that contains it, the framework accepts the first valid result before the state machine runs, records the outcome, and calls
onEffectResulton 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.The ledger now records CONFIRMED and the reference you returned.
- deterministic, on every member
- app ledger members
- node-local execution
- your system
- final
- failed or refused
Identity and idempotency
Section titled “Identity and idempotency”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.
Result policy: NONE or CHAIN
Section titled “Result policy: NONE or CHAIN”ResultPolicy decides whether the outcome comes back into the ledger. There are
two values, and the default is NONE.
| Policy | What happens to the outcome | Expiry | onEffectResult |
|---|---|---|---|
NONE (default) | Recorded only on the executing node; visible through REST and metrics. | Not allowed | Never called |
CHAIN | Re-enters as a member-signed ~fx/result message and is incorporated exactly once. | Mandatory | Called with CONFIRMED, FAILED, CANCELLED, or EXPIRED |
Choose CHAIN whenever the application must react to the outcome or prove it
later.
Enabling effects
Section titled “Enabling effects”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: trueyano.app-chain.effects.max-per-block: 256 # effects one block may emityano.app-chain.effects.max-payload-bytes: 16384 # per-effect payload capyano.app-chain.effects.max-expiry-blocks: 100000yano.app-chain.effects.result-window-blocks: 100000 # results incorporable within this windowyano.app-chain.effects.outcome-commitment: per-effect # per-effect | per-blockyano.app-chain.effects.default-gate: app-final # app-final | l1-anchored | zk-settledEnabling 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.
Emitting from a state machine
Section titled “Emitting from a state machine”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.
Finality gates
Section titled “Finality gates”A gate decides when an emitted effect becomes eligible to execute.
| Gate | Eligible when | Notes |
|---|---|---|
app-final | The block is committed. | The chain is append-only after finality, so emission is already irrevocable. |
l1-anchored | The 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-settled | Covered 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.
Expiry is mandatory
Section titled “Expiry is mandatory”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.
Running an executor
Section titled “Running an executor”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 effectsyano.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 withPOST …/effects/claimand reports withPOST …/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.
Executors and sinks
Section titled “Executors and sinks”| Kind | What it does | Availability |
|---|---|---|
webhook.post executor and finalized webhook sink | HTTP delivery of an authorized action or a finalized block stream. | Bundled |
kafka.publish executor and Kafka sink | Acknowledged effects and finalized blocks to Kafka topics. | First-party optional plugin |
object.put executor | Immutable or versioned writes to tested S3-compatible stores. | First-party optional plugin |
ipfs.pin executor | Reconciled pin-only effects through a configured Kubo RPC. | First-party optional plugin |
cardano.payment executor | Cardano 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.
Try it
Section titled “Try it”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.
./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.webhookExecutor external demo worker on node 0Delivery CONFIRMEDExternal 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.