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.6.7"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
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 event projections stay raw until #[validators] proves
they can become typed machines.
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 an alias for compatibility. - 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 that starts minimal and adds features one by one, see docs/tutorial-review-workflow.md. For the flagship persistence story, see docs/case-study-event-log-rebuild.md.
Statum can also emit typed machine introspection directly from the machine definition itself. Use it when downstream tooling needs the machine structure without rebuilding a parallel graph table by hand: CLI explainers, generated docs, graph exports, branch-strip views, test assertions about exact legal transitions, and replay or debug tooling.
The generated graph is derived from macro-expanded, cfg-pruned
#[transition] method signatures. Supported return shapes are direct
Machine<NextState> values plus canonical wrapper paths around those machine
types: ::core::option::Option<...>, ::core::result::Result<..., E>, and
::statum::Branch<..., ...>. Unsupported custom decision enums, wrapper
aliases, and differently-qualified machine paths are rejected instead of
exported as best-effort graph metadata. Whole-item #[cfg] gates are
supported, but nested #[cfg] or #[cfg_attr] on #[state] variants,
variant payload fields, or #[machine] fields are rejected because they would
otherwise drift the generated metadata from the active build.
See docs/introspection.md for the full guide and statum-examples/src/toy_demos/16-machine-introspection.rs for a runnable example.
For source-local labels and descriptions, use #[present(...)] on the machine,
state variants, and transition methods. If you also want typed metadata in the
generated machine::PRESENTATION constant, declare
#[presentation_types(machine = ..., state = ..., transition = ...)] on the
machine and add metadata = ... to each annotated item in the typed
categories. Manual MachinePresentation overlays still remain first-class when
the generated sugar is not the right fit.
#[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) {
Ok(ReviewData {
reviewer: format!("reviewer-for-{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 run against your persisted type and return either
statum::Result<T>for simple yes/no membership orstatum::Validation<T>when a failed match should carry a stable reason key and optional message 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_report()and.build_reports()keep the same rebuild semantics as.build(), but they also record validator attempts in order. Diagnostic validators populateRebuildAttempt.reason_keyandRebuildAttempt.message..build()returns the generated wrapper enum, which you can match astask_machine::SomeState.task_machine::Stateis kept as an alias so older code still compiles.- 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()and.build_report().into_result()both returnstatum::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
#[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.
- Extra machine lifetime generics are effectively unavailable because Rust requires lifetimes before type parameters, and Statum reserves the first generic slot for the state family.
- 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 canonical::core::result::Result,::core::option::Option, or::statum::Branchwhen the transition is conditional. - Use
transition_with(data)when the target state carries data.
#[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/
- Crate docs: statum, statum-core, statum-macros
- Review showcase binary: statum-examples/src/bin/axum-sqlite-review.rs
- Deploy pipeline binary: statum-examples/src/bin/clap-sqlite-deploy-pipeline.rs
- Event log binary: statum-examples/src/bin/sqlite-event-log-rebuild.rs
- Job runner binary: statum-examples/src/bin/tokio-sqlite-job-runner.rs
- Session binary: statum-examples/src/bin/tokio-websocket-session.rs
- Coding-agent kit: docs/agents/README.md
- 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
- Patterns and advanced usage: docs/patterns.md
- Typestate builder design playbook: docs/typestate-builder-design-playbook.md
- API docs: docs.rs/statum
- Stable Rust is the target.
- MSRV:
1.93