Gas burner ignition — purge before spark, lockout is forever
NFPA 86 exists because furnaces that spark into a fuel-laden chamber detonate: the prepurge (4+ air changes) is the single step operators skip when restarts are slow, and skipped purges are a recurring CSB explosion class. Here `off -> pilot_spark` simply does not exist: the only road to a spark runs through `purging` and `purge_done`. A failed ignition or flame loss lands in `lockout` — a TERMINAL state soma verify proves has no exit. Re-light means a human walks to the burner; software cannot talk its way out. The retry ledger caps trial-for- ignition attempts at 3 in memory itself.
A Soma program from the safety_interlocks part of the verified corpus: 82 lines, cells BurnerControl, BurnerTests. It passes soma check with soma 3.2.4.
How it is built
The cell BurnerControl declares 5 signals in its face: start_purge(id: String) -> String, purge_complete(id: String) -> String, try_ignition(id: String, flame_proved: Bool) -> Map, flame_failure(id: String) -> String and shut_off(id: String) -> String; keeps 1 memory slot: ignition_tries (Map<String, Int>, persistent and consistent) under the invariant ignition_tries >= 0 && ignition_tries <= 3. Its state machine burner starts in off and allows 8 transitions: off -> purging, purging -> purge_done, purge_done -> pilot_spark, pilot_spark -> burning, pilot_spark -> lockout, pilot_spark -> purge_done, burning -> off and burning -> lockout. lockout is terminal. Handlers: start_purge (line 38), purge_complete (line 39), shut_off (line 40), flame_failure (line 41) and try_ignition (line 43).
The file also holds 1 test cell (BurnerTests) with 10 assertions, run by soma test.
The source
gas_burner_purge.cell
// GAS BURNER IGNITION — purge before spark, lockout is forever.
// NFPA 86 exists because furnaces that spark into a fuel-laden chamber
// detonate: the prepurge (4+ air changes) is the single step operators
// skip when restarts are slow, and skipped purges are a recurring CSB
// explosion class. Here `off -> pilot_spark` simply does not exist: the
// only road to a spark runs through `purging` and `purge_done`. A failed
// ignition or flame loss lands in `lockout` — a TERMINAL state soma
// verify proves has no exit. Re-light means a human walks to the burner;
// software cannot talk its way out. The retry ledger caps trial-for-
// ignition attempts at 3 in memory itself.
cell BurnerControl {
face {
signal start_purge(id: String) -> String
signal purge_complete(id: String) -> String
signal try_ignition(id: String, flame_proved: Bool) -> Map
signal flame_failure(id: String) -> String
signal shut_off(id: String) -> String
}
memory {
ignition_tries: Map<String, Int> [persistent, consistent]
invariant ignition_tries >= 0 && ignition_tries <= 3
}
state burner {
initial: off
off -> purging
purging -> purge_done
purge_done -> pilot_spark
pilot_spark -> burning // flame proved within trial time
pilot_spark -> lockout // no flame: fuel valve slams shut
pilot_spark -> purge_done // retry, chamber still proven clear
burning -> off // controlled shutdown
burning -> lockout // flame failure mid-run
}
on start_purge(id: String) { transition(id, "purging") return get_status(id) }
on purge_complete(id: String) { transition(id, "purge_done") return get_status(id) }
on shut_off(id: String) { transition(id, "off") return get_status(id) }
on flame_failure(id: String) { transition(id, "lockout") return get_status(id) }
on try_ignition(id: String, flame_proved: Bool) {
let tries = (ignition_tries.get(id) ?? 0) + 1
let w = try { ignition_tries.set(id, tries) }
if w.error != () {
transition(id, "lockout") // 4th try is refused by the ledger
return map("ok", false, "state", get_status(id), "reason", "retry budget spent")
}
transition(id, "pilot_spark")
if flame_proved {
transition(id, "burning")
ignition_tries.set(id, 0)
return map("ok", true, "state", get_status(id), "tries", tries)
}
transition(id, "purge_done")
return map("ok", false, "state", get_status(id), "tries", tries)
}
}
cell test BurnerTests {
rules {
// THE explosion: spark with no purge. The transition is absent.
assert_fails transition("b1", "pilot_spark")
assert_fails transition("b1", "burning")
// the only road: purge, prove it, then spark
assert start_purge("b1") == "purging"
assert_fails transition("b1", "pilot_spark") // purge not done yet
assert purge_complete("b1") == "purge_done"
assert try_ignition("b1", true).state == "burning"
// flame failure mid-run -> lockout, and lockout is TERMINAL
assert flame_failure("b1") == "lockout"
assert_fails transition("b1", "off")
assert_fails transition("b1", "purging")
// the retry ledger refuses a 4th trial-for-ignition
assert_fails ignition_tries.set("b2", 4)
}
}What the toolchain says
$ soma check gas_burner_purge.cell
✓ All checks passed.$ soma test gas_burner_purge.cell
test BurnerTests ...
✓ assert_fails transition("b1", "pilot_spark") — raised invalid transition: off → pilot_spark. Current state: 'off'. Valid targets: [purging]
✓ assert_fails transition("b1", "burning") — raised invalid transition: off → burning. Current state: 'off'. Valid targets: [purging]
✓ assert start_purge("b1") == "purging"
✓ assert_fails transition("b1", "pilot_spark") — raised invalid transition: purging → pilot_spark. Current state: 'purging'. Valid targets: [purge_done]
✓ assert purge_complete("b1") == "purge_done"
✓ assert try_ignition("b1", true).state == "burning"
✓ assert flame_failure("b1") == "lockout"
✓ assert_fails transition("b1", "off") — raised invalid transition: lockout → off. Current state: 'lockout'. Valid targets: []
✓ assert_fails transition("b1", "purging") — raised invalid transition: lockout → purging. Current state: 'lockout'. Valid targets: []
✓ assert_fails ignition_tries.set("b2", 4) — raised memory invariant violated on 'ignition_tries': ignition_tries >= 0 && ignition_tries <= 3 — rejected write of 4 (key "b2"); the slot is unchanged
10 tests: 10 passed, 0 failed$ soma verify gas_burner_purge.cell
Verifying gas_burner_purge.cell...
State machine 'burner': 6 states, initial 'off'
States: [burning, lockout, off, pilot_spark, purge_done, purging]
✓ 6 states, 8 transitions
✓ all states reachable from 'off'
✓ terminal states: [lockout]
✓ no deadlocks
✓ liveness: every state can eventually reach a terminal state
✓ think-isolated: CTL safety properties hold regardless of LLM output (5 handlers, 8 literal transitions, 0 dynamic)
✓ termination: all 5 handlers structurally terminate
✓ refinement: handler `start_purge` ⟶ {purging}
✓ refinement: handler `purge_complete` ⟶ {purge_done}
✓ refinement: handler `shut_off` ⟶ {off}
✓ refinement: handler `flame_failure` ⟶ {lockout}
✓ refinement: handler `try_ignition` ⟶ {lockout [if w.error != ()], pilot_spark, pilot_spark → burning [if flame_proved], pilot_spark → purge_done}
· transition sources: `start_purge`, `purge_complete`, `shut_off`, `flame_failure`, `try_ignition` 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)
BurnerControl/invariants:
✓ invariant ignition_tries >= 0 — writer 'try_ignition' proven by induction (writes tries)
✓ invariant ignition_tries >= 0 && ignition_tries <= 3 — writer 'try_ignition' proven (writes 0)
⚠ invariant ignition_tries >= 0 && ignition_tries <= 3 — runtime-checked (computed values): try_ignition → ignition_tries [ignition_tries <= 3] because `tries` is only known to lie in [1, 4] — narrow it: `require tries <= 3 else …`
14 passed, 1 warnings, 0 failures
Temporal properties for 'burner':
✓ deadlock_free — no deadlocks in any reachable state
Temporal: 1 passed, 0 failed
VERIFY OK