This site is driven by two keys, like the machine it came from. Reading: j and k scroll, d and u move half a page, gg goes to the top, G to the bottom, H and L switch windows, ? opens the help. Space is the Neovim leader and handles content: Space then h home, r research, p projects, g gear, a about, / tags, or Space then a digit to jump to that window. Home is the tmux prefix and handles windows: Home then c opens a terminal, Home then & closes a window, Home then space goes to the next one. While focus is on the keyboard, h j k l move between keys and Enter opens one.

ResearchR AboutA Gear: G for gear, keyboard, terminal, editor, homelab.G
    ProjectsP Tags: / as in vim: search./
    ×
    Menu

    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 conceptIn fate
    Atomic / compound / parallel / final statesNodeAtomic / NodeCompound / NodeParallel / NodeFinal
    Shallow & deep historyNodeHistory with HistoryShallow / HistoryDeep
    GuardsTransitionConfig.Guard plus And / Or / Not / StateIn combinators
    Entry, exit and transition actionsAssign, Raise, Log, EnqueueActions, Named
    Delayed transitionsStateNodeConfig.After
    Invoked or spawned child actorsStateNodeConfig.Invoke
    PersistenceActor.Persist / NewActorFromSnapshot
    Tooling metadataTransitionConfig.CondMeta, StateNodeConfig.UIState
    Rendering and diffingfate/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/httphandler wraps 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.md says so.
    • fate/snapshot writes 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/fate is 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.

    id en
    rss gh in