|
1 | 1 | # crucible/state |
2 | 2 |
|
3 | | -The pure, abstract state machine kernel of the [Crucible](../README.md) suite. |
| 3 | +A pure, embeddable **statechart engine** for Go — the kernel of the |
| 4 | +[Crucible](../README.md) suite. |
4 | 5 |
|
5 | | -> **Status:** experimental, released as `state/v0.1.0`. The kernel is complete |
6 | | -> and tested; the API may still change before v1. |
| 6 | +> **Status:** experimental, pre-1.0. The engine is feature-complete and |
| 7 | +> extensively tested; the API may still change before v1. |
7 | 8 |
|
8 | 9 | Import path: `github.com/stablekernel/crucible/state` |
9 | 10 |
|
10 | 11 | ## What it is |
11 | 12 |
|
12 | | -`state` is a **portable, domain-agnostic state machine kernel**, generic over |
13 | | -state, event, and context types. It knows nothing about any particular |
14 | | -application domain, so the same machine definition runs unchanged from a unit |
15 | | -test, a synchronous request handler, and an asynchronous event consumer. |
16 | | - |
17 | | -It is the extreme end of the suite's thin-seams philosophy: **stdlib-only**, no |
18 | | -injected IO, no forced dependencies. A tiny dependency graph is a tiny attack |
19 | | -surface. |
20 | | - |
21 | | -### Highlights |
22 | | - |
23 | | -- **Pure-function step semantics** — firing an event returns |
24 | | - `(newState, effects, trace)` with no IO; the caller dispatches the effects. |
25 | | -- **Serializable definition IR** — the canonical machine is pure data, lossless |
26 | | - to and from JSON. Guards, actions, and effects are named references with |
27 | | - serializable params, bound to a host-provided registry. |
28 | | -- **Two front-ends, one IR** — a Go DSL and a future visual editor emit the same |
29 | | - IR; a machine authored in code and one loaded from JSON are the same machine. |
30 | | -- **Foundry vocabulary** — `Forge`, `Temper`, `Quench`, `Cast`, `Fire`, `Assay`. |
31 | | -- **Functional options everywhere**, Trace-first observability, injected |
32 | | - clock/ID seams for determinism. |
| 13 | +`state` is a **portable, domain-agnostic statechart engine**, generic over |
| 14 | +state, event, and context types (`Machine[S, E, C]`). It knows nothing about any |
| 15 | +particular application domain, so the same machine definition runs unchanged |
| 16 | +from a unit test, a synchronous request handler, and an asynchronous event |
| 17 | +consumer. |
| 18 | + |
| 19 | +It is the extreme end of the suite's thin-seams philosophy: the engine is |
| 20 | +**stdlib-only** — it imports only the Go standard library and performs no |
| 21 | +injected IO. A tiny dependency graph is a tiny attack surface, and the boundary |
| 22 | +is enforced mechanically by an import-graph test. |
| 23 | + |
| 24 | +### The pure kernel, host-driven drivers |
| 25 | + |
| 26 | +The heart of the engine is a pure function. Firing an event returns |
| 27 | +`(newState, effects, trace)` and performs no IO of its own: |
| 28 | + |
| 29 | +```go |
| 30 | +res := inst.Fire(ctx, Submit) |
| 31 | +// res.NewState — the resulting configuration |
| 32 | +// res.Effects — abstract data the host dispatches |
| 33 | +// res.Trace — a structured record of what happened |
| 34 | +``` |
| 35 | + |
| 36 | +Everything stateful — timers, invoked services, actors, mailboxes — lives in |
| 37 | +**host-driven drivers** that the engine feeds with effect *data*. Entering a |
| 38 | +state that arms a timer emits a `ScheduleAfter` effect; a host `Scheduler` owns |
| 39 | +the real clock and re-fires the delayed event back through `Fire`. The same |
| 40 | +pattern carries invoked services (`ServiceRunner`) and actors (`ActorSystem`). |
| 41 | +Because the kernel only ever emits data and never starts a goroutine, reads a |
| 42 | +clock, or touches the network, it stays portable and statically analyzable, and |
| 43 | +every driver is deterministically testable with a `FakeClock`. |
| 44 | + |
| 45 | +## Features |
| 46 | + |
| 47 | +A complete statechart feature surface: |
| 48 | + |
| 49 | +- **State kinds** — atomic, compound (hierarchical), **parallel** (orthogonal |
| 50 | + regions), and final states, nesting to arbitrary depth. |
| 51 | +- **History** — shallow and deep history pseudo-states that re-enter a compound |
| 52 | + state's last active configuration rather than its initial child. |
| 53 | +- **Guards** — named guard refs plus the **`And` / `Or` / `Not`** combinators and |
| 54 | + the config-aware **`stateIn`** built-in, composable into a serializable boolean |
| 55 | + expression tree. |
| 56 | +- **Actions & effects** — named action refs with serializable params; actions |
| 57 | + return abstract effects the host dispatches. |
| 58 | +- **Run-to-completion** — **eventless (`Always`) transitions**, **`Raise`** for |
| 59 | + internal events, and a macrostep loop that drains both to a stable |
| 60 | + configuration, with overflow protection. |
| 61 | +- **Transition forms** — **wildcard** catch-alls (`OnAny`), **forbidden** |
| 62 | + transitions (`Forbid`), and **`Reenter`** to force the external (exit/entry) |
| 63 | + form of an otherwise-internal self-transition. |
| 64 | +- **Delayed transitions** — `Transition(from).After(delay).On(event).GoTo(...)`, |
| 65 | + scheduled and auto-cancelled on exit by a host `Scheduler`. |
| 66 | +- **Invoked services** — state-scoped `Invoke(src, onDone, onError)` with |
| 67 | + result/error routing, auto-stopped on exit, driven by a host `ServiceRunner`. |
| 68 | +- **Actor model** — child-machine actors, a host `ActorSystem`, mailboxes, and |
| 69 | + dynamic `Spawn`, with **message passing** — `SendTo`, `SendParent`, `Respond`, |
| 70 | + `ForwardTo`, and `StopChild` — and sender-tracked routing. |
| 71 | +- **Snapshots** — `Instance.Snapshot()` captures the full runtime state |
| 72 | + (configuration, history, context, traces, pending timers/services/actors); |
| 73 | + `Machine.Restore` resumes from it without re-running entry actions, and |
| 74 | + `ResumeEffects` re-arms pending children. Actor trees persist recursively. |
| 75 | +- **Inspection & waiting** — an `Inspector` observer sink for the live |
| 76 | + event/transition/snapshot/actor stream, and `WaitFor(ctx, inst, predicate)` |
| 77 | + (plus `WaitInState` / `WaitDone`) that drives an instance until a predicate |
| 78 | + over its snapshot holds. |
| 79 | +- **Path enumeration** — `PlanPath` finds the shortest sequence to a target; |
| 80 | + `state/analysis` adds `ShortestPaths` and `SimplePaths` over the whole graph. |
| 81 | + |
| 82 | +## What sets it apart |
| 83 | + |
| 84 | +These are Crucible's own strengths, stated plainly: |
| 85 | + |
| 86 | +- **Static analysis / model-checking** — `state/analysis` runs over a machine's |
| 87 | + IR to report reachability (unreachable/dead states), dead transitions, |
| 88 | + guardless nondeterminism, non-final dead ends, and liveness. Checks are exact |
| 89 | + where the IR proves them and heuristic where opaque guards limit certainty. |
| 90 | +- **Serializable IR** — the canonical machine is pure data, lossless to and from |
| 91 | + JSON. Behavior is not embedded as closures; guards, actions, effects, and |
| 92 | + services are named references with serializable params, bound to a host |
| 93 | + registry at freeze time. A machine authored in Go and one loaded from JSON are |
| 94 | + the same machine. |
| 95 | +- **Conformance harness** — a reusable harness drives golden scenarios against |
| 96 | + any machine, so a definition can be held to a fixed behavioral contract. |
| 97 | +- **Machine-evolution diffing** — `state/evolution` classifies the difference |
| 98 | + between two definitions as additive or breaking and maps the result onto a |
| 99 | + SemVer bump, treating a machine definition as a schema. |
| 100 | +- **Visualization** — Mermaid and DOT export, with delayed edges annotated by |
| 101 | + their delay. |
| 102 | +- **Compile-time type safety** — the engine is generic over `S`, `E`, and `C`; |
| 103 | + states, events, and context are checked by the Go type system, not stringly |
| 104 | + typed. |
| 105 | +- **Pluggable telemetry** — a `WithLogger(*slog.Logger)` seam (no-op by default) |
| 106 | + is the only logging hook; the engine never logs unless asked and never imports |
| 107 | + a third-party logger. Determinism is preserved by injecting time and ID seams. |
| 108 | + |
| 109 | +## Foundry vocabulary |
| 110 | + |
| 111 | +The lifecycle API uses a small "foundry" verb vocabulary. The noun stays plain — |
| 112 | +the type is a `Machine` — only the verbs are themed: |
| 113 | + |
| 114 | +| Verb | Role | |
| 115 | +| -------- | ---------------------------------------------------------------------- | |
| 116 | +| `Forge` | Open the builder DSL. | |
| 117 | +| `Temper` | Optional, non-failing dev-time diagnostics pass (lint / analysis). | |
| 118 | +| `Quench` | Freeze the definition into an immutable `Machine`; binds refs. | |
| 119 | +| `Cast` | Pour a running instance from the machine. | |
| 120 | +| `Fire` | Send an event to an instance and advance it. | |
| 121 | +| `Assay` | Check that an externally-built entity is legally in a given state. | |
| 122 | + |
| 123 | +Operations that favor discoverability over metaphor stay plain: `PlanPath`, |
| 124 | +`Requirements`, `Trace`, and the `To*` / `LoadFromJSON` serializers. |
| 125 | + |
| 126 | +The public API follows the suite's functional-options convention: required |
| 127 | +inputs stay positional; everything optional is a variadic option, so a |
| 128 | +zero-option call reads clean and new capability arrives additively. |
| 129 | + |
| 130 | +## Usage |
| 131 | + |
| 132 | +A small document-approval machine, forged, frozen, and fired: |
| 133 | + |
| 134 | +```go |
| 135 | +package main |
| 136 | + |
| 137 | +import ( |
| 138 | + "context" |
| 139 | + "fmt" |
| 140 | + |
| 141 | + "github.com/stablekernel/crucible/state" |
| 142 | +) |
| 143 | + |
| 144 | +func main() { |
| 145 | + m := state.Forge[DocState, DocEvent, *Document]("document"). |
| 146 | + Guard("hasReviewer", func(ctx state.GuardCtx[*Document]) bool { |
| 147 | + return ctx.Entity.ReviewerID != nil |
| 148 | + }). |
| 149 | + Action("emit", emit). |
| 150 | + State(Draft). |
| 151 | + State(Submitted). |
| 152 | + State(Approved). |
| 153 | + Initial(Draft). |
| 154 | + CurrentStateFn(func(d *Document) DocState { return d.Status }). |
| 155 | + Transition(Draft).On(Submit).GoTo(Submitted). |
| 156 | + Do("emit", state.P{"event": "submitted"}). |
| 157 | + Transition(Submitted).On(Approve).GoTo(Approved). |
| 158 | + When("hasReviewer"). |
| 159 | + Quench(state.Strict()) |
| 160 | + |
| 161 | + doc := &Document{Status: Draft} |
| 162 | + res := m.Cast(doc).Fire(context.Background(), Submit) |
| 163 | + |
| 164 | + fmt.Println("state:", res.NewState) // Submitted |
| 165 | + fmt.Println("effects:", res.Effects) // [{submitted}] |
| 166 | +} |
| 167 | +``` |
| 168 | + |
| 169 | +`Cast` returns a running `Instance`; `Fire` advances it and returns the new |
| 170 | +state, the emitted effects, and the trace. The same machine can be serialized |
| 171 | +with `m.ToJSON()`, reloaded with `state.LoadFromJSON`, analyzed with |
| 172 | +`analysis.Analyze`, or rendered to Mermaid/DOT — all from the one definition. |
| 173 | + |
| 174 | +## Subpackages |
| 175 | + |
| 176 | +| Package | What it is | |
| 177 | +| ------------------- | ------------------------------------------------------------------ | |
| 178 | +| `state/analysis` | Static model-checking and path enumeration over a machine's IR. | |
| 179 | +| `state/evolution` | Diffs two machine definitions and classifies the SemVer bump. | |
| 180 | +| `state/conformance` | Reusable harness for driving golden scenarios against a machine. | |
33 | 181 |
|
34 | 182 | ## Stability |
35 | 183 |
|
36 | | -Stability label: **experimental** (pre-v1; the API may change). |
37 | | - |
38 | | -## What's implemented |
39 | | - |
40 | | -The builder and transition engine; guards, actions, and effects; typed errors |
41 | | -and always-recorded traces; hierarchical and orthogonal states; entity |
42 | | -validation; path planning; batch firing; the serializable IR and host registry; |
43 | | -a reusable conformance harness; and Mermaid/DOT export. The kernel depends only |
44 | | -on the standard library. |
| 184 | +Stability label: **experimental** (pre-1.0; the API may change). Each module is |
| 185 | +independently versioned per-module SemVer. |
45 | 186 |
|
46 | | -Reserved for later releases: history states, invoked services, the actor model, |
47 | | -and timed transitions. |
| 187 | +## Design & discussions |
48 | 188 |
|
49 | | -Design rationale lives on the GitHub Discussions board under the **State |
50 | | -Machine** category — see the |
| 189 | +Design rationale lives on the GitHub |
| 190 | +[Discussions board](https://github.com/stablekernel/crucible/discussions) under |
| 191 | +the **State Machine** category — see the |
51 | 192 | [Overview](https://github.com/stablekernel/crucible/discussions/1) and |
52 | 193 | [Kernel Core](https://github.com/stablekernel/crucible/discussions/2) |
53 | 194 | discussions. |
|
0 commit comments