Statum helps you build beautiful Rust APIs where invalid paths simply do not appear. Use it when a concept moves through distinct states and each state should expose a different, more precise method surface.
The point is the same spirit that makes Option<T> and Result<T, E> powerful:
make undesirable states unrepresentable in code. Option makes absence explicit
instead of hiding it in null. Result makes failure explicit instead of hiding it
in an ambient exception or sentinel value. Statum applies that idea to API
design: a draft document, an in-review document, and a published document can be
different Rust types with different methods and different data.
The core promise is representational correctness:
DocumentMachine<Draft>can be submitted for review.DocumentMachine<InReview>can be approved.- Reviewer assignment data only exists while the document is in review.
- Code cannot accidentally call phase-specific behavior from the wrong phase.
Statum provides this through four macros:
#[state] named protocol phases
#[machine] shared workflow context carried across phases
#[transition] legal edges between phases
#[validators] typed rehydration from stored or projected data
Enable the optional introspection feature when you also want generated graph
metadata for docs, tests, CLIs, or review tooling.
Here is the smallest useful mental model:
let draft: DocumentMachine<Draft> = DocumentMachine::builder()
.id(1)
.title("Launch notes".to_owned())
.body("...".to_owned())
.build();
let review: DocumentMachine<InReview> = draft.submit("ada".to_owned());
let published: DocumentMachine<Published> = review.approve();After submit, the value is no longer a draft. After approve, it is no
longer in review. Methods and fields follow the type, so callers do not need to
remember which operations are currently legal.
A Statum machine does not have to feel like a state-machine API at the call
site. One of the most useful patterns is a guided builder: each choice narrows
what the caller can do next until only complete, valid construction paths remain.
Developers do not need to think about phantom types or protocol states; they
press . and see the next valid methods.
For a Maud or design-system button, choosing the icon-only variant can require an accessible label before rendering:
button::Button::builder()
.icon_only(icon::Settings)
.aria_label("Open settings")
.render();The caller just sees a builder. Internally, the builder has moved into an
IconOnlyNeedsLabel phase, so render() is not available until the accessibility
contract is satisfied. Link buttons can expose href() while submit buttons
expose form_id(). Destructive buttons can require either confirm(...) or an
explicit no_confirmation_needed() before on_click(...) appears.
The same idea works outside UI. A non-stringy quest DSL can make narrative structure explicit with typed IDs and typed assets:
quest::Quest::builder(quest::LostRelic)
.starts_with(dialogue::ElderIntro)
.requires(item::AncientMap)
.branch(choice::Accept)
.dialogue(dialogue::ElderAccept)
.objective(objective::FindRelic)
.reward(reward::RelicBlade)
.branch(choice::Decline)
.dialogue(dialogue::ElderDecline)
.ends()
.build();A branch builder cannot return to the quest until it has an ending. Rewards can only appear on successful branches. Dialogue and objective references are typed values, not ad-hoc strings. The core idea is still the same: make undesirable state unrepresentable in code, while giving developers an ordinary, discoverable, beautiful builder surface.
Plain enums are great for many state machines, but they usually keep every operation at the same call site. You match, branch, and remember to reject the wrong phase at runtime.
Statum moves that boundary into the type system. If approve() only exists on
DocumentMachine<InReview>, then code holding DocumentMachine<Draft> cannot
call it. The invalid program is not a failing branch; it is not representable.
Use a plain enum when every state has roughly the same behavior or when the state graph is mostly runtime-authored. Use Statum when each phase should expose a different API.
Statum targets stable Rust and currently supports Rust 1.93+. The repository
pins rust-toolchain.toml to Rust 1.96.0 for day-to-day development and keeps
rust-version = "1.93" in Cargo metadata for the supported minimum.
[dependencies]
statum = "0.9.0"No default features are enabled. Add graph metadata when you need it:
[dependencies]
statum = { version = "0.9.0", features = ["introspection"] }For the strongest graph-metadata authority boundary, enable strict mode:
[dependencies]
statum = { version = "0.9.0", features = ["strict-introspection"] }strict-introspection only changes graph metadata generation. Unsupported
transition return shapes are rejected unless the method provides an explicit
#[introspect(return = ...)] annotation.
To reproduce the primary GitHub Actions gate locally:
bash scripts/check_ci_parity.shA document approval protocol has three phases:
Draft: editable content with no reviewer yet.InReview: the same document plus a requiredReviewAssignment.Published: approved content; the reviewer field is gone again.
Statum makes those phases different Rust types and puts transitions only on the phases that may use them:
use statum::{machine, state, transition};
#[state]
enum DocumentState {
Draft,
InReview(ReviewAssignment),
Published,
}
struct ReviewAssignment {
reviewer: String,
}
#[machine]
struct DocumentMachine<DocumentState> {
id: i64,
title: String,
body: String,
}
#[transition]
impl DocumentMachine<Draft> {
fn submit(self, reviewer: String) -> DocumentMachine<InReview> {
self.transition_with(ReviewAssignment { reviewer })
}
}
#[transition]
impl DocumentMachine<InReview> {
fn approve(self) -> DocumentMachine<Published> {
self.transition()
}
}Now the compiler enforces the workflow shape:
submit()is only callable onDocumentMachine<Draft>.approve()is only callable onDocumentMachine<InReview>.ReviewAssignmentis only accessible on the in-review machine.- A persisted row can be rebuilt into
document_machine::SomeState, matched, and only then transitioned by an HTTP handler or worker.
Statum is storage-agnostic. The SQLite/sqlx examples are integration patterns, not built-in adapters.
Start with the guided document-approval walkthrough: docs/tutorial-review-workflow.md. The service-shaped implementation lives in statum-examples/src/showcases/axum_sqlite_review.rs.
Typed rehydration is the boundary feature for services that store or receive
state dynamically. The central model is still typestate: undesirable states are
unrepresentable in the core API. #[validators] is how a database row, event
projection, or API payload earns its way back into that typed world.
A validator block lives on the persisted type and names the machine it rebuilds:
use statum::{machine, state, validators};
#[state]
enum TaskState {
Draft,
InReview(ReviewData),
Published,
}
struct ReviewData {
reviewer: String,
}
#[machine]
struct TaskMachine<TaskState> {
client: String,
name: String,
}
struct DbRow {
status: String,
}
#[validators(TaskMachine)]
impl DbRow {
fn is_draft(&self) -> statum::Result<()> {
(self.status == "draft")
.then_some(())
.ok_or(statum::Error::InvalidState)
}
fn is_in_review(&self) -> statum::Result<ReviewData> {
let _ = (&client, &name); // generated machine-field bindings
(self.status == "in_review")
.then(|| ReviewData {
reviewer: format!("reviewer-for-{client}"),
})
.ok_or(statum::Error::InvalidState)
}
fn is_published(&self) -> statum::Result<()> {
(self.status == "published")
.then_some(())
.ok_or(statum::Error::InvalidState)
}
}Then rebuild through the machine:
let machine = TaskMachine::rebuild(&row)
.client("acme".to_owned())
.name("spec".to_owned())
.build()?;
match machine {
task_machine::SomeState::Draft(task) => { /* edit */ }
task_machine::SomeState::InReview(task) => { /* approve */ }
task_machine::SomeState::Published(task) => { /* serve */ }
}Key options:
- Use
statum::Validation<T>instead ofstatum::Result<T>when failed candidates should carry reason keys and messages into rebuild reports. - Enable
rebuild-reportsfor single-row.build_report()and.explain(). - Enable
rebuild-batchfor.into_machines(),.into_machines_by(...), andMachine::rebuild_many(...). - Project append-only event logs into validator rows first; the small
statum::projectionhelpers cover common reductions.
Full guide: docs/persistence-and-validators.md. Event-log case study: docs/case-study-event-log-rebuild.md.
With introspection, Statum emits machine metadata from the active, cfg-pruned
macro input. That lets downstream tools read the workflow graph without
maintaining a parallel definition by hand.
Use it for:
- CLI explainers and generated docs.
- Graph snapshots and pull-request diffs.
- Tests that assert legal transitions.
- Replay, debugging, and review tooling.
With strict-introspection, supported return shapes are exact at the
transition-site level: direct Machine<NextState> values and canonical wrappers
around those machine types (Option, Result, and statum::Branch). Strict
mode is exact for macro-readable transition targets, not for runtime guards or
the semantic truth of explicit overrides.
Read docs/introspection.md and docs/introspection-authority.md, or run statum-examples/src/toy_demos/16-machine-introspection.rs.
Use Statum when:
- A value's phase should change what callers are allowed to do with it.
- Invalid transitions are expensive enough to prevent at compile time.
- Some data is only valid in specific states.
- Workflow order, validation order, or resolution order is stable and meaningful.
- Dynamic or persisted state needs a typed re-entry point.
Skip Statum when:
- The staging is private implementation detail inside one function.
- The legal method surface barely changes across phases.
- The workflow is highly ad hoc, user-authored, or runtime-editable.
- States are still changing faster than the API around them.
If you are comparing approaches, read docs/why-not-just-an-enum.md.
Statum uses proc macros, but the intended boundary is narrow:
- Macro input is ordinary Rust items: enums, structs, and impl blocks.
- Generated code is machine-shaped: marker types, builders, transition helpers, typed rehydration builders, and optional metadata constants.
- Unsupported syntax is rejected with compile-time diagnostics instead of being approximated silently.
- Strict introspection is opt-in for teams that want graph metadata to stay exact for macro-readable transition targets.
The repository treats those boundaries as part of the public contract: macro errors are covered by trybuild fixtures, docs build with warnings denied, and CI checks stable, MSRV, macOS, Windows, security, and a nightly canary.
missing fields marker and state_data
Your derives expanded before #[machine]. Put #[machine] above
#[derive(...)]:
#[machine]
#[derive(Debug, Clone)]
struct DocumentMachine<DocumentState> {
id: i64,
title: String,
}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. Generics on the state enum are not supported.
For 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-review: row rehydration before each HTTP transition.clap-sqlite-deploy-pipeline: repeated CLI invocations and rollback phases.sqlite-event-log-rebuild: append-only event storage and batch rebuilds.tokio-sqlite-job-runner: retries, leases, async effects, and worker loops.tokio-websocket-session: protocol-safe frames and session lifecycle phases.
Start with docs by job, not by macro name:
- Documentation map: docs/README.md
- First workflow: docs/start-here.md, docs/tutorial-review-workflow.md, and docs/why-not-just-an-enum.md
- Builders and phase data: docs/generated-builder-reference.md, docs/typestate-builder-design-playbook.md, and docs/builder-ux-positioning.md
- Rehydration: docs/persistence-and-validators.md, docs/rehydration-vocabulary.md, and docs/migration.md
- Graph metadata: docs/introspection.md, docs/introspection-authority.md, and docs/mcp-protocol-resource-design.md
- Agent adoption kit: docs/agents/README.md
- Escape-hatch audit: docs/escape-hatches.md
Examples and API references:
- Toy demos: statum-examples/src/toy_demos/
- Showcase apps: statum-examples/src/showcases/
- Crate docs: statum, statum-core, statum-macros
- Development toolchain: Rust
1.96.0viarust-toolchain.toml. - MSRV: Rust
1.93, declared as workspacerust-version = "1.93"and checked in CI with Rust1.93.1. - Edition split:
statumandstatum-coreuse Rust 2021;statum-macros,statum-examples, andcargo-statumuse Rust 2024.