# API Contract Specification
This document defines the vocabulary, types, traits, and behavioral contracts of `ready-active-safe`. It is the authoritative reference for what the API promises and what it does not.
---
## Core Vocabulary
| **Mode** | A phase of operation in the system lifecycle. Not an arbitrary state. A deliberate phase with entry/exit semantics (e.g., Ready, Active, Safe). |
| **Event** | An external stimulus delivered to the system. Events are immutable data. The system never generates its own events. |
| **Command** | An instruction emitted by the machine for the runtime to execute. Commands describe intent, not effects. |
| **Decision** | The outcome of processing an event: an optional mode change plus zero or more commands. Pure data. |
| **ModeChange** | A requested transition from the current mode to a target mode. Contained within a `Decision`. |
| **Policy** | An external rule that determines whether a mode transition is allowed. Checked by the runtime before applying a `ModeChange`. |
| **Machine** | A pure function from `(mode, event)` to `Decision`. The central trait of the crate. |
| **Runtime** | The external loop that owns the current mode, feeds events, checks policies, and dispatches commands. Not part of the base trait. |
| **Journal** | A record of all mode transitions, enabling auditing and replay. Feature-gated on `journal`. |
| **TransitionRecord** | A single entry in the journal capturing a processed event (from/to modes, commands, timestamp). |
---
## Machine Trait
The `Machine` trait is the core abstraction. It defines how a system responds to events in each mode.
```rust
pub trait Machine<E = core::convert::Infallible> {
type Mode;
type Event;
type Command;
fn initial_mode(&self) -> Self::Mode;
fn on_event(
&self,
mode: &Self::Mode,
event: &Self::Event,
) -> Decision<Self::Mode, Self::Command, E>;
// Provided methods:
fn decide(&self, mode, event) -> Decision // alias for on_event
fn step(&self, mode, event) -> (Mode, Vec<Command>) // decide + apply
fn step_checked(&self, mode, event, policy) -> Result<(Mode, Vec<Command>), LifecycleError>
}
```
### Required Methods
| `initial_mode()` | Returns the starting mode. Every lifecycle has a well-defined beginning. |
| `on_event()` | Evaluates an event in the context of the current mode. Returns a `Decision`. |
### Provided Methods
| `decide()` | Alias for `on_event()`. Reads well in runtimes and tests. |
| `step()` | Calls `decide()` and applies the result to an owned mode. Returns `(next_mode, commands)`. |
| `step_checked()` | Like `step()` but checks a `Policy` first. Returns `Result`. |
### Signature Rationale
| `&self` | Immutable borrow | The machine is pure. It holds configuration, not mutable state. |
| `mode: &M` | Borrow | The runtime owns the mode. The machine inspects it but does not consume it. |
| `event: &E` | Borrow | Events are read-only stimuli. The machine does not take ownership. |
| No `now` param | Omitted | Time composes externally. See "Time Composes Externally" below. |
| Returns `Decision` | Owned value | Decisions are data the runtime consumes. Ownership transfer is natural. |
### Associated Type Constraints
The trait places **no trait bounds** on associated types. This is deliberate: the machine does not need its types to be `Clone`, `Debug`, or `Display`.
The runtime or journal may impose additional bounds where needed, but the base contract is maximally flexible.
The error type `E` is a type parameter with a default. Most machines are infallible and can use the default.
---
## Decision Type
`Decision<M, C, E = Infallible>` captures the outcome of processing an event.
### Structure
```rust
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Decision<M, C, E = core::convert::Infallible> {
change: Option<ModeChange<M>>,
commands: Vec<C>,
_error: core::marker::PhantomData<E>,
}
```
### Constructors
| `Decision::stay()` | `None` | Empty | Event acknowledged, no action needed |
| `Decision::transition(target)` | `Some(target)` | Empty | Mode change requested, no side effects |
| `Decision::stay().with_command(c)` | `None` | `[c]` | Stay in current mode, emit a command |
| `Decision::stay().with_commands([c1, c2])` | `None` | `[c1, c2]` | Stay in current mode, emit multiple commands |
| `Decision::transition(t).with_command(c)` | `Some(t)` | `[c]` | Mode change with an accompanying command |
| `Decision::transition(t).with_commands([c1, c2])` | `Some(t)` | `[c1, c2]` | Mode change with multiple commands |
### Aliases
- `emit(c)` is the same as `with_command(c)`
- `emit_all([..])` is the same as `with_commands([..])`
- `stay()` and `transition(target)` are free function wrappers for use in readable match arms
- `ignore()` is semantically identical to `stay()`, but signals that an unmatched event is deliberately ignored
### Accessors
| `mode_change()` | `Option<&ModeChange<M>>` | The requested mode change, if any |
| `target_mode()` | `Option<&M>` | The requested target mode, if any |
| `commands()` | `&[C]` | The list of commands to dispatch |
| `is_transition()` | `bool` | `true` if a mode change is requested |
| `is_stay()` | `bool` | `true` if no mode change is requested |
### Consuming Accessors
| `into_parts()` | `(Option<ModeChange<M>>, Vec<C>)` | Move out both parts |
| `into_mode_change()` | `Option<ModeChange<M>>` | Move out the mode change |
| `into_commands()` | `Vec<C>` | Move out the commands |
### Applying Decisions
| `apply(current_mode)` | `(M, Vec<C>)` | Apply to an owned mode |
| `apply_checked(mode, policy)` | `Result<(M, Vec<C>), LifecycleError<M>>` | Apply after policy check |
Free function equivalents:
- `apply_decision(current_mode, decision)` — same as `decision.apply(current_mode)`
- `apply_decision_checked(current_mode, decision, policy)` — same as `decision.apply_checked(current_mode, policy)`
### Free Functions
| `stay()` | `Decision` | Stay in current mode. Use in match arms. |
| `transition(m)` | `Decision` | Transition to mode `m`. Use in match arms. |
| `ignore()` | `Decision` | Stay in current mode. Signals deliberate silence. |
`stay()` and `ignore()` produce the same `Decision`. The difference is intent:
- `stay()` — "I processed this event and chose to stay."
- `ignore()` — "This event is not relevant here." Use in catch-all `_ =>` arms.
### Invariants
1. **Decisions are pure data.** They describe intent. They never perform I/O, allocate beyond their own storage, or observe external state.
2. **Commands are ordered.** `commands()` returns a slice in the order commands were added via `with_command` and `with_commands`. The runtime should dispatch them in this order.
3. **A `stay()` decision is not an error.** Returning `Decision::stay()` means the event was processed and the machine chose to remain in the current mode. It is a valid, expected outcome.
4. **Mode change and commands are orthogonal.** A decision may have a mode change with no commands, commands with no mode change, both, or neither.
---
## ModeChange Type
```rust
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ModeChange<M> {
/// The mode to transition to.
pub target: M,
}
```
`ModeChange` is a simple wrapper that identifies the target mode. It is `#[non_exhaustive]` to allow future fields (e.g., `reason`, `priority`) without breaking changes.
---
## Policy Trait
The `Policy` trait governs which mode transitions are permitted.
```rust
pub trait Policy<M> {
/// Returns `true` if transitioning from `from` to `to` is permitted.
fn is_allowed(&self, from: &M, to: &M) -> bool;
/// Returns `Ok(())` if the transition is allowed, or a reason string if it is denied.
fn check(&self, from: &M, to: &M) -> Result<(), &'static str> { ... }
}
```
### Contract
- The runtime calls `policy.is_allowed(¤t_mode, &target)` or `policy.check(...)` before applying any `ModeChange`.
- If the policy denies the transition, the runtime produces a `LifecycleError::TransitionDenied`.
- The policy is **stateless with respect to the machine**. It receives only the `from` and `to` modes, not the event or commands.
- Multiple policies can be composed by the runtime (e.g., `AllOf`, `AnyOf`), but this composition is a runtime concern, not a trait concern.
### Why Policy is Separate from Machine
The machine decides what **should** happen. The policy decides what **may** happen. Separating these concerns means:
- Machine logic does not need to encode transition rules.
- Policies can be swapped without changing machine logic (e.g., permissive in tests, strict in production).
- Policies can be audited independently.
### Built-In Policies
| `AllowAll` | Permits every transition. | Tests, prototypes, permissive runtimes. |
| `DenyAll` | Denies every transition. | Testing policy enforcement paths. |
`AllowAll` and `DenyAll` implement `Policy<M>` for any `M`. They are zero-sized types with no configuration.
---
## Error Contract
### LifecycleError
```rust
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum LifecycleError<M> {
/// A mode transition was rejected by the Policy.
TransitionDenied {
from: M,
to: M,
reason: &'static str,
},
}
```
### Guarantees
- **Generic over mode type `M`.** The error carries the actual mode values that were involved in the denied transition.
- **`Display` implementation** produces a human-readable message: `"transition denied from {from} to {to}: {reason}"`.
- **`std::error::Error` implementation** is available when the `std` feature is enabled, gated on `M: Debug + Display`.
- **`#[non_exhaustive]`** allows new error variants in minor versions (e.g., `JournalFull`, `TimerExpired`).
- **`reason` is `&'static str`.** Error reasons are compile-time constants, not dynamically allocated. This ensures errors are cheap to create and safe in `no_std` contexts.
---
## Testing Helpers
The crate provides assertion macros for testing machines with clear failure messages.
| `assert_transitions_to!(decision, mode)` | Decision transitions to the expected mode. |
| `assert_stays!(decision)` | Decision stays in the current mode. |
| `assert_emits!(decision, [commands])` | Decision emits exactly the expected commands. |
### Failure Messages
These macros produce clear, actionable failure messages:
- `assert_transitions_to!` — `"expected transition to Active, but decision stays in current mode"`
- `assert_stays!` — `"expected decision to stay, but got transition to Some(Active)"`
- `assert_emits!` — `"expected commands [Initialize], got [Initialize, Begin]"`
For partial command checks, use standard assertions on `decision.commands()` directly.
---
## Behavioral Contracts
These are the invariants that the crate upholds and that users can rely on.
### 1. Machine is Pure
`Machine::on_event` takes `&self`. It must not mutate the machine, perform I/O, read global state, or produce different results for the same `(mode, event)` input (given the same `&self` configuration). The machine is a function, not a stateful object.
### 2. Decisions are Data
A `Decision` is an inert value. Creating a `Decision` has no side effects. The runtime is solely responsible for interpreting and executing decisions. This means decision logic can be tested without any runtime, I/O, or mocking.
### 3. Modes are Phases, Not Arbitrary States
The crate is designed for systems that progress through lifecycle phases. While the `Machine` trait does not enforce a specific set of modes, the conceptual model is:
- **Ready**: The system is initialized and waiting to become active.
- **Active**: The system is performing its primary function.
- **Safe**: The system has shut down or is in a recovery state.
Users define their own mode enums, but the design optimizes for this linear-with-recovery pattern rather than arbitrary graph topologies.
### 4. Commands are Instructions, Not Effects
Commands in a `Decision` are data values that describe what the runtime should do. The machine never executes commands itself. This separation means:
- Command types can be simple enums (cheap, cloneable, testable).
- The same machine logic can be driven by different runtimes with different command executors.
- Testing machine logic requires no mocking of external systems.
### 5. Policy is Checked Before Mode Change
The runtime checks the `Policy` before applying a `ModeChange`. If the policy denies the transition, the mode does not change. This contract means:
- The machine does not need to know about policy rules.
- The policy sees the transition request before it takes effect.
- Denied transitions produce a `LifecycleError::TransitionDenied` with full context.
### 6. Time Composes Externally
The base `Machine` trait has no concept of time. Systems that need time-aware transitions encode time in their event types or use the `time` feature module. This means:
- The core trait works on `no_std` targets without clock hardware.
- Time can be virtual (for testing) or real (for production) without changing machine logic.
- The runtime is responsible for time management, not the machine.
### 7. Journal Records, Does Not Modify
The journal observes transitions and records them. It does not influence decisions, modify modes, or filter commands. It is a passive observer, not an active participant in the lifecycle.
---
## Module Boundary Table
| `model` | `core`, `alloc` | (none) | Yes | `Machine`, `Decision`, `ModeChange`, `Policy`, `AllowAll`, `DenyAll`, `stay`, `transition`, `ignore`, `apply_decision`, `apply_decision_checked` |
| `error` | `core` | (none) | Yes | `LifecycleError` |
| `lib` | `model`, `error` | (none) | Yes | `assert_transitions_to!`, `assert_stays!`, `assert_emits!` |
| `time` | `core` | `time` | Yes | `Clock`, `Instant`, `Deadline`, `ManualClock` (+ `SystemClock` with `std`) |
| `runtime` | `model`, `error` | `runtime` | No | `Runner` (mode storage + feed/apply loop) |
| `journal` | `model` | `journal` | No | `TransitionRecord`, `InMemoryJournal`, replay helpers |
### Dependency Direction
```
journal -----> model
runtime -----> model, error
time --------> (standalone, core only)
error -------> (standalone, core only)
model -------> (standalone, core + alloc)
```
Dependencies point inward. Feature-gated modules depend on core modules. Core modules depend on nothing external. This ensures that `--no-default-features` always compiles and that the core contract has zero supply-chain risk.