Machine authoring

A machine is a subclass of Machine<TState, TTrigger> that declares itself in Configure. TState and TTrigger are your own enums. The fluent builder compiles to the same total engine the conformance fixtures drive, so a fluently-authored machine behaves identically to a hand-written definition.

public abstract class Machine<TState, TTrigger>
    where TState : struct, Enum
    where TTrigger : struct, Enum
{
    protected abstract void Configure(IMachineBuilder<TState, TTrigger> machine);
}

IMachineBuilder

MethodDescription
Id(string id)The machine's stable name (what the machine mutation field selects). It must be kebab-case, matching ^[a-z][a-z0-9]*(-[a-z0-9]+)*\z (checkout, turnstile-two; a trailing newline is refused too), because it becomes a file name, a module name and a key segment in everything generated from the machine. Anything else throws ArgumentException.
Version(int version)The definition version stamped onto every snapshot.
StartsAt(TState state, Func<JsonObject> context)The initial state and a factory for its context. A factory, not a value, so instances never alias.
MigrateFrom(int fromVersion, Func<string, JsonObject, MigrationResult> migrate)Forward-migrate a stored snapshot from fromVersion to this definition's version. The migrator gets the stored state name and context and returns a MigrationResult.
Differential(Action<IDifferentialBuilder> configure)Authors the cross-language differential fuzzing inputs (test-only): per-trigger input samples, per-state seed contexts, and dense probe contexts. Exported into the IR's differential block so the differential harness enumerates off the one C# source, with no hand-written machine.json. Only valid on a declaratively-authored machine. See IDifferentialBuilder.
In(TState state)Opens a state to declare its context rule and outgoing transitions. Returns an IStateBuilder.
CustomGuard(string name, Func<JsonObject, JsonNode?, bool> guard)Binds the C# handler for Rule.Custom(name), wherever the machine uses it (a When or a Requires, nested or not). The TypeScript twin's customGuards is the other half.
CustomReducer(string name, Func<JsonObject, JsonNode?, JsonObject> reducer)Binds the C# handler for Reduction.Custom(name): it gets the context and the trigger input and returns the destination context. The twin's customReducers is the other half.

Build() throws InvalidOperationException for a custom rule or reduction whose name has no handler bound, so an unbound name fails when the machine is built rather than refusing its edge forever. It also throws for a machine that binds more than one effect with RunsOnce: a machine runs exactly one irreversible effect, and a second binding would otherwise be declared and never run. And it throws for a machine that enters its effect's target state by any transition other than the effect's own: that state means the effect ran, and a plain advance of the other edge would put a draft there without it. A self-loop on the target does not enter it and is allowed. Handlers may be bound before or after the rules that name them. CustomGuard and CustomReducer are default interface methods that throw NotSupportedException on a custom IMachineBuilder implementation that does not override them.

IStateBuilder

MethodDescription
Holds(Func<JsonObject, string?> validator)The state's context rule: return null when valid, or a reason string. Enforced on rehydrate and on advance.
Context<TContext>()The declarative, string-free replacement for Holds: the state's context shape comes from a record. Field names, JSON types, nullability, and attribute constraints ([MinLength(1)]) become both the validator and the exportable schema.
Context()Declares that the state carries no context (an empty schema).
Requires(Rule constraint)A per-state policy layered on the schema (composed, ANDed, chainable): what the state demands beyond its shape, e.g. a complete draft or an absent receipt. Exports as the state's invariants entry. Build the rule with the Rules vocabulary.
Committed()Marks the state as completed. A soft autosave can neither put a draft into it nor move a draft out of it (except a reset to the initial state); only the effect runner puts a draft there. See what each path may write.
On(TTrigger trigger)Starts a transition out of this state. Returns an ITransitionBuilder.

ITransitionBuilder

MethodDescription
When(Func<JsonObject, JsonNode?, bool> guard)Admits the transition only when the guard passes. The first matching edge wins.
When(Rule guard)The declarative, exportable guard: admits the edge only when the Rule holds. Build rules with the Rules vocabulary.
WithInput<TInput>()Declares this trigger's input shape from a record, so the IR carries a typed input schema for it.
Because(string message)The detail surfaced when the guard rejects the trigger.
Reduce(Func<JsonObject, JsonNode?, JsonObject> reducer)Computes the next context. Return a fresh JSON object; never mutate the input.
Reduce(Reduction reduce)The declarative, exportable reducer: Set(...).FromInput(...), Clear(), Reset(), Keep(). See the Rules vocabulary.
RunsOnce<TEffect>(string? keyPrefix = null)Binds an ISnapshotEffect that runs exactly once when this transition is sent, keyed on {keyPrefix}:{user}:{id}. Omit keyPrefix and it defaults to {machineId}:{trigger}. Only the send fires this transition: an advance of its trigger is refused as effect-bound. A machine binds at most one.
To(TState state)The destination state. Also closes the transition, so you can chain another On(...).

IDifferentialBuilder

Authors a machine's differential fuzzing inputs, consumed only by the cross-language differential test: the harness enumerates the reachable space plus these inputs, records each outcome into a committed corpus, and every runtime replays it. Declared in C# so the machine is the single source; the IR carries them and the generated runtime machine strips them. The harness always fires a no-input case per trigger, so an explicit empty sample is a distinct case. Typed overloads serialize the record with camelCase names (nulls kept); raw JsonObject overloads give exact control.

MethodDescription
Sample<TInput>(TTrigger trigger, TInput input)A representative input for a trigger, from a typed record.
Sample(TTrigger trigger, JsonObject input)The same, as a raw JSON object.
EmptySample(TTrigger trigger)An empty ({}) input for a trigger, distinct from the always-added no-input case.
Seed<TContext>(TState state, TContext context) / Seed(TState state, JsonObject context)A seed context used as a BFS start point, reaching states the initial snapshot can't.
Probe<TContext>(TContext context) / Probe(JsonObject context)A dense probe context crossed with every state, exercising guards and validators on unreachable-but-sendable snapshots.

Delegate vs declarative

Holds, When(Func...), and Reduce(Func...) take C# delegates. They run, but they are opaque: a machine authored with them cannot be exported to the IR that drives cross-language codegen. The declarative overloads (Context<T>, When(Rule), Reduce(Reduction), WithInput<T>) express the same validators, guards, and reducers as data. They compile to the identical engine delegates, so behaviour is unchanged, and they also record the DeclarativeModel that IrExporter turns into the machine's .ir.json.

The two styles coexist on one builder, so you can migrate a machine edge by edge, but the export cannot see a delegate. Once a machine makes any declarative call, the export includes every edge, and an edge whose guard or reducer is a delegate is exported with no guard or no reduce: an unconditional edge that keeps the context. Nothing refuses or warns. A generated twin then accepts that trigger for any input and leaves the context as it was, while the server runs the delegate, so the client predicts transitions the server refuses and contexts the server does not produce. The C# replay of the differential corpus notices only if a sample or probe happens to exercise the delegate. A Holds validator is not exported either.

On a machine with a generated twin, keep every guard and reducer declarative. For logic the vocabulary cannot express, use Rule.Custom(name) or Reduction.Custom(name) with CustomGuard / CustomReducer: the IR then names the rule, and each runtime binds its own handler, rather than the edge silently losing it.

Author the data form with the Rules vocabulary, and see Declarative authoring for a machine built end to end.

Exporting the IR

Machine<TState, TTrigger> (and the IMachine a host discovers) exposes ExportIr(), the in-process entry point that turns a built machine into its canonical IR:

public sealed class CheckoutMachine : Machine<CheckoutState, CheckoutTrigger> { /* ... */ }
 
string ir = new CheckoutMachine().ExportIr();   // canonical single-line JSON

The result is the same canonical JSON the trax machine CLI writes to <machine>.ir.json; the CLI calls ExportIr() directly. It requires a declaratively-authored machine (Context/When(Rule)/Reduce(Reduction)): it throws InvalidOperationException only for a machine that made no declarative call at all. A machine that mixes the styles exports without complaint, with each delegate edge exported as unconditional (see Delegate vs declarative). In practice you rarely call ExportIr() by hand: trax machine generate exports the IR and regenerates every downstream artifact in one command.

Result codes

Every advance and rehydrate returns a typed result, never an exception. See Result codes for the full set and when each is returned.