Verified state machines for humans and AI agents.
A family of Rust crates for state machines you can trust: declare your states and transitions once, enforce them at runtime, and verify them in your tests — then build deterministic, replayable aggregates and an event journal on top.
A state machine is just the set of states something can be in plus the legal
moves between them. An order goes placed → paid → shipped, and you must not
ship before payment. Most code writes this as scattered ifs and matches, and
that's where the bugs hide: an illegal transition slips through, a case goes
unhandled, the "this can't happen" happens.
ironstate lets you declare the states and the rules once, in plain Rust, and then does the part that usually gets skipped:
- It enforces the rules at runtime. An illegal move comes back as a clear, typed error instead of quietly going through. That's a guarantee, not a convention you hope everyone remembers.
- It tests the rules for you. From the same definition it generates thousands of scenarios to check your rules hold, and walks the state graph looking for dead ends and unreachable states. The definition is the test, so there's no separate model to keep in sync.
If your system changes through a stream of events (a bank ledger, a multiplayer match, an approval workflow), the aggregate and journal crates add three things those systems usually build by hand and get subtly wrong:
- Deterministic replay. The same events always rebuild the exact same state, down to the byte, so history is auditable and bugs are reproducible.
- Per-viewer secrecy (redaction). Show each player only what they're allowed to see — their own hand, not their opponent's — enforced by the type system.
- An append-only event log you can replay, fork, and resume: the durable record of what actually happened.
You write ordinary enums and a match; ironstate handles the enforcement, the
testing, and the bookkeeping. The family splits along one line: lifecycle
machines may react to transitions, while aggregates only record events. That
keeps the two from quietly contaminating each other.
| Crate | What it is |
|---|---|
ironstate |
Core lifecycle machines: enum states, one event → one hop, structural enforcement, listeners, a verification ladder |
ironstate-derive |
The StateMachine / Event derive macros |
ironstate-aggregate |
Deterministic aggregates: struct state, command → events via decide/evolve, journaled entropy, redaction, StableHash |
ironstate-journal |
Event journal: append/snapshot/replay/fork, entropy positions, versioned upcasting, subscriptions, seeded simulation |
New here? The guide is a step-by-step walkthrough from a
first machine to a deterministic, event-sourced aggregate; docs/
indexes the rest — design, testing, decisions — with a reading order. API
reference is on docs.rs (one page per crate); build it locally with make doc.
Three pieces: a state enum (#[derive(StateMachine)]), an event enum
(#[derive(Event)]), and one pure transition function. The runtime enforces the
declared structure — terminal states reject everything, restricted states reject
events of the wrong kind — before your transition function runs.
use ironstate::prelude::*;
#[derive(StateMachine, Clone, Debug, PartialEq)]
#[state_machine(initial = Draft, terminal = [Archived])]
enum Article { Draft, Review, Published, Archived }
#[derive(Event, Clone, Debug, PartialEq)]
enum Edit { Submit, Approve, Reject, Archive }
impl TransitionRules for Article {
type Event = Edit;
fn transition(&self, event: &Edit) -> Option<Article> {
use Article::*;
use Edit::*;
match (self, event) {
(Draft, Submit) => Some(Review),
(Review, Approve) => Some(Published),
(Review, Reject) => Some(Draft),
(Published, Archive) => Some(Archived),
_ => None,
}
}
}
let mut m = Machine::<Article>::new(); // starts in Draft
assert_eq!(m.apply(Edit::Submit).unwrap(), Article::Review);
// Three ways to look before you leap, cheapest first:
assert!(m.could_apply(&Edit::Approve)); // bool
assert!(m.why_not(&Edit::Submit).is_some()); // the exact typed rejection, or None
assert_eq!(m.peek_transition(&Edit::Approve), Some(Article::Published)); // the target
// On rejection the event moves into the error, so you get it back without a clone:
let err = Machine::<Article>::restore(Article::Archived).apply(Edit::Submit).unwrap_err();
assert!(matches!(err, TransitionError::TerminalState { .. }));
assert_eq!(err.into_event(), Edit::Submit);The definition is the test. Drop these in a #[cfg(test)] module; each
expands to a #[test].
analyze! walks the variant-level state graph and proves structural
facts — failing the build on design errors and reporting the rest. It needs no
runtime; it is pure graph analysis. Every line it prints is labeled [proven]
(holds by construction) or [sampled] (depends on the data a variant carries,
which test! exercises instead) — there are no unlabeled claims.
ironstate::analyze!(Article);→(Fe) ironstate analysis of `Article`
· all 4 variants are reachable from Draft [proven]
· no dead transitions [proven]
· coverage: 4 of 16 (state, event) pairs produce transitions — variant-level; … [sampled]
It fails the test on: an unreachable state, a deadlock (a non-terminal state with no way out), an inescapable cycle (a state that can never reach a terminal), or a dead transition (one the transition function defines but structural enforcement could never let fire). It caught a real bug while this repo was being built — a state declared terminal that still had an outgoing transition.
test! generates random event sequences and, after every step, checks that
declared invariants hold and nothing panicked. On a violation, proptest shrinks
to the minimal failing sequence. Runs are reproducible with a seed.
ironstate::test!(Article); // defaults: 500 cases
ironstate::test!(Article, cases = 1000, max_steps = 50);
ironstate::test!(Article, seed = 0xDEC0DE); // reproducibleInvariants are optional and declared via the Invariants trait; test! runs
either way (it always checks structural enforcement and absence of panics). They
earn their keep on a condition the structure can't state on its own — a bound on
the data a state carries:
// A cart whose `Shopping` state carries its running item count.
impl Invariants for Cart {
fn invariants() -> Vec<Invariant<Self, Self::Event>> {
vec![Invariant::custom("a cart never holds more than 50 items")
.assert(|_before, _event, after| match after {
Some(Cart::Shopping { items }) => *items <= 50,
_ => true,
})]
}
}States can restrict which kinds of event they accept — for states driven by an external system or gated behind an operator — and persisted machines can migrate forward through a chain of versions on load.
use ironstate::prelude::*;
use serde::{Serialize, Deserialize};
#[derive(StateMachine, Serialize, Deserialize, Clone, Debug, PartialEq)]
#[state_machine(initial = Draft, terminal = [Retired], version = 3, history = [DocV1, DocV2])]
enum Doc { Draft, Live, Retired }
# #[derive(Event, Clone, Debug, PartialEq)] enum DocEvent { Publish, Retire }
# impl TransitionRules for Doc {
# type Event = DocEvent;
# fn transition(&self, e: &DocEvent) -> Option<Doc> {
# use Doc::*; use DocEvent::*;
# match (self, e) { (Draft, Publish) => Some(Live), (Live, Retire) => Some(Retired), _ => None }
# }
# }
# #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] enum DocV1 { Draft, Live }
# #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] enum DocV2 { Draft, Live, Retired }
// `history` lists the retired types oldest-first; the derive requires a
// contiguous `MigrateFrom` chain at compile time.
impl MigrateFrom<DocV1> for DocV2 { fn migrate(o: DocV1) -> DocV2 { match o { DocV1::Draft => DocV2::Draft, DocV1::Live => DocV2::Live } } }
impl MigrateFrom<DocV2> for Doc { fn migrate(o: DocV2) -> Doc { match o { DocV2::Draft => Doc::Draft, DocV2::Live => Doc::Live, DocV2::Retired => Doc::Retired } } }
// A `{version, payload}` envelope written by any past version decodes and
// migrates to the current schema; a too-new version is a typed RestoreError.
let bytes = serde_json::to_vec(&serde_json::json!({ "version": 1, "payload": "Live" })).unwrap();
let m = Machine::<Doc>::restore_versioned(&bytes).unwrap();
assert_eq!(m.state(), &Doc::Live);Restricting events by kind (#[only_accepts(kind = "external")] on a state,
#[event_kind = "external"] on an event) makes the runtime reject mismatched
events with a typed EventKindRejected before the transition function runs.
A struct changed by a decide/evolve pair: decide validates intent, draws
any entropy, and emits events; evolve applies one event, total and infallible.
Identical (initial state, events) replays bit-for-bit. Adds redaction
(#[hidden] fields with per-viewer views), a frozen canonical StableHash
digest, and the test!/determinism_test!/leak_test! macros.
See the crate README for a runnable
decide/evolve example and the hidden-information walkthrough.
ironstate_aggregate::determinism_test!(MatchState); // two seeded runs must agree
ironstate_aggregate::leak_test!(MatchState, excluding = [PlayCard]); // no covert hidden → view flowPersists events with the entropy position they consumed, so an aggregate can be
replayed, forked, and resumed bit-identically. A reference in-memory journal
passes a seven-property conformance suite (journal_contract_test!) every
storage adapter is judged against, and a seeded fault-schedule simulation
(scenario_test!) checks faults are invisible to outcomes. See the
crate README.
Each example under app/crates/examples/ is a runnable
demo whose test module is an end-to-end test of a realistic use case; the
examples index has a table to help you pick by use case.
hidden-info— a hidden-information card match: redaction, journaled entropy, a system timeout, and a subscription. The redaction integration template.release-pipeline— a CI/CD release as a core lifecycle machine: operator- and external-gated states, declared invariants,analyze!/test!.ledger— an account as an aggregate over a journal: deposits/withdrawals, a non-negative-balance invariant,execute/resume, anddeterminism_test!.catalog-ctx— adopting ironstate's ownedCtxin an engine that threaded a borrowing context: read-only catalog byArc, live entropy byBox, withexecuteowning the rewind.async-store— an async, authoritative store (think tokio-postgres) made durable without implementing the synchronousJournal: the append-before-ack loop viaprepare/commit/abortaround an awaited append, with a sync twin held tojournal_contract_test!.
The determinism contract is enforced by executable conditions, not prose: the
StableHash derive rejects floats, hash maps, and wall clocks in state at
compile time; the EntropySource API has no float or clock method; and
determinism_test! fails if two identically-seeded runs ever diverge.
The Cargo workspace lives under app/; the root Makefile drives cargo
there.
Prerequisites
rustup. The workspace tracks the latest stable toolchain viaapp/rust-toolchain.toml;rustupinstalls it on first build.rustup target add wasm32-unknown-unknownformake wasm, which builds the determinism-sensitive crates for wasm32 to prove they have no host coupling.- Optional:
cargo install cargo-deny(the supply-chain gate).
Common tasks (make help lists them all)
make build # build the workspace
make test # cargo test --workspace --all-features
make check # the done-gate: fmt-check + clippy (-D warnings) + test
make doc # build the rustdoc with warnings denied
make wasm # cross-target build for wasm32
make deny # licenses / advisories / duplicate majors
make msrv # build on the minimum supported Rust (1.96)
make fuzz # fuzz the restore-decode path (needs nightly + cargo-fuzz)
make mutants # mutation-test the code (cargo-mutants)make check is the single done-gate — the same for a human at a keyboard and an
agent in a loop.
Workflow. Documents are law: design intent is written down
(docs/design.md), code implements it, tests cite it. The
order is doc change → code → tests → gates. See AGENTS.md,
docs/testing.md, and docs/decisions/.
Publishing is deliberate but CI-driven by release-plz,
split across two workflows. A maintainer dispatches
release-pr.yml from the Actions tab to open
(or refresh) a release PR that bumps the versions of the crates that changed — and
their dependents, since release-plz cascades a bump up the family's dependency
graph — and updates each bumped crate's changelog. Merging that PR is a push to
main, which fires release.yml: it publishes
only the bumped crates, in dependency order, and tags each. That publish step
is idempotent — it ships only crates whose version is ahead of crates.io, so
ordinary merges with no bump are a no-op and nothing is published until a release
PR lands. The
examples/* crates are publish = false, and release-plz maintains a
CHANGELOG.md per crate (each crate's 0.1.0 entry seeds it); the root
CHANGELOG.md is a family index linking to them.
Security practices across the workflows:
- Trusted Publishing (OIDC) — crates.io issues a short-lived token at
runtime, so there is no long-lived
CARGO_REGISTRY_TOKENsecret. (Each crate was bootstrapped with one manual publish, then this repo + workflow registered as its trusted publisher — a one-time setup, now complete.) - Build-provenance attestations — published artifacts get SLSA provenance signed via Sigstore and logged in the public Rekor transparency log.
- Signed release tags and SHA-pinned actions, with Dependabot bumping those pinned actions and the Cargo dependencies weekly — so pinning tightly doesn't mean going stale.
cargo deny(licenses, advisories, yanked, duplicate majors) gates every PR, andCargo.lockis committed.
crates.io does not verify per-crate signatures today, so the transparency story is Trusted Publishing + provenance + signed tags rather than a crate signature.
Apache-2.0.