Files
hsmc/docs/000. original-hsmc-spec.md
T
swaits ad2aa6e04d docs(safety): document the unsafe contract end-to-end
After Opt #2 added six bounded `get_unchecked` blocks per chart, the
project's two "no unsafe" claims (README §"At a glance" + spec §11
rule 12) were stale, and there was no single place answering "how
do I know the unsafe is correct?".

This commit closes both gaps:

- New: docs/004. unsafe-safety-contract.md
  Inventories every unsafe site, names the predicate, names what
  establishes it (codegen-time guarantee), and lists the five
  layered checks (const-eval, debug_assert, miri, mutants, Creusot)
  with current status. Documents what's *not* yet covered (the
  16-of-53 missed mutants in the LCA depth walk are behavior, not
  safety; transition() codegen invariants are still Phase-4 for
  Creusot). Includes a maintenance checklist for future unsafe.

- README points at the doc and gives an honest one-paragraph
  summary instead of "no unsafe".

- Spec rule 12 rewritten from "no unsafe in generated code" to
  "bounded unsafe in generated code, none in user code" with a
  pointer to the audit.

- justfile gains `just safety` — runs const-eval (cargo check),
  debug_assert tests, miri, and mutants in one shot. Mirrors the
  doc's audit matrix so the doc never goes stale silently.
2026-05-04 15:29:24 -06:00

1232 lines
41 KiB
Markdown

# `hsmc` — Hierarchical State Machine Crate for Rust
## Specification v0.1
---
## 1. Overview
`hsmc` is a Rust crate for defining and running Harel-style hierarchical state machines (statecharts). It provides a `statechart!` proc macro for declarative machine definition, separating structure from behavior. The crate targets `no_std` embedded systems through `std` async runtimes, with near-zero runtime cost by doing as much work as possible at compile time.
### 1.1 Design Principles
1. **Separation of structure and behavior.** The statechart macro defines states, transitions, and wiring. Action implementations live in a separate `impl` block on a generated trait. A developer can read the statechart and reason about flow without seeing any implementation code.
2. **No framework leakage.** The user's context struct is their data. No framework fields, no marker types, no special derives. The crate wraps the context internally and provides `emit()` without polluting the user's type.
3. **Compile-time maximalism.** State enums, transition tables, and structural validation are resolved at compile time. Invalid statecharts (duplicate transitions on the same event, missing default states, terminate outside root) are compile errors, not runtime panics.
4. **Runtime portability.** The same statechart definition and action implementations work across bare metal (`no_std`), Embassy, and Tokio. The runtime is selected via a Cargo feature flag. No code changes required.
5. **Minimal surface area.** The crate provides exactly the primitives from Harel's statecharts that are needed: states, substates, entry/exit actions, event-driven transitions, timer-driven transitions, event-driven actions, timer-driven actions, default initial states, and termination. No guards, no orthogonal regions, no history states, no deferred events.
---
## 2. Concepts and Semantics
### 2.1 Machine
A machine is the root-level container. It is itself a state (the root state) and may contain all the same elements as any other state, plus `terminate` and `default` declarations. A machine owns its context, its event queue, its timer state, and its current state configuration.
### 2.2 States
A state is a named node in the state hierarchy. States may be nested arbitrarily deep. A state may contain:
- `entry:` — one or more action function references, called in declaration order when the state is entered.
- `exit:` — one or more action function references, called in declaration order when the state is exited.
- `default(ChildState)` — required if and only if the state has child states. Specifies which child state to enter when this state becomes active.
- `state ChildName { ... }` — nested child states.
- `transition(trigger) => TargetState;` — at most one per trigger per state.
- `action(trigger) => function_name;` — zero or more per trigger per state. Multiple actions on the same trigger are called in declaration order.
### 2.3 State Configuration
At any point in time, the machine is in exactly one "state configuration," which is a path from the root state down through the hierarchy to the **innermost active state**. The innermost active state may be a state with no declared children, or a composite state that has become the innermost active state via a transition to an already-active ancestor (§2.6) and therefore has no active child. If state `A` contains state `B` which contains state `C`, and `C` is the innermost active state, then the machine is simultaneously "in" states `A`, `B`, and `C`.
When a state becomes newly active — that is, it was not in the previous configuration — and it has children, the machine automatically descends into the `default` child, recursively, until it reaches a state with no children or no default. A transition that targets a state which is already active (see §2.6) does NOT trigger default descent, because the target is not newly entered.
### 2.4 Events
Events are variants of a user-defined Rust enum. They are the primary external input to the machine. Events are delivered to the machine via a channel (async runtimes) or polled queue (bare metal). Action functions may also emit events back into the machine via `self.emit()`.
### 2.5 Triggers
A trigger is either:
- **An event:** a variant of the event enum (e.g., `Failure`, `ButtonPressed`).
- **A duration:** a `core::time::Duration` value (e.g., `Duration::from_secs(5)`). This starts an internal timer when the containing state is entered. The timer fires once. If the state is exited before the timer fires, the timer is cancelled. Descending into a child state is NOT an exit and does NOT cancel the parent's timer.
### 2.6 Transitions
A transition specifies: when trigger `T` occurs in state `S`, move to state `Target`.
Transitions are classified by the relationship between `Target` and the **innermost active state** `I` at the time the transition fires:
- **Up-transition**: `Target` is already active — that is, `Target` lies on the path from the root down to `I`, and `Target ≠ I`. You cannot enter a state you never left. An up-transition therefore only unwinds the subtree strictly below `Target`.
- **Self-transition**: `Target = I`. The innermost active state is exited and re-entered (timers restart; entry/exit actions fire).
- **Lateral transition**: `Target` is not on the active path. Standard LCA semantics apply.
The transition algorithm:
1. Classify the transition as up, self, or lateral (see above).
2. Compute the effective LCA:
- **Up**: effective LCA = `Target`.
- **Self**: effective LCA = parent of `Target` (so `Target` is exited and re-entered).
- **Lateral**: effective LCA = LCA(`I`, `Target`).
3. Exit states from `I` upward, calling exit actions inner-to-outer, stopping **before** the effective LCA. (The effective LCA is never exited.)
4. Entry behavior:
- **Up**: no entry actions fire. `Target` was already active and remains so; it is now the innermost active state, with no active child.
- **Self** and **Lateral**: enter states from the effective LCA down to `Target` (outer-to-inner), then if `Target` has children, descend into defaults recursively, calling entry actions along the way.
Transitions may target any state in the entire machine, not just siblings.
Only one transition per trigger per state is allowed. This is enforced at compile time.
### 2.7 Actions
An action specifies: when trigger `T` occurs in state `S`, call function `F`. No state change occurs. Multiple actions may be registered on the same trigger within the same state. They are called in declaration order.
If both actions and a transition are registered on the same trigger in the same state, the actions are called first (in declaration order), then the transition executes (which triggers exit/entry actions as described above).
### 2.8 Event Dispatch
When an event is received (or a timer fires), the machine searches for a matching transition or action starting from the innermost active state and walking up toward the root. The first state that has a handler for the trigger processes it. If no state handles the trigger, the event is silently discarded.
Timer triggers do NOT bubble. A timer belongs to the state that declared it and only fires in that state's scope. Event triggers DO bubble up the hierarchy.
### 2.9 Entry and Exit Actions
- **Entry actions** are called when a state is entered, in declaration order. Multiple entry actions are allowed per state.
- **Exit actions** are called when a state is exited, in declaration order. Multiple exit actions are allowed per state.
- Entry and exit actions are called during transitions (as described in §2.6), during initial machine startup (entering the default state path), and during termination (exiting all active states).
### 2.10 Default State
`default(ChildState)` specifies the child state to descend into when the containing state is newly entered (either at initial machine startup, or via a lateral/self-transition that enters it fresh). It is required in any state that has children. It is required at the root level. The referenced state must be a direct child of the containing state. This is enforced at compile time.
`default` does NOT fire when a state becomes the innermost active state via an up-transition (§2.6): the parent was already active, so there is no "new entry" to descend from.
### 2.11 Terminate
`terminate(EventVariant)` may only appear at the root level of the machine. It specifies that when the given event is received, the machine shuts down. Shutdown means:
1. Exit the innermost active state, calling its exit actions.
2. Exit each ancestor state up to and including the root state, calling exit actions in inner-to-outer order.
3. The machine is now terminated. `run()` returns. `is_terminated()` returns true.
Terminate is only valid at the root level. Placing `terminate` inside a child state is a compile error.
### 2.12 Emitting Events from Actions
Inside any action function, the user may call `self.emit(EventVariant)` to push an event into the machine's internal queue. Emitted events are processed after the current event's handling is complete (i.e., after all actions and the transition for the current trigger have fully resolved, including all entry/exit actions). This prevents re-entrant state changes.
### 2.13 Timer Semantics
- A timer is started when its containing state is entered.
- A timer is cancelled when its containing state is exited.
- Entering a child state does NOT cancel the parent's timer.
- Exiting a child state (to a sibling) does NOT cancel the parent's timer.
- Exiting the parent state cancels the timer.
- A timer fires at most once. After firing, it is not restarted unless the state is re-entered.
- If multiple timers expire at the same instant, they are processed in innermost-state-first order.
---
## 3. Macro Syntax
### 3.1 Grammar
```
statechart! <MachineName> {
context: <ContextType>;
events: <EventType>;
[entry: <fn_name> [, <fn_name>]* ;]*
[exit: <fn_name> [, <fn_name>]* ;]*
[default(<StateName>);]
[terminate(<EventVariant>);]
[transition(<trigger>) => <StateName>;]*
[action(<trigger>) => <fn_name>;]*
[state <StateName> {
[entry: <fn_name> [, <fn_name>]* ;]*
[exit: <fn_name> [, <fn_name>]* ;]*
[default(<ChildStateName>);]
[transition(<trigger>) => <StateName>;]*
[action(<trigger>) => <fn_name>;]*
[state <ChildStateName> { ... }]*
}]*
}
```
Where `<trigger>` is one of:
- An event variant name (e.g., `Failure`, `ButtonPressed`)
- A `Duration` expression (e.g., `Duration::from_secs(5)`, `Duration::from_millis(200)`)
### 3.2 Entry and Exit Shorthand
Both of these are valid and equivalent:
```rust
// Comma-separated on one line
entry: setup, configure, log_start;
// Repeated keyword on separate lines
entry: setup;
entry: configure;
entry: log_start;
// Mixed
entry: setup, configure;
entry: log_start;
```
All forms result in the functions being called in the order they appear, top to bottom, left to right.
### 3.3 Declaration Order
Within a state, declarations may appear in any order. The following orderings are all valid:
```rust
state Foo {
exit: cleanup;
entry: setup;
transition(SomeEvent) => Bar;
}
```
However, the relative order of multiple `entry:`, `exit:`, or `action:` declarations IS significant. They are called in declaration order.
---
## 4. Generated Code
The `statechart!` macro generates the following items, where `<Name>` is the machine name:
### 4.1 `<Name>State` Enum
An enum with one variant per state declared in the machine (including nested states). Derives `Debug`, `Clone`, `Copy`, `PartialEq`, `Eq`. The exact representation of nested states is an implementation detail but must support comparison and debugging.
### 4.2 `<Name>Actions` Trait
A trait with one method per unique action function name referenced anywhere in the statechart (entry, exit, action, across all states). Every method has the signature:
```rust
fn <fn_name>(&mut self);
```
The trait is implemented by the user on `<Name>ActionContext<'_>`.
### 4.3 `<Name>ActionContext<'a>` Struct
A struct that wraps `&'a mut <ContextType>` and provides:
- `Deref<Target = ContextType>` and `DerefMut` so that `self.field` accesses the user's context directly.
- `fn emit(&mut self, event: <EventType>)` to push an event into the machine's internal queue.
The user implements `<Name>Actions` on this type.
### 4.4 `<Name>` Struct (The Machine)
The machine struct, with the following public API:
```rust
impl<Name> {
/// Create a new machine instance. Does NOT enter the initial state.
/// Call `run()` or `step()` to start.
fn new(context: ContextType) -> Self;
/// Returns a cloneable sender handle for pushing events
/// into the machine from external code (other tasks, ISRs, threads).
fn sender(&self) -> <Name>Sender;
/// Run the machine to completion (async runtimes).
/// Enters the default state on first call.
/// Returns when the machine terminates.
/// Returns Err if the machine is already terminated.
async fn run(&mut self) -> Result<(), HsmcError>;
/// Process one unit of work: one pending event or one expired timer.
/// For bare metal / synchronous use.
/// `elapsed` is the time since the last call to `step()`.
/// Enters the default state on first call.
/// Returns the duration until the next timer fires, or None.
fn step(&mut self, elapsed: Duration) -> Option<Duration>;
/// Returns true if the machine has terminated.
fn is_terminated(&self) -> bool;
/// Returns the innermost active state (see §2.3). This is typically a
/// leaf, but may be a composite state that became innermost via an
/// up-transition (§2.6).
fn current_state(&self) -> <Name>State;
/// Consumes the machine, returns the user's context.
fn into_context(self) -> ContextType;
}
```
### 4.5 `<Name>Sender` Struct
A cloneable, send-safe handle for pushing events into the machine.
```rust
impl<Name>Sender {
/// Push an event. Non-blocking.
/// Returns Err if the queue is full.
fn send(&self, event: EventType) -> Result<(), HsmcError>;
}
```
---
## 5. Runtime Configuration
### 5.1 Feature Flags
| Feature | Channel Backend | Timer Backend | `run()` available | `step()` available |
|------------|------------------------------|-------------------------|--------------------|--------------------|
| (default) | `heapless::spsc::Queue` | manual via `step(elapsed)` | No | Yes |
| `embassy` | `embassy_sync::channel::Channel` | `embassy_time::Timer` | Yes (async) | Yes |
| `tokio` | `tokio::sync::mpsc` | `tokio::time::sleep` | Yes (async) | Yes |
Only one async feature may be enabled at a time. Enabling both `embassy` and `tokio` is a compile error.
`step()` is always available regardless of feature flags, for testing and for mixed-use scenarios.
### 5.2 Queue Capacity
The default internal event queue capacity is 8. This may be overridden at instantiation:
```rust
let mut machine = Stoplight::with_queue_capacity::<16>(context);
```
---
## 6. Error Handling
### 6.1 `HsmcError` Enum
```rust
#[derive(Debug, Clone, PartialEq, Eq)]
enum HsmcError {
/// The event queue is full. Event was not enqueued.
QueueFull,
/// The machine has already terminated. No further operations are valid.
AlreadyTerminated,
}
```
### 6.2 Compile-Time Errors
The macro produces compile errors for:
| Condition | Error Message |
|-----------|---------------|
| Duplicate transition on same trigger in same state | "duplicate transition on trigger `X` in state `Y`" |
| `terminate` inside a non-root state | "`terminate` is only valid at the root level" |
| `default` missing in a state that has children | "state `X` has children but no `default` declaration" |
| `default` referencing a non-direct-child | "`default(Y)` in state `X`: `Y` is not a direct child of `X`" |
| `default` in a state that has no children | "`default` in state `X` which has no child states" |
| Action function name used but not in trait impl | Standard Rust trait error: "method `X` not found" |
| State referenced in transition not found | "unknown state `X` in transition" |
| Event variant in trigger not found in event enum | "unknown event variant `X`" |
| Empty state (no entries, exits, transitions, actions, or children) | "empty state `X`" |
| Multiple `default` declarations in one state | "multiple `default` declarations in state `X`" |
| Multiple `terminate` declarations at root | "multiple `terminate` declarations" |
---
## 7. Complete Example: Traffic Light
```rust
use hsmc::{statechart, Duration};
// --- Events ---
#[derive(Event, Debug, Clone)]
enum StoplightEvent {
Failure,
}
// --- User Context ---
struct StoplightContext {
led_controller: LedController,
}
// --- Statechart Definition ---
statechart! Stoplight {
context: StoplightContext;
events: StoplightEvent;
entry: do_post;
default(Red);
terminate(Failure);
exit: enable_red_flash_mode;
state Red {
entry: red_on;
exit: red_off;
transition(Duration::from_secs(5)) => Green;
}
state Green {
entry: green_on;
exit: green_off;
transition(Duration::from_secs(5)) => Yellow;
}
state Yellow {
entry: yellow_on;
exit: yellow_off;
transition(Duration::from_millis(1000)) => Red;
}
}
// --- Action Implementations ---
impl StoplightActions for StoplightActionContext<'_> {
fn do_post(&mut self) {
assert!(self.led_controller.self_test());
}
fn enable_red_flash_mode(&mut self) {
self.led_controller.flash(Led::Red);
}
fn red_on(&mut self) { self.led_controller.set(Led::Red, true); }
fn red_off(&mut self) { self.led_controller.set(Led::Red, false); }
fn green_on(&mut self) { self.led_controller.set(Led::Green, true); }
fn green_off(&mut self) { self.led_controller.set(Led::Green, false); }
fn yellow_on(&mut self) { self.led_controller.set(Led::Yellow, true); }
fn yellow_off(&mut self){ self.led_controller.set(Led::Yellow, false); }
}
// --- Embassy Usage ---
#[embassy_executor::task]
async fn traffic_light_task() {
let ctx = StoplightContext {
led_controller: LedController::new(),
};
let mut light = Stoplight::new(ctx);
let sender = light.sender();
// Wire up fault detection
spawn(async move {
if fault_detected().await {
sender.send(StoplightEvent::Failure).unwrap();
}
});
light.run().await.unwrap();
}
// --- Bare Metal Usage ---
fn main() -> ! {
let ctx = StoplightContext {
led_controller: LedController::new(),
};
let mut light = Stoplight::new(ctx);
loop {
let elapsed = hw_timer.lap();
light.step(elapsed);
if light.is_terminated() {
enter_safe_mode();
}
}
}
```
---
## 8. Complete Example: Nested States (Parse Test)
This exercises the full grammar.
```rust
use hsmc::{statechart, Duration};
#[derive(Event, Debug, Clone)]
enum MyEvent {
Event1,
Event2,
Halt,
}
struct MyContext {
log: Vec<String>,
}
statechart! Name {
context: MyContext;
events: MyEvent;
default(FirstState);
entry: mainentry, mainentry2, mainentry3;
exit: mainexit;
terminate(Halt);
// Root-level transitions and actions
transition(Duration::from_secs(1)) => FirstState;
action(Duration::from_secs_f64(0.99)) => asldkfj;
action(Duration::from_secs_f64(0.99)) => asldkfj1;
action(Duration::from_secs_f64(0.99)) => asldkfj2;
state FirstState {
entry: blah_entry;
exit: blah_exit;
transition(Event2) => SecondState;
action(Event1) => x_action;
state SubState {
default(SubSubState);
state SubSubState {
default(SubSubSubState);
state SubSubSubState {
}
}
}
}
state SecondState {
transition(Event1) => FirstState;
}
state XSecondState {
entry: x_entry;
}
state SomeEndState {
entry: end_entry;
exit: end_exit;
action(Duration::from_secs_f64(0.23)) => something;
}
}
impl NameActions for NameActionContext<'_> {
fn mainentry(&mut self) { self.log.push("mainentry".into()); }
fn mainentry2(&mut self) { self.log.push("mainentry2".into()); }
fn mainentry3(&mut self) { self.log.push("mainentry3".into()); }
fn mainexit(&mut self) { self.log.push("mainexit".into()); }
fn blah_entry(&mut self) { self.log.push("blah_entry".into()); }
fn blah_exit(&mut self) { self.log.push("blah_exit".into()); }
fn x_action(&mut self) { self.log.push("x_action".into()); }
fn asldkfj(&mut self) { self.log.push("asldkfj".into()); }
fn asldkfj1(&mut self) { self.log.push("asldkfj1".into()); }
fn asldkfj2(&mut self) { self.log.push("asldkfj2".into()); }
fn x_entry(&mut self) { self.log.push("x_entry".into()); }
fn end_entry(&mut self) { self.log.push("end_entry".into()); }
fn end_exit(&mut self) { self.log.push("end_exit".into()); }
fn something(&mut self) { self.log.push("something".into()); }
}
```
---
## 9. Test Cases
All test cases use `step()` with manual time advancement for deterministic testing.
### 9.1 Initialization
#### T1.1: Default state entry on first step
```
Given: A machine with default(Red), root entry: [do_post], Red entry: [red_on]
When: First call to step(Duration::ZERO)
Then: Actions called in order: [do_post, red_on]
current_state() == Red
is_terminated() == false
```
#### T1.2: Nested default descent on initialization
```
Given: A machine with:
default(A)
state A { default(B); state B { default(C); state C {} } }
Root entry: [r_entry], A entry: [a_entry], B entry: [b_entry], C entry: [c_entry]
When: First call to step(Duration::ZERO)
Then: Actions called in order: [r_entry, a_entry, b_entry, c_entry]
current_state() == C
```
#### T1.3: Machine not started until first step
```
Given: A new machine created with Stoplight::new(ctx)
When: No calls to step() or run()
Then: is_terminated() == false
No action functions have been called
```
### 9.2 Transitions
#### T2.1: Event-driven transition between siblings
```
Given: Machine in state Red, with transition(GoGreen) => Green
Red exit: [red_off], Green entry: [green_on]
When: Event GoGreen is delivered
Then: Actions called in order: [red_off, green_on]
current_state() == Green
```
#### T2.2: Timer-driven transition
```
Given: Machine in state Red, with transition(Duration::from_secs(5)) => Green
When: step() called with cumulative elapsed time reaching 5 seconds
Then: Transition fires. current_state() == Green
```
#### T2.3: Timer cancelled on state exit
```
Given: Machine in state Red, with:
transition(Duration::from_secs(5)) => Green
transition(EarlyExit) => Yellow
When: At t=3s, event EarlyExit is delivered
At t=5s, step() is called
Then: Machine is in Yellow (not Green). The 5s timer was cancelled when Red was exited.
```
#### T2.4: Cross-hierarchy transition
```
Given: Machine with:
state A { state B { state C { transition(Jump) => D } } }
state D {}
Machine is in state C.
When: Event Jump is delivered
Then: Exit order: [C_exit, B_exit, A_exit]
Entry order: [D_entry]
current_state() == D
```
#### T2.5: Transition to a state with children descends to default
```
Given: Machine with:
state A { transition(Go) => B }
state B { default(C); state C { default(D); state D {} } }
Machine is in state A.
When: Event Go is delivered
Then: Entry order: [B_entry, C_entry, D_entry]
current_state() == D
```
#### T2.6: Self-transition (transition to own state)
```
Given: Machine in state Red, with transition(Reset) => Red
Red exit: [red_off], Red entry: [red_on]
When: Event Reset is delivered
Then: Actions called: [red_off, red_on]
current_state() == Red
Any timers in Red are restarted
```
#### T2.7: Transition from child to sibling of parent
```
Given: Machine with:
state A { state B { transition(Go) => C } }
state C {}
Machine is in B.
When: Event Go is delivered
Then: Exit order: [B_exit, A_exit]
Entry order: [C_entry]
current_state() == C
```
#### T2.8: Up-transition to an ancestor state
A transition whose target is already active (an ancestor of the innermost active state) only unwinds the subtree strictly below the target. The target itself is not exited and not re-entered. Its `default` is not followed — it was already active, so there is no "new entry."
```
Given: Machine with:
state A { default(B); state B { transition(Up) => A } }
Machine is in B.
When: Event Up is delivered
Then: Exit order: [B_exit]
Entry order: []
current_state() == A
```
#### T2.8b: Up-transition across multiple levels
```
Given: Machine with:
state A { default(B); state B { default(C); state C { transition(Up) => A } } }
Machine is in C.
When: Event Up is delivered
Then: Exit order: [C_exit, B_exit]
Entry order: []
current_state() == A
```
#### T2.8c: Handler on ancestor, target is a mid-ancestor
When the handler bubbles up to an ancestor, classification is based on the innermost active state at the time the transition fires — not on where the handler was found. If the target is on the active path and not the innermost, it is still an up-transition.
```
Given: Machine with:
state A {
default(B);
transition(Up) => B; // handler on A, target B
state B { default(C); state C {} }
}
Machine is in C.
When: Event Up is delivered
Then: Exit order: [C_exit]
Entry order: []
current_state() == B
```
### 9.3 Actions
#### T3.1: Action does not change state
```
Given: Machine in state Red, with action(Beep) => honk
When: Event Beep is delivered
Then: honk() is called
current_state() == Red (unchanged)
```
#### T3.2: Multiple actions on same trigger, declaration order
```
Given: Machine in state Red, with:
action(Beep) => honk1;
action(Beep) => honk2;
action(Beep) => honk3;
When: Event Beep is delivered
Then: Actions called in order: [honk1, honk2, honk3]
```
#### T3.3: Actions fire before transition on same trigger
```
Given: Machine in state Red, with:
action(Go) => log_go;
action(Go) => prepare;
transition(Go) => Green;
Red exit: [red_off], Green entry: [green_on]
When: Event Go is delivered
Then: Actions called in order: [log_go, prepare, red_off, green_on]
current_state() == Green
```
#### T3.4: Timer-driven action
```
Given: Machine in state Red, with:
action(Duration::from_millis(500)) => heartbeat;
When: step() called with cumulative elapsed time reaching 500ms
Then: heartbeat() is called
current_state() == Red (unchanged)
Timer does NOT restart automatically
```
### 9.4 Event Dispatch and Bubbling
#### T4.1: Event handled by current leaf state
```
Given: Machine in state C (inside B inside A)
C has transition(Foo) => D
When: Event Foo is delivered
Then: Transition fires from C. C handled the event.
```
#### T4.2: Event bubbles to parent
```
Given: Machine in state C (inside B inside A)
C has no handler for Foo
B has no handler for Foo
A has transition(Foo) => D
When: Event Foo is delivered
Then: A handles the event. Exit order: [C_exit, B_exit, A_exit]. Entry: [D_entry].
```
#### T4.3: Event bubbles to root
```
Given: Machine in state C (inside B inside A)
Root has action(Foo) => root_handler
No other state handles Foo
When: Event Foo is delivered
Then: root_handler() is called
```
#### T4.4: Leaf state shadows parent handler
```
Given: Machine in state C (inside A)
C has transition(Foo) => D
A has transition(Foo) => E
When: Event Foo is delivered
Then: C's handler wins. Machine transitions to D, not E.
```
#### T4.5: Unhandled event is silently discarded
```
Given: Machine in state Red. No state handles event Unknown.
When: Event Unknown is delivered
Then: No actions called. No state change. No error.
```
#### T4.6: Timer triggers do NOT bubble
```
Given: Machine in state C (inside B inside A)
A has transition(Duration::from_secs(10)) => D
C has no timer
When: A's timer fires while machine is in C
Then: A's timer handler fires (machine is "in" A). This is NOT bubbling;
A's timer belongs to A and fires because A is active.
```
### 9.5 Entry and Exit Ordering
#### T5.1: Multiple entry actions in declaration order
```
Given: State A with entry: alpha, beta, gamma;
When: State A is entered
Then: Called in order: [alpha, beta, gamma]
```
#### T5.2: Multiple exit actions in declaration order
```
Given: State A with:
exit: cleanup1;
exit: cleanup2;
When: State A is exited
Then: Called in order: [cleanup1, cleanup2]
```
#### T5.3: Entry across hierarchy (outer to inner)
```
Given: Transition into state C (inside B inside A), all having entry actions
When: Transition fires
Then: Entry order: [A_entry, B_entry, C_entry]
```
#### T5.4: Exit across hierarchy (inner to outer)
```
Given: Machine in state C (inside B inside A), all having exit actions
When: Transition out of the entire subtree fires
Then: Exit order: [C_exit, B_exit, A_exit]
```
#### T5.5: Transition between states with a common ancestor
```
Given: Machine with:
state Parent {
default(A);
state A { transition(Go) => B }
state B {}
}
Machine is in A.
When: Event Go is delivered
Then: Exit: [A_exit] (Parent is NOT exited — it's the LCA)
Entry: [B_entry] (Parent is NOT re-entered)
Parent's timers remain active
```
### 9.6 Terminate
#### T6.1: Terminate event exits all states
```
Given: Machine in state C (inside B inside A, inside root)
Root has terminate(Halt)
All states have exit actions
When: Event Halt is delivered
Then: Exit order: [C_exit, B_exit, A_exit, root_exit]
is_terminated() == true
```
#### T6.2: Terminated machine rejects further events
```
Given: Machine is terminated
When: sender.send(SomeEvent) is called
Then: Returns Err(HsmcError::QueueFull) or similar
(or the event is silently dropped — TBD based on runtime)
```
#### T6.3: run() returns after termination
```
Given: Machine running via run().await
When: Terminate event is received and processed
Then: run() returns Ok(())
```
#### T6.4: step() after termination
```
Given: Machine is terminated
When: step(elapsed) is called
Then: Returns None. No actions called. No state changes.
```
### 9.7 Emit (Events from Actions)
#### T7.1: Emitted event is processed after current event completes
```
Given: Machine in state A.
A has action(Foo) => emit_bar, where emit_bar calls self.emit(Bar)
A has transition(Bar) => B
When: Event Foo is delivered
Then: emit_bar() runs first.
After Foo is fully processed, Bar is dequeued.
Transition to B fires.
current_state() == B
```
#### T7.2: Emitted events during transition entry/exit
```
Given: Machine in state A, transition(Go) => B
A exit action calls self.emit(Cleanup)
Root has action(Cleanup) => do_cleanup
When: Event Go is delivered
Then: A_exit runs (emits Cleanup).
B_entry runs.
Transition completes.
Then Cleanup event is processed: do_cleanup() runs.
```
#### T7.3: Queue overflow from emit
```
Given: Machine with queue capacity 8
An action calls self.emit() 9 times
When: The 9th emit() is called
Then: Returns Err(HsmcError::QueueFull) or panics (TBD — define behavior)
```
### 9.8 Timer Semantics
#### T8.1: Timer starts on state entry
```
Given: State Red with transition(Duration::from_secs(5)) => Green
When: Red is entered at t=0
step() called at t=4.9s
Then: No transition. current_state() == Red
When: step() called at t=5.0s
Then: Transition fires. current_state() == Green
```
#### T8.2: Timer cancelled on state exit
```
Given: State Red with:
transition(Duration::from_secs(5)) => Green
transition(Interrupt) => Yellow
When: Red entered at t=0
Event Interrupt at t=2s -> transitions to Yellow
step() at t=5s
Then: current_state() == Yellow (timer was cancelled, no transition to Green)
```
#### T8.3: Parent timer survives child state transitions
```
Given: State Parent with:
transition(Duration::from_secs(10)) => Done
default(ChildA)
state ChildA { transition(Duration::from_secs(3)) => ChildB }
state ChildB {}
When: Parent entered at t=0 (descends to ChildA)
t=3s: ChildA transitions to ChildB
t=10s: Parent timer fires
Then: At t=3s: current_state() == ChildB, Parent timer still running
At t=10s: Parent timer fires, machine transitions to Done
Exit order: [ChildB_exit, Parent_exit, Done_entry]
```
#### T8.4: Timer restarts on re-entry
```
Given: State Red with transition(Duration::from_secs(5)) => Green
State Green with transition(Duration::from_secs(5)) => Red
When: Red entered at t=0
t=5s: transitions to Green
t=10s: transitions back to Red (timer restarts)
t=14s: step()
Then: current_state() == Red (timer hasn't fired yet, only 4s since re-entry)
When: t=15s: step()
Then: current_state() == Green (timer fires at 5s after re-entry)
```
#### T8.5: Multiple timers in different hierarchy levels
```
Given: State A with:
action(Duration::from_secs(2)) => ping
default(B)
state B { transition(Duration::from_secs(5)) => C }
state C {}
When: A entered at t=0 (descends to B)
t=2s: A's action timer fires
Then: ping() called. current_state() == B (unchanged)
When: t=5s: B's transition timer fires
Then: current_state() == C
A's timer has already fired and does not repeat
```
### 9.9 Compile-Time Validation
#### T9.1: Duplicate transition on same trigger
```rust
state Red {
transition(Go) => Green;
transition(Go) => Yellow; // COMPILE ERROR
}
```
#### T9.2: Missing default with children
```rust
state Parent {
state ChildA {}
state ChildB {}
// No default — COMPILE ERROR
}
```
#### T9.3: Terminate in child state
```rust
state SomeChild {
terminate(Halt); // COMPILE ERROR
}
```
#### T9.4: Default references non-child
```rust
state Parent {
default(Cousin); // COMPILE ERROR — Cousin is not a direct child
state Child {}
}
```
#### T9.5: Default in childless state
```rust
state Leaf {
default(Ghost); // COMPILE ERROR — Leaf has no children
}
```
#### T9.6: Empty state
```rust
state Empty {} // COMPILE ERROR
```
#### T9.7: Duplicate default
```rust
state Parent {
default(A);
default(B); // COMPILE ERROR
state A {}
state B {}
}
```
#### T9.8: Unknown state in transition target
```rust
state A {
transition(Go) => NonExistent; // COMPILE ERROR
}
```
#### T9.9: Multiple terminate declarations
```rust
statechart! Foo {
terminate(Halt);
terminate(Abort); // COMPILE ERROR
}
```
### 9.10 Edge Cases
#### T10.1: Same action name used in multiple states
```
Given: States A and B both reference entry: shared_setup
When: A is entered, then later B is entered
Then: shared_setup() is called both times. Single trait method, reused.
```
#### T10.2: Machine with no events (timer-only)
```rust
#[derive(Event)]
enum NoEvents {} // empty enum
statechart! TimerOnly {
context: Ctx;
events: NoEvents;
default(A);
state A {
transition(Duration::from_secs(1)) => B;
}
state B {
transition(Duration::from_secs(1)) => A;
}
}
// Must compile and run. Machine oscillates between A and B on timers alone.
```
#### T10.3: Deeply nested states (5+ levels)
```
Given: States nested 5 levels deep: A > B > C > D > E
Default chain descends all the way to E
When: Machine starts
Then: Entry order: [root_entry, A_entry, B_entry, C_entry, D_entry, E_entry]
current_state() == E
```
#### T10.4: Rapid event emission chain
```
Given: State A action on Foo emits Bar
State A action on Bar emits Baz
State A transition on Baz => B
When: Event Foo delivered
Then: Foo processed (Bar emitted), then Bar processed (Baz emitted),
then Baz processed (transition to B).
No stack overflow. Events are queued, not recursed.
```
#### T10.5: Event emitted during termination exit cascade
```
Given: Root has terminate(Halt)
State A exit calls self.emit(Cleanup)
When: Halt event received while in A
Then: A_exit runs (emits Cleanup). Root_exit runs.
Machine is terminated.
Cleanup event is NOT processed (machine is terminated).
```
#### T10.6: Transition from root level
```
Given: Root has transition(Duration::from_secs(60)) => FirstState
Machine is in some deep nested state
When: Root timer fires
Then: All nested states exit (inner to outer), then FirstState is entered
via default descent. Root is NOT exited (it's the LCA of itself).
```
#### T10.7: into_context() after termination
```
Given: Machine has terminated
When: into_context() is called
Then: Returns the user's context struct with any mutations made by actions
```
---
## 10. Acceptance Criteria
The crate is considered complete when:
1. **All test cases in §9 pass.** Every test case must have a corresponding automated test.
2. **Compiles with `#![no_std]`** with default features (no `embassy`, no `tokio`).
3. **Zero dynamic allocation** with default features. No `alloc` crate dependency. The `tokio` feature may use heap allocation for channels.
4. **All compile-time validations in §9.9 produce clear, actionable error messages** pointing to the offending line in the macro invocation.
5. **The generated `StoplightActions` trait exactly matches** the set of unique action function names in the statechart, with no extra methods and no missing methods.
6. **`Deref`/`DerefMut` on `ActionContext`** allows transparent access to all fields of the user's context struct without qualification.
7. **`emit()` on `ActionContext`** successfully enqueues events that are processed after the current event completes.
8. **`sender()` returns a handle** that is `Send + Clone` (when the event type is `Send`), usable from other threads, tasks, or ISRs.
9. **Feature flag mutual exclusion.** Enabling both `embassy` and `tokio` produces a compile error.
10. **`step()` is deterministic.** Given the same sequence of events and elapsed durations, the same sequence of actions is always called in the same order.
11. **Timer precision.** Timers fire on the first `step()` call where cumulative elapsed time meets or exceeds the duration. They do not fire early.
12. **Bounded `unsafe` in generated code, none in user code.** The
generated dispatch path uses six `slice::get_unchecked` blocks
for hot-path table indexing; their bounds are guaranteed at
codegen time, checked at build time by const-eval, in dev/test
by `debug_assert!`, and in CI under Miri. Inventory + audit in
[`docs/004. unsafe-safety-contract.md`](./004.%20unsafe-safety-contract.md).
User-facing code (chart definitions, action bodies, durings,
public API surface) contains no `unsafe`.
13. **Documentation.** Every public type, method, and trait has rustdoc with examples.
---
## 11. Out of Scope
The following are explicitly NOT part of this design:
- Guard conditions on transitions
- Orthogonal / parallel regions
- History states (shallow or deep)
- Deferred events
- Internal transitions (transitions that don't trigger exit/re-entry)
- State-local context / per-state data
- Visual statechart rendering or diagram export
- Runtime statechart modification
- Event priority or ordering guarantees beyond FIFO
- Inter-machine communication primitives
These may be considered for future versions but are not part of v0.1.
---
## 12. Open Questions
1. **`emit()` overflow behavior.** Should `emit()` return `Result<(), HsmcError>` (forcing the action to handle it), or panic on overflow (simpler action code, fails loud)? Recommendation: return `Result` for `no_std` safety, provide a `emit_or_panic()` convenience method.
2. **Empty event enum.** Should a timer-only machine require an event enum at all, or should `events:` be optional in the macro? Recommendation: make it optional; if omitted, no `sender()` is generated.
3. **`step()` return semantics.** Should `step()` return how many events/timers it processed, or just the next deadline? Recommendation: return `Option<Duration>` (time until next timer), plus a separate `has_pending_events()` method.
4. **Action context lifetime.** The `Deref` approach means users write `impl StoplightActions for StoplightActionContext<'_>`. Is the lifetime annotation too noisy? Alternative: use a type alias. Recommendation: provide `type StoplightCtx<'a> = StoplightActionContext<'a>;` as a convenience alias.
5. **Const Duration expressions in macro.** `Duration::from_secs(5)` works in const context, but `Duration::from_secs_f64(0.23)` does not (as of Rust 1.82). Should the macro accept float literals and convert them internally? Recommendation: yes, accept bare float literals like `5.0` and integer literals like `5` as seconds, converting to `Duration` internally. Reserve `Duration::` syntax for when the user needs explicit units.