Skip to content

eboody/statum

Repository files navigation

statum logo

Beautiful Rust APIs, backed by typestate.

build status crates.io docs.rs

Statum

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.

The Shape of the Idea

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.

Guided Builders

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.

Why Not a Plain Enum?

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.

Install

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

A Small Workflow

A document approval protocol has three phases:

  • Draft: editable content with no reviewer yet.
  • InReview: the same document plus a required ReviewAssignment.
  • 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 on DocumentMachine<Draft>.
  • approve() is only callable on DocumentMachine<InReview>.
  • ReviewAssignment is 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

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 of statum::Result<T> when failed candidates should carry reason keys and messages into rebuild reports.
  • Enable rebuild-reports for single-row .build_report() and .explain().
  • Enable rebuild-batch for .into_machines(), .into_machines_by(...), and Machine::rebuild_many(...).
  • Project append-only event logs into validator rows first; the small statum::projection helpers cover common reductions.

Full guide: docs/persistence-and-validators.md. Event-log case study: docs/case-study-event-log-rebuild.md.

Machine Introspection

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.

When To Use Statum

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.

Macro Boundaries

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.

Common Gotchas

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.

Showcases

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-session
  • axum-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.

Learn More

Start with docs by job, not by macro name:

Examples and API references:

Stability

  • Development toolchain: Rust 1.96.0 via rust-toolchain.toml.
  • MSRV: Rust 1.93, declared as workspace rust-version = "1.93" and checked in CI with Rust 1.93.1.
  • Edition split: statum and statum-core use Rust 2021; statum-macros, statum-examples, and cargo-statum use Rust 2024.

About

Statum helps make undesirable state impossible to represent in code, at compile time

Topics

Resources

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors