Skip to content

eboody/statum

Repository files navigation

statum logo

Statum is a Rust typestate framework for making invalid, undesirable, or not-yet-validated states unrepresentable in ordinary code.

build status crates.io docs.rs

Statum

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.

Install

Statum targets stable Rust and currently supports Rust 1.93+.

[dependencies]
statum = "0.7.0"

60-Second Example

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

Start Here

What The Compiler Enforces

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> and LightSwitch<On> are different types.
  • switch_on() only exists on LightSwitch<Off>.
  • switch_off() only exists on LightSwitch<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.

Mental Model

#[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 Off and On.
  • A machine type parameterized by the current state, with hidden marker and state_data fields.
  • Builders for new machines, such as LightSwitch::<Off>::builder().
  • A machine-scoped enum like task_machine::SomeState for matching reconstructed machines. task_machine::State remains as a compatibility alias.
  • A machine-scoped task_machine::Fields struct 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.

Typed Rehydration

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 or statum::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 client and name are usable without boilerplate parameter plumbing. Persisted-row fields still live on self.
  • Unit states return statum::Result<()> or statum::Validation<()>; data-bearing states return statum::Result<StateData> or statum::Validation<StateData>.
  • .build() returns the generated wrapper enum, which you can match as task_machine::SomeState. task_machine::State remains 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 becomes async.
  • 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_one and reduce_grouped are the small helper layer for that.
  • If no validator matches, .build() returns statum::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

Machine Introspection And Exact Relations

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.

Core Rules

#[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 self or mut self.
  • Return Machine<NextState> directly, or wrap it in canonical Result, Option, or statum::Branch when 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 generates from_* 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 an impl block for your persisted type.
  • Define one is_{state} method per state variant.
  • Return statum::Result<()> or statum::Validation<()> for unit states.
  • Return statum::Result<StateData> or statum::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.

When To Use Statum

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

Common Gotchas

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.

Showcases

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-session
  • axum-sqlite-review demonstrates #[validators] rebuilding typed machines from database rows before each HTTP transition.
  • clap-sqlite-deploy-pipeline demonstrates repeated CLI invocations, SQLite-backed typed rehydration, and explicit apply/failure/rollback phases.
  • sqlite-event-log-rebuild demonstrates append-only event storage, projection-based typed rehydration, and batch .into_machines() reconstruction.
  • tokio-sqlite-job-runner demonstrates retries, leases, async side effects, and typed rehydration in a background worker loop.
  • tokio-websocket-session demonstrates 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.

Use With Coding Agents

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.

Learn More

Stability

  • Stable Rust is the target.
  • MSRV: 1.93

About

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

Topics

Resources

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors