Statum: Type-Safe State Machines for Rust

repository·main·Indexed 42 days ago

https://github.com/eboody/statum

A Rust library for building type-safe state machines (typestates) that make invalid API states unrepresentable. It provides macros like #[state], #[machine], #[transition], and #[validators] to define workflow phases, legal transitions, and typed rehydration from external storage such as SQL databases or JSON.

Tokens
83K
Snippets
218
Records
299
Agent score
52%

What's inside Statum

  1. Explore statum-examples content types

    main

    The statum-examples crate is organized into two main categories to help you learn Statum at different levels of complexity:

    Toy Demos (src/toy_demos/)

    Small, syntax-first examples (e.g., example_01_setup.rs through 16-machine-introspection.rs). These are best for learning specific macros or individual helper functions in isolation. They include introspection examples showing branch alternatives and runtime transition recording.

    Showcases (src/showcases/)

    Realistic, integrated examples of Statum in production-like environments:

    • axum-sqlite-review: Document approval using HTTP, SQLite, and typed rehydration.
    • serde_json_snapshot: JSON snapshot persistence involving Serde deserialization, typed rebuilding, legal checkouts, and serialization.
    • clap-sqlite-deploy-pipeline: A multi-invocation CLI workflow.
    • sqlite-event-log-rebuild: An append-only projection and rebuild companion.
    • tokio-sqlite-job-runner: Managing retries, leases, and background work.
    • tokio-websocket-session: Managing protocol-safe session lifecycles.
  2. Choose the right Statum documentation based on your task

    main

    Statum is a workflow protocol implementation using macros to manage named phases, phase-specific data, legal transitions, and typed rehydration. Instead of browsing by macro name, select your documentation based on your current development goal:

    1. Starting a Workflow

    Use these to evaluate if Statum fits your needs and to build a minimal machine:

    • Evaluation: Compare Statum against plain enums, runtime validation, or builder crates in why-not-just-an-enum.md.
    • Learning: Follow the tutorial-review-workflow.md to grow a workflow one feature at a time.
    • Design: Consult patterns.md for recurring design patterns once the core model is established.

    2. Managing Phase Data

    Use these when different workflow phases require different fields or methods:

    • Implementation: See the generated-builder-reference.md for details on shared fields, state payloads, and visibility.
    • Architecture: Use typestate-builder-design-playbook.md to decide between a durable workflow machine or a staged builder surface.

    3. Encoding Transitions

    Use these to prevent values from moving to incorrect phases:

    • Modeling: Use patterns.md to model linear, branching, nested, or side-effecting workflows.

    4. Rehydrating Persisted State

    Use these when converting external data (JSON, SQL rows, events) into typed machines:

    • Core Logic: Read persistence-and-validators.md for single-item rebuilds, async validators, and failure models.
    • Integration: See persistence-integration-examples.md for SQLx, Serde, and Axum examples.
    • Terminology: Refer to rehydration-vocabulary.md for terms like project, rebuild, rehydrate, and recover.

    5. Processing Batches and Event Logs

    Use these for high-volume or append-only data:

    • Event Logs: See case-study-event-log-rebuild.md for projecting append-only events into snapshots.
    • Batching: Consult batch-rehydration-design.md for machine-scoped batch helper design.

    6. Introspection and Metadata

    Use these for tooling, documentation, or agent-based inspection:

    • Graph View: Use introspection.md to access generated graph metadata and transition identities.
    • Documentation: Use protocol-doc-generation.md to render Mermaid diagrams or transition tables.
    • Testing: See testing-helper-api-design.md for compile-time assertions and legal walks.
  3. Manage Types and Conversions

    main

    To maintain semantic integrity in your type system:

    • Avoid naked primitives for meaningful values.
    • Use semantic modules for typed IDs and domain values (e.g., insurance::member::Id).
    • Ensure meaningful conversions are explicit and named.
    • Boundary-to-domain promotion must be visible and fallible.
    • Warning: Do not use casual .into() calls to hide validation, normalization, trust-boundary crossings, unit conversions, or domain promotions.
  4. Understand Statum graph metadata authority boundaries

    main

    Statum graph metadata is a representation of the workflow structure derived from active, cfg-pruned attribute-macro inputs. It is not a type-checker, a runtime trace, an expanded Rust program, or a persistence audit.

    When using Statum metadata for documentation, CLI output, or agent prompts, you must respect these boundaries:

    Observation Stages

    • Raw source text: Not observed. Comments and inactive #[cfg] branches are not graph facts.
    • Parsed AST / macro item input: Partially observed. Claims are limited to syntax explicitly supported by the macros.
    • Cfg-pruned AST: Observed. Statum sees the items remaining after Rust has removed disabled #[cfg] branches.
    • Expanded items: Not observed. Statum cannot see code generated by other macros unless that code is visible to the Statum macro itself.
    • Type-checked items: Not observed. Statum relies on syntactic paths and return-shape parsing; it does not inspect rustc's resolved type graph.
    • Runtime values: Only used as carriers. Runtime choices do not add new static edges to the graph.
    • Persisted state: Not observed. Rehydration and validators inspect values at runtime, but the metadata does not claim database or JSON snapshots are valid.
  5. Manage state-specific data in machines

    main

    Attach data to a state variant only when that data is an invariant of that specific phase.

    • State-local data: Use for metadata meaningful only during a phase (e.g., InReview(ReviewData)).
    • Global data: Keep data that is relevant across all phases (e.g., id, tenant, repository handle) on the main machine struct.

    Ownership Tip: If transitions require frequent cloning of large payloads, move that data to the machine context or use lightweight identifiers instead of full payloads.

  6. Evaluate if Statum is right for your project

    main

    Statum is designed for scenarios where the phase of a value should dictate which methods are legally available on it. Use Statum when you want to ensure that invalid, undesirable, or not-yet-validated states cannot exist as ordinary values within your core API.

    Common use cases include:

    • Durable workflows: Managing long-running processes where state transitions must be strictly controlled.
    • Validated surfaces: Creating build-ready or resolved surfaces where later phases of a lifecycle expose different operations than earlier ones.
  7. Use Types to Carry Domain Obligations

    main

    Types should not just be data containers; they should represent what has been validated and what is permitted. Moving values into specific types proves validity rather than just checking it.

    Example of Type-Driven Design: Instead of a single Appointment struct with a mutable status: String field, use distinct types to represent valid states:

    • appointment::Confirmed
    • appointment::Cancelled
    • appointment::Pending

    This ensures that operations allowed on a Confirmed appointment cannot be accidentally called on a Pending one.

  8. Enforce Boundaries and Abstractions

    main

    Maintain clean separation between domain logic and external concerns:

    • Quarantine Boundary Ugliness: Keep DTOs, adapters, and framework glue separate from the domain.
    • Prevent Leakage: Do not allow external API, DB, config, or framework names to leak into the domain core unless they are part of the domain language.
    • Avoid Premature Abstraction: Do not abstract code just because it looks similar; only abstract if it represents a real shared semantic concept.
    • Avoid Helper Soup: Domain behavior should live in semantic modules or on the semantic owner rather than in generic helper functions.
  9. How Statum works: The Mental Model

    main

    Statum uses typestate to make undesirable states unrepresentable in code. Instead of using a plain enum where you must check state at runtime, Statum moves legal behavior onto phase-specific machine types. This ensures that if a method (e.g., publish()) only exists for a specific state (e.g., Draft), it is physically impossible to call that method when the machine is in another state (e.g., Published).

    Core abstractions:

    • #[state]: Defines the legal phases of your model.
    • #[machine]: Defines the durable context/data shared across all phases.
    • #[transition]: Defines the legal edges (moves) between states.
    • #[validators]: Used to bring dynamic or persisted data back into the typed model (rehydration).
  10. How Statum handles state-specific data during rehydration

    main

    A key benefit of using Statum for rehydrating workflows from persistence is that state-specific data is encapsulated within the specific machine state.

    In a standard projection row, fields like payment_receipt or tracking_number are often optional and exist across all rows. When rebuilt using Statum, these fields only exist in the states where they are valid according to the workflow logic:

    • Paid state carries payment_receipt.
    • Packed state carries both payment_receipt and pick_ticket.
    • Shipped state carries tracking_number.

    This prevents 'data leaking' where fields are accessible in phases where they shouldn't logically exist, and it forces the compiler to ensure you only access data relevant to the current proven state.

  11. Understand the authority boundary of generated docs

    main

    The generated protocol documentation describes the state of the StableGraphMetadata observation point at the time of generation.

    Currently, this observation point is cfg_pruned_macro_input for supported macro-input transition shapes. Users should note that the generated docs do not represent complete Rust semantics, runtime authorization, validator behavior, persistence migrations, or external side effects. If the metadata authority or the list of unsupported cases changes, refer to the 'Introspection authority boundaries' documentation.

  12. Understand unchecked and `assume_state` escape hatches

    main

    Unchecked APIs (such as hypothetical assume_state or from_parts_unchecked methods) allow you to construct a typed machine without running validators.

    WARNING: CALLER INVARIANT BURDEN Using these APIs bypasses the safety proofs provided by Statum. The caller assumes full responsibility for ensuring that:

    1. The state marker matches the actual persisted workflow phase.
    2. State-specific data satisfies all required invariants.
    3. Shared machine fields are consistent with the asserted state.

    These should only be used for:

    • Narrowly scoped tests with impossible fixtures.
    • One-off data repairs or migrations where an audit has already been performed.
    • Interop with legacy systems that already enforce the same invariants.