Safety model
This document is normative. station/state.py implements the transition table
below verbatim, and a test asserts they match. If you change one, change both.
The hazard
The dangerous operation in this system is changing a route while an output is live — a relay opening under current arcs and welds; a relay closing into a biased net back-drives a pin no profile mentions. The sweep itself is the least dangerous step. Every rule below exists to make the hazardous transition impossible in software, and to be honest when software cannot know the physical state.
States
DISCONNECTED
CONNECTED_SAFE both drivers connected; outputs off; all routes open
CONFIGURED a compiled plan is loaded; nothing energized
ARMED preflight token issued and consumed; about to run
RUNNING_ROUTING relays may move; outputs are OFF (verified where possible)
RUNNING_MEASURING output may be live; routes are FROZEN
COMPLETED | ABORTED terminal, safe
FAULT_SAFE terminal; something failed but outputs confirmed off
FAULT_UNCONFIRMED terminal, LATCHED; output state is UNKNOWN
RUNNING_ROUTING and RUNNING_MEASURING are separate states, not flags. The
mutual exclusion is by construction: there is no state in which relays may move
while an output may be live.
Transition table
| From | Allowed to |
|---|---|
| DISCONNECTED | CONNECTED_SAFE |
| CONNECTED_SAFE | CONFIGURED, DISCONNECTED |
| CONFIGURED | ARMED, CONNECTED_SAFE, DISCONNECTED |
| ARMED | RUNNING_ROUTING, CONNECTED_SAFE |
| RUNNING_ROUTING | RUNNING_MEASURING, COMPLETED, ABORTED, FAULT_SAFE, FAULT_UNCONFIRMED |
| RUNNING_MEASURING | RUNNING_ROUTING, ABORTED, FAULT_SAFE, FAULT_UNCONFIRMED |
| COMPLETED | CONFIGURED, CONNECTED_SAFE |
| ABORTED | CONFIGURED, CONNECTED_SAFE |
| FAULT_SAFE | CONNECTED_SAFE |
| FAULT_UNCONFIRMED | DISCONNECTED (manual recovery only) |
Any other transition raises InvalidTransition.
FAULT_UNCONFIRMED — why it exists
Reaching a safe state is not guaranteed. If outputs_off() times out,
errors, or the instrument disconnects mid-run, the physical output state is
unknown. Opening relays at that moment is exactly the hazard. Therefore:
- The matrix is opened only after output-off is acknowledged by the instrument, and verified by read-back where the instrument supports it.
- If output-off cannot be confirmed, the station latches
FAULT_UNCONFIRMED, leaves all relays exactly as they are, and requires explicit operator action (recover from the bench, then disconnect) to leave the state. safe_state()is ordered: instrument outputs off, then matrix open-all. Never the reverse, on any path, including abort and process shutdown.
Cancellation is not an escape hatch
Cancelling the run task (client disconnect, service shutdown, Ctrl-C) runs the same safe sequence as an abort, shielded so a second cancel cannot interrupt it: instrument abort → output-off confirmed → open all → ABORTED, or latch FAULT_UNCONFIRMED and touch nothing. The cancellation then proceeds. A cancelled run's partial bundle is finalized, never left interrupted.
Connecting cannot move a relay
MotherboardController.connect() is contractually NON-ACTUATING, and the
station does not rely on that promise: the instrument is connected first and
its output confirmed off before any matrix operation of any kind. A station
booting into a world where an output is already live refuses to connect.
Output-state provenance
A boolean outputs_off in a log is a software assertion, not physics. Every
route record and every output-off attempt records a verification status:
| Status | Meaning |
|---|---|
confirmed |
the instrument reports its output state and we read it back off |
command_acknowledged |
the command returned success; the instrument cannot be queried |
unsupported |
the driver offers no acknowledgement at all |
unknown |
the call failed or timed out |
Reports must never describe a command_acknowledged or unsupported output as
"proven off."
Arming
Arming is server-owned. preflight() validates every route against the matrix
and every sweep against the instrument, then issues an expiring, single-use
token bound to: the exact plan hash, both capability snapshots, the station
connection epoch, and the module identity. execute() consumes the token under a
single-run lock. A UI gesture (the 600 ms hold) may request preflight; it never
is the authorization.
Known gap — hardware interlock
Software shutdown is best-effort: a kernel panic, a yanked USB cable, or a hung driver leaves software unable to guarantee anything. Real operation needs a hardware interlock or dead-man path (instrument hardware compliance at minimum). Whether the ABSMAX board interlocks route changes against load is sponsor question §03 Q7 in the buildbook and is unanswered. Until answered, treat the matrix as having no interlock.
Simulation is never silent
Simulated mode is entered explicitly, shows a persistent SIMULATED banner in any
UI, is recorded in every run and event record (source_mode), and cannot be
switched during a run. There is no automatic live→demo fallback anywhere.
Generated from docs/SAFETY.md at build time — that file is the source of truth.