Skip to content

kassian-dev/ironstate

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

20 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ironstate

Verified state machines for humans and AI agents.

crates.io docs.rs CI license

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.

Why ironstate?

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.


ironstate — core lifecycle machines

Define a machine

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);

Verification: analyze! and test!

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);           // reproducible

Invariants 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,
            })]
    }
}

Event kinds and versioned restore

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.

ironstate-aggregate — deterministic aggregates

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 flow

ironstate-journal — the event journal

Persists 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.

Examples (end-to-end tests)

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, and determinism_test!.
  • catalog-ctx — adopting ironstate's owned Ctx in an engine that threaded a borrowing context: read-only catalog by Arc, live entropy by Box, with execute owning the rewind.
  • async-store — an async, authoritative store (think tokio-postgres) made durable without implementing the synchronous Journal: the append-before-ack loop via prepare/commit/abort around an awaited append, with a sync twin held to journal_contract_test!.

Determinism

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.


Development

The Cargo workspace lives under app/; the root Makefile drives cargo there.

Prerequisites

  • rustup. The workspace tracks the latest stable toolchain via app/rust-toolchain.toml; rustup installs it on first build.
  • rustup target add wasm32-unknown-unknown for make 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/.

Releasing & supply-chain security

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_TOKEN secret. (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, and Cargo.lock is 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.

License

Apache-2.0.