Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## Unreleased

- Replace the caller-owned, effectful `Machine.Fire` interface with pure
`Machine.Next`. `Next` runs Guards and selects a destination but cannot run
`Do`; transition effects now execute only through `Instance`, queued Runtime,
or Store-backed state owners. This is a breaking interface change that removes
the lost-transition hazard where an effect ran but its returned state was
discarded.

## 1.3.0

This release is the API. The project is pre-adoption, so it stays in v1
Expand Down
5 changes: 4 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ This repository separates immutable transition definitions from the mutable exec
## Language

**Machine**:
An immutable compiled table defining flat state transitions.
An immutable compiled table defining flat state transitions. Next runs Guards
and reports a destination but never runs Do; effects require a state owner.
_Avoid_: Instance, runtime

**Instance**:
Expand Down Expand Up @@ -79,6 +80,8 @@ _Avoid_: Store, Recorder
## Relationships

- One **Machine** is shared by zero or more **Instances** and **Runtimes**.
- A **Machine** may answer a pure Next query for caller-owned state, but only an
**Instance**, **Runtime**, or **Store**-backed execution runs a transition's Do.
- One **Instance** owns exactly one current state.
- One **Runtime** owns exactly one current state and serializes zero or more **Runs**.
- One **Run** contains one root event and zero or more follow-up events.
Expand Down
51 changes: 28 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,17 @@ import "github.com/open-ships/statemachine"
A finite state machine is a partial function from `(state, event)` to `state`. This package keeps
that function as its core and builds optional state owners around it.

**A `Machine` does not hold the current state.** It is an immutable compiled definition. State can
remain a value you own — a struct field, a database column — and a step is the application of the
definition to that value:
**A `Machine` does not hold the current state.** It is an immutable compiled definition. `Next`
answers a pure question about a value you own and never runs the selected row's effect:

```go
order.State, err = orders.Fire(ctx, order.State, Pay, cmd)
next, err := orders.Next(ctx, order.State, Submit, cmd)
```

Use `Next` for planning or a row with no `Do`. To perform an effectful row, use
an `Instance`, queued Runtime, or Store-backed execution; assigning the `Next`
result would intentionally skip its effect.

One definition can serve a million aggregates and any number of goroutines. When the package should
own execution state instead, construct one `Instance` per aggregate. The [`queued`](queued) package
adds serialized run-to-completion execution, [`persist`](persist) runs the flat definition inside an
Expand Down Expand Up @@ -49,7 +52,7 @@ var light = statemachine.MustCompile([]statemachine.Transition[State, Event, str

func main() {
s := Off
s, _ = light.Fire(context.Background(), s, Flip, struct{}{})
s, _ = light.Next(context.Background(), s, Flip, struct{}{})
fmt.Println(s) // on
}
```
Expand Down Expand Up @@ -83,25 +86,25 @@ failure — it drops that row and tries the next one, so a trailing unguarded ro
semantics of a `switch`. Only when no row is left does the reason reach the caller:

```go
_, err := orders.Fire(ctx, Delivered, Refund, cmd)
_, err := orders.Next(ctx, Delivered, Refund, cmd)

errors.Is(err, statemachine.ErrNotPermitted) // true: the machine refused -> 409
errors.Is(err, ErrWindowClosed) // true: and this is why -> 422
```

That is the whole 409-versus-422 story, with no second error type and no `errors.As`.

**Effects fail with `return err`.** `Fire` reports the destination if and only if `Do` returned `nil`,
and the error comes back to you unwrapped, so `errors.Is` against your own sentinels works with no
ceremony:
**Effects run only behind a state owner.** `Machine.Next` never calls `Do`. `Instance.Fire`, a queued
runtime, or Store-backed execution runs the selected effect and publishes the destination only when
`Do` returns `nil`. The error comes back unwrapped:

```go
{From: Pending, Event: Pay, To: Paid, Do: func(ctx context.Context, c *Cmd) error {
return c.gateway.Charge(ctx, c.Order.ID, c.Order.Cents) // fails -> stays Pending
}},
```

**Affordances come from the same rule as firing**, so a rendered button and the handler that receives
**Affordances come from the same selection rule as execution**, so a rendered button and the handler that receives
its click cannot disagree about where an event leads:

```go
Expand All @@ -118,7 +121,7 @@ for event, to := range orders.Permitted(ctx, o.State, cmd) {
| `Machine[S, E comparable, T any]` | a compiled table; immutable, safe for concurrent use |
| `Compile(transitions)` | build a machine, reporting an unreachable row |
| `MustCompile(transitions)` | the same, panicking — for tables that are program text |
| `Machine.Fire(ctx, from, event, data)` | apply an event; report the state to move to |
| `Machine.Next(ctx, from, event, data)` | select and report a destination without running `Do` |
| `Machine.Permitted(ctx, from, data)` | iterate the events accepted now, each with its destination |
| `ErrNotPermitted` | the sentinel every refusal wraps |

Expand All @@ -144,8 +147,9 @@ observation-silent. A shared observer can be called concurrently by different
executions and must synchronize its own census or sink.

`T` is the value handed to every `Guard` and `Do` — your aggregate, plus whatever this command needs.
It is passed to `Fire` rather than stored, so one immutable `Machine` serves every request while
still seeing request-scoped values. A machine with nothing to carry uses `struct{}`.
It is passed to `Next` or the state-owning execution rather than stored, so one immutable `Machine`
serves every request while still seeing request-scoped values. A machine with nothing to carry uses
`struct{}`.

Visualization and reachability checking remain ordinary loops over the flat table. Per-state entry
and exit actions do not belong to a flat `Machine`; use the `statechart` package when those semantics
Expand All @@ -155,7 +159,7 @@ are required. Runnable flat-machine examples are in [`example_test.go`](example_

| Need | Module | State and concurrency semantics |
|---|---|---|
| Database aggregate or explicit assignment | `Machine` | caller-owned |
| Pure planning or explicit assignment with no effects | `Machine.Next` | caller-owned value; never runs `Do` |
| One in-memory aggregate | `Instance` | owned state; overlapping fire fails fast |
| Follow-up events and FIFO serialization | `queued.Runtime` | owned state; each root run drains to completion |
| Database state and outbox work | `persist.Fire` | transactional when the Store supplies a transaction; never auto-retried |
Expand Down Expand Up @@ -190,9 +194,10 @@ relationships.

## Hazards

**Discarding `Machine.Fire`'s returned state is never correct.** The effect has already run, and
neither the compiler nor `go vet` reports the lost transition. A state-owning `Instance.Fire` may
discard its returned state, but its error still must be handled.
**`Machine.Next` never runs `Do`.** Effectful execution is available only through a state owner, so
discarding a pure query cannot leave an external effect behind. A state-owning `Instance.Fire` may
discard its returned state because the Instance has already committed it, but its error still must
be handled.

An `Instance` or queued runtime is the sole owner of its state. Do not also keep an authoritative
copy in the value passed as `T`. Effects can still be partial: the state owner guarantees its state
Expand All @@ -217,16 +222,16 @@ documented in full on
## Performance

Apple M1 Pro, Go 1.26, measured with `testing.B.Loop`. Nothing on the flat `Machine`'s successful
path allocates: `Fire` is a map lookup, a guard call and a return, and `Permitted`'s lazy iterator
path allocates: `Next` is a map lookup, a guard call and a return, and `Permitted`'s lazy iterator
stays on the stack. A refusal allocates only the error. The state-owning modules intentionally add
synchronization and, where required, eager snapshots or queued work.

```
BenchmarkFireAccepted-10 60591013 21.32 ns/op 0 B/op 0 allocs/op
BenchmarkFireDefaultArm-10 45832159 25.39 ns/op 0 B/op 0 allocs/op
BenchmarkFireRefused-10 11011408 113.90 ns/op 80 B/op 2 allocs/op
BenchmarkRefusalErrorsIs-10 117287949 10.30 ns/op 0 B/op 0 allocs/op
BenchmarkPermitted-10 26448175 47.00 ns/op 0 B/op 0 allocs/op
BenchmarkNextAccepted-10 44001490 27.18 ns/op 0 B/op 0 allocs/op
BenchmarkNextDefaultArm-10 61790773 19.42 ns/op 0 B/op 0 allocs/op
BenchmarkNextRefused-10 25778685 46.09 ns/op 80 B/op 2 allocs/op
BenchmarkRefusalErrorsIs-10 132888273 9.03 ns/op 0 B/op 0 allocs/op
BenchmarkPermitted-10 30415488 39.53 ns/op 0 B/op 0 allocs/op
```

Go 1.26 is what makes `Permitted` free: the `iter.Seq2` it returns closes over the machine, the state
Expand Down
6 changes: 6 additions & 0 deletions SAFETY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ This repository is general-purpose software, not a safety-rated controller or a

The flat Machine, Instance, queued Runtime, Store-backed execution, Statechart, and supervised Supervisor may coordinate application logic. Hazardous motion must remain bounded by an independent, hazard-analyzed safety layer responsible for functions such as emergency stop, safe torque off, protective stop, guarding, overspeed, collision protection, and human-presence separation.

`Machine.Next` is selection-only: it runs Guards and reports a destination but
never runs a transition's `Do`. Flat effects are available only through an
Instance, queued Runtime, or Store-backed execution that owns the associated
state commit. This prevents an ignored Machine result from leaving an effect
behind; it does not make arbitrary external I/O atomic or reversible.

The `supervised` module exists for safety-adjacent orchestration where callers need mandatory checks, explicit issue and verification, finite time budgets, first-cause Fault latching, and reconciliation before startup or recovery. It does not preempt arbitrary Go code or stop hardware.

## Logical and physical state
Expand Down
68 changes: 26 additions & 42 deletions doc.go
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
// Package statemachine compiles finite state-machine definitions and runs them
// with caller-owned or package-owned state.
// Package statemachine compiles finite state-machine definitions and executes
// them through explicit state owners.
//
// A [Machine] is the flat transition function: an immutable table of
// [Transition] rows, compiled once and safe for use by any number of
// goroutines. It depends only on the standard library.
//
// A Machine does not hold the current state. State is a value the caller owns —
// a field on a struct, a column in a row — and a step is the application of the
// machine to that value:
// A Machine does not hold the current state. [Machine.Next] is a pure
// transition query for a caller-owned value: it runs Guards and reports the
// selected destination, but never runs the row's Do effect:
//
// var err error
// order.State, err = orders.Fire(ctx, order.State, Pay, cmd)
// next, err := orders.Next(ctx, order.State, Submit, cmd)
//
// This form is useful for database rows and explicit read-modify-write flows:
// restoring an aggregate means passing the loaded state to Fire, and one
// Machine can serve millions of aggregates without creating runtime objects.
// One Machine can serve millions of aggregates and pure planning queries. To
// run effects, choose the module that owns the resulting state.
//
// When the package should own one in-memory aggregate's current state, use an
// [Instance]:
Expand All @@ -29,9 +27,9 @@
// unrestricted state setter.
//
// State ownership does not change definition semantics. This package remains
// a flat machine with row effects. The queued subpackage adds serialized
// run-to-completion cascades, persist places Machine.Fire inside an
// adapter-owned unit of work, and statechart supplies hierarchy, initial
// a flat machine with row effects, but only a state owner may run those effects.
// The queued subpackage adds serialized run-to-completion cascades, persist
// executes through an adapter-owned unit of work, and statechart supplies hierarchy, initial
// substates, lifecycle actions, and explicit transition kinds. The supervised
// subpackage supplies a separate strict definition with mandatory checks,
// explicit issue and verification, finite time limits, startup reconciliation,
Expand Down Expand Up @@ -72,9 +70,9 @@
//
// The third type parameter is the value handed to every Guard and Do of that
// machine — the aggregate being transitioned, plus whatever this particular
// command needs. It is passed to [Machine.Fire] rather than stored, so one
// immutable Machine serves every request while still seeing request-scoped
// values. A machine with nothing to carry uses struct{}.
// command needs. It is passed to [Machine.Next] or a state-owning execution
// rather than stored, so one immutable Machine serves every request while still
// seeing request-scoped values. A machine with nothing to carry uses struct{}.
//
// Two rows may share a From and an Event. The first whose Guard applies wins, so
// a trailing unguarded row is a default arm — the semantics of a switch, of
Expand All @@ -83,26 +81,11 @@
//
// # Observing transitions
//
// A Machine has no observer or per-state hooks. Fire returns the new state and
// the caller already holds the old state and the event, so every transition is
// visible on the line that performs it. Write the wrapper once per machine and
// put the logging, the metrics and the write there:
//
// func (s *Service) fire(ctx context.Context, o *Order, e Event, c *Cmd) error {
// from := o.State
// next, err := orders.Fire(ctx, from, e, c)
// o.State = next
// s.log.InfoContext(ctx, "transition",
// "id", o.ID, "from", from, "event", e, "to", next, "err", err)
// return err
// }
//
// Assigning the returned state is always correct: Fire reports the state it was
// given whenever it reports an error.
//
// State-owning executions are different: reading State before Fire is a second,
// racy operation, and a queued Runtime may commit several follow-up transitions
// before Fire returns. [NewInstanceWithObservers], queued.NewWithObservers and
// A Machine has no observer or per-state hooks because Next selects without
// committing anything. State-owning executions can publish committed changes.
// Reading State before Fire is a second, racy operation, and a queued Runtime
// may commit several follow-up transitions before Fire returns.
// [NewInstanceWithObservers], queued.NewWithObservers and
// statechart.Chart.NewWithObservers attach immutable observers at the ownership
// seam. They emit an [Observation] for every committed node exit and entry. A
// flat self-transition changes no position and emits nothing.
Expand Down Expand Up @@ -132,9 +115,11 @@
//
// # Hazards
//
// - Discarding [Machine.Fire]'s returned state is never correct. The effect
// has already run. [Instance.Fire] owns and publishes its state, so callers
// may discard that state result but must still handle its error.
// - [Machine.Next] never runs Do. Effects are available only through a
// state-owning execution, so discarding a pure query cannot leave an effect
// behind. Do not use Next to commit an effectful row: that would skip Do.
// [Instance.Fire] owns and publishes its state, so callers may discard that
// state result but must still handle its error.
// - An Instance is the sole authority for its state. Do not retain another
// authoritative state field in T; it can diverge on errors and panics.
// - Instance rejects overlap and same-instance recursive firing. Use the
Expand All @@ -150,8 +135,7 @@
// - A Guard must be pure. It is called for rows that lose, and by Permitted.
// - A Guard vetoes only if no other row for that From and Event applies.
// - [ErrNotPermitted] is a sentinel and travels like one. A Guard or Do that
// fires another machine must not return its refusal, wrapped or otherwise.
// runs another execution must not return its refusal, wrapped or otherwise.
// - Guards and effects see data, which the caller also owns. Recursively
// calling Machine.Fire for the same caller-owned state loses the nested
// transition; Instance reports ErrInFlight instead.
// calling [Instance.Fire] on the same execution reports ErrInFlight.
package statemachine
20 changes: 20 additions & 0 deletions docs/adr/0004-keep-machine-selection-pure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Keep Machine selection pure and effects behind state owners

`Machine.Next` runs Guards and reports the first applicable transition's
destination, but never runs `Do`. The prior `Machine.Fire` interface is removed.
It combined an external effect with a destination returned to caller-owned
state, allowing valid Go code to discard the only state commit after the effect
had already occurred.

Flat effects execute only through a module that owns the corresponding state:
Instance for fail-fast in-memory execution, Runtime for queued execution, or a
Store-backed execution for persistence. Runtime and Store-backed execution use
Instance internally so transition selection, `Do`, and in-process publication
remain local to one implementation.

This is intentionally a breaking interface change. A pure caller-owned query
may still be ignored and therefore accomplish nothing, but it cannot leave an
effect behind. State ownership does not make arbitrary I/O atomic: effects can
remain partial on error, panic, cancellation, or process failure, so durable or
physical work still requires the Store and supervised protocols documented by
their modules.
Loading