# Workflows API

This page is the method-and-type reference for the BackWave Pro workflow API. It covers the startup calls that turn the surface on, the strongly-typed builder that composes a graph, the composition shapes it exposes, typed Job Output and the immutable Workflow Input seed, the enqueue and append entry points, the operator cancel, and the monitor reads that expose a Workflow's derived status. For what a [Workflow](/docs/core-concepts/workflows) is and how it sits over the [Dependencies](/docs/core-concepts/dependencies) it groups, start with those concept pages; this page states the public contract of each call.

Workflows ship in a separate package. Once you reference it and register it, the authoring, read, and cancel methods appear as extension methods on the same `BackWaveClient`, `BackWaveMonitor`, and `BackWaveOperator` you already use for ordinary Jobs. The Pro-specific types, `TypedWorkflowBuilder`, `WorkflowBranch`, `WorkflowStepRef`, `IWorkflow<TSeed>`, `IWorkflowStep` / `IWorkflowStep<TOut>`, `IWorkflowInput`, `IWorkflowGate<>`, `WorkflowView`, `WorkflowCancelResult`, and `InvalidWorkflowException`, live in the `BackWave.Pro` namespace. The definition and enum types the methods pass and return, `WorkflowDefinition`, `WorkflowStatus`, `WorkflowEdge`, `DependencyMode`, and the rest, live in the base `BackWave.Storage` namespace, because a Workflow is composed from the same store primitives as any Job.

## Registration

`AddBackWavePro` is the whole boundary. Call it once at startup, after `AddBackWave`, and every Workflow method lights up on the existing client, monitor, and operator.

```csharp title="Program.cs"
builder.Services.AddBackWavePro(builder.Configuration["BackWave:ProLicense"]);
```

```csharp title="AddBackWavePro.cs"
public static IServiceCollection AddBackWavePro(
    this IServiceCollection services,
    string? license = null)
```

The `license` argument defaults to `null`, which is the correct value for the free tier (organizations under one million dollars in annual revenue). Above that, pass the purchased license string. The license is evaluated fully offline against a public key embedded in the package, with no network call. Evaluation always soft-fails: a missing, malformed, or out-of-term license logs a single startup warning and does nothing else. Features behave identically in every license state, so the call never changes what runs. See [Licensing and Pricing](/licensing) for the model.

### AddWorkflowGate

A conditional (`.If`) workflow decides at run time on a registered gate type. Register each distinct gate once, after `AddBackWavePro`, so the worker that later claims the gate step can resolve its predicate. The gate's codec is supplied explicitly here; it is the one place a conditional needs a `JsonTypeInfo`.

```csharp title="AddWorkflowGate.cs"
public static IServiceCollection AddWorkflowGate<TGate, TStep, TOut>(
    this IServiceCollection services,
    string wireName,
    JsonTypeInfo<WorkflowGate<TGate, TStep, TOut>> gateTypeInfo,
    string queue = "default")

// seed-aware overload:
public static IServiceCollection AddWorkflowGate<TGate, TStep, TOut, TInput>(
    this IServiceCollection services,
    string wireName,
    JsonTypeInfo<WorkflowGate<TGate, TStep, TOut, TInput>> gateTypeInfo,
    string queue = "default")
```

`wireName` is the gate step's Wire Name; `queue` overrides the default queue the gate runs on, which matters when no worker group serves `"default"`. The gate step type (`WorkflowGate<…>`) is minted by the builder, never constructed by you, but it must be registered here and listed in a `JsonSerializerContext` like any other job. See [Conditional branching](#conditional-branching-if) below.

## Marking a job as a workflow step

A step is an ordinary `[Job]` payload record that additionally wears the `IWorkflowStep` marker. The marker is the compile-time gate: a plain `[Job]` payload can no longer be added to a workflow, and a mistyped or renamed step is a compile error rather than a runtime one. A step that also produces typed output implements `IWorkflowStep<TOut>` instead.

```csharp title="IWorkflowStep.cs"
public interface IWorkflowStep;
public interface IWorkflowStep<TOut> : IWorkflowStep;
```

```csharp title="Steps.cs"
[Job("price-order", Queue = "critical")]
public sealed record PriceOrder(string OrderRef, int Cents) : IWorkflowStep<OrderPrice>;

[Job("reserve-stock")]
public sealed record ReserveStock(string OrderRef, int ItemCount) : IWorkflowStep;
```

Steps must tolerate unknown JSON properties (the System.Text.Json default). A step record configured with `JsonUnmappedMemberHandling.Disallow` is rejected, because BackWave splices its own workflow-input and dependency metadata into a member's payload at enqueue, and a step whose payload declares a property in that reserved namespace is rejected for the same reason.

## Starting a workflow

Five extension methods on `BackWaveClient` begin a workflow. `Workflow(...)` opens a fresh builder, `WorkflowAppend(id)` opens a builder that grows an existing workflow, and `StartWorkflow<TWorkflow, TInput>(...)` enqueues a reusable `IWorkflow<TSeed>` definition in one call.

```csharp title="Start.cs"
// No seed:
public static TypedWorkflowBuilder Workflow(
    this BackWaveClient client, string? name = null)

// Seed with an explicit codec:
public static TypedWorkflowBuilder Workflow<TInput>(
    this BackWaveClient client, TInput seed,
    JsonTypeInfo<TInput> seedTypeInfo, string? name = null)

// Seed, codec auto-resolved (TInput : IWorkflowInput listed in a JsonSerializerContext):
public static TypedWorkflowBuilder Workflow<TInput>(
    this BackWaveClient client, TInput seed, string? name = null)
    where TInput : IWorkflowInput

// Append into an existing workflow:
public static TypedWorkflowBuilder WorkflowAppend(
    this BackWaveClient client, Guid workflowId)

// Start a reusable IWorkflow<TSeed> definition (explicit codec, then auto):
public static ValueTask<Guid> StartWorkflow<TWorkflow, TInput>(
    this BackWaveClient client, TInput seed, JsonTypeInfo<TInput> seedTypeInfo,
    string? name = null, DbTransaction? transaction = null,
    CancellationToken cancellationToken = default)
    where TWorkflow : IWorkflow<TInput>, new()

public static ValueTask<Guid> StartWorkflow<TWorkflow, TInput>(
    this BackWaveClient client, TInput seed,
    string? name = null, DbTransaction? transaction = null,
    CancellationToken cancellationToken = default)
    where TWorkflow : IWorkflow<TInput>, new()
    where TInput : IWorkflowInput
```

The optional `name` is a human-readable label for the graph view; it is not unique, and the trivial "run B after A" case needs none. An append never takes a `name`, so it can never rename its target workflow.

## The workflow builder

`TypedWorkflowBuilder` composes a graph by chaining `[Job]` steps referenced by .NET type. It is pure: it touches no store and holds no I/O until `EnqueueAsync`, and the whole graph lowers to the same below-boundary member-and-edge dependency spine an ordinary set of dependencies would. The builder exposes the identity of the Workflow it is composing:

```csharp title="WorkflowId.cs"
public Guid WorkflowId { get; }
```

This is a fresh `Guid` on a new Workflow, or the target id on an append.

### Then

`Then<TStep>` adds one step. In its linear form the step depends on the current **frontier** (the previously-added step, or nothing for the first step) and becomes the new frontier, which is the common case. Overloads add fan-in and a parameterless-step convenience.

```csharp title="Then.cs"
// Linear: depends on the frontier, becomes the new frontier.
public TypedWorkflowBuilder Then<TStep>(
    TStep step,
    DependencyMode mode = DependencyMode.OnSuccess,
    string? name = null, string? queue = null, JobTags? tags = null)
    where TStep : IWorkflowStep

// Parameterless-step convenience.
public TypedWorkflowBuilder Then<TStep>(
    DependencyMode mode = DependencyMode.OnSuccess,
    string? name = null, string? queue = null, JobTags? tags = null)
    where TStep : IWorkflowStep, new()

// Fan-in by type list.
public TypedWorkflowBuilder Then<TStep>(
    TStep step, IReadOnlyList<Type> after,
    DependencyMode mode = DependencyMode.OnSuccess,
    IEnumerable<Guid>? afterExisting = null,
    string? name = null, string? queue = null, JobTags? tags = null)
    where TStep : IWorkflowStep

// Fan-in by WorkflowStepRef list (name-disambiguated).
public TypedWorkflowBuilder Then<TStep>(
    TStep step, IReadOnlyList<WorkflowStepRef> after,
    DependencyMode mode = DependencyMode.OnSuccess,
    IEnumerable<Guid>? afterExisting = null,
    string? name = null, string? queue = null, JobTags? tags = null)
    where TStep : IWorkflowStep
```

A fan-in step names its upstreams by type: `after: [typeof(StepA), typeof(StepB)]`. Each type is resolved to the one already-added node of that type. On an append, `afterExisting` names existing members by their `Guid`.

| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `step` | `TStep` | required | The step payload, a `[Job]` record wearing `IWorkflowStep`. |
| `after` | `IReadOnlyList<Type>` or `IReadOnlyList<WorkflowStepRef>` | (none) | The upstream steps this one fans in over, resolved by type. Omit to depend on the frontier. |
| `mode` | `DependencyMode` | `OnSuccess` | When the parent edges release this step. See [DependencyMode](#dependencymode). |
| `afterExisting` | `IEnumerable<Guid>?` | `null` | Append-only: existing member ids this new step depends on. |
| `name` | `string?` | `null` | Disambiguates a repeated step type; also the Wire Name label in the graph view. |
| `queue` | `string?` | `null` | The Queue this step runs on. Defaults to the step type's registered Queue. |
| `tags` | `JobTags?` | `null` | Extra [Tags](/docs/core-concepts/tags), unioned with the type's default Tags. Additive only. |

Fan-in by type is ambiguous if the type repeats: `after: [typeof(X)]` throws `InvalidWorkflowException` when `X` appears more than once in the workflow. To fan in over a repeated type, disambiguate at add time with `name:` and reference the step through a `WorkflowStepRef` (below).

### WorkflowStepRef

`WorkflowStepRef` names a step by type plus an optional disambiguating name. A plain `typeof(X)` converts implicitly, so a name-less list reads exactly like the by-type overload.

```csharp title="WorkflowStepRef.cs"
public readonly struct WorkflowStepRef
{
    public Type StepType { get; }
    public string? Name { get; }
    public WorkflowStepRef(Type stepType, string? name = null);
    public static implicit operator WorkflowStepRef(Type stepType) => new(stepType);
}
```

To fan in on one of several same-type steps, add each with a distinct `name:` and reference the one you want as `new WorkflowStepRef(typeof(X), "the-name")`.

### Parallel and WorkflowBranch

`Parallel(...)` fans out one branch per argument from the current frontier, then makes the set of all branch tips the new frontier. There is no synthetic join node: the next `Then(..., after: [...])` that names the branch tips is the join, and a plain `.Then` after a `Parallel` fans in over every tip. Leaving no follow-on keeps the branches as parallel leaves.

```csharp title="Parallel.cs"
public TypedWorkflowBuilder Parallel(params WorkflowBranch[] branches)
public TypedWorkflowBuilder Parallel(params IWorkflowStep[] steps)   // fast path: each step is its own branch
```

```csharp title="WorkflowBranch.cs"
public static WorkflowBranch Step<TStep>(
    TStep step, string? name = null, string? queue = null, JobTags? tags = null)
    where TStep : IWorkflowStep

public static WorkflowBranch Do(Action<TypedWorkflowBuilder> build)
```

`WorkflowBranch.Step(...)` runs a single step; `WorkflowBranch.Do(b => ...)` hands you a sub-builder to chain several steps (and nest further `Parallel`) inside one branch. At least one branch is required, every branch must add at least one step, and branch types may not collide with each other or with the rest of the graph.

### Conditional branching (If)

`.If<...>(then:, otherwise:)` adds a runtime conditional that never reshapes the graph. Both arms are enqueued up front and a gate step is inserted after the current frontier. At run time the gate's handler pulls the already-decided output of the ancestor its predicate observes, evaluates the predicate, and cancels the arm that was not taken (its whole subtree); the taken arm proceeds. No node is created, skipped, or reordered.

```csharp title="If.cs"
public TypedWorkflowBuilder If<TGate, TStep, TOut>(
    Action<TypedWorkflowBuilder> then,
    Action<TypedWorkflowBuilder>? otherwise = null,
    string? name = null, string? queue = null, JobTags? tags = null)
    where TGate : IWorkflowGate<TStep, TOut>, new()
    where TStep : IWorkflowStep<TOut>

// seed-aware overload:
public TypedWorkflowBuilder If<TGate, TStep, TOut, TInput>(
    Action<TypedWorkflowBuilder> then,
    Action<TypedWorkflowBuilder>? otherwise = null,
    string? name = null, string? queue = null, JobTags? tags = null)
    where TGate : IWorkflowGate<TStep, TOut, TInput>, new()
    where TStep : IWorkflowStep<TOut>
    where TInput : IWorkflowInput
```

The predicate is a named gate type, not a lambda, because it runs on whatever worker later claims the gate step and so must survive enqueue as payload bytes:

```csharp title="IWorkflowGate.cs"
public interface IWorkflowGate<TStep, TOut> where TStep : IWorkflowStep<TOut>
{
    bool Enter(DependencyOutput<TOut> observed);
}

public interface IWorkflowGate<TStep, TOut, TInput>
    where TStep : IWorkflowStep<TOut> where TInput : IWorkflowInput
{
    bool Enter(DependencyOutput<TOut> observed, TInput input);
}
```

`Enter` returning `true` runs the `then` arm and cancels the alternate; `false` runs `otherwise`, or, if `otherwise` is omitted, cancels the primary arm and runs nothing. The seed-aware `Enter(observed, input)` is read-only: it receives only the observed output and the typed seed, never the builder or job context, so it can neither enqueue nor cancel. Register each gate once with [AddWorkflowGate](#addworkflowgate).

```csharp title="LargeOrderGate.cs"
public sealed class LargeOrderGate : IWorkflowGate<PriceOrder, OrderPrice, CheckoutSeed>
{
    public bool Enter(DependencyOutput<OrderPrice> observed, CheckoutSeed input)
        => input.Expedite || (observed.HasOutput && observed.Output!.Cents >= input.ExpressThresholdCents);
}
```

`.If` leaves both arms' tips as the frontier. A join that converges past the gate MUST use `mode: DependencyMode.OnAnyTerminal`, so it releases once one arm succeeded and the other reached the terminal *cancelled* state. An `OnSuccess` join spanning both arms is rejected at `Build()`, because the gate always cancels one arm and such a join would always cascade-cancel. An arm step may also depend only on the gate or another step in the same arm.

### WithCompensation

`WithCompensation(...)` attaches a saga-style undo guarding the current frontier. The compensation is an ordinary step wired `OnAnyTerminal` to the protected work, so it always becomes reachable once that work is terminal, whatever the outcome. Its handler reads the protected step's already-decided state and decides: undo if the protected work failed, no-op if it succeeded. So the compensation always runs but usually no-ops; there is deliberately no `OnFailure` release mode.

```csharp title="WithCompensation.cs"
public TypedWorkflowBuilder WithCompensation<TUndo>(
    TUndo undo, string? name = null, string? queue = null, JobTags? tags = null)
    where TUndo : IWorkflowStep

public TypedWorkflowBuilder WithCompensation<TUndo>(
    string? name = null, string? queue = null, JobTags? tags = null)
    where TUndo : IWorkflowStep, new()
```

The frontier is deliberately left on the protected work: a compensation is a side-branch, and whatever you chain after `WithCompensation` still depends on the protected work, not the undo. Calling it with an empty frontier throws. For a reverse-order saga, call `WithCompensation` again for earlier steps: each new compensation makes the previous one wait for it, so later-protected work undoes first. That reverse-order chain is scoped to the builder it is called on and does not span a `Parallel`/`If` branch or a `ThenWorkflow` child.

### ThenWorkflow and IWorkflow

`ThenWorkflow<TChild, TSeed>(seed)` splices a reusable `IWorkflow<TSeed>` definition into the current graph as an inline subgraph. The child's `Build` runs now, at construction; its steps are grafted onto the same flat graph rooted at the current frontier, and the child's leaf steps become the new frontier. There is no nested identity: one flat graph, one workflow row, one derived status, one retention unit.

```csharp title="ThenWorkflow.cs"
public TypedWorkflowBuilder ThenWorkflow<TChild, TSeed>(TSeed seed)
    where TChild : IWorkflow<TSeed>, new()

public interface IWorkflow<in TSeed>
{
    void Build(TypedWorkflowBuilder builder, TSeed seed);
}
```

```csharp title="FulfilmentWorkflow.cs"
public sealed class FulfilmentWorkflow : IWorkflow<FulfilmentSeed>
{
    public void Build(TypedWorkflowBuilder builder, FulfilmentSeed seed)
        => builder.Then(new PackParcel(seed.OrderRef)).Then(new PrintLabel(seed.OrderRef));
}
```

The `TSeed` here is build-time only: it shapes the child's graph and constructs its step payloads at build time. The spliced child shares the parent's Workflow Input, so a child step calling `ctx.Input<T>()` reads the *parent's* input, not this seed; read the seed's values into the child's step payloads at build time. Step-identity collisions span the combined graph, so a child step whose type (plus optional name) collides with a parent step throws. To run a child under its own independent identity with no join back, do not use `ThenWorkflow`; have a step call `StartWorkflow<TChild, TSeed>(seed)` instead.

### Build and EnqueueAsync

`Build` validates the graph and returns the prepared `WorkflowDefinition`; `EnqueueAsync` builds and writes it atomically, returning the new `WorkflowId`.

```csharp title="EnqueueAsync.cs"
public WorkflowDefinition Build()

public async ValueTask<Guid> EnqueueAsync(
    DbTransaction? transaction = null,
    CancellationToken cancellationToken = default)
```

`EnqueueAsync` writes every member Job and the Workflow record in one all-or-nothing transaction. When you pass a `transaction`, the whole graph commits or rolls back together with your own writes on that same transaction, the Transactional Enqueue guarantee extended to a Workflow. If you pass a transaction and the storage adapter does not support it, the method throws `NotSupportedException`.

```csharp title="Enqueue.cs"
var workflowId = await client.Workflow("order-fulfillment")
    .Then(new ValidateOrder(orderId))
    .Then(new ChargePayment(orderId))                                          // runs after validate
    .Then(new ReserveInventory(orderId), after: [typeof(ValidateOrder)])       // also after validate
    .Then(new PackShipment(orderId), after: [typeof(ChargePayment), typeof(ReserveInventory)]) // fan-in
    .Then(new NotifyBuyer(orderId))                                            // runs after pack
    .EnqueueAsync();
```

The store accepts or rejects a Workflow as a single unit, and a rejection inserts nothing. Any rejection surfaces as an `InvalidOperationException` whose message is `Workflow enqueue failed: {reason}.`, where `{reason}` is one of the [rejection reasons](#rejection-reasons) below. `Build()` and `EnqueueAsync()` throw `InvalidOperationException` if called on a sub-builder handed to a `Parallel`/`If` branch lambda or an `IWorkflow` child; those only accrete into the parent graph.

### InvalidWorkflowException

`InvalidWorkflowException` is the single exception the builder raises for an invalid graph: an empty or cyclic graph, a duplicate step identity, a fan-in over a type that is ambiguous or unresolved, a reserved-namespace payload, or one of the conditional guardrails above. Because the builder is pure and runs in your process, this is caught at build time rather than in production.

```csharp title="InvalidWorkflowException.cs"
public sealed class InvalidWorkflowException(string message) : Exception(message);
```

## Typed Job Output

A step declares the output type it produces once, on its own contract, via `IWorkflowStep<TOut>`. It emits with `ctx.SetOutput<TStep, TOut>(value)`; a downstream step pulls an ancestor's output with `ctx.Output<TStep, TOut>()`, type-checked against the producer's declaration. Pull, never push: nothing is injected into a reader's args.

```csharp title="JobOutput.cs"
public static void SetOutput<TStep, TOut>(this JobContext context, TOut value)
    where TStep : IWorkflowStep<TOut>

public static ValueTask<DependencyOutput<TOut>> Output<TStep, TOut>(
    this JobContext context, CancellationToken cancellationToken = default)
    where TStep : IWorkflowStep<TOut>
```

`DependencyOutput<TOut>` exposes `HasOutput` (bool), `Output` (nullable `TOut`), and `AncestorState` (`JobState`). Absence is normal.

```csharp title="Read.cs"
// in the producer's handler:
context.SetOutput<PriceOrder, OrderPrice>(new OrderPrice(job.Cents));

// in a downstream handler, reading an upstream typed output:
var charge = await context.Output<AuthorizeCharge, ChargeResult>(cancellationToken);
if (charge.AncestorState == JobState.Succeeded) { /* use charge.Output */ }
```

The output codec is auto-sourced from the app's `JsonSerializerContext`, so no serializer is passed at any call site (see [Codec auto-wiring](#codec-auto-wiring-and-bw0007)). A few rules keep it safe:

- **`SetOutput<TStep, TOut>` only works for the running step's own type.** Writing under another step's type throws `InvalidOperationException`.
- **Reads are ambiguous over a repeated step type.** An `Output<TStep, TOut>()` where `TStep` appears more than once among the reader's ancestors throws; there is no by-name output read. Structure the graph so at most one ancestor of the reader is that type.
- **Absence is normal, not an error.** An ancestor that failed, was cancelled, emitted nothing, or a non-ancestor sibling on a parallel branch all resolve to a clean `HasOutput == false`.

The lower-level string-handle form, `ctx.GetDependencyOutputAsync("wire-name", typeInfo, ct)`, still exists; the typed `Output<TStep, TOut>` is the workflow sugar over it.

## Workflow Input

Workflow Input is an immutable, set-once value supplied when a workflow is started, baked into every member's payload at enqueue, and read back inside a handler via `ctx.Input<TInput>()`. It shapes the graph at build time and supplies constant data to step payloads. It is never mutated and is not shared accumulating state; read an upstream step's *result* through Job Output, never through the seed.

```csharp title="IWorkflowInput.cs"
public interface IWorkflowInput;

public static TInput Input<TInput>(this JobContext context)
    where TInput : IWorkflowInput
public static TInput Input<TInput>(this JobContext context, JsonTypeInfo<TInput> typeInfo)
```

Wearing `IWorkflowInput` is the explicit opt-in that makes `ctx.Input<TInput>()` resolvable and tells the `[Job]` generator to wire the seed's codec. A seed marked but not listed in any `JsonSerializerContext` is a [BW0007](#codec-auto-wiring-and-bw0007) build error.

```csharp title="Seed.cs"
public sealed record CheckoutSeed(string OrderRef, bool Expedite, int ExpressThresholdCents) : IWorkflowInput;

await client.Workflow(new CheckoutSeed(orderRef, expedite, ExpressThresholdCents: 100_000), name: $"checkout {orderRef}")
    // ...
    .EnqueueAsync();

// read back in any member's handler (no serializer arg):
var seed = ctx.Input<CheckoutSeed>();
```

**Appended members carry no seed.** `WorkflowAppend` does not re-bake the target workflow's seed into new members, so a handler for an appended step that calls `ctx.Input<T>()` throws at runtime. Give appended steps their constant data through their own payload. Reading a seed on a job started without one likewise throws `InvalidOperationException`.

## Codec auto-wiring and BW0007

Typed output codecs and seed codecs are auto-sourced from the app's own `JsonSerializerContext`; you pass no `JsonTypeInfo` at a workflow call site. The `[Job]` source generator scans every `[JsonSerializable]` listing in the compilation and emits the right codec reference for each workflow output type (a `[Job]` implementing `IWorkflowStep<TOut>`) and each seed type (marked `IWorkflowInput`). List the output DTO and the seed in a context and they are wired for you.

```csharp title="SampleOutputs.cs"
[JsonSerializable(typeof(OrderPrice))]
[JsonSerializable(typeof(ChargeResult))]
public partial class SampleOutputJsonContext : JsonSerializerContext;
```

A workflow output type or seed type listed in **no** context is the **BW0007** build error (severity Error):

```text title="BW0007"
Workflow type '{0}' ({1}) is not listed in any JsonSerializerContext - add
[JsonSerializable(typeof({0}))] to a JsonSerializerContext so BackWave can wire its
serialization, or register it by hand with an explicit JsonTypeInfo
```

A one-click code fix adds the `[JsonSerializable(...)]` attribute to an existing context, or scaffolds a new one when none exists, and supports fix-all.

**Gotchas:**

- **Cross-assembly invisibility.** Discovery is syntax over the current compilation only. A context, or an `IWorkflowInput` seed, declared in a *referenced project* is a compiled symbol, not source syntax, so a type whose only listing lives across an assembly boundary trips a BW0007 false-positive. List it in a context in the same assembly, or use the explicit-`JsonTypeInfo` escape hatch.
- A type listed in more than one context binds to the first by ordinal context name, with no diagnostic (every listing serializes identically).
- Array-typed listings work: `[JsonSerializable(typeof(T[]))]` resolves an `IWorkflowStep<T[]>` codec.
- The explicit-`JsonTypeInfo` overloads (`Workflow(seed, seedTypeInfo)`, `ctx.Input(typeInfo)`, `AddWorkflowGate(…, gateTypeInfo, …)`, `ctx.GetDependencyOutputAsync(handle, typeInfo, ct)`) remain as escape hatches.

## Enqueue types

### WorkflowDefinition

`WorkflowDefinition` is the prepared graph `Build()` emits and `EnqueueAsync` writes. It is a `record` in `BackWave.Storage`.

| Member | Type | Meaning |
|---|---|---|
| `WorkflowId` | `Guid` (required) | The Workflow's identity. |
| `Name` | `string?` | The optional label, null when unnamed. |
| `Members` | `IReadOnlyList<NewJob>` (required) | The member Jobs, each with its resolved parent ids. |
| `Retention` | `WorkflowRetentionPolicy` | Defaults to `UnitUntilDrained`. See [WorkflowRetentionPolicy](#workflowretentionpolicy). |
| `RestartedFrom` | `Guid?` | Set on a restart to the original Workflow id, a lineage pointer. Ignored on an append. |
| `IsAppend` | `bool` | True when this enqueue appends into an existing Workflow. When true, `WorkflowId` must already exist, the Workflow row is left untouched, and existing members' Dependencies are never rewritten. |

### NewJob

Each member of a `WorkflowDefinition` is a `NewJob`. The builder constructs these for you.

```csharp title="NewJob.cs"
public sealed record NewJob(
    Guid JobId,
    string WireName,
    ReadOnlyMemory<byte> Payload,
    string Queue,
    DateTimeOffset DueTime)
```

It carries four init-only extras: `Parents` (`IReadOnlyList<Guid>`, default empty), `Mode` (`DependencyMode`, default `OnSuccess`), `TraceContext` (`string?`), and `Tags` (`JobTags`, default `JobTags.Empty`). A non-empty `Parents` makes the Job wait in AwaitingParent until every parent is terminal, then release per `Mode`. A member that carries a Workflow Input seed or any parent also carries reserved workflow-input or dependency metadata in its payload; only a seedless, parentless member is byte-for-byte identical to a standalone enqueue of the same step. Payload is opaque bytes to every adapter, so no below-boundary fact changes.

### DependencyMode

`DependencyMode` decides when a step's parent edges release it. It is the same release vocabulary documented on the [Dependencies](/docs/core-concepts/dependencies) page.

```csharp title="DependencyMode.cs"
public enum DependencyMode { OnSuccess, OnAnyTerminal }
```

| Value | Release rule |
|---|---|
| `OnSuccess` | Release only when every parent Succeeded. Any other terminal outcome on a parent cancels the dependent. This is the default. |
| `OnAnyTerminal` | Release once every parent is terminal, whatever the outcomes. |

`OnAnyTerminal` is what converges past a conditional [`.If`](#conditional-branching-if): the not-taken arm terminates *cancelled*, so a join over both arms must release on any terminal state, not only success.

### WorkflowRetentionPolicy

`WorkflowRetentionPolicy` governs when a drained Workflow's members become eligible for the normal per-Job retention window. It has a single value today, which is also the default.

```csharp title="WorkflowRetentionPolicy.cs"
public enum WorkflowRetentionPolicy { UnitUntilDrained }
```

Under `UnitUntilDrained`, members are retained as a unit until the whole Workflow drains, meaning every member reaches a terminal state. Only then does each member's retention window begin, measured from the drain point. This keeps the graph coherent for the Workflow's whole life.

### Rejection reasons

The store validates a Workflow independently of the builder and can reject the enqueue as a unit. Anything other than acceptance inserts nothing. These reasons surface to your code through the `InvalidOperationException` thrown by [EnqueueAsync](#build-and-enqueueasync).

| Reason | Cause |
|---|---|
| `DuplicateWorkflow` | A Workflow with this id already exists on the creation path. |
| `WorkflowNotFound` | An append targeted a Workflow id that does not exist. |
| `DuplicateMember` | A member Job id already exists in the store, or appears twice in the batch. |
| `ContainmentViolation` | A member's gating parent is not a member of the same Workflow. |
| `EmptyWorkflow` | The Workflow has no members. |
| `PayloadTooLarge` | A member's payload exceeds the store's payload size limit. |
| `WireNameTooLong` | A member's wire name exceeds the store's length limit. |
| `TooManyParents` | A member exceeds the store's per-Job parent limit. |

The numeric bounds behind the last three are the same store bounds that apply to any Job; see [Limits and Defaults](/docs/reference/limits-and-defaults).

## Appending to a Workflow

`WorkflowAppend` returns a builder that adds new members to an existing Workflow. The existing members and their Dependencies are never rewritten; only new steps are added, and each new step depends on the frontier, on other new steps (via `after: [typeof(...)]`), or on existing members (via `afterExisting:` ids). The target Workflow must already exist or the enqueue is rejected as `WorkflowNotFound`.

```csharp title="Append.cs"
await client.WorkflowAppend(workflowId)
    .Then(new Reconcile(orderRef), afterExisting: [loadJobId])
    .EnqueueAsync();
```

Appended members carry no Workflow Input; give them their constant data through their own payload.

## Cancelling a Workflow

`CancelWorkflowAsync` on `BackWaveOperator` cancels a Workflow as a group.

```csharp title="CancelWorkflowAsync.cs"
public static async ValueTask<WorkflowCancelResult> CancelWorkflowAsync(
    this BackWaveOperator @operator,
    Guid workflowId,
    string actor,
    DateTimeOffset? now = null,
    CancellationToken cancellationToken = default)
```

The cancel is a one-time snapshot fan-out, not a standing rule. It cancels each member that is running or pending at the moment of the call. A member that starts after the call returns is unaffected, and members that have already finished are left untouched. Each per-member cancel is recorded against the `actor` you pass, for audit. A currently-leased member cancels cooperatively through its `CancellationToken`, driven by the heartbeat, exactly like a single-Job cancel; threads are never killed. See [Execution Guarantee](/docs/core-concepts/execution-guarantee) for that contract. Because an operator cancel produces no failed members, the Workflow's derived status reads as Cancelled, not Failed.

The `now` argument defaults to the operator's clock. When no Workflow has the given id, the method makes no cancel calls and returns `WorkflowCancelResult.NotFound`.

```csharp title="Cancel.cs"
var result = await operator.CancelWorkflowAsync(workflowId, actor: "ops-oncall");
```

### WorkflowCancelResult

`CancelWorkflowAsync` returns a `WorkflowCancelResult` tallying what the snapshot did.

```csharp title="WorkflowCancelResult.cs"
public sealed record WorkflowCancelResult(
    bool Found,
    int CancelledImmediately,
    int CancellationRequested)
{
    public static readonly WorkflowCancelResult NotFound = new(Found: false, 0, 0);
}
```

| Member | Meaning |
|---|---|
| `Found` | True when a Workflow with the id existed. False means nothing was cancelled. |
| `CancelledImmediately` | How many still-pending members (Scheduled or AwaitingParent) were cancelled outright. |
| `CancellationRequested` | How many running, leased members were asked to stop cooperatively and will cancel when their handler next checks. |

Members that were already finished are counted in neither, and a member that terminates between the read and its own cancel is also counted in neither.

### CancelResult

Each per-member cancel resolves to a `CancelResult`, the same enum that governs a single-Job cancel. It is not returned by `CancelWorkflowAsync` directly; it feeds the two tallies above.

```csharp title="CancelResult.cs"
public enum CancelResult { CancelledImmediately, CancellationRequested, NotCancellable }
```

A Scheduled or AwaitingParent member cancels immediately; a leased member has its cancellation flag set and cancels cooperatively on its next heartbeat; a member that is absent or already terminal comes back `NotCancellable` and moves neither tally.

## Reading Workflows

Two extension methods on `BackWaveMonitor` expose Workflows. A Workflow's status is always computed from its members' current states and never stored, so every read reflects where the members actually are.

### ListWorkflowsAsync

`ListWorkflowsAsync` returns every Workflow ordered by creation time, oldest first, each as a `WorkflowSnapshot`. The list is empty when no Workflows exist.

```csharp title="ListWorkflowsAsync.cs"
public static ValueTask<IReadOnlyList<WorkflowSnapshot>> ListWorkflowsAsync(
    this BackWaveMonitor monitor,
    CancellationToken cancellationToken = default)
```

### GetWorkflowAsync

`GetWorkflowAsync` returns one Workflow's full graph as a `WorkflowView`, or `null` when no Workflow has that id. The members come back as Job snapshots that never include payload bytes.

```csharp title="GetWorkflowAsync.cs"
public static async ValueTask<WorkflowView?> GetWorkflowAsync(
    this BackWaveMonitor monitor,
    Guid workflowId,
    CancellationToken cancellationToken = default)
```

```csharp title="ReadStatus.cs"
var view = await monitor.GetWorkflowAsync(workflowId);
if (view is not null)
{
    Console.WriteLine($"{view.Name}: {view.Status} ({view.Members.Count} members)");
    foreach (var edge in view.Edges)
        Console.WriteLine($"  {edge.Parent} -> {edge.Child}");
}
```

### WorkflowSnapshot

`WorkflowSnapshot` is the per-Workflow row `ListWorkflowsAsync` returns.

| Member | Type | Meaning |
|---|---|---|
| `WorkflowId` | `Guid` (required) | The Workflow's identity. |
| `Name` | `string?` | The optional label, null when unnamed. |
| `CreatedAt` | `DateTimeOffset` (required) | When the Workflow was created. |
| `Status` | `WorkflowStatus` (required) | The derived status. |
| `MemberCount` | `int` (required) | How many member Jobs the Workflow has. |
| `RestartedFrom` | `Guid?` | The Workflow this was restarted from, or null if created fresh. |

### WorkflowView

`WorkflowView` is the full graph `GetWorkflowAsync` returns. Use it to render a Workflow's dependency graph and to drill from a member node into that Job's detail.

| Member | Type | Meaning |
|---|---|---|
| `WorkflowId` | `Guid` (required) | The Workflow's identity. |
| `Name` | `string?` | The optional label, null when unnamed. |
| `CreatedAt` | `DateTimeOffset` (required) | When the Workflow was created. |
| `Status` | `WorkflowStatus` (required) | The derived status. |
| `Members` | `IReadOnlyList<JobSnapshot>` (required) | The member Jobs, never with payload bytes. |
| `Edges` | `IReadOnlyList<WorkflowEdge>` (required) | The fixed dependency edges defining run order. |
| `RestartedFrom` | `Guid?` | The Workflow this was restarted from, or null if created fresh. |

### WorkflowEdge

A `WorkflowEdge` is one structural dependency edge: `Child` depends on `Parent`.

```csharp title="WorkflowEdge.cs"
public sealed record WorkflowEdge(Guid Parent, Guid Child);
```

Unlike the live gating edges that resolve away as parents terminate, a Workflow's structural edges are immutable and recorded at enqueue, so the graph view stays complete for the Workflow's whole life.

### WorkflowStatus

`WorkflowStatus` is the derived status carried on every snapshot and view. It is always a projection of the member states, never stored, so it recomputes on every read and can legitimately move backward from Succeeded to Running when live work is appended to a drained Workflow.

```csharp title="WorkflowStatus.cs"
public enum WorkflowStatus { Running, Failed, Cancelled, Succeeded }
```

The projection resolves by first-match-wins precedence, `Running` over `Failed` over `Cancelled` over `Succeeded`:

| Status | When it applies |
|---|---|
| `Running` | At least one member is still non-terminal. |
| `Failed` | All members are terminal and at least one is Dead-Lettered or Quarantined. Failure dominates: one failed member makes the whole Workflow Failed even when its siblings Succeeded. |
| `Cancelled` | All members are terminal, none failed, and at least one is Cancelled. An operator cancel produces no failures, so it reads here. |
| `Succeeded` | Every member Succeeded. The empty, no-member case also reads as Succeeded. |

There is no partial state. **A conditional (`.If`) workflow derives `Cancelled` even when every step that actually ran Succeeded**, because the not-taken arm terminates cancelled and a cancelled member with no failed member makes the whole-workflow rollup `Cancelled`. This is expected and healthy for any conditional workflow; read per-step state from `Members`, not the whole-workflow rollup, to tell a healthy conditional run from a genuinely aborted one. The terminal member states that feed this projection come from the [Job Lifecycle](/docs/core-concepts/job-lifecycle).

### JobSnapshot

Each member in a `WorkflowView` is a `JobSnapshot`, the read-only view of a Job that never carries payload bytes. It is the same snapshot type the monitor returns for any Job.

| Member | Type | Meaning |
|---|---|---|
| `JobId` | `Guid` (required) | The Job's identity. |
| `WireName` | `string` (required) | The registered wire name of the Job type. |
| `Queue` | `string` (required) | The Queue the Job runs on. |
| `State` | `JobState` (required) | The current lifecycle state. |
| `Attempt` | `int` (required) | The current attempt number. |
| `DueTime` | `DateTimeOffset` (required) | When the Job becomes claimable. |
| `LeaseOwner` | `string?` | Set while leased, the "executing now" view. |
| `LeaseExpiry` | `DateTimeOffset?` | When the current lease expires. |
| `CancelRequested` | `bool` | Whether a cooperative cancel has been requested. |
| `TerminalAt` | `DateTimeOffset?` | When the Job reached a terminal state. |
| `TerminalCause` | `string?` | The recorded cause of a terminal outcome. |
| `ScheduleId` | `string?` | The Recurring Schedule that minted the Job, if any. |
| `Sequence` | `long` | The paging cursor. |
| `WorkflowId` | `Guid?` | The Workflow this Job belongs to, null if none. |
| `Tags` | `IReadOnlyList<JobTag>` | The Job's Tags, defaulting to empty. |

## Recovering a Workflow

Recovery is a full redo, not a resume: BackWave records nothing inside a handler and cannot resume a graph part-way. To re-run a failed workflow, build the same graph again with the typed builder and `EnqueueAsync` it. That re-runs every step from the start, including steps that already Succeeded, so any non-idempotent step runs again and idempotency stays the handler's responsibility.

For a shape-preserving redo that carries a lineage pointer, `Build()` returns a `WorkflowDefinition`, and `WorkflowDefinition.RestartAsNew()` produces a fresh definition with a new Workflow id, fresh member ids remapped consistently, the same shape, and `RestartedFrom` set to the original. The monitor surfaces that lineage as `RestartedFrom` on both the snapshot and the view. Restart only ever creates new Jobs; the original's terminal Jobs are left as they are.

## Where to go next

- [Workflows](/docs/core-concepts/workflows): the concept behind this API, and where a Workflow sits over the Dependencies it groups.
- [Dependencies](/docs/core-concepts/dependencies): the edges, release modes, and fan-in countdown that a Workflow composes.
- [Execution Guarantee](/docs/core-concepts/execution-guarantee): the at-least-once contract and cooperative cancellation every member inherits.
- [Job Lifecycle](/docs/core-concepts/job-lifecycle): the terminal states that drive a Workflow's derived status.
- [Build a Workflow](/docs/guides/build-a-workflow): a guided walkthrough from builder to enqueue.
- [Testing Workflows](/docs/testing/workflows): asserting on a Workflow's shape and derived status.
- [Limits and Defaults](/docs/reference/limits-and-defaults): the shared store bounds behind the enqueue rejection reasons.
