Service lifecycle — zero-drift spec from a transition table¶
What this teaches: how to build a model directly from the allowed-transitions table your code already enforces, so the spec and the implementation share a single source of truth and can never diverge.
The problem: drift between spec and code¶
The standard way to write a model checker spec is to hand-code the guards and transitions separately from the production state machine. Those two artefacts then drift — a new transition is added to the code, the spec is forgotten, and the checker proves a property of a system that no longer exists.
The solution is to feed status_field_actions the exact ALLOWED dict your code uses at runtime:
ALLOWED: dict[str, set[str]] = {
"pending": {"placed", "failed"},
"placed": {"running", "failed", "stopped", "pending"},
"running": {"stopped", "failed", "pending"},
"failed": {"placed", "pending"},
"stopped": {"placed"},
}
musil reads that map and generates one Action per legal edge. There is no separate spec file — the model is the table.
What musil checks¶
- Safety: every reachable
statusvalue is one of the declared states. No transition leads somewhere uncharted. - Deadlock: every state with no declared successors is marked terminal; no state is a surprise dead end.
- Full reachability: the BFS visits all declared states, confirming the table has no unreachable corners.
terminal_states(ALLOWED) derives the sinks (stopped), and declared_states(ALLOWED) gives the full set — both computed from the same dict, never written twice.
Source¶
"""The zero-drift bridge: build a model straight from an allowed-transitions table.
Point musil at the very ``ALLOWED[entity]`` map your code already enforces and the model *is* the
code's table, so it can never drift -- there is no separate spec file to keep in sync. Here a small
service lifecycle stands in for that table. musil checks that every declared state is reachable with
no deadlock (sinks are declared terminal).
Run: python examples/service_lifecycle.py
"""
from __future__ import annotations
from dataclasses import dataclass
from musil import Model, check, declared_states, status_field_actions, terminal_states, to_dot
# a service lifecycle state machine, expressed as an allowed-transitions table
ALLOWED: dict[str, set[str]] = {
"pending": {"placed", "failed"},
"placed": {"running", "failed", "stopped", "pending"},
"running": {"stopped", "failed", "pending"},
"failed": {"placed", "pending"},
"stopped": {"placed"},
}
@dataclass(frozen=True)
class Service:
status: str = "pending"
MODEL = Model(
init=Service("pending"),
actions=status_field_actions(ALLOWED),
invariants={"status-declared": lambda s: s.status in declared_states(ALLOWED)},
terminal=lambda s: s.status in terminal_states(ALLOWED),
)
if __name__ == "__main__":
result = check(MODEL)
print(result)
print(f"reached {result.states_explored} of {len(declared_states(ALLOWED))} declared states")
print("\n--- DOT ---")
print(to_dot(MODEL))
Run it¶
See also¶
- Traffic light — the minimal FSM with hand-coded transitions.
- Lost update — what happens when two actors share state and their steps interleave.