ready-active-safe 0.1.3

Lifecycle engine for externally driven systems
Documentation
# 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

| Term               | Definition                                                                                   |
|--------------------|----------------------------------------------------------------------------------------------|
| **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

| Method           | Purpose                                                          |
|------------------|------------------------------------------------------------------|
| `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

| Method           | Purpose                                                          |
|------------------|------------------------------------------------------------------|
| `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

| Aspect         | Choice           | Why                                                                      |
|----------------|------------------|--------------------------------------------------------------------------|
| `&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

| Constructor                     | Mode Change | Commands | Use Case                                      |
|---------------------------------|-------------|----------|-----------------------------------------------|
| `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

| Method           | Returns                    | Description                        |
|------------------|----------------------------|------------------------------------|
| `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

| Method              | Returns                              | Description                        |
|---------------------|--------------------------------------|------------------------------------|
| `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

| Method                          | Returns                                    | Description                         |
|---------------------------------|--------------------------------------------|-------------------------------------|
| `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

| Function         | Returns          | Purpose                                             |
|------------------|------------------|------------------------------------------------------|
| `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(&current_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

| Policy     | Behavior                                     | Use Case                          |
|------------|----------------------------------------------|-----------------------------------|
| `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.

| Macro                                  | Asserts                                        |
|----------------------------------------|------------------------------------------------|
| `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

| Module     | Depends On       | Feature Gate | `no_std` | Exports                                                     |
|------------|------------------|-------------|----------|--------------------------------------------------------------|
| `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.