Exactly-Once Side Effects: What a Workflow Engine Can Actually Promise

A workflow retries a step that already charged a customer. The engine cannot know whether the charge landed — the response was lost, not the request. Exactly-once is unachievable here. At-most-once plus an explicit unknown state is achievable, and it is what an honest engine should offer.

By Oleksii Vasylenko, Distributed Systems Architect & Hands-on Engineer · · 14 min read

Where this comes from. Orch8 — a durable workflow engine I built solo in Rust: every crate, every SDK, every integration. Ten-crate workspace, PostgreSQL and SQLite backends, server and on-device execution. The details below are the actual implementation, not a reference architecture.

A workflow step calls a payment API. The engine crashes between sending the request and persisting the response. On restart it finds a step that started and never finished. Retrying might charge the customer twice. Not retrying might leave the order unpaid forever.

This is not a rare edge case; it is the defining problem of durable execution. Every framework that advertises "exactly-once" is describing exactly-once state transitions inside its own database — which is genuinely valuable and completely different from exactly-once effects on someone else's system. Those are separated by a network, and no protocol closes that gap without cooperation from the other side.

The honest decomposition has three parts. Internal state transitions can be exactly-once, because one database transaction owns them. External effects can be at-most-once, because you can durably record intent before dispatching. And the outcome of a dispatched effect is sometimes genuinely unknown — at which point the only correct behaviour is to stop and say so.

The crash window between dispatching a side effect and recording its outcomeThe engine writes a receipt, sends a charge request, then dies before the response arrives. On restart the receipt reads Dispatched with an unknown outcome, retry is blocked, and only a reconciler or operator can settle it.Payment APIReceipt storeEngineprocess dies hereon restart the receipt saysDispatched, outcome unknownonly a reconciler or operatorcan settle this receiptwrite receipt (Prepared)1POST /charge2200 OK (nobody listening)3Dispatched → Unknown4retry blocked5
The window that defines the problem. The request left; the answer never arrived. Nothing in the engine can distinguish “the charge failed” from “the charge succeeded and the response was lost.”

The usual model for a side effect is a flag: done or not done. That model has no way to represent "we sent it and never heard back," which is the state that actually causes incidents. So effects get a full lifecycle with eight states:

pub enum EffectState {
    Planned,      // decided to do it
    Prepared,     // receipt durably written, not yet sent
    Dispatched,   // sent; outcome not yet known
    Committed,    // provider confirmed
    Unknown,      // sent, no answer — blocks automatic retry
    Verified,     // reconciled against the provider after the fact
    Compensated,  // undone by a compensating action
    Abandoned,    // explicitly written off by an operator
}
orch8-types/src/continuity.rs — the entire vocabulary for what happened to an effect.
The eight states of an effect receiptPlanned, Prepared, Dispatched, then either Committed or Unknown. Unknown blocks automatic retry and resolves to Verified, Compensated, or Abandoned.receipt durablesentprovider confirmedcrash / no answerreconciledundonewritten offnew attemptPlannedPreparedDispatchedCommittedUnknownVerifiedCompensatedAbandonedblocks automatic retry
Three of the eight states are operator or reconciler outcomes. Unknown is the hinge: it is reachable from Dispatched on any crash, and it refuses to advance on its own.

The important state is Unknown, and it exists so the engine never has to guess. Dispatched becoming Unknown on restart is the crash case: the process died after sending and before recording an outcome. Verified is how it gets out — a reconciliation job or an operator queries the provider, discovers what actually happened, and settles the receipt with evidence.

Every transition is a compare-and-swap against the stored state, not a blind write. If the CAS fails, some other process already advanced the receipt, and the current caller is working from a stale view — so it stops rather than racing:

async fn advance(&mut self, next: EffectState) -> Result<(), EngineError> {
    let expected = self.receipt.state;
    let mut updated = self.receipt.clone();
    updated.transition(next, Utc::now())?;   // rejects illegal transitions

    if !self.storage
        .cas_effect_receipt(&updated.tenant_id, updated.id, expected, &updated)
        .await?
    {
        let current = self.storage
            .get_effect_receipt(&updated.tenant_id, updated.id).await?
            .unwrap_or(self.receipt.clone());
        return Err(blocked(&current));   // stop; do not re-dispatch
    }
    self.receipt = updated;
    Ok(())
}
The advance path. A lost CAS is treated as a blocking condition, never as a retry signal.

A retry has to find the receipt the first attempt created, or the whole scheme collapses into a fresh receipt per attempt and no protection at all. Generating a random ID at dispatch time does exactly that. So the effect ID is derived, not generated:

let id = deterministic_effect_id(
    execution.continuity_id,   // which logical execution
    execution.epoch,           // which ownership generation
    instance_id,               // which run
    block_id,                  // which step
    attempt,                   // which attempt
);
The identity of an effect is a function of where it sits in the execution, not of when it ran.

Two processes racing on the same step compute the same ID and converge on one row. ensure_effect_receipt is an idempotent upsert, and its result is checked against the candidate. If an existing receipt carries different evidence, the system has found either a hash collision or an implementation bug. The engine stops rather than reuse someone else's receipt.

Including attempt in the derivation is a deliberate choice with a trade-off. It means a legitimate retry after a known failure gets a fresh receipt and is allowed to proceed. It also means the deduplication boundary is one attempt wide, and the cross-attempt protection has to come from the Unknown-blocks-retry rule rather than from the ID alone. Those two mechanisms are complementary; neither is sufficient by itself.

The receipt also captures a destination fingerprint, a SHA-256 of the canonicalised request, any caller-supplied idempotency key, and the provider's own receipt ID once it comes back. That is the evidence a reconciliation job needs later, and it is why the receipt is written before dispatch rather than after.

Wrapping every step in a durable receipt would add two round trips to work that cannot hurt anyone. A log step or a transform has no external footprint. So the guard is only constructed for handlers that can touch the outside world — and the classification is deliberately pessimistic:

const SIDE_EFFECT_BUILTINS: &[&str] = &[
    "http_request", "llm_call", "tool_call", "mcp_call", "agent",
    "emit_event", "send_signal", "self_modify",
    "memory_store", "memory_delete", "blob_put", "embed",
];

pub(crate) fn handler_has_side_effects(handler: &str) -> bool {
    SIDE_EFFECT_BUILTINS.contains(&handler)
        || !BUILTIN_HANDLER_NAMES.contains(&handler)   // user handler: assume yes
}
orch8-engine/src/release_diff.rs — unknown handlers are assumed dangerous.

That second clause is the one that matters. A handler the engine does not recognise is a handler someone wrote, and the engine cannot prove it does not send email. Defaulting to "protected" costs a little latency on custom steps. Defaulting to "safe" would silently exclude exactly the code most likely to have effects the engine has never seen.

This same list drives release-risk classification: changing a step that touches one of these handlers is flagged as a side-effect risk in the semantic diff between two workflow versions, not merely as a behavioural change.

Application-level checks cannot prevent two processes from dispatching concurrently — the check and the act are separated in time. So when a workflow declares an at-most-once invariant for a class of effect, the transition to Dispatched becomes a conditional storage operation that returns which of three things happened:

match storage.dispatch_effect_receipt_at_most_once(&tenant, &updated).await? {
    EffectDispatchOutcome::Dispatched => { /* we own it; proceed */ }

    EffectDispatchOutcome::Duplicate => {
        record_effect_guard_results(storage, &guards, &updated, true).await?;
        Err(EngineError::InvariantViolation {          // a second dispatch was attempted
            invariant_id: guards[0].id,
            block_id: updated.block_id,
        })
    }

    EffectDispatchOutcome::Stale => {                  // an older epoch tried to act
        let current = storage.get_effect_receipt(&tenant, updated.id).await?;
        Err(blocked(&current))
    }
}
The database, not the application, decides who gets to dispatch.
Three outcomes of a conditional dispatch at the storage boundaryDispatched means the caller owns the effect. Duplicate means an at-most-once invariant caught a second dispatch. Stale means the caller belongs to a superseded execution epoch.DispatchedDuplicateStaleconditional dispatchat the storage boundaryoutcomewe own itcall the providera second dispatch was attemptedrecord invariant violationcaller belongs to asuperseded epochblock and re-readCommitted or Unknown
The database decides who dispatches. Duplicate is a workflow bug; Stale is a handoff race. Collapsing them into one error would make two very different incidents look identical.

Duplicate and Stale are separate outcomes on purpose. Duplicate means the invariant caught a genuine double-dispatch attempt. The record tells an operator which rule fired and where. Stale means the caller belongs to an older generation of the execution after ownership moved to another node. Collapsing them into one error would make two different incidents look identical in the logs.

The invariants themselves are per-workflow-version configuration rather than global engine behaviour. A workflow declares which effect kinds are at-most-once and which of those act as commit guards; the guard loads only the invariants matching the current receipt's kind. Charging a card and appending to a log do not need the same protection, and paying for the strong version everywhere would be a tax on the steps that do not need it.

Every side-effecting step pays for a durable receipt write before dispatch and a CAS after it. That is real latency on the hot path, and it is the price of being able to answer "did it happen?" after a crash. There is no version of this that is free.

It also means workflows can get stuck in a way that a naive engine would not. An Unknown receipt blocks automatic retry until something resolves it. If you do not build the reconciliation path — a job that queries providers and settles receipts — you have traded silent duplicate charges for a queue of manually stuck workflows. That is a better failure mode, but only if someone is watching the queue.

GuaranteeAchievable?Mechanism
Exactly-once internal stateYesSingle database transaction
At-most-once external effectYesDurable receipt written before dispatch
At-least-once external effectYesRetry until confirmed — duplicates possible
Exactly-once external effectNoRequires provider-side idempotency keys

The bottom row is the one vendors blur. You can get close with a provider that honours idempotency keys — but that is the provider's guarantee, not the engine's, and it is worth being clear about whose promise you are relying on.

Which is the practical takeaway: pass an idempotency key to every provider that accepts one, and let the engine carry it in the receipt so a reconciliation can use it. The engine's job is to make sure you never dispatch twice without knowing, and to remember enough to find out what happened. The provider's job is to make a duplicate harmless if you do.

You do not need a workflow engine to apply the pattern. The parts that matter transfer to any system that calls someone else's API from inside a retryable unit of work:

  1. Write intent durably before you dispatch. If the record is not committed, you cannot later prove you tried.
  2. Derive the record identity from position in the work, not from a fresh UUID at call time. Retries must converge on one row.
  3. Give ambiguity its own state. "Sent, outcome unknown" is not a failure and not a success, and modelling it as either causes a different bug.
  4. Make the state transition conditional at the storage layer. Check-then-act in application code is not mutual exclusion.
  5. Classify handlers pessimistically. Anything you did not write is side-effecting until proven otherwise.
  6. Build the reconciliation path before you need it, and alert on the depth of the unresolved queue.

And say what you actually guarantee. A system that says "at-most-once, with an explicit unknown state and a reconciliation path" is more trustworthy than one that says "exactly-once" and quietly means something narrower.

StateMeaningRetry allowed?How it resolves
PlannedDecided, nothing written yetYesAdvances to Prepared
PreparedReceipt durable, not yet sentYesAdvances to Dispatched
DispatchedSent, outcome pendingNoCommitted or Unknown
CommittedProvider confirmedNoTerminal success
UnknownSent, never heard backNo — blocksVerified, Compensated, or Abandoned
VerifiedReconciled against providerNoTerminal
CompensatedUndone by compensating actionYes, new attemptTerminal
AbandonedOperator wrote it offNoTerminal, audited

Three of the eight states are operator or reconciler outcomes. That ratio is the point: ambiguity is common enough to deserve first-class handling rather than a comment in a runbook.

The pillar guide covers the full engine: snapshot execution, the crate architecture, storage backends, SDK design, and the trade-offs behind each decision.

Read the durable workflow engine architecture guide