Lockout/tagout — a machine with a personal lock on it cannot start
OSHA calls 1910.147 violations a perennial top-ten killer: maintenance inside a machine, someone hits start, the machine wins. The padlock works because ONLY its owner holds the key. Here `locked_out` has no edge to `running` — re-energizing exists only from `isolated`, and the handler that leaves `locked_out` refuses while any personal lock remains on the hasp. The lock ledger itself rejects a negative count (the classic double-remove bookkeeping bug) and more locks than the 6-hole hasp can hold.
A Soma program from the safety_interlocks part of the verified corpus: 91 lines, cells LotoControl, LotoTests. It passes soma check with soma 3.2.4.
How it is built
The cell LotoControl declares 5 signals in its face: shut_down(id: String) -> String, apply_lock(id: String, worker: String) -> Map, remove_lock(id: String, worker: String) -> Map, energize(id: String) -> Map and locks_on(id: String) -> Int; keeps 1 memory slot: lock_count (Map<String, Int>, persistent and consistent) under the invariant lock_count >= 0 && lock_count <= 6. Its state machine energy starts in running and allows 4 transitions: running -> isolated, isolated -> running, isolated -> locked_out and locked_out -> isolated. Handlers: shut_down (line 34), apply_lock (line 36), remove_lock (line 46), energize (line 53) and locks_on (line 62).
The file also holds 1 test cell (LotoTests) with 13 assertions, run by soma test.
The source
lockout_tagout.cell
// LOCKOUT/TAGOUT — a machine with a personal lock on it cannot start.
// OSHA calls 1910.147 violations a perennial top-ten killer: maintenance
// inside a machine, someone hits start, the machine wins. The padlock
// works because ONLY its owner holds the key. Here `locked_out` has no
// edge to `running` — re-energizing exists only from `isolated`, and the
// handler that leaves `locked_out` refuses while any personal lock
// remains on the hasp. The lock ledger itself rejects a negative count
// (the classic double-remove bookkeeping bug) and more locks than the
// 6-hole hasp can hold.
cell LotoControl {
face {
signal shut_down(id: String) -> String
signal apply_lock(id: String, worker: String) -> Map
signal remove_lock(id: String, worker: String) -> Map
signal energize(id: String) -> Map
signal locks_on(id: String) -> Int
}
memory {
// personal locks on the hasp — 0..6, enforced by the slot
lock_count: Map<String, Int> [persistent, consistent]
invariant lock_count >= 0 && lock_count <= 6
}
state energy {
initial: running
running -> isolated // stopped, disconnect open, verified dead
isolated -> running // restart allowed: nobody is inside
isolated -> locked_out // first padlock goes on
locked_out -> isolated // ONLY exit — and only at zero locks
}
on shut_down(id: String) { transition(id, "isolated") return get_status(id) }
on apply_lock(id: String, worker: String) {
if get_status(id) == "running" {
return map("ok", false, "reason", "machine still energized — isolate first")
}
let w = try { lock_count.set(id, locks_on(id) + 1) }
if w.error != () { return map("ok", false, "reason", "hasp full (6 locks)") }
if get_status(id) == "isolated" { transition(id, "locked_out") }
return map("ok", true, "locks", locks_on(id), "state", get_status(id))
}
on remove_lock(id: String, worker: String) {
let w = try { lock_count.set(id, locks_on(id) - 1) }
if w.error != () { return map("ok", false, "reason", "no lock of {worker} on hasp") }
if locks_on(id) == 0 { transition(id, "isolated") }
return map("ok", true, "locks", locks_on(id), "state", get_status(id))
}
on energize(id: String) {
let r = try { transition(id, "running") }
if r.error != () {
let n = locks_on(id)
return map("ok", false, "reason", "{n} personal lock(s) still on the hasp")
}
return map("ok", true, "state", get_status(id))
}
on locks_on(id: String) { return lock_count.get(id) ?? 0 }
}
cell test LotoTests {
rules {
// no lock onto a running machine; isolate first
assert apply_lock("m1", "ana").ok == false
assert shut_down("m1") == "isolated"
assert apply_lock("m1", "ana").ok == true
assert apply_lock("m1", "bo").ok == true
assert locks_on("m1") == 2
// THE kill: start button with people inside. The edge is absent.
assert energize("m1").ok == false
assert_fails transition("m1", "running")
// one lock off is not enough — bo is still inside
assert remove_lock("m1", "ana").ok == true
assert energize("m1").ok == false
// last lock off -> isolated -> restart is lawful
assert remove_lock("m1", "bo").ok == true
assert energize("m1").ok == true
// the ledger refuses phantom removals and an overfull hasp
assert_fails lock_count.set("m1", -1)
assert_fails lock_count.set("m1", 7)
}
}What the toolchain says
$ soma check lockout_tagout.cell
✓ All checks passed.$ soma test lockout_tagout.cell
test LotoTests ...
✓ assert apply_lock("m1", "ana").ok == false
✓ assert shut_down("m1") == "isolated"
✓ assert apply_lock("m1", "ana").ok == true
✓ assert apply_lock("m1", "bo").ok == true
✓ assert locks_on("m1") == 2
✓ assert energize("m1").ok == false
✓ assert_fails transition("m1", "running") — raised invalid transition: locked_out → running. Current state: 'locked_out'. Valid targets: [isolated]
✓ assert remove_lock("m1", "ana").ok == true
✓ assert energize("m1").ok == false
✓ assert remove_lock("m1", "bo").ok == true
✓ assert energize("m1").ok == true
✓ assert_fails lock_count.set("m1", -1) — raised memory invariant violated on 'lock_count': lock_count >= 0 && lock_count <= 6 — rejected write of -1 (key "m1"); the slot is unchanged
✓ assert_fails lock_count.set("m1", 7) — raised memory invariant violated on 'lock_count': lock_count >= 0 && lock_count <= 6 — rejected write of 7 (key "m1"); the slot is unchanged
13 tests: 13 passed, 0 failed$ soma verify lockout_tagout.cell
Verifying lockout_tagout.cell...
State machine 'energy': 3 states, initial 'running'
States: [isolated, locked_out, running]
✓ 3 states, 4 transitions
✓ all states reachable from 'running'
✓ reactive machine: no terminal state, and every reachable state can return to 'running'
✓ no deadlocks
✓ liveness: every reachable state can return to 'running' (reactive machine)
✓ think-isolated: CTL safety properties hold regardless of LLM output (4 handlers, 4 literal transitions, 0 dynamic)
✓ termination: all 5 handlers structurally terminate
✓ refinement: handler `shut_down` ⟶ {isolated}
✓ refinement: handler `apply_lock` ⟶ {isolated → locked_out [if get_status(id) == "isolated"]}
✓ refinement: handler `remove_lock` ⟶ {isolated [if locks_on(id) == 0]}
✓ refinement: handler `energize` ⟶ {running}
· transition sources: `shut_down`, `remove_lock`, `energize` call transition(id, target) without declaring the source — the runtime refuses a move the graph forbids, but the proof does not see where the handler starts; `transition(id, from, to)` names the edge (checked by `soma check`, refused at run time from any other state)
LotoControl/invariants:
✓ invariant lock_count >= 0 — writer 'apply_lock' proven (writes locks_on(id) + 1)
✓ invariant lock_count <= 6 — writer 'remove_lock' proven (writes locks_on(id) - 1)
⚠ invariant lock_count >= 0 && lock_count <= 6 — runtime-checked (computed values): apply_lock → lock_count [lock_count <= 6] because `locks_on(id) + 1` is only known to lie in [1, 7] — narrow it: `require (locks_on(id) + 1) <= 6 else …`, remove_lock → lock_count [lock_count >= 0] because `locks_on(id) - 1` is only known to lie in [-1, 5] — narrow it: `require (locks_on(id) - 1) >= 0 else …`
13 passed, 1 warnings, 0 failures
Temporal properties for 'energy':
✓ deadlock_free — no deadlocks in any reachable state
Temporal: 1 passed, 0 failed
VERIFY OK