Define your business state and rules in Rust, then run the same typed handlers in local tests and through the production platform. Follow the Bank example: deposit 100, refuse a withdrawal of 150, and verify that records and events stay unchanged.

Allow five minutes after dependencies are installed. A first Rust build may take longer. You need Git and Rust 1.91 or newer; no cluster is needed for the local exercise.

Know what you own

You defineStateVec supplies
Records, commands, events, unique keys and indexesSchema checks, generated builders and typed record access
Deterministic handlers and typed business refusalsTransaction context, provisional writes and execution results
Business rules and execution revisionProduction execution, Raft replication, durability and recovery

The application library is the business plugin. Write handlers against TypedTxContext; keep canonical business state in records rather than mutable runtime fields. The local test engine and production adapter provide their own transaction boundaries around those handlers.

Shell
git clone https://github.com/jumpex-io/statevec-rs.git
cd statevec-rs
cargo test -p bank --test local_runtime

This runs two tests: refusal without state change, and malformed input rejected before the handler runs. Keep this checkout open for the following steps.

Model records and commands

Open demo/bank/src/schema.rs. This excerpt declares an account's immutable identity, mutable credit/debit totals, and a deposit command:

Rust
#[schema_module(version = "1.0")]
pub mod v1_0 {
    use super::*;

    #[record(kind = 1, record_len = 64, uk(id = 0, fields = [account_id]))]
    pub struct Account {
        #[field(index = 1, immutable = true)]
        pub account_id: u64,
        #[field(index = 2)]
        pub total_credit: u64,
        #[field(index = 3)]
        pub total_debit: u64,
    }

    #[command(kind = 1)]
    pub struct Deposit {
        #[field(index = 1)]
        pub account_id: u64,
        #[field(index = 2)]
        pub amount: u64,
    }
}

Use this inside the existing example; the excerpt omits imports, other commands and events. The macros generate Account::uk(...), read/write views, command builders and a schema registry. Unique-key fields must be immutable: initialize them with init_<field> at creation; update mutable fields with set_<field>. Field indexes start at 1 and remain contiguous.

Bank uses integer amounts. For scaled amounts, the contract also supports fixed-point Decimal<S>. Choose units and bounds explicitly. Canonical indexes may include mutable fields and support bounded prefix counts inside a transaction; product query views belong to a separate read-only integration.

Write a deterministic transition

Open demo/bank/src/lib.rs. BankRuntime::dispatch validates the complete command payload before calling a typed handler. A deposit checks inputs, updates both the customer and platform accounts, then stages BalanceChanged events. A withdrawal returns BankError::InsufficientFunds when it would overdraw the customer.

Follow that pattern in your own handler:

  1. Validate business inputs and read records through the transaction context.
  2. Use checked arithmetic and stage every related record change in the same transaction.
  3. Stage events describing the successful transition, or return a typed business refusal.

An error escaping the local transaction discards its provisional records, keys and events. In production, the adapter maps an expected refusal to a stable business result. Bank maps InsufficientFunds to code 1; business codes occupy 1..=59999. Host failures and malformed input have separate failure handling. Classify typed errors, not their rendered messages.

Handlers can run during preparation, follower validation and recovery. Put external inputs, including business time, into commands or the qualified platform context. Keep clocks, randomness, network calls and external writes out of handlers. Deliver external effects through a consumer of committed events.

Check the result, including refusal

This complete local test uses the existing Bank library. It builds checked payloads and verifies both account totals and events after refusal. Add it to a test file under demo/bank/tests/ to try it, or inspect the existing local_runtime.rs for the same pattern.

Rust
use bank::{Account, BankError, BankRuntime, Deposit, Withdraw, registry};
use statevec::{Command, CommandSchema, GeneratedCommandAccess};
use statevec_test::TestHost;

#[test]
fn refusal_preserves_account_and_events() {
    let runtime = BankRuntime;
    let mut engine = TestHost::new(registry());

    let deposit = Command::new(
        Deposit::KIND, 1, 0,
        Deposit::builder().set_account_id(42).set_amount(100).build().unwrap(),
    );
    engine.transaction(|tx| runtime.dispatch(tx, &deposit)).unwrap();
    let events_before = engine.events().to_vec();

    let withdrawal = Command::new(
        Withdraw::KIND, 2, 0,
        Withdraw::builder().set_account_id(42).set_amount(150).build().unwrap(),
    );
    let error = engine.transaction(|tx| runtime.dispatch(tx, &withdrawal)).unwrap_err();

    assert_eq!(error, BankError::InsufficientFunds);
    assert_eq!(error.rejection_code().unwrap().get(), 1);
    assert_eq!(engine.expect::<Account, _>(Account::uk(42),
        |r| (r.total_credit(), r.total_debit())), (100, 0));
    assert_eq!(engine.events(), events_before);
}

Command::new takes the kind, external sequence, reference time and payload. Here 1 and 2 are explicit test sequences and 0 is the test reference time. Payload builders return Result; the unwrap() calls are test assertions. Application code should handle build errors.

For a new application crate, depend on statevec and add statevec-test as a development dependency, both pinned to the same platform-supported revision. Use TestHost::transaction to preserve the application's typed error. The optional plugin callback wrapper returns RuntimePluginError and does not preserve that enum.

Connect the production contract

The production platform binds your schema and handlers to its engine and cluster. Raft establishes ordered durable commitment; the engine executes and verifies business transitions. Commitment and local application are separate boundaries: a staged event or a committed log entry alone does not mean the handler's state is already installed locally.

Verify mode is the default: leader preparation and follower validation establish execution evidence, and committed apply checks the actual local result. Performance mode is an explicit alternative that performs business execution at committed apply. It is never an automatic fallback after a verification fault. Handlers satisfy the same deterministic business contract in either mode.

Recovery restores the selected checkpoint plus the committed Raft WAL suffix. Engine TxResult, result digests and chain hashes provide execution evidence; they are not a second cluster journal. The platform owns node assembly, membership, checkpoint selection, storage and recovery.

Schema identity and execution revision are bound into the production execution profile. Schema changes affect identity; changes to outcomes, refusal codes or events require an execution revision change. Existing roots reject a different profile, so coordinate changes with the platform migration and release workflow.

The public Rust repository supplies development libraries and local tests. TestHost has no WAL, checkpoints or Raft; passing this exercise validates business behavior. Production durability and recovery need platform tests. Plugin ABI exports are available for integration work; dynamic loading and online upgrades depend on separate platform support.

Continue with your own domain

Replace accounts and deposits with your own records, commands and events. Keep schema and handlers together, test success and refusal paths, then integrate with the platform's native binding and client workflow.

Reviewed on 2026-10-05 against statevec-rs revision 2a6710a4e4a988e7f7411dbc1fdb98cccdf5e5fe and the cluster single-journal contract at 9ee48fcd9970d0124c2616c628d75b55b300447e.