soma.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.

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.

Generated from soma describe on this file.

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

Run on a fresh copy of the program at build time; the outputs are pasted unedited.

$ 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

Builtins used

get_status, map, transition

Source file: /corpus/safety_interlocks/lockout_tagout.cell · examples/corpus/safety_interlocks/lockout_tagout.cell