State Machine
NO WARRANTY. Trax auth is plumbing, not a security product. You are solely responsible for securing systems that use it. See API Security.
samples/StateMachine in Trax.Samples is a GraphQL host
over two snapshot state machines, authored with the fluent API and driven through the four generic
stateMachine mutations, plus a React app (web/) that drives them from a browser.
| Machine | Shape | What it shows |
|---|---|---|
turnstile | Locked ⇄ Unlocked | Structure only: guards, reducers, per-state invariants. No effect. |
checkout | Cart → Review → Paid, version 2 | A committed state, one irreversible charge run exactly once, a forward migration from v1, and a total the server checks |
What it proves
Each row is proved over GraphQL against the running host by tests/Trax.Samples.StateMachine.E2E (see
Tests).
| Feature | Code | Proved by |
|---|---|---|
One-line discovery: AddStateMachines(assembly) before AddMediator; both machines driven through the four mutations | Api/Program.cs | TurnstileTests, CheckoutTests |
Illegal transitions refused as data (guard-failed, no-transition, invalid-context), the stored draft unchanged | Machines.cs | TurnstileTests, CheckoutTests |
An effect that runs once per intent, however often sendSnapshot is repeated, and only a send reaches Paid | Machines.cs, RunsOnce<ICharge>, Payments.cs | CheckoutTests, PaymentTests |
A v1 draft upgraded to v2 by MigrateFrom(1, ...), on load and on save | Machines.cs | ForwardMigrationTests |
| Server authority over what a client autosaves: a draft whose total disagrees with its items is refused, and the charge takes the stored draft's total | Machines.cs, LoggingCharge | ServerOwnedTotalTests |
The two bindings a host supplies: ISnapshotPrincipal (whose draft) and the effect (ICharge); each caller sees only their own drafts | Api/Program.cs, SnapshotPrincipal.cs | TurnstileTests |
| Anonymous callers refused; the demo keys exist only in Development | Api/Program.cs | AuthenticationTests |
Run
From the Trax.Samples root:
docker compose up -d # Postgres on 5432, database trax_statemachine
dotnet run --project samples/StateMachine/Trax.Samples.StateMachine.ApiThe host listens on http://localhost:5280 in Development (Properties/launchSettings.json), the
only environment that registers the demo keys alice-key-do-not-use-in-production and
bob-key-do-not-use-in-production. The snapshot_draft and effect_claim tables come from the Trax
Postgres provider's own migrations; the sample writes no schema.
For the browser app, start the host, then:
cd samples/StateMachine/web
npm install
npm run dev # http://localhost:5173The page teaches the machine before the example. Each machine has a tab: the turnstile first, as the simplest
machine, then the checkout, a machine with an effect. A state diagram follows every move and marks the moves out of
the current state, and every trigger can be sent from any state, so pressing one with no arrow shows the server's
no-transition. What the server just did lists the checks the server made for the last request, in the order
it makes them, and where it stopped (guard-failed for a penny, invalid-context for a $0.01 total). On the
checkout, Side effect: the charge shows the RunsOnce<ICharge> binding, the whole request the page sends to pay
(only the draft's id), and the payment provider's own list of charges, which grows by one on Pay and not at all on
a second Pay. Show the raw requests opens every GraphQL request the page sends.
Try it
Send X-Api-Key: alice-key-do-not-use-in-production with each request (Nitro at
http://localhost:5280/trax/graphql, or curl).
# What machines are there? (anonymous)
{ discover { stateMachine { listMachines { machines { name hasEffect } } } } }
# Save a checkout draft at Review. The total must be 999 cents per item.
mutation {
dispatch { stateMachine { saveSnapshot(input: {
machine: "checkout",
id: "11111111-1111-1111-1111-111111111111",
snapshot: "{\"machine\":\"checkout\",\"version\":2,\"state\":\"Review\",\"context\":{\"items\":[\"book\"],\"receipt\":null,\"total\":999}}"
}) { output { snapshot problem { code } } } } }
}
# Pay: runs the charge once and moves to Paid
mutation {
dispatch { stateMachine { sendSnapshot(input: {
machine: "checkout", id: "11111111-1111-1111-1111-111111111111", requestId: "pay-1"
}) { output { snapshot problem { code } } } } }
}The sendSnapshot answer is the Paid snapshot with a receipt, and the host logs
Charged 999 cents for checkout Review -> receipt rcpt_.... Send it again and the same snapshot comes
back with no second charge. Save a draft with "items":["book","pen"] and "total":1 under another
id and the answer is problem { code: "invalid-context" }.
The turnstile shows a refused transition. Save it Locked, then try a penny and a quarter:
mutation {
dispatch { stateMachine { saveSnapshot(input: {
machine: "turnstile", id: "33333333-3333-3333-3333-333333333333",
snapshot: "{\"machine\":\"turnstile\",\"version\":1,\"state\":\"Locked\",\"context\":{}}"
}) { output { snapshot problem { code } } } } }
}
mutation {
dispatch { stateMachine { advanceSnapshot(input: {
machine: "turnstile", id: "33333333-3333-3333-3333-333333333333",
trigger: "Coin", input: "{\"coin\":\"penny\"}"
}) { output { snapshot problem { code message } } } } }
}The penny comes back as problem { code: "guard-failed", message: "Only a quarter or a dollar is accepted." }
and the draft stays Locked. The same mutation with "quarter" answers the Unlocked snapshot with
"paidWith":"quarter".
To see the migration, save a version 1 checkout, which has no total:
mutation {
dispatch { stateMachine { saveSnapshot(input: {
machine: "checkout", id: "44444444-4444-4444-4444-444444444444",
snapshot: "{\"machine\":\"checkout\",\"version\":1,\"state\":\"Review\",\"context\":{\"items\":[\"book\",\"pen\"],\"receipt\":null}}"
}) { output { snapshot problem { code } } } } }
}The answer is the draft at version 2 with "total":1998, which is what is stored.
Without the X-Api-Key header, each of these mutations answers HTTP 200 with
errors: [{ message: "Not authorized.", extensions: { code: "TRAX_AUTHORIZATION" } }]; only listMachines is
anonymous.
How it works
Registration
using Trax.Effect.Data.Postgres.Extensions;
using Trax.Effect.Extensions;
using Trax.Effect.Provider.Json.Extensions;
using Trax.Effect.StateMachine.Persistence;
using Trax.Mediator.Extensions;
builder.Services.AddTrax(trax =>
trax.AddEffects(effects => effects.UsePostgres(connectionString).AddJson())
.AddStateMachines(typeof(TurnstileMachine).Assembly) // before AddMediator
.AddMediator(typeof(TurnstileMachine).Assembly)
);
builder.Services.AddScoped<ISnapshotPrincipal, TraxCallerSnapshotPrincipal>();
builder.Services.AddSingleton<SimulatedPaymentProvider>();
builder.Services.AddScoped<ICharge, LoggingCharge>();
builder.Services.AddTraxGraphQL(graphql => graphql);AddStateMachines discovers every Machine<TState, TTrigger> in the assembly and contributes the four
mutations to the mediator scan, so it must come before AddMediator. TraxCallerSnapshotPrincipal
maps the authenticated Trax caller to the draft's owner, so Alice and Bob each see their own draft
for the same id. See AddStateMachines.
LoggingCharge takes the payment from SimulatedPaymentProvider, an in-memory stand-in for a payment gateway, for
the draft's user and the stored draft's total. Nothing else calls the provider: the page has no charge endpoint, only
payments.listCharges, a [TraxAuthorize] query that returns the caller's own charges, which is how the page shows
the effect ran once.
The server owns the total
saveSnapshot is the soft path: the client writes the whole snapshot, context included, and the
server validates and stores it. Anything an effect will act on must therefore be checked by the
state's Holds, or the client decides it. The checkout's total is the amount a real ICharge
would take, so every state holds it to the item price:
private static bool TotalMatchesItems(JsonObject ctx) =>
ctx["total"] is JsonValue total
&& total.GetValueKind() == JsonValueKind.Number
&& total.ToJsonString()
== ((long)ItemsCount(ctx) * UnitPriceCents).ToString(CultureInfo.InvariantCulture);
m.In(CheckoutState.Review)
.Holds(ctx =>
ItemsCount(ctx) > 0 && ReceiptEmpty(ctx) && TotalMatchesItems(ctx)
? null
: "Review: non-empty items, no receipt, total = 999 cents per item."
)
.On(CheckoutTrigger.Pay)
.When((ctx, input) => ItemsCount(ctx) > 0 && Receipt(input) is not null)
.RunsOnce<ICharge>("checkout:charge")
.To(CheckoutState.Paid);The charge then reads the amount from the snapshot it is handed, which is the server's stored copy, already validated. See State Machines: Persistence.
The forward migration
Version 2 added total. A draft an older host stored at version 1 has none, so it would fail the v2
invariants; MigrateFrom(1, ...) backfills it from the item count whenever the server reads a version 1
snapshot: a stored draft on load, advance or send, and a snapshot an older client sends to saveSnapshot:
m.Id("checkout").Version(2).StartsAt(CheckoutState.Cart, Fresh)
.MigrateFrom(1, (state, ctx) =>
{
var next = (JsonObject)ctx.DeepClone();
next["total"] = ItemsCount(ctx) * UnitPriceCents;
return new MigrationResult(state, next);
});A snapshot newer than the server's version is refused as version-mismatch. See
Migrations.
Tests
dotnet test tests/Trax.Samples.StateMachine.Tests # the machines over an in-memory store, no database
dotnet test tests/Trax.Samples.StateMachine.E2E # the real host over GraphQL, needs PostgresTrax.Samples.StateMachine.Tests drives both machines through the draft service over an in-memory store: the
v1 to v2 migration, autosave then advance, the canonical wire, and the total check.
Trax.Samples.StateMachine.E2E starts the real host with WebApplicationFactory<Program> against a Postgres
database named statemachine_e2e_tests (Host=localhost;Port=5432;Username=trax;Password=trax123; set
TRAX_TEST_PG_PORT when your Postgres listens elsewhere) and sends every request to /trax/graphql, as the web
client does. Without the database the suite fails; it never skips. Three techniques carry over to testing any
state machine host:
-
Observe the effect by replacing it. The factory binds a recording
IChargein place of the sample's logging one, which is the binding a real host swaps for a payment gateway anyway. The recording charge reads the amount the same way,CheckoutMachine.AmountCents(snapshot), so a test can count charges and check the amount:using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseSetting("ConnectionStrings:TraxDatabase", connectionString); builder.UseEnvironment("Development"); // the demo keys exist only here builder.ConfigureTestServices(services => services.AddSingleton<ICharge>(charges)); } -
Seed what an older host stored through the store.
ISnapshotStoredoes not validate, so writing a version 1 draft through it reproduces a row the old host left behind, whichloadSnapshotthen upgrades. Drafts are keyed byISnapshotPrincipal.CurrentUserKey, which for the sample'sTraxCallerSnapshotPrincipalis the scheme-qualified principal id,TraxApiKey:alicefor the demo key:using var scope = factory.Services.CreateScope(); var store = scope.ServiceProvider.GetRequiredService<ISnapshotStore>(); await store.Insert("TraxApiKey:alice", id, new Snapshot { Machine = "checkout", Version = 1, State = "Review", Context = new JsonObject { ["items"] = new JsonArray("book"), ["receipt"] = null }, }); -
Isolate tests by id and data, not by database. Every test uses a fresh draft id and puts an item no other test uses in its cart, so one host and one database serve the whole suite and each test sees only its own drafts and charges.
AuthenticationTests also starts the host in Production: no API key scheme is registered there, the demo key is
refused, and registering a do-not-use-in-production key makes the host refuse to start.
SDK Reference
AddStateMachines | Fluent authoring | Effects | Migrations | Persistence ports