Statum is a Rust typestate framework for making invalid, undesirable, or not-yet-validated states unrepresentable in ordinary code.
Statum is about correctness. More specifically, it is about representational correctness: how accurately your code models the thing you are trying to model.
The goal is to make invalid, undesirable, or not-yet-validated states
impossible to represent in ordinary code. In that sense it is similar to
Option or Result: they make absence or failure explicit in the type system
instead of leaving it implicit.
Statum applies that same idea to workflow and protocol state. You describe
lifecycle phases with #[state], durable context with #[machine], legal
moves with #[transition], and typed rehydration from existing data with
#[validators].
It is opinionated on purpose: explicit transitions, state-specific data, and compile-time method gating. If that is the shape of your problem, the API stays small and the safety payoff is high.
Statum targets stable Rust and currently supports Rust 1.93+.
[dependencies]
statum = "0.7.0"use statum::{machine, state, transition};
#[state]
enum LightState {
Off,
On,
}
#[machine]
struct LightSwitch<LightState> {
name: String,
}
#[transition]
impl LightSwitch<Off> {
fn switch_on(self) -> LightSwitch<On> {
self.transition()
}
}
#[transition]
impl LightSwitch<On> {
fn switch_off(self) -> LightSwitch<Off> {
self.transition()
}
}
fn main() {
let light = LightSwitch::<Off>::builder()
.name("desk lamp".to_owned())
.build();
let light = light.switch_on();
let _light = light.switch_off();
}Example: statum-examples/src/toy_demos/example_01_setup.rs
- Want the idea quickly: read What The Compiler Enforces
- Want the flagship feature: jump to Typed Rehydration
- Want graphs and CLI tooling: read Machine Introspection And Exact Relations
- Want composition-first workspace flow: read Composition Machine Migration
- Want a full app-shaped example: go to Showcases or docs/tutorial-review-workflow.md
The syntax example above is small. The point is not the syntax. The point is that legal and illegal states stop looking the same in your API.
The workflow shape becomes part of the type system instead of hiding in status enums, optional fields, and comments:
LightSwitch<Off>andLightSwitch<On>are different types.switch_on()only exists onLightSwitch<Off>.switch_off()only exists onLightSwitch<On>.- If a state carries data, that data only exists when the machine is actually in that state.
This is the point of Statum: only legal, understood states become first-class
values. Raw rows and projections are not trusted as typed states until
#[validators] proves them.
If you add derives, place them below #[state] and #[machine]:
# use statum::{machine, state};
# #[state]
# #[derive(Debug, Clone)]
# enum LightState {
# Off,
# }
#[machine]
#[derive(Debug, Clone)]
struct LightSwitch<LightState> {
name: String,
}
# fn main() {}That avoids the common missing fields marker and state_data error.
#[state] -> lifecycle phases
#[machine] -> durable machine context
#[transition] -> legal edges between phases
#[validators] -> typed rehydration from stored data
Roughly, Statum generates:
- Marker types for each state variant, such as
OffandOn. - A machine type parameterized by the current state, with hidden
markerandstate_datafields. - Builders for new machines, such as
LightSwitch::<Off>::builder(). - A machine-scoped enum like
task_machine::SomeStatefor matching reconstructed machines.task_machine::Stateremains as a compatibility alias. - A machine-scoped
task_machine::Fieldsstruct for batch rebuilds where each row needs different machine context. - A machine-scoped batch rehydration trait like
task_machine::IntoMachinesExt.
This is the whole model. The rest of the crate is about making those four pieces ergonomic.
Typed rehydration is the unusual part: if you already have rows, events, or persisted workflow data,
#[validators]can rebuild them into typed machines. Full example below.
If you are evaluating Statum from the outside, start with docs/start-here.md. For a guided app-shaped walkthrough, see docs/tutorial-review-workflow.md. For the flagship persistence story, see docs/case-study-event-log-rebuild.md.
This example shows how raw database rows become typed workflow states.
#[validators] is the feature that turns stored data back into typed machines.
Each is_* method checks whether the persisted value belongs to a state,
returns () or state-specific data, and Statum builds the right typed output:
use statum::{machine, state, validators};
#[state]
enum TaskState {
Draft,
InReview(ReviewData),
Published,
}
struct ReviewData {
reviewer: String,
}
#[machine]
struct TaskMachine<TaskState> {
client: String,
name: String,
}
enum Status {
Draft,
InReview,
Published,
}
struct DbRow {
status: Status,
}
#[validators(TaskMachine)]
impl DbRow {
fn is_draft(&self) -> statum::Result<()> {
let _ = (&client, &name);
if matches!(self.status, Status::Draft) {
Ok(())
} else {
Err(statum::Error::InvalidState)
}
}
fn is_in_review(&self) -> statum::Result<ReviewData> {
let _ = &name;
if matches!(self.status, Status::InReview) {
let current_client = client;
Ok(ReviewData {
reviewer: format!("reviewer-for-{current_client}"),
})
} else {
Err(statum::Error::InvalidState)
}
}
fn is_published(&self) -> statum::Result<()> {
if matches!(self.status, Status::Published) {
Ok(())
} else {
Err(statum::Error::InvalidState)
}
}
}
fn main() -> statum::Result<()> {
let row = DbRow {
status: Status::InReview,
};
let machine = row
.into_machine()
.client("acme".to_owned())
.name("spec".to_owned())
.build()?;
match machine {
task_machine::SomeState::Draft(_) => {}
task_machine::SomeState::InReview(task) => {
assert_eq!(task.state_data.reviewer.as_str(), "reviewer-for-acme");
}
task_machine::SomeState::Published(_) => {}
}
Ok(())
}Key details:
- Validator methods return either
statum::Result<T>for simple membership orstatum::Validation<T>when a failed match should carry a stable diagnostic reason into rebuild reports. - Machine fields are available by name inside validator methods through
generated bindings, so
clientandnameare usable without boilerplate parameter plumbing. Persisted-row fields still live onself. - Unit states return
statum::Result<()>orstatum::Validation<()>; data-bearing states returnstatum::Result<StateData>orstatum::Validation<StateData>. .build()returns the generated wrapper enum, which you can match astask_machine::SomeState.task_machine::Stateremains as an alias..build_report()and.build_reports()preserve the same rebuild semantics while also recording validator attempts in order.- If any validator is
async, the generated builder becomesasync. - Use
.into_machines_by(|row| task_machine::Fields { ... })when batch reconstruction needs different machine fields per row. - For append-only event logs, project events into validator rows first.
statum::projection::reduce_oneandreduce_groupedare the small helper layer for that. - If no validator matches,
.build()returnsstatum::Error::InvalidState.
Examples: statum-examples/src/toy_demos/09-persistent-data.rs, statum-examples/src/toy_demos/10-persistent-data-vecs.rs, statum-examples/src/toy_demos/14-batch-machine-fields.rs, statum-examples/src/showcases/sqlite_event_log_rebuild.rs
More detail: docs/persistence-and-validators.md
Statum can also emit typed machine introspection directly from the machine
definition itself. That graph comes from macro-expanded, cfg-pruned
#[transition] signatures, so downstream tooling can render exact transition
sites and legal targets without maintaining a parallel graph table by hand.
Real cargo builds fail closed if Statum can't recover enough source context
to prove the #[machine] to #[state] linkage exactly.
See docs/introspection.md for the full guide, supported wrapper shapes, and exactness limits. Runnable examples: statum-examples/src/toy_demos/16-machine-introspection.rs and statum-examples/src/toy_demos/17-attested-composition.rs and statum-examples/src/toy_demos/example_18_composition_machine.rs.
If you want ready-made graph renderers, the workspace also ships statum-graph. If you want a linked-build codebase graph and inspector TUI, see cargo-statum-graph.
If the main business flow should itself be exact protocol truth, prefer
#[machine(role = composition)] over leaving that story in heuristics or
external wiring. The migration guide is
docs/composition-migration.md.
Statum also supports exact cross-machine transition provenance. Direct
single-target transitions get generated *_and_attest() companions returning
statum::Attested<Machine<NextState>, Via>. On the consumer side, annotate one
machine parameter with #[via(...)], and Statum generates binders like
.from_capture(...).start_shipping() while exporting that dependency into the
linked relation metadata and inspector detail. If the boundary is a detached
artifact instead of a child machine, keep the same producer provenance and map
the attested machine into the artifact once:
let receipt = PaymentMachine::<Authorized>::builder()
.build()
.capture_and_attest()
.map_inner(Receipt::from);
let shipping = FulfillmentMachine::<ReadyToShip>::builder()
.build()
.from_capture(receipt)
.start_shipping();That same exact route metadata now flows through composition-machine state payloads, machine fields, and transition parameters when they carry attested handoffs. This improves exact relation graphs; it does not infer a workflow or protocol-stage graph for you.
#[state]
- Apply it to an enum.
- Variants must be unit variants, single-field tuple variants, or named-field variants.
- Generics on the state enum are not supported.
#[machine]
- Apply it to a struct.
- The first generic parameter must match the
#[state]enum name. - Additional type and const generics are supported after the state generic.
- Put
#[machine]above#[derive(...)].
#[transition]
- Apply it to
impl Machine<State>blocks that define legal transitions. - Transition methods must take
selformut self. - Return
Machine<NextState>directly, or wrap it in canonicalResult,Option, orstatum::Branchwhen the transition is conditional. - Use
transition_with(data)when the target state carries data. - Direct single-target transitions also get generated
*_and_attest()companions. - To require exact child-transition provenance in another transition, annotate
one machine parameter with
#[via(...)]; Statum then generatesfrom_*binders such as.from_capture(...).start_shipping(). - If the consumer boundary is a detached artifact instead of a child machine,
use
*_and_attest().map_inner(...)on the producer side and keep the same#[via(...)]binder on the consumer side.
#[validators]
- Use
#[validators(Machine)]on animplblock for your persisted type. - Define one
is_{state}method per state variant. - Return
statum::Result<()>orstatum::Validation<()>for unit states. - Return
statum::Result<StateData>orstatum::Validation<StateData>for data-bearing states. - Prefer
into_machine()for single-item reconstruction. - For collections that share machine fields, call
.into_machines(). - For collections where machine fields vary per item, call
.into_machines_by(|row| machine::Fields { ... }). - From other modules, import
machine::IntoMachinesExt as _first.
Use Statum when:
- You care about representational correctness and want invalid, undesirable, or not-yet-validated states out of the core API.
- Workflow order is stable and meaningful.
- Invalid transitions are expensive.
- Available methods should change by phase.
- Some data is only valid in specific states.
Do not use Statum when:
- The workflow is highly ad hoc or user-authored.
- The workflow is dominated by large runtime branching or dynamic graph edits.
- States are still changing faster than the API around them.
More design guidance: docs/typestate-builder-design-playbook.md
missing fields marker and state_data
Your derives expanded before #[machine]. Put #[machine] above
#[derive(...)].
Transition helpers in the wrong place
Keep non-transition helpers in normal impl blocks. #[transition] is for
protocol edges, not general utility methods.
State shape errors
#[state] accepts unit variants, single-field tuple variants, and named-field
variants.
For real service-shaped examples, run one of these:
cargo run -p statum-examples --bin axum-sqlite-review
cargo run -p statum-examples --bin clap-sqlite-deploy-pipeline
cargo run -p statum-examples --bin sqlite-event-log-rebuild
cargo run -p statum-examples --bin tokio-sqlite-job-runner
cargo run -p statum-examples --bin tokio-websocket-sessionaxum-sqlite-reviewdemonstrates#[validators]rebuilding typed machines from database rows before each HTTP transition.clap-sqlite-deploy-pipelinedemonstrates repeated CLI invocations, SQLite-backed typed rehydration, and explicit apply/failure/rollback phases.sqlite-event-log-rebuilddemonstrates append-only event storage, projection-based typed rehydration, and batch.into_machines()reconstruction.tokio-sqlite-job-runnerdemonstrates retries, leases, async side effects, and typed rehydration in a background worker loop.tokio-websocket-sessiondemonstrates protocol-safe frame handling, phase-gated behavior, and a session lifecycle that is not persistence-driven.
Start with the guided review tutorial if you want one example explained in order: docs/tutorial-review-workflow.md.
Start with sqlite-event-log-rebuild if you want the strongest “why Statum”
example:
docs/case-study-event-log-rebuild.md.
If you use coding agents, Statum ships an adoption kit with copyable instruction templates, audit heuristics, and prompts for targeted refactors and reviews. Start with docs/agents/README.md.
If you are starting from an architecture memo or protocol guide rather than
from code, use the prompts under docs/agents/prompts/. If you use Codex
locally, an explicit statum-skill works well as a deeper layer on top of the
conservative templates in this repo.
- Toy demos: statum-examples/src/toy_demos/
- Showcase apps: statum-examples/src/showcases/
- Start here: docs/start-here.md
- Guided review tutorial: docs/tutorial-review-workflow.md
- Event-log case study: docs/case-study-event-log-rebuild.md
- Typed rehydration and validators: docs/persistence-and-validators.md
- Machine introspection and exact relations: docs/introspection.md
- Patterns and advanced usage: docs/patterns.md
- Typestate builder design playbook: docs/typestate-builder-design-playbook.md
- Coding-agent kit: docs/agents/README.md
- Crate docs: statum, statum-core, statum-macros
- Graph tooling: statum-graph, cargo-statum-graph
- Stable Rust is the target.
- MSRV:
1.93