EvalApp docs

EvalApp docs

A practical guide to building with EvalApp. Describe a process as a typed data record and a sequence of steps, gate every resource it touches, and let tuning find the concurrency that suits the hardware. Every program on this page is compiled and run against the published package.

Install

Add the package with dotnet add package EvaluatedApplications.EvalApp. It targets net8.0 and net10.0 and has no dependencies, so the package reference is the whole installation.

The package ships as a binary, without source or XML documentation. Author against the EvalApp namespace and let IntelliSense on the public types be your reference.

The fluent builder and the AddStep<TStep>() and AddBranch<TStep>() step-construction paths are safe to trim and to compile ahead of time. The tuner's JSON file persistence and a couple of internal reflection caches are not fully trim-clean, so leave WithTuning off if you publish trimmed and that matters to you.

One namespace

Everything you need lives in the root EvalApp namespace: Eval, IAppBuilder, ResourceKind, Tunable, PipelineResult<T>, ICompiledPipeline<T>, the step base classes and licensing. The engine underneath lives in a separate EvalAppEngine namespace that is not part of the supported public surface. If something you need is not reachable from EvalApp, that is a gap to report, not a reason to reach into the engine.

Your first pipeline

The quick start on the overview page builds a three-step order pipeline. Four things in it carry through everything below.

AddStep
Takes a name and a lambda, an async lambda, an IStep<T> instance or a PureStep<T> or AsyncStep<T> subclass.
Records and with
Each step returns a new value. That is what makes a parallel ForEach safe without locks: no step can see another step's unfinished state.
Run, then Build
Run(out pipeline) hands you the compiled, reusable pipeline, and Build finalizes the whole app. Build once at startup and call RunAsync many times. Rebuilding per request throws away everything the tuner has learned.
Results as a union
PipelineResult<T> is Success, Failure or Skipped. Pattern-match it, or use GetData(), IsSuccess, IsFailure and IsSkipped when you only need the value.

Design before you build

The biggest lever on whether a pipeline reads cleanly is answering these questions before touching the builder. Each answer constrains the next.

1. What is the unit of work?
One sentence: one order, one invoice, one request, one batch.
2. What does it start with, carry and produce?
List every piece of information that exists at any point and encode it as a record. A non-nullable field is a required input, a nullable one is an output that appears once its step runs, and a bool is control (loop, branch, skip). If you cannot describe the whole lifecycle by reading the fields, the design is not finished.
3. What resource does each operation touch?
Classify each operation as Network, DiskIO, Cpu, Database, a custom ResourceKind.Of("name"), or none. This decides which operations need a gate.
4. What runs per item in parallel?
Independent items in a collection, which must not read each other's unfinished state, are ForEach boundaries.
5. What conditional branches exist?
For every If and Else, the condition, what each side does, and whether both write the same fields.
6. What must be rolled back on failure?
Walk the steps asking what has happened outside the process by the time each completes: money moved, mail sent, a record written. Only external effects need compensation.
7. Wire it together
Only now write the fluent declaration. If it does not read like a plain description of the process, the data model needs more work, not more code.

A single-item pipeline with no branches and no external effects skips straight to a flat sequence of steps. That means the problem was simple, not that the method failed.

Steps: pure, async and side-effecting

PureStep<T> is a synchronous transform with no side effects, and AsyncStep<T> is the same for work that waits on a network, a disk or a database. A step whose job is a side effect (a database write, a published event, an email) derives from SideEffectStep<T> and declares the ResourceKind it touches.

A SideEffectStep<T> cannot be added with the plain AddStep(name, instance) overload, which fails to compile. Add it with AddStep<TStep>(), which gates it on its declared resource, or with AddStep<TStep>(name, factory) when it needs a constructor-injected dependency. The resource has to be registered on the app first, with WithResource. If you would rather gate explicitly, write the side effect as an async lambda inside a Gate and the gate supplies the resource. Either way there is no path to an ungated side effect.

Pure, async and side-effect steps
using EvalApp;

Eval.App("StepKinds")
    .WithResource(ResourceKind.Network)   // a side-effect step is gated on its declared resource
    .DefineDomain("Orders")
        .DefineTask<OrderData>("ProcessOrder")
            .AddStep("Tax", new ApplyTaxStep())                  // PureStep<T> instance
            .AddStep("Fetch", new FetchTotalStep())              // AsyncStep<T> instance
            .AddStep<SendConfirmationStep>("SendConfirmation")   // SideEffectStep<T>: AddStep<TStep>() only
            .AddStep<PublishEventStep>("Publish", () => new PublishEventStep(new InMemoryBus()))
        .Run(out ICompiledPipeline<OrderData> pipeline)
    .Build();

var result = await pipeline.RunAsync(new OrderData("Alice", 100m));
var data = result.GetData();
Console.WriteLine($"{data.Status}: total {data.Total}, tax {data.Tax}");

public sealed record OrderData(string CustomerName, decimal Total, string Status = "New", decimal Tax = 0m);

public sealed class ApplyTaxStep : PureStep<OrderData>
{
    public override OrderData Execute(OrderData data) => data with { Tax = data.Total * 0.08m };
}

public sealed class FetchTotalStep : AsyncStep<OrderData>
{
    public override async ValueTask<OrderData> ExecuteAsync(OrderData data, CancellationToken ct)
    {
        await Task.Yield();                       // real code: await the actual I/O here
        return data with { Total = data.Total + 1m };
    }
}

public sealed class SendConfirmationStep : SideEffectStep<OrderData>
{
    public override ResourceKind? ResourceKind => EvalApp.ResourceKind.Network;
    public override async ValueTask<OrderData> ExecuteAsync(OrderData data, CancellationToken ct)
    {
        await Task.Yield();
        return data with { Status = "Confirmed" };
    }
}

public interface IOrderEventBus { ValueTask PublishAsync(string evt, CancellationToken ct); }
public sealed class InMemoryBus : IOrderEventBus
{
    public ValueTask PublishAsync(string evt, CancellationToken ct) => ValueTask.CompletedTask;
}

// A constructor-injected dependency: AddStep<TStep>(name, factory) builds it without a parameterless constructor.
public sealed class PublishEventStep(IOrderEventBus bus) : CrossDomainStep<OrderData, IOrderEventBus>(bus)
{
    public override ResourceKind? ResourceKind => EvalApp.ResourceKind.Network;
    public override async ValueTask<OrderData> ExecuteAsync(OrderData data, CancellationToken ct)
    {
        await Shared.PublishAsync("OrderPlaced", ct);
        return data with { Status = "Published" };
    }
}
Output
Published: total 101, tax 8.00

Context-aware steps

When a step needs data that is constant for a whole app run (a configuration, a connection factory) or for a whole domain (a tenant, a region), subclass GlobalContext or DomainContext instead of threading it through every record. The context is populated on the step before it runs.

Add with AddStep<TStep>()
ContextPureStep and ContextSideEffectStep do not implement the plain IStep<T> that the instance overload expects, so AddStep(name, instance) does not compile for them.
No StepContext parameter
The public base classes never take one. A type of that name belongs to the internal engine and does not compile against the published package.
Exceptions become results
Context steps catch their own exceptions and report a Failure, unlike the plain step kinds, which let the exception reach the pipeline.
Global and domain context
using EvalApp;

Eval.App("Context")
    .WithContext(new AppGlobal { Prefix = "ORD" })
    .DefineDomain("Orders", new OrderDomain { Region = "US" })
        .DefineTask<OrderData>("ProcessOrder")
            .AddStep<TagWithRegionStep>("TagRegion")   // context steps are added with AddStep<TStep>() only
            .AddStep<NotifyStep>("Notify")
        .Run(out ICompiledPipeline<OrderData> pipeline)
    .Build();

var result = await pipeline.RunAsync(new OrderData("Alice", 10m));
Console.WriteLine(result.GetData().Status);

public sealed record OrderData(string CustomerName, decimal Total, string Status = "New");
public sealed class AppGlobal : GlobalContext { public string Prefix { get; init; } = ""; }
public sealed class OrderDomain : DomainContext { public string Region { get; init; } = ""; }

public sealed class TagWithRegionStep : ContextPureStep<AppGlobal, OrderDomain, OrderData>
{
    protected override ValueTask<OrderData> TransformAsync(
        OrderData data, AppGlobal global, OrderDomain domain, CancellationToken ct)
        => ValueTask.FromResult(data with { Status = $"{global.Prefix}:{domain.Region}" });
}

public sealed class NotifyStep : ContextSideEffectStep<AppGlobal, OrderDomain, OrderData>
{
    protected override async ValueTask<OrderData> ExecuteAsync(
        OrderData data, AppGlobal global, OrderDomain domain, CancellationToken ct)
    {
        await Task.Yield();   // real code: the actual notification I/O
        return data with { Status = data.Status + ":Notified" };
    }
}
Output
ORD:US:Notified

Gates and resource kinds

A Gate declares that everything inside it contends for a named, shared resource. The built-in kinds are Network, DiskIO, Cpu and Database, and ResourceKind.Of("name") defines a pool of your own. Declare a resource's bounds at the app level with WithResource, then gate any block of steps against it.

Without tuning, a gate holds at its declared default. With tuning on, the tuner moves it inside the declared minimum and maximum.

Tunable.Between(min, max, default)
Explicit bounds.
Tunable.FixedAt(value)
Minimum, maximum and default are the same, so there is nothing to tune.
Tunable.ForCpu()
Scales with the number of processors.
Tunable.ForItems()
A bound for per-item fan-out, which the tuner can move.
Tunable.ModelOps()
Fixed at 1, for exclusive access such as GPU training.
Tunable.DiskIo() and Tunable.Network()
Starting points for those two resource kinds.
Gates on a built-in kind and on a pool of your own
using EvalApp;

Eval.App("Gate")
    .WithResource(ResourceKind.Database, Tunable.Between(1, 8, 2))
    .WithResource(ResourceKind.Of("report-engine"), maxConcurrency: 1)
    .DefineDomain("Orders")
        .DefineTask<OrderData>("ProcessOrder")
            .Gate(ResourceKind.Database, onWaiting: null, configure: g => g
                .AddStep("CheckInventory", d => d with { Status = "Checked" }))
            .Gate("CustomPool", Tunable.FixedAt(1),
                onWaiting: null,
                configure: g => g.AddStep("RunReport", d => d with { Status = "Reported" }))
        .Run(out ICompiledPipeline<OrderData> pipeline)
    .Build();

var result = await pipeline.RunAsync(new OrderData("Alice", 10m));
Console.WriteLine(result.GetData().Status);

public sealed record OrderData(string CustomerName, decimal Total, string Status = "New");
Output
Reported

Parallel collections: ForEach

ForEach runs an independent pipeline over each item of a collection and merges the results back in source order. select extracts the collection, merge combines the results into the parent, and configure builds the per-item pipeline. Parallelism can be a plain number, a function re-read on each run, or a Tunable that the tuner can move.

A ForEach with fewer items than its minimum for parallel work runs inline on the calling thread, whatever the bound says, and the same is true when adaptive dispatch has learned that the items are cheap. The bound caps a fan-out; it never forces one. maxParallelism of 1 is always strictly sequential. Report() shows which path each ForEach took.

CollectAndThrow
The default. Every item runs and, if any failed, the result is a Failure carrying every exception.
FailFast
Remaining items are cancelled as soon as one fails.
ContinueOnError
Failed items are dropped and the result is a Success with the survivors.

For a tight, pure-CPU loop over many cheap rows, repeated many times, use ChunkedWork instead. It fans out in chunks with scratch space allocated once per chunk, which is the right cost model when a per-item pipeline would dominate the work.

ForEach with a tunable bound and a per-item progress hook
using EvalApp;

var progress = 0;

Eval.App("ForEach")
    .DefineDomain("Orders")
        .DefineTask<LineItemsData>("CalcLineTotals")
            .ForEach(
                select: d => d.Items,
                merge: (d, items) => d with { Items = items.ToList() },
                collectionName: "Items",
                parallelism: Tunable.ForItems(),
                configure: sub => sub
                    .OnItemCompleted((item, result) => Interlocked.Increment(ref progress))
                    .AddStep("Calc", (LineItem i) => i with { LineTotal = i.Price * i.Quantity }))
        .Run(out ICompiledPipeline<LineItemsData> pipeline)
    .Build();

var items = Enumerable.Range(1, 12).Select(n => new LineItem($"item-{n}", n, 2.5m)).ToList();
var result = await pipeline.RunAsync(new LineItemsData(items));
var total = result.GetData().Items.Sum(i => i.LineTotal);
Console.WriteLine($"items: {result.GetData().Items.Count}, progress callbacks: {progress}, total: {total}");

public sealed record LineItem(string Name, int Quantity, decimal Price, decimal LineTotal = 0m);
public sealed record LineItemsData(List<LineItem> Items);
Output
items: 12, progress callbacks: 12, total: 195.0

Conditional and parallel branches

If takes a condition and builder actions for the then and else sides. Without an else and with a false condition the step reports Skipped. Classify is a first-match-wins cascade that names the pattern instead of repeating near-identical If blocks.

AddParallelGroup runs a fixed set of branches at once, as opposed to ForEach, which runs one pipeline per item. Branches that write different fields merge cleanly by default. When two branches might write the same field and that should be a conflict, pass an IMergeStrategy<T>.

If, then a parallel group with a merge strategy
using EvalApp;

Eval.App("Branches")
    .DefineDomain("Orders")
        .DefineTask<OrderData>("ProcessOrder")
            .If(d => d.Total > 100,
                then: b => b.AddStep("VipDiscount", d => d with { Total = d.Total * 0.8m, Status = "VIP" }),
                @else: b => b.AddStep("StandardProcess", d => d with { Status = "Standard" }))
            .AddParallelGroup(
                pg => pg
                    .AddBranch("CalcTax", d => d with { Tax = d.Total * 0.08m })
                    .AddBranch<CalcShippingBranch>("CalcShipping")
                    .AddBranch("CalcHandling", new CalcHandlingFeeStep()),
                mergeStrategy: new SumNumericFieldsMerge())
        .Run(out ICompiledPipeline<OrderData> pipeline)
    .Build();

foreach (var total in new[] { 50m, 200m })
{
    var d = (await pipeline.RunAsync(new OrderData("Alice", total))).GetData();
    Console.WriteLine($"{d.Status}: total {d.Total}, tax {d.Tax}, shipping {d.Shipping}, handling {d.Handling}");
}

public sealed record OrderData(string CustomerName, decimal Total, string Status = "New",
    decimal Tax = 0m, decimal Shipping = 0m, decimal Handling = 0m);

public sealed class CalcShippingBranch : PureStep<OrderData>
{
    public override OrderData Execute(OrderData data) => data with { Shipping = 9.99m };
}

public sealed class CalcHandlingFeeStep : IStep<OrderData>
{
    public ValueTask<OrderData> ExecuteAsync(OrderData data, CancellationToken ct = default)
        => ValueTask.FromResult(data with { Handling = 1.5m });
}

public sealed class SumNumericFieldsMerge : IMergeStrategy<OrderData>
{
    public OrderData Merge(OrderData original, IReadOnlyList<OrderData> results)
        => original with
        {
            Tax      = results.Max(r => r.Tax),
            Shipping = results.Max(r => r.Shipping),
            Handling = results.Max(r => r.Handling),
        };
}
Output
Standard: total 50, tax 4.00, shipping 9.99, handling 1.5
VIP: total 160.0, tax 12.800, shipping 9.99, handling 1.5

Sagas: compensation on failure

A saga chains steps that each declare an undo. If a later step fails, every earlier compensation runs in reverse order. BeginSaga and EndSaga bracket the block, and the compiler refuses a pipeline whose saga is left open.

BestEffort
The default. Every compensation runs even if one of them throws.
AbortOnFirst
Stop compensating at the first compensation that fails.
SwallowErrors
Run every compensation and swallow any exception from them.
A saga that compensates in reverse order
using EvalApp;

Eval.App("Saga")
    .WithResource(ResourceKind.Database, Tunable.FixedAt(2))
    .DefineDomain("Orders")
        .DefineTask<OrderData>("ProcessOrder")
            .BeginSaga(CompensationPolicy.BestEffort)
                .AddStepWithCompensation(
                    "ReserveInventory",
                    forward:    d => d with { Status = "Reserved" },
                    compensate: d => { Console.WriteLine("compensate: release the inventory"); return d with { Status = "Unreserved" }; })
                .AddGate(
                    ResourceKind.Database,
                    onWaiting: null,
                    configure: g => g.AddStep("WriteLedger", d => d with { Total = d.Total }),
                    compensate: new RevertLedgerStep())
                .AddStep(
                    "ChargePayment",
                    d => d.Total > 1_000_000m
                        ? throw new InvalidOperationException("Payment declined")
                        : d with { Status = "Charged" })
            .EndSaga()
        .Run(out ICompiledPipeline<OrderData> pipeline)
    .Build();

var ok = await pipeline.RunAsync(new OrderData("Ivy", 10m));
var failed = await pipeline.RunAsync(new OrderData("Jin", 2_000_000m));
Console.WriteLine($"ok: {ok.IsSuccess}, status {ok.GetData().Status}");
Console.WriteLine($"failed: {failed.IsFailure}, status after compensation {failed.GetData().Status}");

public sealed record OrderData(string CustomerName, decimal Total, string Status = "New");

public sealed class RevertLedgerStep : IStep<OrderData>
{
    public ValueTask<OrderData> ExecuteAsync(OrderData data, CancellationToken ct = default)
    {
        Console.WriteLine("compensate: revert the ledger entry");
        return ValueTask.FromResult(data with { Status = "LedgerReverted" });
    }
}
Output
compensate: revert the ledger entry
compensate: release the inventory
ok: True, status Charged
failed: True, status after compensation New

Middleware

IStepMiddleware<T> wraps each named step individually, for timing, retry, audit or per-step tracing. No middleware ships ready-made; it is a seam you implement. To wrap the steps of a ForEach item pipeline, register an IStepMiddleware for the item type on that sub-builder.

An exception thrown by a step comes back as a Failure. An exception thrown by middleware outside the call to next is thrown from RunAsync on a top-level pipeline, and is a Failure only when the pipeline is nested in another. A ForEach whose item pipeline is a single synchronous step normally runs as one fused loop, and attaching middleware turns that fast path off.

Dependency injection

AddStep<TStep>(name) and AddBranch<TStep>(name) resolve the step through an IStepFactory. The default creates it with a public parameterless constructor. ServiceProviderStepFactory asks your container first and falls back to that for anything not registered.

The compiler enforces the new() constraint whichever factory you register, so a type needs a literal parameterless constructor even if the container always supplies the real one. The factory path is for plain IStep<T> implementers. A side-effect step that needs a constructor-injected dependency uses AddStep<TStep>(name, factory) instead.

Resolving a step from a service provider
using EvalApp;

// Any real IServiceProvider works; this minimal one keeps the sample dependency-free.
var services = new SimpleServiceProvider();
services.Register(typeof(ValidateOrderStep), new ValidateOrderStep("DI-resolved"));

Eval.App("Di")
    .WithStepFactory(new ServiceProviderStepFactory(services))
    .DefineDomain("Orders")
        .DefineTask<OrderData>("ProcessOrder")
            .AddStep<ValidateOrderStep>("Validate")     // resolved from the container, not Activator
        .Run(out ICompiledPipeline<OrderData> pipeline)
    .Build();

var result = await pipeline.RunAsync(new OrderData("Alice", 10m));
Console.WriteLine(result.GetData().Status);

public sealed record OrderData(string CustomerName, decimal Total, string Status = "New");

public sealed class ValidateOrderStep : IStep<OrderData>
{
    private readonly string _tag;
    // AddStep<TStep>() needs a literal parameterless constructor; the tagged one is what the container supplies.
    public ValidateOrderStep() : this("default") { }
    public ValidateOrderStep(string tag) => _tag = tag;
    public ValueTask<OrderData> ExecuteAsync(OrderData data, CancellationToken ct = default)
        => ValueTask.FromResult(data with { Status = _tag });
}

public sealed class SimpleServiceProvider : IServiceProvider
{
    private readonly Dictionary<Type, object> _services = new();
    public void Register(Type type, object instance) => _services[type] = instance;
    public object? GetService(Type serviceType) => _services.TryGetValue(serviceType, out var s) ? s : null;
}
Output
DI-resolved

Pipelines that run pipelines

A compiled pipeline is a value. A step can run another pipeline or itself, and a context can hold a registry of them, so an outer schedule loop drives an inner per-batch pipeline, or a pipeline recurses through a tree, without a hand-written loop around RunAsync. A loop of that kind is invisible to gates and to the tuner; this is how it becomes visible.

PipelineRef<T> forward-declares a pipeline before it is built, PipelineTrace tells same-named steps apart across levels, and a recursion that runs away fails cleanly with PipelineRecursionException instead of overflowing the stack.

Exclusive resources

Some things are not thread-safe: a GPU context, a native handle, a file with one writer. Declare an ExclusiveResource once and guard a scope, or named parallel branches, with Exclusive. A second concurrent entry fails loudly with ExclusiveViolationException, or queues if you choose that policy. The guard is re-entrant for recursion, and forks inside a held scope exclude each other instead of silently inheriting it.

One-thread hosts

Fanning out only pays when there is a second thread to fan out to. EvalApp detects a single-thread host once, at startup: a browser running Blazor WebAssembly, a process with one processor or one allowed CPU, or a host you declare with ExecutionHost.OverrideSingleThreaded or WithSingleThread. In that mode ForEach, parallel groups and ChunkedWork run inline on the caller, and long runs hand the thread back to the host every few milliseconds (ExecutionHost.YieldBudget) so a page or UI stays responsive. The cancellation token is checked at each yield.

A single very long synchronous step cannot be sliced. Split it into smaller steps or items. Inside a UI, use ChunkedWork.ForAsync and ChunkedWork.RunAsync, which work in time slices. On a multi-core host none of this is active and nothing is added to the fast path.

Licensing

No feature in EvalApp is gated behind a tier or a licence key. Every pipeline shape on this page runs at its full declared concurrency whether or not you pass a key, the tuner attaches whenever you call WithTuning, and Build(null) behaves exactly like Build(key).

The licence check exists so that every Evaluated Applications package validates a key the same way. LicenseValidator.ForEvalApp().Check(key) returns Unlicensed or Licensed and never throws, and CheckPeriodic(key, interval) is cheap enough for a hot loop. Verification is entirely offline, with ECDsa P-256, and makes no network call.

EvalApp is proprietary software under a commercial licence. Evaluation and non-commercial development are licensed without a key; commercial, production or revenue-generating use needs a separate licence.

Tuning and inspecting

Two opt-in modes. WithTuning is reactive: it rediscovers the operating point on each start, which suits a long-running, stable, single-instance service. WithBayesianTuning is predictive: a performance model persists across restarts, which suits frequent restarts and many instances. Both take an optional store path and share one phase machine, so switching between them does not reset what was learned.

The defaults come from a simulation harness and are what WithTuning uses out of the box. To override them, load your own JSON file with WithTunerSettings(path); the file is read when you call it, and a missing or invalid path throws at that point.

ICompiledPipeline<T> is opaque on purpose: a name and RunAsync. The read-only window on a plan is pipeline.Report(), which lists the plan's nodes and, for each ForEach, how many calls ran inline and how many fanned out. ExecutionHost.Yields counts the yields a process has taken.

Tuning on, then reading the plan report
using EvalApp;

var store = Path.Combine(Path.GetTempPath(), "evalapp-tuning-sample.json");

Eval.App("Tuned")
    .WithResource(ResourceKind.Network, Tunable.Between(1, 8, 2))
    .WithTuning(storePath: store)
    .DefineDomain("Orders")
        .DefineTask<Batch>("Fetch")
            .ForEach(
                select: b => b.Items,
                merge: (b, items) => b with { Items = items.ToList() },
                collectionName: "Items",
                configure: sub => sub
                    .Gate(ResourceKind.Network, onWaiting: null, configure: g => g
                        .AddStep("Download", async (int i, CancellationToken ct) => { await Task.Delay(5, ct); return i + 1; })))
        .Run(out ICompiledPipeline<Batch> pipeline)
    .Build();

for (var run = 0; run < 5; run++)
    await pipeline.RunAsync(new Batch(Enumerable.Range(0, 16).ToList()));

// A read-only window on the compiled plan: each ForEach node counts its calls (and, in Inline and FanOut, how they ran).
foreach (var node in pipeline.Report().Of("ForEach"))
    Console.WriteLine($"{node.Label}: {node.Calls} calls");

File.Delete(store);

public sealed record Batch(List<int> Items);
Output
Items: 5 calls

Anti-patterns to avoid

The tuner and the gates can only account for work they can see. Anything that happens outside the declared pipeline is invisible to both.

Task.WhenAll in a step
Use a declared ForEach instead.
A background Task kept on the record
It races the pipeline, ungated.
Async work around RunAsync
Work before or after the call, outside the pipeline, is unthrottled and untuned.
A shared collection written from a ForEach item
It is not thread-safe. Let each item own its result and collect results in merge.
Mutating the record
Use with. Mutation breaks the guarantee that makes parallel ForEach safe without locks.
State in step instance fields
Steps are reused by every concurrent run. Per-run state belongs on the record.
Rebuilding the pipeline per request
It discards everything the tuner has learned. Build once, run many times.
Blocking calls in a synchronous step
Use the async overload, and pass the cancellation token to every awaited call.
A hand-written loop or recursion around RunAsync
The loop is invisible to gates and the tuner. Layer and recurse through context instead.
Guarding a non-thread-safe singleton with a comment
Use an ExclusiveResource.

Extension seams

Beyond the fluent API and the step base classes, four extension points are public: GlobalContext and DomainContext (subclass them for WithContext and DefineDomain), IMergeStrategy<T> (custom conflict handling for AddParallelGroup) and IPressureResource (a soft, non-blocking budget signal for Pressure and WithPressure). Everything else is internal to the engine.