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.
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.
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 found | The 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
- 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.
- 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.
- Read models tolerate reordering. Events from different slices arrive in any order: a late event must not move a view backwards.
- Stale replies are expected. Redelivered replies and replies to earlier attempts are ignored; a success is welcome whenever it arrives.