fate: A Statechart Engine for Go
Harel statecharts in Go, hierarchy, parallel regions, history, guards and delayed transitions. Zero dependencies, deterministic, and snapshots that restore exactly, including inside Temporal workflows. v0.5.0 fixes how parallel regions exit and makes dropped events observable.
fate is an implementation of Harel statecharts in Go: nested states, parallel regions, shallow and deep history. Not a flat finite automaton. The semantics follow SCXML and XState v5, expressed with generics so context and events are strongly typed.
import "github.com/arisros/fate"
The current release is v0.5.1, tagged on 2026-09-17, one day after v0.5.0.
Two rules that shaped everything
First: zero dependencies. The root module’s go.mod requires nothing at all, and CI enforces it: a job named “engine has zero external dependencies” fails the build if go list -m all returns anything besides the module itself. That rule is what keeps go get github.com/arisros/fate light, it’s why the Temporal integration is a separate module (fate/temporal), and it’s why fate-studio lives in a different repository entirely.
It also forced the package split in v0.5.0: the renderers moved to fate/render (render.ASCII, render.Mermaid, render.GraphJSON) and snapshot diffing to fate/diff. It’s an API break, flagged as one in the changelog with a migration table from 0.4.0, but anyone who only needs the engine no longer carries visualisation code with it.
Second: determinism. A Machine is immutable and shareable; the Actor holds the state, and that state serialises to JSON and restores byte for byte:
blob, _ := a.Persist()
b, _ := fate.NewActorFromSnapshot[Ctx, Evt](m, blob)
The engine doesn’t decide what time it is
This is the part that shaped the design most. Delayed (after) transitions don’t call time.After inside the engine. The engine only reports which timers are outstanding, and something outside decides when they fire:
for _, pt := range a.PendingTimers() {
a.FireTimer(pt.ID)
}
That looks roundabout until you remember who the consumer is. Inside a Temporal workflow, reading a real clock is forbidden: a replay has to produce identical results. With timers pulled rather than pushed, the core is blind to the clock and safe in any deterministic environment. The same pattern covers invocations: PendingInvocations, ResolveInvocation and RejectInvocation.
The bug v0.5.0 exists for
The biggest fix in v0.5.0 was in parallel regions, and it was the kind of bug that the state value alone never shows.
computeExitSet walked up from the alphabetically first active leaf instead of from the transition’s domain. So a transition fired inside one region could run another region’s Exit actions and disarm its timers and invocations, while the snapshot still reported that region active. The result was an armed-looking state with a delayed transition that could never fire. The entry side had the mirror problem: entering a parallel node entered the node and none of its regions, so their Entry actions never ran.
The old exit set had been hiding the entry bug, because it never disarmed those effects on the way out either. Fixing one exposed the other. Now the exit set is every active node below the transition’s domain, the entry set enters every region on the path, and no result depends on which region’s name sorts first. Shallow history had the same first-leaf assumption and was fixed with it.
v0.5.1 is the follow-up, and it is only that fix: an internal transition that targets its own state now re-enters its initial states, and deep history into a parallel node restores the saved states instead of the defaults.
The changelog also keeps a “known limitations” section, which I think matters as much as the fixes: a cross-region transition moves only the target’s region, parallel OnDone does not exist, and exit order is by depth rather than reverse document order. Those are narrower than SCXML, and saying so is cheaper than someone finding out.
”The machine ignored that” is now visible
In a statechart, an event that no state handles is not an error, so Send drops it silently. That is correct, and it is also exactly what makes “nothing happened” hard to debug. v0.5.0 adds Can, which evaluates guards without touching the actor:
if !actor.Can(evt) {
// the machine would ignore this event in its current state
}
_ = actor.Send(ctx, evt)
Because guards are pure by contract, the answer is exact, not a guess. In the same spirit, FireTimer, ResolveInvocation and RejectInvocation now return a bool saying whether the engine accepted the call. That breaks method values and interface satisfaction, which is why it is in the changelog’s breaking section.
Metadata for tools, not for the engine
The other half of v0.5.0 is for tooling like fate-studio. A guard is a Go function, so a diagram cannot say what it checks. CondMeta lets you describe it:
fate.TransitionConfig[Ctx, Evt]{
Guard: approved,
CondMeta: fate.Gates(
fate.Field("$.score").Gte(60),
fate.Field("$.status").Eq("approved"),
).Sample(`{"score": 65, "status": "approved"}`),
}
It never affects whether a transition fires; the guard still decides. It only travels in the machine’s descriptor so a viewer can show it. UIStateOf does the same for per-state view models: a function from context to a struct, whose JSON Schema is derived by reflection and published alongside the state. fate.Named and TransitionConfig.GuardName give actions and guards readable names. An earlier attempt inferred guard names from function pointers and mislabelled them, which is why the name is now explicit.
What’s in it
| Statechart concept | In fate |
|---|---|
| Atomic / compound / parallel / final states | NodeAtomic / NodeCompound / NodeParallel / NodeFinal |
| Shallow & deep history | NodeHistory with HistoryShallow / HistoryDeep |
| Guards | TransitionConfig.Guard plus And / Or / Not / StateIn combinators |
| Entry, exit and transition actions | Assign, Raise, Log, EnqueueActions, Named |
| Delayed transitions | StateNodeConfig.After |
| Invoked or spawned child actors | StateNodeConfig.Invoke |
| Persistence | Actor.Persist / NewActorFromSnapshot |
| Tooling metadata | TransitionConfig.CondMeta, StateNodeConfig.UIState |
| Rendering and diffing | fate/render, fate/diff |
The hardest part lives in algorithm.go, which adapts the SCXML transition algorithm: find the LCCA (least common compound ancestor) of source and target, then build the exit set deepest-first and the entry set outermost-first. That ordering isn’t cosmetic: it determines the order exit and entry actions run in, and getting it wrong means actions running against states that are no longer active. The parallel bug above is what getting it wrong looks like.
Around the engine
fate/httphandlerwraps a machine as an HTTP simulator: an SSE stream plus endpoints to send events, fire timers, resolve invocations, undo and export. It speaks the same wire format as fate-studio’s simulator, so the studio can proxy to a machine running inside your own service. It has no authentication and is meant for development only;SECURITY.mdsays so.fate/snapshotwrites a machine’s descriptor to<dir>/<name>.json. Point it at.fate/, the directory fate-studio loads by default, and the studio can draw the machine without running anything.cmd/fateis a small CLI (go install github.com/arisros/fate/cmd/fate@latest) that renders descriptors to ASCII, Mermaid or a JSON graph and diffs two snapshots.
Status
Still v0.x: the API can change between minor releases until v1.0.0, and breaking changes are listed explicitly in CHANGELOG.md.
The release process changed with this version. Releases are now cut by release-please, which keeps a release PR open and, when it is merged, tags the version and lets GoReleaser attach the CLI binaries. Every GitHub Action is pinned to a commit SHA, Dependabot watches the actions and the Temporal module, and the test suite (197 tests across 33 files, run with -race on Go 1.24 and 1.25) has an 85% coverage gate.