Effects

A machine's one consequential transition (send a letter, charge a card, provision a resource) binds an ISnapshotEffect. Trax runs it exactly once when that transition is sent, records its receipt in the snapshot, and never runs it twice, even across a crash and retry.

public interface ISnapshotEffect
{
    Task<string> Run(Snapshot snapshot, CancellationToken cancellationToken = default);
}

Run performs the side effect and returns a receipt, the downstream id (a message-log id, a charge id) that proves it happened. Throwing means the effect did not complete: the claim is released, the transition is not applied, and the client can retry from the same state.

An OperationCanceledException is different: a charge can land and its response time out, so a cancellation does not say whether the effect happened. The claim stays in flight until its lease passes, and a send before then is refused as effect-in-progress instead of running the effect again. The send reports it as delivery-failed unless the request itself was cancelled. The send passes Run CancellationToken.None, not the request's token: a client that disconnects mid-charge does not cancel the charge. Bound a slow downstream call with its own timeout, and never after the irreversible step.

Binding it

Bind the effect inline on the transition with RunsOnce<TEffect>, and implement it in the host:

m.In(CheckoutState.Review)
    .On(CheckoutTrigger.Pay)
        .When(Field((ReviewContext c) => c.Items).CountAtLeast(1))
        .RunsOnce<ICharge>()                       // keyPrefix defaults to "checkout:Pay"
        .Reduce(Set((PaidContext p) => p.Receipt).FromInput((PayInput i) => i.Receipt))
        .To(CheckoutState.Paid);
public interface ICharge : ISnapshotEffect;
 
public sealed class StripeCharge(IPaymentGateway gateway) : ICharge
{
    public async Task<string> Run(Snapshot snapshot, CancellationToken ct)
    {
        var chargeId = await gateway.Charge(snapshot.Context);
        return chargeId;                            // becomes the receipt
    }
}

Register the implementation like any service; the machine resolves TEffect from DI, so nothing is wired in the composition root by hand:

services.AddScoped<ICharge, StripeCharge>();

Exactly-once and the receipt

The effect runs through the persistence layer's idempotent path: a claim is taken before the effect, held under a lease with a fence token, and a crash mid-flight replays without re-running a completed effect. The key is {keyPrefix}:{userKey}:{id}, so it is scoped per draft per user. A draft deleted by the draft TTL releases the key, and so does a reset to the initial state once the effect's outcome is settled on the draft: a reset while the effect runs, or after a receipt that never reached the draft, keeps the claim, and the next send replays its receipt. See what each path may write.

Once the effect has returned, its receipt is recorded and the draft advanced on a token the request cannot cancel. A client that disconnects right after a charge still leaves the draft showing the charge, and the next send replays it instead of charging again. The receipt is recorded only on the draft exactly as the effect loaded it: if the draft was saved, reset or advanced while the effect ran, the send reports conflict and records nothing, and the claim keeps the receipt.

The claim also records a fingerprint of the content the effect ran on: the SHA-256 of the draft's canonical wire, SnapshotFingerprint.Of(service.Serialize(snapshot)). A later send replays the receipt only onto a draft whose content has that fingerprint. If the draft now holds anything else, the send is refused as draft-changed: the receipt is not recorded and the effect does not run again. Restoring the content the effect ran on, for example by autosaving the snapshot the client sent, makes the next send replay the receipt, so a client keeps that snapshot until its send settles. A claim recorded before the fingerprint existed has none and replays onto whatever the draft holds.

The transition itself is fired only by the send. An advance of its trigger is refused as effect-bound, and an autosave cannot put a draft into its destination state; see what each path may write.

The receipt Run returns is handed to the transition's reducer as input["receipt"], which is how the send gets recorded in the destination context. It must be non-empty. The ledger reads a claim with no receipt as still in flight, so a null or "" receipt is treated as a failed effect: the claim is released, IdempotentEffect.RunOnce throws InvalidOperationException, and the send returns delivery-failed with the draft unchanged, exactly as if Run had thrown. A guard on the same edge can require it (so the transition only completes once the effect has produced a receipt).

Cancelling and starting over

A draft is not deleted by the client. There is no delete mutation: a draft that is abandoned is left to the draft TTL, and what a cancellation means is declared in the machine, because only the domain knows what it undoes. What to do depends on where the draft is relative to its effect.

Before the effect runs, nothing irreversible has happened. To start over, save a new draft under a new id: an effect's claim is keyed by the draft id, so the new draft starts clean and the old one expires. A machine can also declare a reset edge back to its initial state.

While the effect runs, leave the draft alone until the send settles: show the user it is processing and do not autosave. An edit in that window is what makes a send report conflict or draft-changed, and the recovery is to restore the snapshot that was sent, as described above.

After the effect ran, the draft in its committed state is the record that it happened, and it stays as it is. Undoing the effect is a second irreversible action (a refund, a void, releasing stock), not a delete. A machine runs exactly one effect, so the compensation is a machine of its own whose one effect is the undo, started from the receipt the first one recorded:

public sealed class RefundMachine : Machine<RefundState, RefundTrigger>
{
    protected override void Configure(IMachineBuilder<RefundState, RefundTrigger> m)
    {
        m.Id("refund").Version(1).StartsAt(RefundState.Requested, () => new JsonObject());
 
        m.In(RefundState.Requested)
            .On(RefundTrigger.Refund)
            .RunsOnce<IRefund>()
            .Reduce((context, input) => new JsonObject
            {
                ["chargeId"] = context["chargeId"]?.DeepClone(),
                ["refundId"] = input?["receipt"]?.DeepClone(),
            })
            .To(RefundState.Refunded);
 
        m.In(RefundState.Refunded).Committed();
    }
}
 
public interface IRefund : ISnapshotEffect;
 
public sealed class StripeRefund(IPaymentGateway gateway) : IRefund
{
    public async Task<string> Run(Snapshot snapshot, CancellationToken ct)
    {
        var chargeId = snapshot.Context["chargeId"]?.GetValue<string>()
            ?? throw new InvalidOperationException("A refund names the charge it refunds.");
        return await gateway.Refund(chargeId, idempotencyKey: $"refund:{chargeId}");
    }
}

The client autosaves a refund draft whose context holds the checkout's receipt as chargeId, then sends Refund. A refund draft with no charge throws, so the effect has not run and the send can be retried. Trax runs the refund once per refund draft. Two refund drafts for one charge are two drafts, so pass the downstream service an idempotency key derived from the charge, as above, to make the refund once per charge. The checkout draft keeps showing the charge, and the refund draft shows the refund; together they are the history a support request needs.