Skuld · what will be

Simulation

Run your scenario in hundreds of hostile futures before production does, and replay any broken one exactly.

The futures your tests never see

In production, commands fail transiently, brokers deliver messages twice and events arrive in any order. Skuld runs a scenario in many seeded futures with those perturbations, settles the clock so every retry and timeout fires, and checks your invariants at the end of each one. It runs entirely on the in-memory runtime: no broker, no database, milliseconds per future.

ShopSimulation.cs
C#
private static readonly Invariant StockIsConserved = new("stock is conserved", shop =>
{
    var shipped = shop.Views<OrderSummary>().Where(o => o.Status == "Shipped").Sum(o => o.Quantity);
    var available = shop.StateOf<StockItem>("KEYBOARD").Available;
    return available == 5 - shipped ? null : $"{available} available, expected {5 - shipped}";
});

[Fact]
public async Task the_business_holds_in_every_future()
{
    var report = await Skuld.SimulateAsync(
        ShopModule.CreateRuntime,
        async shop =>
        {
            await shop.SendAsync(new Restock("KEYBOARD", 5));
            await shop.SendAsync(new PlaceOrder("A-1", "KEYBOARD", 1, 89m));
            await shop.SendAsync(new PlaceOrder("A-2", "KEYBOARD", 2, 2_500m)); // card declined
            await shop.PublishAsync(new PaymentConfirmed("A-1"));
        },
        [.. Invariant.Defaults, StockIsConserved],
        new SimulationOptions { Runs = 200, FaultRate = 0.1, DuplicateRate = 0.1, ReorderRate = 0.3 });

    report.ThrowIfViolated();
}

Invariants

An invariant returns null when it holds, or a sentence describing what is wrong. Invariant.Defaults checks that no process is left half-way and that nothing ended needing a human; add the rules your business can never break, such as stock that is never lost.

Read the report, replay the future

Each broken invariant comes with the first future that broke it: the perturbations Skuld injected, the journal and the seed. Skuld then shrinks the future: it replays it with fewer perturbations while the invariant still breaks, and the report shows the smallest failing future first (SimulationOptions.Shrink, on by default, with at most ShrinkBudget = 200 replays per broken future). options.Only(seed) replays the original future exactly, as many times as you need to debug it.

Test output (a deliberately fragile process)
text
Skuld explored 200 futures: 42 broke an invariant.

✗ "valid orders go through" broke in 42 futures. First: seed 9.
  Smallest future that breaks it: 1 perturbation (4 replays; smallest found within budget).
  A-1 didn't go through because of a transient failure
  Perturbations: ReserveStock (A-1) failed transiently
  Journal:
    [00:00:00] -                A-1      -> PlaceOrder
    [00:00:00] -                A-1      ok OrderPlaced
    [00:00:00] Fragile          A-1      started by OrderPlaced
    [00:00:00] Fragile          A-1      -> ReserveStock
    [00:00:00] skuld                     ReserveStock (A-1) failed transiently
    [00:00:00] Fragile          A-1      transient failure, no retry policy
    [00:00:00] Fragile          A-1      SUSPENDED: Stock.Reserve failed after 1 attempt, needs attention
    [00:00:00] -                A-2      -> PlaceOrder
    [00:00:00] -                A-2      ok OrderPlaced
    [00:00:00] Fragile          A-2      started by OrderPlaced
    [00:00:00] Fragile          A-2      -> ReserveStock
    [00:00:00] Fragile          A-2      ok StockReserved
    [00:00:00] Fragile          A-2      -> ChargePayment
    [00:00:00] Fragile          A-2      rejected Declined
    [00:00:00] Fragile          A-2      compensating
    [00:00:00] Fragile          A-2      -> ReleaseStock
    [00:00:00] Fragile          A-2      ok StockReleased
    [00:00:00] Fragile          A-2      compensated
  Replay it: Skuld.SimulateAsync(..., options.Only(9))

What it found in our own sample

The first time we pointed Skuld at the sample shop, it broke 85 of 200 futures. Every finding became a fix in Wyrd or in the sample:

Skuld foundThe fix
An undo without retries leaves stock reserved after a single transient failure.Undos always insist with their own policy instead of borrowing one from the step, and every step declares .Retry(...) or .NoRetry("why") (WYRD0014).
A redelivered failure reply consumed an extra retry.Replies carry their attempt: stale faults are ignored, a late success is always accepted.
A command the process gave up on still ran later, and its stock was never released.Its undo fences the original command: if it never ran, it never will; if it ran, it is undone.
A late event moved an order from Cancelled back to Released.Read models tolerate reordering: statuses only move forward.

After the fixes the shop holds in every one of 5,000 futures with 20% faults, 20% duplicates and 50% reordering: stock is never lost, and no order is left open silently.

Four rules for distributed processes

  1. Retries are decided, never assumed, and undos insist. An undo that gives up after one transient failure leaves the world inconsistent. Every step declares .Retry(...) or .NoRetry("why") (WYRD0014), and undos always retry with their own adjustable policy.
  2. A step given up on is fenced, never guessed. When a process gives up on a step, a delayed copy may still execute. The undo carries the original command id and the owner decides atomically: undo it if it ran, fence it if it never did. A domain rejection is never proof that there was nothing to undo.
  3. Read models tolerate reordering. Events from different slices arrive in any order: a late event must not move a view backwards.
  4. Stale replies are expected. Redelivered replies and replies to earlier attempts are ignored; a success is welcome whenever it arrives.