fate

package module
v0.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 5, 2026 License: MIT Imports: 11 Imported by: 0

README

fate

A statechart engine for Go.

fate implements Harel statecharts: hierarchical states, parallel regions, and deep/shallow history — not a flat finite automaton. It is inspired by the semantics of SCXML and XState v5, expressed idiomatically in Go with strong typing via generics.

import "github.com/arisros/fate"
  • Zero dependencies. The engine imports only the standard library. The optional Temporal integration lives in a separate module so you never pull in the Temporal SDK unless you ask for it.
  • Deterministic & persistable. A Machine is immutable and shareable; an Actor's state serialises to JSON and restores byte-for-byte. Safe to drive from deterministic environments such as Temporal workflows.
  • Hierarchy, parallelism, history, guards, actions, delayed transitions, and invoked/spawned child actors — the full statechart feature set.

Status: pre-release (v0.x). The API may change between minor versions until v1.0.0. See CHANGELOG.md.

Install

go get github.com/arisros/fate

Temporal integration (optional, separate module):

go get github.com/arisros/fate/temporal

Quickstart

package main

import (
	"context"
	"fmt"

	"github.com/arisros/fate"
)

type Ctx struct{ Count int }

type Evt interface{ isEvt() }
type Inc struct{}
type Reset struct{}

func (Inc) isEvt()   {}
func (Reset) isEvt() {}

func main() {
	m, err := fate.CreateMachine(fate.MachineConfig[Ctx, Evt]{
		ID:      "counter",
		Initial: "active",
		Context: Ctx{},
		States: map[string]fate.StateNodeConfig[Ctx, Evt]{
			"active": {
				On: map[string][]fate.TransitionConfig[Ctx, Evt]{
					"Inc": {{Actions: []fate.Action[Ctx, Evt]{
						fate.Assign(func(c Ctx, _ Evt) Ctx { c.Count++; return c }),
					}}},
					"Reset": {{Target: "active", Actions: []fate.Action[Ctx, Evt]{
						fate.Assign(func(c Ctx, _ Evt) Ctx { c.Count = 0; return c }),
					}}},
				},
			},
		},
	})
	if err != nil {
		panic(err)
	}

	a := fate.NewActor(m)
	_ = a.Start(context.Background())
	_ = a.Send(context.Background(), Inc{})
	_ = a.Send(context.Background(), Inc{})

	fmt.Println(a.Snapshot().Context.Count) // 2

	// Persist and restore — the restored actor is identical.
	blob, _ := a.Persist()
	b, _ := fate.NewActorFromSnapshot[Ctx, Evt](m, blob)
	fmt.Println(b.Snapshot().Context.Count) // 2
}

See examples/ for hierarchical, parallel, history, delayed, and invoked-actor machines, and the package examples on pkg.go.dev.

Concepts

Statechart concept fate
Atomic / compound / parallel / final state NodeAtomic / NodeCompound / NodeParallel / NodeFinal
History (shallow / deep) NodeHistory with HistoryShallow / HistoryDeep
Guarded transition TransitionConfig.Guard (+ And/Or/Not/StateIn combinators)
Entry/exit & transition actions Assign, Raise, Log, EnqueueActions
Delayed (after) transitions StateNodeConfig.After, driven by an adapter via PendingTimers/FireTimer
Invoked work / spawned child machines StateNodeConfig.Invoke, driven via PendingInvocations/ResolveInvocation
Snapshot persistence Actor.Persist / NewActorFromSnapshot
Visualisation RenderASCII, RenderMermaid, RenderGraphJSON

Tooling

  • fate — a small CLI to render (ASCII / Mermaid / graph), inspect, and diff statecharts from JSON descriptors and snapshots. Install with go install github.com/arisros/fate/cmd/fate@latest.
  • fate-studio — a separate project: an embeddable, self-hosted chart viewer and live simulator that renders and drives any fate machine in the browser. It lives in its own repository so the engine stays dependency-free.

Documentation

License

MIT © Aris Kurniawan

Documentation

Overview

Package fate is a statechart engine for Go.

fate implements Harel statecharts: hierarchical (nested) states, parallel regions, and deep/shallow history — not a flat finite automaton. It is inspired by the semantics of SCXML and XState v5, expressed idiomatically in Go with strong typing via generics over a user-defined context (Ctx) and event (Evt) type.

Why "statechart", not "state machine"

A classic finite-state machine has one active state at a time and no nesting. A statechart adds hierarchy (a state can contain sub-states), orthogonality (independent parallel regions that are all active at once), and history (re-entering a compound state can restore the sub-state it was last in). These features collapse the combinatorial state explosion that makes flat machines unmanageable for real workflows. fate is a statechart engine; the name is not an acronym.

Design principles

  • Zero dependencies: the root module imports only the standard library. Anything that needs an external dependency (the Temporal integration) lives in a separate module so adopters opt in explicitly.
  • Determinism: a Machine is immutable once constructed and is safe to share across goroutines. All observable iteration is ordered. Given the same machine and the same event sequence, an Actor produces a byte-identical persisted snapshot. This makes fate safe to drive from deterministic execution environments such as Temporal workflows.
  • Persistence first: actor state serialises to and restores from JSON via Actor.Persist and NewActorFromSnapshot. The snapshot shape is versioned so it can evolve without breaking stored data.

Two ways to define a machine

Define a machine directly with CreateMachine and the declarative MachineConfig / StateNodeConfig / TransitionConfig structs, or use the type-safe Setup builder to register named guards, actions and actors once and reference them by name from the config. Both produce the same immutable Machine.

Driving a machine

Construct an Actor from a Machine, Actor.Start it, and feed it events with Actor.Send. Read the current state with Actor.Snapshot, observe changes with Actor.Subscribe, and persist/restore with Actor.Persist and NewActorFromSnapshot. To drive a machine inside a Temporal workflow, use the WorkflowActor from the github.com/arisros/fate/temporal module instead of a bare Actor.

See the examples directory and the package examples for runnable machines.

Example

Example builds a one-state counter machine, drives it with two events, and reads the accumulated context.

package main

import (
	"context"
	"fmt"

	fate "github.com/arisros/fate"
)

func main() {
	type Ctx struct{ Count int }

	m, err := fate.CreateMachine(fate.MachineConfig[Ctx, string]{
		ID:      "counter",
		Initial: "active",
		States: map[string]fate.StateNodeConfig[Ctx, string]{
			"active": {On: map[string][]fate.TransitionConfig[Ctx, string]{
				"INC": {{Actions: []fate.Action[Ctx, string]{
					fate.Assign(func(c Ctx, _ string) Ctx { c.Count++; return c }),
				}}},
			}},
		},
	})
	if err != nil {
		panic(err)
	}

	a := fate.NewActor(m)
	_ = a.Start(context.Background())
	_ = a.Send(context.Background(), "INC")
	_ = a.Send(context.Background(), "INC")

	fmt.Println(a.Snapshot().Context.Count)
}
Output:
2
Example (DelayedTransition)

Example_delayedTransition shows the clock-agnostic timer model: the engine records a pending "after" timer but never fires it. A driver (here, the test itself; in production the fate/temporal adapter) decides the delay elapsed and calls FireTimer.

package main

import (
	"context"
	"fmt"
	"time"

	fate "github.com/arisros/fate"
)

func main() {
	m, _ := fate.CreateMachine(fate.MachineConfig[struct{}, string]{
		ID:      "blink",
		Initial: "off",
		States: map[string]fate.StateNodeConfig[struct{}, string]{
			"off": {After: map[time.Duration][]fate.TransitionConfig[struct{}, string]{
				time.Hour: {{Target: "on"}},
			}},
			"on": {Type: fate.NodeFinal},
		},
	})

	a := fate.NewActor(m)
	_ = a.Start(context.Background())
	fmt.Println(a.Snapshot().Value.Path())

	// A driver pulls the pending timer and fires it once the delay elapses.
	a.FireTimer(a.PendingTimers()[0].ID)
	fmt.Println(a.Snapshot().Value.Path())
}
Output:
off
on
Example (Persistence)

Example_persistence shows that an actor round-trips through a JSON snapshot: the restored actor continues from exactly where the original left off.

package main

import (
	"context"
	"fmt"

	fate "github.com/arisros/fate"
)

func main() {
	type Ctx struct{ Count int }

	build := func() *fate.Machine[Ctx, string] {
		m, _ := fate.CreateMachine(fate.MachineConfig[Ctx, string]{
			ID:      "counter",
			Initial: "active",
			States: map[string]fate.StateNodeConfig[Ctx, string]{
				"active": {On: map[string][]fate.TransitionConfig[Ctx, string]{
					"INC": {{Actions: []fate.Action[Ctx, string]{
						fate.Assign(func(c Ctx, _ string) Ctx { c.Count++; return c }),
					}}},
				}},
			},
		})
		return m
	}

	a := fate.NewActor(build())
	_ = a.Start(context.Background())
	_ = a.Send(context.Background(), "INC")

	blob, _ := a.Persist()
	restored, _ := fate.NewActorFromSnapshot[Ctx, string](build(), blob)
	_ = restored.Send(context.Background(), "INC")

	fmt.Println(restored.Snapshot().Context.Count)
}
Output:
2

Index

Examples

Constants

View Source
const SnapshotVersion = 1

SnapshotVersion is the on-disk shape version. Incremented for backward-incompatible changes per ADR-003.

View Source
const Version = "0.4.0"

Version is the semantic version of the fate library. It is updated by the release process and surfaced here so programs can report the engine version they were built against.

Variables

View Source
var (
	// ErrInvalidConfig is returned by CreateMachine when the supplied config
	// fails validation. The wrapped error gives the specific reason.
	ErrInvalidConfig = errors.New("statechart: invalid machine config")

	// ErrUnknownTarget is returned when a transition's Target string does not
	// resolve to any sibling, descendant, or ancestor state path.
	ErrUnknownTarget = errors.New("statechart: unknown transition target")

	// ErrNoInitial is returned when a compound state node lacks an Initial
	// field naming one of its children.
	ErrNoInitial = errors.New("statechart: compound state has no initial child")

	// ErrUnknownInitial is returned when an Initial field names a state that
	// is not among the node's children.
	ErrUnknownInitial = errors.New("statechart: initial state not found among children")

	// ErrDuplicateState is returned when two sibling states share a name.
	ErrDuplicateState = errors.New("statechart: duplicate sibling state name")

	// ErrInvalidNodeType is returned when a state node has a Type the current
	// skeleton does not yet support (e.g. NodeParallel, NodeHistory, NodeFinal
	// before P5).
	ErrInvalidNodeType = errors.New("statechart: state node type not supported in this build")

	// ErrActorNotStarted is returned by Send when the actor's Start has not
	// been called yet.
	ErrActorNotStarted = errors.New("statechart: actor not started")

	// ErrActorStopped is returned by Send when the actor has been Stopped.
	ErrActorStopped = errors.New("statechart: actor stopped")
)

Functions

func RenderASCII

func RenderASCII(d MachineDescriptor, opts RenderOptions) string

RenderASCII produces a multi-line ASCII rendering of the descriptor. The result is suitable for printing to a terminal, embedding in a fixed-width log, or feeding to the studio's machine_view buffer.

State child order is alphabetical for determinism (matches the rest of the library; see ADR-002 / ADR-007). Initial states are tagged with a trailing "(initial)" annotation.

func RenderMermaid

func RenderMermaid(d MachineDescriptor, opts MermaidOptions) string

RenderMermaid produces a Mermaid `stateDiagram-v2` document for the machine.

Node IDs are the sanitised dot-path (so e.g. main.done and head_vd.done are distinct IDs `main_done` / `head_vd_done`, avoiding the collisions plain leaf names would cause). The human label keeps the leaf name. Compound and parallel states nest; parallel regions are divided by `--`.

func RenderTransitions

func RenderTransitions(d MachineDescriptor, path string) string

RenderTransitions emits a sidebar block showing every transition out of the state at the given dot-path. Returns an empty string if the path is not found in the descriptor. Format:

<event> [guard:NAME]: → <target> {Internal} [actions: A1, A2]

Multiple alternatives for the same event appear on consecutive lines.

Types

type Action

type Action[Ctx any, Evt any] interface {
	// contains filtered or unexported methods
}

Action is something executed as part of a transition or on state entry / exit. Actions may update the context, raise internal events, or log; I/O is forbidden (see ADR-002). All actions are pure with respect to time and randomness.

Action is an interface because we want polymorphic concrete types (assignAction, raiseAction, etc.) while keeping the API ergonomic.

func Assign

func Assign[Ctx any, Evt any](fn func(ctx Ctx, evt Evt) Ctx) Action[Ctx, Evt]

Assign returns an action that replaces the context with the result of fn. The function must be pure with respect to time, randomness, and I/O.

Note: fn returns a whole new Ctx rather than patching in place. For struct contexts, the idiomatic pattern is `func(c Ctx, _ Evt) Ctx { c.Field = v; return c }` which leverages Go's value semantics.

func EnqueueActions

func EnqueueActions[Ctx any, Evt any](fn func(enq *Enqueuer[Ctx, Evt])) Action[Ctx, Evt]

EnqueueActions returns an action that runs fn against an Enqueuer. All context updates are applied; raises are deferred until the batch completes (then enqueued into the actor's internal queue in order).

func Log

func Log[Ctx any, Evt any](msg string) Action[Ctx, Evt]

Log returns an action that emits a log message. The actor routes log messages to its configured logger (default: discard).

func Raise

func Raise[Ctx any, Evt any](evt Evt) Action[Ctx, Evt]

Raise returns an action that places an event onto the actor's internal queue. The event is processed before Send returns control to the caller. Equivalent to XState's `raise()`.

type Actor

type Actor[Ctx any, Evt any] struct {
	// contains filtered or unexported fields
}

Actor is the runtime instance of a statechart Machine. One Actor is instantiated per workflow execution / unit test. NOT safe for use inside Temporal workflows — use WorkflowActor (P6) for that.

func NewActor

func NewActor[Ctx any, Evt any](m *Machine[Ctx, Evt], opts ...ActorOption) *Actor[Ctx, Evt]

NewActor constructs a fresh Actor in the Stopped status. Call Start to transition it to Running and observe the initial entry actions.

func NewActorFromSnapshot

func NewActorFromSnapshot[Ctx any, Evt any](m *Machine[Ctx, Evt], persisted []byte) (*Actor[Ctx, Evt], error)

NewActorFromSnapshot constructs an actor seeded from a JSON snapshot.

Restoration sequence:

  • Validates the snapshot version is supported.
  • Rebuilds the history memory by resolving stored path strings to stateNode pointers within the supplied machine.
  • Restores any queued internal events.

The restored actor has the same status as when persisted; if it was running, it is running after restoration (no Start needed).

func (*Actor[Ctx, Evt]) FireTimer

func (a *Actor[Ctx, Evt]) FireTimer(id TimerID)

FireTimer fires the armed delayed transition with the given id. It is the write half of the pull-based timer interface (see Actor.PendingTimers) and is how an adapter delivers an elapsed "after" delay back to the machine. Firing an id that is not currently armed, or firing when the owning state is no longer active, is a safe no-op.

func (*Actor[Ctx, Evt]) PendingInvocations

func (a *Actor[Ctx, Evt]) PendingInvocations() []PendingInvocation

PendingInvocations returns the actor's currently-armed invocations, in deterministic order (by ID). It is the read half of the invoke effect: an adapter runs each Src and reports the outcome via ResolveInvocation / RejectInvocation. The core never runs an invocation itself. See ADR-0004.

func (*Actor[Ctx, Evt]) PendingTimers

func (a *Actor[Ctx, Evt]) PendingTimers() []PendingTimer

PendingTimers returns the actor's currently-armed delayed transitions, in deterministic order (by TimerID). It is the read half of the timer interface: an adapter arms its own durable or wall-clock timers from this list and calls Actor.FireTimer when a delay elapses. The core never fires a timer itself, so without an adapter pending timers simply remain armed. See ADR-0003.

func (*Actor[Ctx, Evt]) Persist

func (a *Actor[Ctx, Evt]) Persist() ([]byte, error)

Persist returns a JSON snapshot of the actor's state suitable for storage (e.g. ArangoDB) and later restoration via NewActorFromSnapshot.

Round-trip guarantee: NewActorFromSnapshot(m, actor.Persist()) produces an actor that, given the same future events, yields byte-identical Persist output to the original.

func (*Actor[Ctx, Evt]) PersistDeterministic

func (a *Actor[Ctx, Evt]) PersistDeterministic() ([]byte, error)

PersistDeterministic returns a JSON snapshot with sorted map keys, matching the ADR-007 determinism requirement: identical actor state must produce byte-identical Persist output across runs.

The standard library's json.Marshal already sorts struct fields; map keys in StateValue.MarshalJSON are sorted; the history map iteration uses sorted keys here. So Persist() and PersistDeterministic() currently produce the same bytes — but PersistDeterministic is the explicit guarantee surface that downstream code should call when byte-equality matters.

func (*Actor[Ctx, Evt]) RejectInvocation

func (a *Actor[Ctx, Evt]) RejectInvocation(id InvokeID, err error)

RejectInvocation reports failure of the invocation with the given id. If it is still armed and declares OnError, the mapped event is processed as an internal step. Rejecting an unknown or already-settled id is a safe no-op.

func (*Actor[Ctx, Evt]) ResolveInvocation

func (a *Actor[Ctx, Evt]) ResolveInvocation(id InvokeID, output any)

ResolveInvocation reports successful completion of the invocation with the given id. If it is still armed (its state still active) and declares OnDone, the mapped event is processed as an internal step. Resolving an unknown or already-settled id is a safe no-op.

func (*Actor[Ctx, Evt]) Send

func (a *Actor[Ctx, Evt]) Send(_ context.Context, evt Evt) error

Send dispatches an event to the actor synchronously. Returns after the event (and any events the transition raised internally) have been processed. Events that no transition handles are silently dropped.

If processing the event causes the actor to reach a top-level final state, its status transitions to StatusDone. Subsequent Sends are silently dropped (matching XState v5 semantics).

func (*Actor[Ctx, Evt]) Snapshot

func (a *Actor[Ctx, Evt]) Snapshot() Snapshot[Ctx]

Snapshot returns the actor's current state. Safe to call concurrently.

func (*Actor[Ctx, Evt]) Start

func (a *Actor[Ctx, Evt]) Start(_ context.Context) error

Start moves the actor into Running and executes entry actions for the initial configuration chain (deepest entry's Entry runs last). Idempotent. If the initial configuration already lands in a top-level final state, the actor immediately transitions to StatusDone.

func (*Actor[Ctx, Evt]) Stop

func (a *Actor[Ctx, Evt]) Stop()

Stop terminates the actor and cancels any pending delayed transitions; subsequent Send returns ErrActorStopped.

func (*Actor[Ctx, Evt]) Subscribe

func (a *Actor[Ctx, Evt]) Subscribe(obs func(Snapshot[Ctx])) func()

Subscribe registers an observer that is called with a snapshot after every Send (and once on Start, after entry actions). Returns an unsubscribe func.

type ActorOption

type ActorOption func(*actorOpts)

ActorOption configures a new Actor.

func WithInitialValue

func WithInitialValue[Ctx any, Evt any](v StateValue) ActorOption

WithInitialValue overrides the actor's starting state. Used by NewActorFromSnapshot (P6) and by tests that need to seed mid-flight. The value must be a valid configuration of the machine; this is not re-validated in the skeleton.

func WithLogger

func WithLogger(fn func(string)) ActorOption

WithLogger sets the function called by Log actions and internal warnings. Default: a no-op (logs are discarded).

type ActorStatus

type ActorStatus string

ActorStatus is the lifecycle phase of an Actor.

const (
	StatusRunning ActorStatus = "running"
	StatusStopped ActorStatus = "stopped"
	StatusDone    ActorStatus = "done"
	StatusError   ActorStatus = "error"
)

type Cond

type Cond interface {
	// contains filtered or unexported methods
}

Cond is a structural transition condition evaluated against the actor's active state configuration, independent of context and event data. It is the fate equivalent of XState's stateIn guard.

A Guard sees only (context, event); a Cond sees only which states are currently active. The two are complementary: set both [TransitionConfig.Guard] and [TransitionConfig.Cond] and the transition fires only when both pass.

Build a Cond with StateIn / InState and compose with CondNot, CondAllOf, and CondAnyOf. Conds hold no mutable state and no reference to any actor, so a Cond built once is safe to share across machines and goroutines.

func CondAllOf

func CondAllOf(cs ...Cond) Cond

CondAllOf returns a Cond that holds only when every supplied condition holds. With no arguments it always holds.

func CondAnyOf

func CondAnyOf(cs ...Cond) Cond

CondAnyOf returns a Cond that holds when at least one supplied condition holds. With no arguments it never holds.

func CondNot

func CondNot(c Cond) Cond

CondNot returns a Cond that holds when c does not.

func InState

func InState(path string) Cond

InState returns a Cond that holds when the active configuration includes the given dot-separated state path. Matching uses StateValue.Matches, so a prefix such as "menu.settings" matches any deeper active leaf beneath it.

func StateIn

func StateIn(path string) Cond

StateIn is an alias of InState, named to match XState's stateIn guard for readers familiar with that library.

type DiffEntry

type DiffEntry struct {
	Kind  DiffKind `json:"kind"`
	Field string   `json:"field,omitempty"` // dot-path; empty for top-level kinds
	From  string   `json:"from"`            // left-hand JSON-encoded value
	To    string   `json:"to"`              // right-hand JSON-encoded value
}

DiffEntry is a single line of difference between two snapshots.

func (DiffEntry) String

func (d DiffEntry) String() string

String returns a one-line human-readable form. Useful for log output and for the divergence-log formatter; the studio diff view uses the structured fields directly.

type DiffKind

type DiffKind string

DiffKind enumerates the categories of difference DiffSnapshots may surface. Kept narrow on purpose; richer typing (e.g. "field added with value X") belongs to the renderer.

const (
	// DiffKindStateValue: the active state configuration changed. Examples:
	//   - left "active.main.verif", right "active.main.asset_doc"
	//   - left "running", right "done"
	DiffKindStateValue DiffKind = "state_value"

	// DiffKindContextField: a field in the marshaled context JSON changed.
	// Field is the dot-path of the leaf, From/To are the JSON-encoded
	// values (so the renderer can pretty-print or color them).
	DiffKindContextField DiffKind = "context_field"

	// DiffKindStatus: actor status differs (running / done / stopped).
	DiffKindStatus DiffKind = "status"

	// DiffKindContextShape: the contexts have structurally different
	// JSON shapes (e.g. different field sets at some nesting level).
	// Surfaced when a sub-tree on one side is an object and the other is
	// a primitive or null. Field is the dot-path.
	DiffKindContextShape DiffKind = "context_shape"
)

type Enqueuer

type Enqueuer[Ctx any, Evt any] struct {
	// contains filtered or unexported fields
}

Enqueuer is the surface inside an EnqueueActions block. It batches a series of context updates, raises, and logs into one atomic application — the raised events accumulate but are not processed until the whole batch's context updates are committed.

func (*Enqueuer[Ctx, Evt]) Assign

func (e *Enqueuer[Ctx, Evt]) Assign(fn func(c Ctx, evt Evt) Ctx)

Assign applies fn to the running context. Subsequent calls compose.

func (*Enqueuer[Ctx, Evt]) Context

func (e *Enqueuer[Ctx, Evt]) Context() Ctx

Context returns the in-progress context value. Useful for reading mid-batch.

func (*Enqueuer[Ctx, Evt]) Log

func (e *Enqueuer[Ctx, Evt]) Log(msg string)

Log emits a log message immediately.

func (*Enqueuer[Ctx, Evt]) Raise

func (e *Enqueuer[Ctx, Evt]) Raise(evt Evt)

Raise schedules an event for processing after this batch's assigns commit.

type Graph

type Graph struct {
	ID      string      `json:"id"`
	Initial string      `json:"initial"` // qualified id of the top-level initial
	Nodes   []GraphNode `json:"nodes"`
	Edges   []GraphEdge `json:"edges"`
}

Graph is the full resolved structure for one machine.

func RenderGraphJSON

func RenderGraphJSON(d MachineDescriptor) Graph

RenderGraphJSON converts a MachineDescriptor into a resolved Graph.

type GraphEdge

type GraphEdge struct {
	ID       string   `json:"id"`
	Source   string   `json:"source"`
	Event    string   `json:"event"`
	Target   string   `json:"target"`
	Guard    string   `json:"guard,omitempty"`
	Actions  []string `json:"actions,omitempty"`
	Internal bool     `json:"internal,omitempty"`
}

GraphEdge is one transition. Source/Target are qualified node ids; Event is the triggering event (the studio anchors the edge to the source node's matching event row, Stately-style).

type GraphNode

type GraphNode struct {
	ID      string   `json:"id"`      // qualified node id (nodeID of dot-path)
	Label   string   `json:"label"`   // leaf name (display)
	Path    string   `json:"path"`    // dot-path (for active-state matching)
	Type    string   `json:"type"`    // atomic|compound|parallel|final|history
	Parent  string   `json:"parent"`  // parent qualified id, "" if top level
	Initial bool     `json:"initial"` // is its parent's initial child
	History string   `json:"history,omitempty"`
	Entry   []string `json:"entry,omitempty"`
	Exit    []string `json:"exit,omitempty"`
}

GraphNode is one state in the graph. Hierarchy is expressed via Parent (the qualified id of the enclosing compound/parallel node, "" for top level).

type Guard

type Guard[Ctx any, Evt any] func(ctx Ctx, evt Evt) bool

Guard is a pure predicate over context and event. Returning true selects the transition; returning false skips it. Guards must be pure (no I/O, no time, no randomness) — see ADR-002.

func AlwaysTrue

func AlwaysTrue[Ctx any, Evt any]() Guard[Ctx, Evt]

AlwaysTrue is the implicit guard for transitions that declare no Guard. Exposed for combinator chaining.

func And

func And[Ctx any, Evt any](gs ...Guard[Ctx, Evt]) Guard[Ctx, Evt]

And returns a guard that passes only when every supplied guard passes. Short-circuits on the first false.

func Not

func Not[Ctx any, Evt any](g Guard[Ctx, Evt]) Guard[Ctx, Evt]

Not negates a guard.

func Or

func Or[Ctx any, Evt any](gs ...Guard[Ctx, Evt]) Guard[Ctx, Evt]

Or returns a guard that passes when any supplied guard passes. Short-circuits on the first true.

type History

type History uint8

History selects the depth of memory for a NodeHistory pseudo-state.

  • HistoryShallow remembers only the immediate child of the parent compound. On re-entry, the parent restarts that child via the child's initial chain.
  • HistoryDeep remembers the full descendant configuration. On re-entry, the entire active sub-tree at exit time is restored.
const (
	HistoryShallow History = iota
	HistoryDeep
)

type Invocation

type Invocation[Ctx any, Evt any] struct {
	// ID is unique within its state; combined with the state path it forms the
	// invocation's stable [InvokeID].
	ID string
	// Src is the opaque logical name of the work to run.
	Src string
	// Input, if non-nil, builds the invocation input from the context captured
	// when the state is entered. Exposed to the adapter via PendingInvocation.
	Input func(ctx Ctx) any
	// OnDone, if non-nil, maps a successful result to an event the machine then
	// processes. If nil, a successful resolution is dropped.
	OnDone func(output any) Evt
	// OnError, if non-nil, maps a failure to an event the machine then
	// processes. If nil, a failure is dropped.
	OnError func(err error) Evt
}

Invocation declares external work a state runs while it is active — XState's invoke. The fate core treats Src as an opaque name and never executes it: on entering the state the core records a pending invocation; on exit it disarms it. An adapter discovers pending invocations via Actor.PendingInvocations, runs the work named by Src, and reports the outcome via Actor.ResolveInvocation or Actor.RejectInvocation. The core then maps the outcome to an event (OnDone / OnError) and processes it — but only if the owning state is still active.

Because Src is opaque, the same mechanism expresses both a service/activity call and a spawned child machine: the adapter decides what Src means (a Temporal activity, a child workflow, a nested actor). See ADR-0004.

type InvokeID

type InvokeID string

InvokeID identifies one armed invocation instance for as long as its state is active. It is derived deterministically from the owning state's path and the invocation's local ID, so the same logical invocation has the same ID across runs and across persistence.

type Machine

type Machine[Ctx any, Evt any] struct {
	// contains filtered or unexported fields
}

Machine is an immutable, validated statechart. Safe to share across goroutines and across multiple Actor instances. Construct via CreateMachine; never mutate.

func CreateMachine

func CreateMachine[Ctx any, Evt any](cfg MachineConfig[Ctx, Evt]) (*Machine[Ctx, Evt], error)

CreateMachine validates a MachineConfig and returns an immutable *Machine. Returns ErrInvalidConfig (with a descriptive wrapped error) for malformed configurations.

func (*Machine[Ctx, Evt]) Describe

func (m *Machine[Ctx, Evt]) Describe() MachineDescriptor

Describe returns a MachineDescriptor for the machine. The context is JSON-marshaled if possible; on marshal failure (e.g. a Ctx containing a channel) the Context field is left nil and the rest of the descriptor still renders correctly.

Action and Guard names come from each value's ImplName() method when implemented, falling back to "" otherwise. Anonymous closures therefore show as empty strings — callers that care should name their actions (see actions.go for helpers like Named, Assign).

func (*Machine[Ctx, Evt]) ID

func (m *Machine[Ctx, Evt]) ID() string

ID returns the machine's configured identifier.

func (*Machine[Ctx, Evt]) IsKnownState

func (m *Machine[Ctx, Evt]) IsKnownState(name string) bool

IsKnownState reports whether `name` is a valid state name anywhere in the machine. The check is recursive — it matches both top-level states and nested children. This mirrors the legacy fp.StateMachine.AsStateValidator behavior used by LPW.

func (*Machine[Ctx, Evt]) IsLegalTransition

func (m *Machine[Ctx, Evt]) IsLegalTransition(from string, eventName string) bool

IsLegalTransition reports whether `eventName` declared on state `from` (or any of its ancestors, mirroring transition bubbling at runtime) has at least one candidate transition. It does NOT evaluate guards — guards require an event payload and context, neither of which are available here.

Use this when you want stricter-than-set-membership validation. The LPW port keeps the legacy set-membership default (via IsKnownState) for backward compat per migration-playbook P12 decision; opt into IsLegalTransition where stricter checks are wanted.

func (*Machine[Ctx, Evt]) IsTerminal

func (m *Machine[Ctx, Evt]) IsTerminal(name string) bool

IsTerminal reports whether `name` is a state with Type == NodeFinal. Replaces the legacy IsTerminalStatus consumer in termination.go.

func (*Machine[Ctx, Evt]) States

func (m *Machine[Ctx, Evt]) States() []string

States returns the names of every state in the machine (top-level + nested) in deterministic order: top-down, alphabetical within siblings. Used by schema-vs-FSM enum sync checks (LPW expects status enum to match machine states exactly).

type MachineConfig

type MachineConfig[Ctx any, Evt any] struct {
	// ID is a human-readable identifier used in inspection output and as the
	// stable prefix for spawn IDs (per ADR-002).
	ID string

	// Initial is the starting child state name. Required.
	Initial string

	// Context is the seed value for the actor's running context.
	Context Ctx

	// States is the map of immediate child state nodes. Keys are local state
	// names (e.g. "idle"); values describe each node.
	States map[string]StateNodeConfig[Ctx, Evt]
}

MachineConfig declares an immutable statechart. Pass to CreateMachine to validate and obtain a *Machine.

Generics: Ctx is the user's context (data accumulated as the machine runs); Evt is the user's event type (typically a sealed interface).

type MachineDescriptor

type MachineDescriptor struct {
	ID      string                         `json:"id"`
	Initial string                         `json:"initial"`
	Context json.RawMessage                `json:"context,omitempty"`
	States  map[string]StateNodeDescriptor `json:"states"`
}

MachineDescriptor is the root of the descriptor tree.

func LoadDescriptor

func LoadDescriptor(data []byte) (MachineDescriptor, error)

LoadDescriptor unmarshals a MachineDescriptor from JSON. Used by the TUI studio's static-view mode (P7) to load a machine without compiling Go code: the workflow team can `go run ./cmd/dump-descriptors` against a service, save the JSON output, and inspect it elsewhere.

Validation is shape-only — non-empty ID, at least one state, every state's type is a recognized string. Round-trip with Describe() is the authoritative contract; the function is intentionally permissive about extra fields (forward compatibility for future descriptor versions).

type MermaidOptions

type MermaidOptions struct {
	// Highlight maps active dot-paths to a marker (only the keys are used).
	// Each active leaf and its ancestor composites get the `active` class.
	Highlight map[string]rune

	// Direction is the Mermaid layout direction: "TB" (default), "LR", etc.
	Direction string
}

MermaidOptions controls the emitted diagram. Zero value renders top-to-bottom with no highlight.

type NodeType

type NodeType uint8

NodeType discriminates state node kinds. As of P5, Atomic, Compound, Final, and History are supported; Parallel is the remaining P5 piece.

const (
	NodeAtomic NodeType = iota
	NodeCompound
	NodeParallel // P5 follow-up
	NodeFinal
	NodeHistory
)

func (NodeType) String

func (t NodeType) String() string

String returns the textual name of the node type. Used in error messages and snapshot debugging output.

type PendingInvocation

type PendingInvocation struct {
	// ID is the invocation's stable identifier.
	ID InvokeID
	// Src is the opaque work name declared on the Invocation.
	Src string
	// Input is the payload built from context at arm time (nil if no Input fn).
	Input any
}

PendingInvocation is what an adapter reads from Actor.PendingInvocations to learn which work to run. ID is passed back to ResolveInvocation / RejectInvocation when the work settles.

type PendingTimer

type PendingTimer struct {
	// ID is the timer's stable identifier, passed back to [Actor.FireTimer].
	ID TimerID
	// Delay is the configured delay of the underlying after-transition. An
	// adapter that resumes a persisted actor is responsible for tracking how
	// much of the delay has already elapsed.
	Delay time.Duration
}

PendingTimer describes one delayed ("after") transition the actor currently has armed. It is what an adapter reads from Actor.PendingTimers to learn which timers to drive; when the adapter decides a delay has elapsed it calls Actor.FireTimer with the ID.

The fate core is clock-agnostic: it never sleeps, reads the wall clock, or starts a goroutine for a timer. It only records that a state wants to fire "after Delay" and exposes that intent. How and when the timer actually fires is entirely the adapter's responsibility (a Temporal adapter maps it to workflow.NewTimer; an in-memory adapter maps it to the OS clock; a test drives it by hand).

type RenderOptions

type RenderOptions struct {
	// Highlight maps dot-paths to a marker rune. The first match (longest
	// path) on each line determines the marker shown.
	Highlight map[string]rune

	// IndentStep is the per-level indent. Defaults to 2 spaces.
	IndentStep int

	// CompoundOpen / CompoundClose bracket compound state blocks. Defaults
	// to "┌─" / "└─" (Unicode box drawing).
	CompoundOpen  string
	CompoundClose string
}

RenderOptions controls cosmetic aspects of ASCII rendering. Zero value renders deterministically with no highlight.

type SelectedTransition

type SelectedTransition[Ctx any, Evt any] struct {
	Source *stateNode[Ctx, Evt]
	Config TransitionConfig[Ctx, Evt]
}

SelectedTransition records the outcome of selectTransitions per active leaf: the resolved source node and the matching transition config.

type Setup

type Setup[Ctx any, Evt any] struct {
	// contains filtered or unexported fields
}

Setup is a type-safe registry of named guards and actions, mirroring XState v5's setup({ guards, actions }) ergonomic. Register implementations once, then reference them by name while declaring a MachineConfig via the Setup.Guard and Setup.Action accessors. This keeps large machine configs readable and lets several transitions share one implementation.

Setup is sugar over CreateMachine; it adds no semantics the declarative config cannot express. A typical use:

s := fate.NewSetup[Ctx, Evt]().
	WithGuard("isHighRisk", func(c Ctx, _ Evt) bool { return c.Risk == "HIGH" }).
	WithAction("clearForm", fate.Assign(func(c Ctx, _ Evt) Ctx { c.Form = nil; return c }))

m, err := s.CreateMachine(fate.MachineConfig[Ctx, Evt]{
	ID: "review", Initial: "open",
	States: map[string]fate.StateNodeConfig[Ctx, Evt]{
		"open": {On: map[string][]fate.TransitionConfig[Ctx, Evt]{
			"NEXT": {{Target: "closed", Guard: s.Guard("isHighRisk"),
				Actions: []fate.Action[Ctx, Evt]{s.Action("clearForm")}}},
		}},
		"closed": {Type: fate.NodeFinal},
	},
})

Referencing a name that was never registered is reported as an error from Setup.CreateMachine, so typos surface at construction time rather than silently doing nothing.

func NewSetup

func NewSetup[Ctx any, Evt any]() *Setup[Ctx, Evt]

NewSetup returns an empty registry. Register entries with Setup.WithGuard and Setup.WithAction (both chainable).

func (*Setup[Ctx, Evt]) Action

func (s *Setup[Ctx, Evt]) Action(name string) Action[Ctx, Evt]

Action returns the action registered under name for use in a TransitionConfig or a state's Entry/Exit. If no action is registered under name, Action records the missing reference (so Setup.CreateMachine returns an error) and returns a no-op action.

func (*Setup[Ctx, Evt]) CreateMachine

func (s *Setup[Ctx, Evt]) CreateMachine(cfg MachineConfig[Ctx, Evt]) (*Machine[Ctx, Evt], error)

CreateMachine validates and builds the machine, first reporting any guard or action names referenced via Setup.Guard / Setup.Action that were never registered. On success it is identical to calling CreateMachine directly.

func (*Setup[Ctx, Evt]) Guard

func (s *Setup[Ctx, Evt]) Guard(name string) Guard[Ctx, Evt]

Guard returns the guard registered under name for use in a TransitionConfig. If no guard is registered under name, Guard records the missing reference (so Setup.CreateMachine returns an error) and returns a guard that never passes, keeping config construction safe to continue.

func (*Setup[Ctx, Evt]) WithAction

func (s *Setup[Ctx, Evt]) WithAction(name string, a Action[Ctx, Evt]) *Setup[Ctx, Evt]

WithAction registers an action under name and returns the Setup for chaining. Registering the same name twice replaces the earlier action.

func (*Setup[Ctx, Evt]) WithGuard

func (s *Setup[Ctx, Evt]) WithGuard(name string, g Guard[Ctx, Evt]) *Setup[Ctx, Evt]

WithGuard registers a guard under name and returns the Setup for chaining. Registering the same name twice replaces the earlier guard.

type Snapshot

type Snapshot[Ctx any] struct {
	Version int             `json:"version"`
	Value   StateValue      `json:"value"`
	Context Ctx             `json:"context"`
	Status  ActorStatus     `json:"status"`
	Output  json.RawMessage `json:"output,omitempty"`
	Error   string          `json:"error,omitempty"`
}

Snapshot is an immutable view of an actor's state at one instant. Safe to marshal to JSON and persist (see ADR-003).

The P3 skeleton only populates Version, Value, Context, and Status. Output, Error, Children, Queue, History, and Timers land in later phases.

func (Snapshot[Ctx]) Matches

func (s Snapshot[Ctx]) Matches(target string) bool

Matches is a convenience wrapper around Value.Matches.

type SnapshotDiff

type SnapshotDiff struct {
	Entries []DiffEntry `json:"entries"`
}

SnapshotDiff is the result of comparing two snapshots.

func DiffSnapshots

func DiffSnapshots[Ctx any](left, right Snapshot[Ctx]) SnapshotDiff

DiffSnapshots computes the structural diff between two Snapshots of the same context type. Order of arguments is left → right; produced entries are listed in a deterministic order suitable for golden-file testing.

Context comparison serializes both sides to JSON and walks the resulting trees. Non-marshalable contexts surface a single DiffKindContextShape entry naming the cause.

func (SnapshotDiff) Empty

func (d SnapshotDiff) Empty() bool

Empty reports whether the snapshots are equivalent — no entries means the renderer should show "no differences" rather than an empty list.

func (SnapshotDiff) Strings

func (d SnapshotDiff) Strings() []string

Strings returns each entry's String form, sorted for stable output. The studio's golden-file tests rely on this ordering.

type StateNodeConfig

type StateNodeConfig[Ctx any, Evt any] struct {
	// Type is the node kind. If zero, it is inferred: NodeAtomic when States
	// is empty; NodeCompound otherwise.
	Type NodeType

	// Initial is the starting child state name. Required when Type is
	// NodeCompound and States is non-empty.
	Initial string

	// States declares immediate child state nodes (compound nesting).
	States map[string]StateNodeConfig[Ctx, Evt]

	// On maps event names to ordered transition candidates. The first
	// candidate whose guard passes (or has no guard) is selected.
	On map[string][]TransitionConfig[Ctx, Evt]

	// After declares delayed transitions, keyed by delay. When this state is
	// entered the actor records one pending timer per delay; exiting the state
	// disarms them. The core never fires a timer itself (it is clock-agnostic):
	// an adapter discovers armed timers via Actor.PendingTimers and delivers an
	// elapsed delay via Actor.FireTimer, at which point the first transition in
	// that delay's slice whose Guard and Cond pass fires (as an internal step
	// with the zero Evt). Mirrors XState's `after`. See ADR-0003.
	After map[time.Duration][]TransitionConfig[Ctx, Evt]

	// Invoke declares external work run while this state is active (XState's
	// invoke). On entry each invocation is recorded as pending; on exit it is
	// disarmed. The core never executes an invocation — an adapter pulls them
	// via Actor.PendingInvocations and reports outcomes via
	// Actor.ResolveInvocation / Actor.RejectInvocation. See ADR-0004.
	Invoke []Invocation[Ctx, Evt]

	// Entry actions run, in declaration order, when this state is entered.
	// For a compound node, Entry runs before the child's Entry.
	Entry []Action[Ctx, Evt]

	// Exit actions run, in declaration order, when this state is exited.
	// For a compound node, Exit runs after the child's Exit (deepest first).
	Exit []Action[Ctx, Evt]

	// OnDone declares transitions to fire when this compound node's active
	// child reaches a final state. Only meaningful for Type=NodeCompound
	// (or NodeParallel in P5 follow-up). Empty for atomic / final nodes.
	OnDone []TransitionConfig[Ctx, Evt]

	// History selects HistoryShallow or HistoryDeep when Type is NodeHistory.
	// Ignored for other node types.
	History History

	// Default is the fallback target for a NodeHistory pseudo-state when
	// no prior memory exists. Optional; if empty, the parent compound's
	// initial child is used.
	Default string

	// Output, set only on a NodeFinal state, builds the machine's output value
	// from the final context when a top-level final state is reached. The
	// result is JSON-marshaled into the snapshot's Output field. Mirrors
	// XState's final-state output.
	Output func(ctx Ctx) any
}

StateNodeConfig declares one state node within a machine. State nodes nest via the States field to form compound hierarchies.

type StateNodeDescriptor

type StateNodeDescriptor struct {
	Type    string                            `json:"type"` // "atomic" | "compound" | "parallel" | "final" | "history"
	Initial string                            `json:"initial,omitempty"`
	Default string                            `json:"default,omitempty"` // history default target
	History string                            `json:"history,omitempty"` // "shallow" | "deep" (only for history nodes)
	Entry   []string                          `json:"entry,omitempty"`   // action names
	Exit    []string                          `json:"exit,omitempty"`    // action names
	On      map[string][]TransitionDescriptor `json:"on,omitempty"`
	OnDone  []TransitionDescriptor            `json:"on_done,omitempty"`
	States  map[string]StateNodeDescriptor    `json:"states,omitempty"`
}

StateNodeDescriptor is the descriptor for a single state node. Mirrors StateNodeConfig but with strings where the original held function values or generic actions.

type StateValue

type StateValue struct {
	Leaf     string
	Children map[string]StateValue
}

StateValue represents the current configuration of a running statechart.

For an atomic or final state, Leaf is the state's local name and Children is nil. For a compound state, Leaf is the empty string and Children has exactly one entry: the active child's name → its own StateValue. For a parallel state, Children may have multiple entries — one per region.

JSON marshaling collapses atomic states to a bare string and compound / parallel states to a {"name": child} object, matching the XState v5 snapshot shape (see ADR-003).

func AtomicValue

func AtomicValue(name string) StateValue

AtomicValue constructs a StateValue for a leaf node.

func CompoundValue

func CompoundValue(children map[string]StateValue) StateValue

CompoundValue constructs a StateValue for a compound or parallel node. The children map keys are immediate child state names; values are their (possibly nested) StateValues.

func (StateValue) IsAtomic

func (v StateValue) IsAtomic() bool

IsAtomic reports whether the value represents a leaf state.

func (StateValue) MarshalJSON

func (v StateValue) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler.

  • Atomic state → "name"
  • Compound/parallel → {"name": <child JSON>, ...}

func (StateValue) Matches

func (v StateValue) Matches(target string) bool

Matches reports whether the state value matches the given dot-separated target path. A target "a.b" matches a value that is in state "a" with active descendant "b". A target "a" matches any value where region "a" is active (used for parallel-region queries).

func (StateValue) Path

func (v StateValue) Path() string

Path returns the state value flattened to a dot-separated path. For a compound state {"a": {"b": "c"}}, it returns "a.b.c". For a parallel state with multiple regions, the regions are joined alphabetically with " | ": {"a": "x", "b": "y"} → "a.x | b.y". Used for human-friendly logging and inspection.

func (*StateValue) UnmarshalJSON

func (v *StateValue) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler. Accepts both the atomic form (a JSON string) and the compound/parallel form (a JSON object).

type TimerID

type TimerID string

TimerID uniquely identifies one armed delayed ("after") transition for as long as it is pending. It is derived deterministically from the owning state's path, the delay, and the delay's index within that state, so the same logical timer keeps the same ID across runs and across persistence — a prerequisite for replay-safe driving by an adapter.

type TransitionConfig

type TransitionConfig[Ctx any, Evt any] struct {
	// Target is the destination state, named by its local name (sibling)
	// or by a dot-separated descendant path (e.g. "parent.child").
	// An empty Target means the transition is internal (no state change).
	Target string

	// Internal, when true, suppresses exit/re-entry of the source state for
	// targets that are descendants of the source (matches XState's
	// `internal: true`). Default false (external transition).
	Internal bool

	// Guard, if non-nil, must return true for the transition to be selected.
	// Otherwise the next candidate in the slice is tried, then ancestors are
	// consulted. A Guard is a pure predicate over context and event.
	Guard Guard[Ctx, Evt]

	// Cond, if non-nil, is a structural condition over the active state
	// configuration (see Cond / StateIn / InState). When both Guard and Cond
	// are set, the transition is selected only if both pass. Use Cond for
	// "in state X" checks that a context/event Guard cannot express.
	Cond Cond

	// Actions run after exit actions and before entry actions when the
	// transition fires. Order: declaration order.
	Actions []Action[Ctx, Evt]
}

TransitionConfig declares one possible transition for an event.

type TransitionDescriptor

type TransitionDescriptor struct {
	Target   string   `json:"target,omitempty"`
	Internal bool     `json:"internal,omitempty"`
	Guard    string   `json:"guard,omitempty"`
	Actions  []string `json:"actions,omitempty"`
}

TransitionDescriptor is the descriptor for a single transition entry. Guards / actions appear as names only.

Directories

Path Synopsis
cmd
fate command
Command fate inspects statecharts from the command line.
Command fate inspects statecharts from the command line.
examples
quickstart command
Command quickstart is the README example: a counter machine driven by events, then persisted and restored.
Command quickstart is the README example: a counter machine driven by events, then persisted and restored.
realtime-timer command
Command realtime-timer shows how to drive a machine's delayed ("after") transitions with the OS clock in a standalone program.
Command realtime-timer shows how to drive a machine's delayed ("after") transitions with the OS clock in a standalone program.
trafficlight command
Command trafficlight is the canonical compound-state example: red → green → yellow → red, with a pedestrian "walk" sub-state inside red.
Command trafficlight is the canonical compound-state example: red → green → yellow → red, with a pedestrian "walk" sub-state inside red.
Package internal provides building blocks not exported from the public statechart API.
Package internal provides building blocks not exported from the public statechart API.
temporal module
Package testing provides helpers for asserting on Actor behavior in unit tests.
Package testing provides helpers for asserting on Actor behavior in unit tests.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL