soma.cell

State lock — two concurrent applies can never both hold the lock

Terraform's docs put it bluntly: two `apply` runs against the same state file at once will corrupt it. Teams learn this the hard way — CI kicks off a plan while an engineer applies from a laptop, the state JSON is half-written by both, and suddenly Terraform wants to destroy and recreate half of production. HashiCorp added state locking for exactly this; here the lock's mutual exclusion is a proof, not a DynamoDB conditional write you hope is configured. The lock is a state machine: `unlocked -> held -> unlocked`, with a `stale` branch for the crashed-runner case — force-unlock exists, but ONLY from `stale`, never as a shortcut around a live holder. The holder-count ledger is invariant-walled at 0..1: a second holder is a write the memory refuses, independent of the machine.

A Soma program from the devops part of the verified corpus: 114 lines, cells StateLock, MutexTests. It passes soma check with soma 3.2.4.

How it is built

The cell StateLock declares 5 signals in its face: acquire(ws: String, runner: String) -> Map, release_lock(ws: String) -> Map, mark_stale(ws: String) -> Map, force_unlock(ws: String) -> Map and lock_state(ws: String) -> Map; keeps 2 memory slots: holders (Map<String, Int>, persistent and consistent) under the invariant holders >= 0 && holders <= 1 and owner (Map<String, String>, persistent and consistent). Its state machine tflock starts in unlocked and allows 4 transitions: unlocked -> held, held -> unlocked, held -> stale and stale -> unlocked. Handlers: acquire (line 46), release_lock (line 61), mark_stale (line 69), force_unlock (line 75) and lock_state (line 85).

The file also holds 1 test cell (MutexTests) with 11 assertions, run by soma test.

Generated from soma describe on this file.

The source

state_lock.cell

// ============================================================================
// STATE LOCK — two concurrent applies can never both hold the lock
// ============================================================================
//
// Terraform's docs put it bluntly: two `apply` runs against the same
// state file at once will corrupt it. Teams learn this the hard way —
// CI kicks off a plan while an engineer applies from a laptop, the
// state JSON is half-written by both, and suddenly Terraform wants to
// destroy and recreate half of production. HashiCorp added state
// locking for exactly this; here the lock's mutual exclusion is a
// proof, not a DynamoDB conditional write you hope is configured.
//
// The lock is a state machine: `unlocked -> held -> unlocked`, with a
// `stale` branch for the crashed-runner case — force-unlock exists,
// but ONLY from `stale`, never as a shortcut around a live holder.
// The holder-count ledger is invariant-walled at 0..1: a second holder
// is a write the memory refuses, independent of the machine.
// ============================================================================

cell StateLock {
    face {
        signal acquire(ws: String, runner: String) -> Map
        signal release_lock(ws: String) -> Map
        signal mark_stale(ws: String) -> Map
        signal force_unlock(ws: String) -> Map
        signal lock_state(ws: String) -> Map
    }

    memory {
        holders: Map<String, Int> [persistent, consistent]
        invariant holders >= 0 && holders <= 1

        owner: Map<String, String> [persistent, consistent]
    }

    // force_unlock is only reachable through `stale` — there is no
    // held -> unlocked side door that skips the release protocol.
    state tflock {
        initial: unlocked
        unlocked -> held
        held -> unlocked
        held -> stale
        stale -> unlocked
    }

    on acquire(ws: String, runner: String) {
        let r = try { transition(ws, "held") }
        if r.error != () {
            let who = owner.get(ws) ?? "unknown"
            return map("ok", false, "reason", "lock held by {who}")
        }
        let n = holders.get(ws) ?? 0
        let w = try { holders.set(ws, n + 1) }
        if w.error != () {
            return map("ok", false, "reason", "second holder refused: {w.error}")
        }
        owner.set(ws, runner)
        return map("ok", true, "holder", runner)
    }

    on release_lock(ws: String) {
        let r = try { transition(ws, "unlocked") }
        if r.error != () { return map("ok", false, "reason", "{r.error}") }
        holders.set(ws, 0)
        owner.set(ws, "")
        return map("ok", true, "state", "unlocked")
    }

    on mark_stale(ws: String) {
        let r = try { transition(ws, "stale") }
        if r.error != () { return map("ok", false, "reason", "{r.error}") }
        return map("ok", true, "state", "stale")
    }

    on force_unlock(ws: String) {
        if get_status(ws) != "stale" {
            return map("ok", false, "reason", "force-unlock only from stale — a live holder is never bulldozed")
        }
        transition(ws, "unlocked")
        holders.set(ws, 0)
        owner.set(ws, "")
        return map("ok", true, "state", "unlocked")
    }

    on lock_state(ws: String) {
        return map("state", get_status(ws), "owner", owner.get(ws) ?? "")
    }
}

cell test MutexTests {
    rules {
        // CI takes the lock; the laptop apply is refused
        assert acquire("prod", "ci-runner-7").ok == true
        assert acquire("prod", "laptop-jane").ok == false
        assert lock_state("prod").owner == "ci-runner-7"

        // the second-holder write itself is impossible
        assert_fails holders.set("prod", 2)

        // a live holder cannot be bulldozed — force path needs `stale`
        assert force_unlock("prod").ok == false
        assert_fails transition("prod", "force_released")

        // crashed runner: stale, THEN force-unlock, then life goes on
        assert mark_stale("prod").ok == true
        assert force_unlock("prod").ok == true
        assert acquire("prod", "laptop-jane").ok == true
        assert release_lock("prod").ok == true

        // an unlocked lock cannot be double-released
        assert release_lock("prod").ok == false
    }
}

What the toolchain says

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

$ soma check state_lock.cell
✓ All checks passed.
$ soma test state_lock.cell
test MutexTests ...
  ✓ assert acquire("prod", "ci-runner-7").ok == true
  ✓ assert acquire("prod", "laptop-jane").ok == false
  ✓ assert lock_state("prod").owner == "ci-runner-7"
  ✓ assert_fails holders.set("prod", 2) — raised memory invariant violated on 'holders': holders >= 0 && holders <= 1 — rejected write of 2 (key "prod"); the slot is unchanged
  ✓ assert force_unlock("prod").ok == false
  ✓ assert_fails transition("prod", "force_released") — raised invalid transition: held → force_released. Current state: 'held'. Valid targets: [unlocked, stale]
  ✓ assert mark_stale("prod").ok == true
  ✓ assert force_unlock("prod").ok == true
  ✓ assert acquire("prod", "laptop-jane").ok == true
  ✓ assert release_lock("prod").ok == true
  ✓ assert release_lock("prod").ok == false

11 tests: 11 passed, 0 failed
$ soma verify state_lock.cell
Verifying state_lock.cell...

State machine 'tflock': 3 states, initial 'unlocked'
  States: [held, stale, unlocked]

  ✓ 3 states, 4 transitions
  ✓ all states reachable from 'unlocked'
  ✓ reactive machine: no terminal state, and every reachable state can return to 'unlocked'
  ✓ no deadlocks
  ✓ liveness: every reachable state can return to 'unlocked' (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 `acquire` ⟶ {held}
  ✓ refinement: handler `release_lock` ⟶ {unlocked}
  ✓ refinement: handler `mark_stale` ⟶ {stale}
  ✓ refinement: handler `force_unlock` ⟶ {stale → unlocked}
  · transition sources: `acquire`, `release_lock`, `mark_stale` 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)

StateLock/invariants:

  ✓ invariant holders >= 0 — writer 'acquire' proven by induction (writes n + 1)
  ✓ invariant holders >= 0 && holders <= 1 — writer 'force_unlock' proven (writes 0)
  ✓ invariant holders >= 0 && holders <= 1 — writer 'release_lock' proven (writes 0)
  ⚠ invariant holders >= 0 && holders <= 1 — runtime-checked (computed values): acquire → holders [holders <= 1] because `n + 1` is only known to lie in [1, 2] — narrow it: `require (n + 1) <= 1 else …`

14 passed, 1 warnings, 0 failures

  Temporal properties for 'tflock':
  ✓ deadlock_free — no deadlocks in any reachable state
Temporal: 1 passed, 0 failed
VERIFY OK

Builtins used

get_status, map, transition

Source file: /corpus/devops/state_lock.cell · examples/corpus/devops/state_lock.cell