soma.cell

Guild bank — the heist needs a key an expelled member cannot hold

EVE Online's Guiding Hand Social Club heist and a thousand WoW guild-bank ninja-loots share one mechanic: ACCESS OUTLIVES STANDING. A member is expelled but a cached session, a stale permission row, or a race lets one last withdrawal through. Here membership is a machine — `expelled` is TERMINAL (soma verify proves there is no readmission edge) and every withdrawal checks live standing. Two memory walls hold even if all that logic were deleted: the vault can never go negative, and no member's daily withdrawals can exceed 500 gold, because larger values cannot be written into the ledger at all.

A Soma program from the games_economy part of the verified corpus: 102 lines, cells GuildBank, BankTests. It passes soma check with soma 3.2.4.

How it is built

The cell GuildBank declares 6 signals in its face: admit(player: String) -> Map, expel(player: String) -> Map, deposit(player: String, amt: Int) -> Map, withdraw(player: String, amt: Int) -> Map, vault_total() -> Int and standing_of(player: String) -> String; keeps 2 memory slots: vault (Map<String, Int>, persistent and consistent) under the invariant vault >= 0 and drawn_today (Map<String, Int>, persistent and consistent) under the invariant drawn_today >= 0 && drawn_today <= 500. Its state machine membership starts in applicant and allows 3 transitions: applicant -> member, applicant -> rejected and member -> expelled. expelled and rejected are terminal. Handlers: admit (line 39), expel (line 45), deposit (line 51), withdraw (line 58), vault_total (line 75) and standing_of (line 76).

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

Generated from soma describe on this file.

The source

guild_bank.cell

// ============================================================================
// GUILD BANK — the heist needs a key an expelled member cannot hold
// ============================================================================
// EVE Online's Guiding Hand Social Club heist and a thousand WoW guild-bank
// ninja-loots share one mechanic: ACCESS OUTLIVES STANDING. A member is
// expelled but a cached session, a stale permission row, or a race lets one
// last withdrawal through. Here membership is a machine — `expelled` is
// TERMINAL (soma verify proves there is no readmission edge) and every
// withdrawal checks live standing. Two memory walls hold even if all that
// logic were deleted: the vault can never go negative, and no member's
// daily withdrawals can exceed 500 gold, because larger values cannot be
// written into the ledger at all.
// ============================================================================

cell GuildBank {
    face {
        signal admit(player: String) -> Map
        signal expel(player: String) -> Map
        signal deposit(player: String, amt: Int) -> Map
        signal withdraw(player: String, amt: Int) -> Map
        signal vault_total() -> Int
        signal standing_of(player: String) -> String
    }

    memory {
        vault: Map<String, Int> [persistent, consistent]
        invariant vault >= 0
        drawn_today: Map<String, Int> [persistent, consistent]
        invariant drawn_today >= 0 && drawn_today <= 500
    }

    state membership {
        initial: applicant
        applicant -> member
        applicant -> rejected
        member -> expelled
    }

    on admit(player: String) {
        let r = try { transition(player, "member") }
        if r.error != () { return map("ok", false, "reason", "{r.error}") }
        return map("ok", true)
    }

    on expel(player: String) {
        let r = try { transition(player, "expelled") }
        if r.error != () { return map("ok", false, "reason", "{r.error}") }
        return map("ok", true)
    }

    on deposit(player: String, amt: Int) {
        if amt <= 0 { return map("ok", false, "reason", "amount must be positive") }
        if standing_of(player) != "member" { return map("ok", false, "reason", "not a member") }
        vault.set("gold", (vault.get("gold") ?? 0) + amt)
        return map("ok", true)
    }

    on withdraw(player: String, amt: Int) {
        if amt <= 0 { return map("ok", false, "reason", "amount must be positive") }
        if standing_of(player) != "member" {
            return map("ok", false, "reason", "no standing to withdraw")
        }
        // wall 1: the daily cap is a memory invariant, not an if-statement
        let w1 = try { drawn_today.set(player, (drawn_today.get(player) ?? 0) + amt) }
        if w1.error != () { return map("ok", false, "reason", "daily cap of 500 reached") }
        // wall 2: the vault refuses to go negative
        let w2 = try { vault.set("gold", (vault.get("gold") ?? 0) - amt) }
        if w2.error != () {
            drawn_today.set(player, (drawn_today.get(player) ?? 0) - amt)
            return map("ok", false, "reason", "vault has insufficient gold")
        }
        return map("ok", true, "drawn", amt)
    }

    on vault_total() { return vault.get("gold") ?? 0 }
    on standing_of(player: String) { return get_status(player) }
}

cell test BankTests {
    rules {
        assert admit("ren").ok == true
        assert deposit("ren", 1000).ok == true
        assert withdraw("ren", 300).ok == true
        // the daily cap: 300 + 300 would write 600 into a <= 500 slot
        assert withdraw("ren", 300).ok == false
        assert withdraw("ren", 200).ok == true
        assert_fails drawn_today.set("ren", 501)
        assert vault_total() == 500
        // THE HEIST: expelled, then withdraw on the cached session
        assert expel("ren").ok == true
        assert withdraw("ren", 100).ok == false
        assert vault_total() == 500
        // expelled is terminal: no quiet readmission, ever
        assert_fails transition("ren", "member")
        assert_fails transition("ren", "applicant")
        // a fresh member still cannot beat the cap or overdraw
        assert admit("kit").ok == true
        assert withdraw("kit", 501).ok == false
        assert_fails vault.set("gold", -1)
    }
}

What the toolchain says

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

$ soma check guild_bank.cell
✓ All checks passed.
$ soma test guild_bank.cell
test BankTests ...
  ✓ assert admit("ren").ok == true
  ✓ assert deposit("ren", 1000).ok == true
  ✓ assert withdraw("ren", 300).ok == true
  ✓ assert withdraw("ren", 300).ok == false
  ✓ assert withdraw("ren", 200).ok == true
  ✓ assert_fails drawn_today.set("ren", 501) — raised memory invariant violated on 'drawn_today': drawn_today >= 0 && drawn_today <= 500 — rejected write of 501 (key "ren"); the slot is unchanged
  ✓ assert vault_total() == 500
  ✓ assert expel("ren").ok == true
  ✓ assert withdraw("ren", 100).ok == false
  ✓ assert vault_total() == 500
  ✓ assert_fails transition("ren", "member") — raised invalid transition: expelled → member. Current state: 'expelled'. Valid targets: []
  ✓ assert_fails transition("ren", "applicant") — raised invalid transition: expelled → applicant. Current state: 'expelled'. Valid targets: []
  ✓ assert admit("kit").ok == true
  ✓ assert withdraw("kit", 501).ok == false
  ✓ assert_fails vault.set("gold", -1) — raised memory invariant violated on 'vault': vault >= 0 — rejected write of -1 (key "gold"); the slot is unchanged

15 tests: 15 passed, 0 failed
$ soma verify guild_bank.cell
Verifying guild_bank.cell...

State machine 'membership': 4 states, initial 'applicant'
  States: [applicant, expelled, member, rejected]

  ✓ 4 states, 3 transitions
  ✓ all states reachable from 'applicant'
  ✓ terminal states: [rejected, expelled]
  ✓ no deadlocks
  ✓ liveness: every state can eventually reach a terminal state
  ✓ think-isolated: CTL safety properties hold regardless of LLM output (2 handlers, 2 literal transitions, 0 dynamic)
  ✓ termination: all 6 handlers structurally terminate
  ⚠ refinement: declared transition `applicant → rejected` is never reached by any handler — spec may be aspirational or stale
  ✓ refinement: handler `admit` ⟶ {member}
  ✓ refinement: handler `expel` ⟶ {expelled}
  · transition sources: `admit`, `expel` 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)

GuildBank/invariants:

  ✓ invariant vault >= 0 — writer 'deposit' proven by induction (writes (vault.get("gold") ?? 0) + amt)
  ⚠ invariant vault >= 0 — runtime-checked (computed values): withdraw → vault because `vault` has no known range
  ✓ invariant drawn_today >= 0 — writer 'withdraw' proven by induction (writes (drawn_today.get(player) ?? 0) + amt)
  ✓ invariant drawn_today <= 500 — writer 'withdraw' proven by induction (writes (drawn_today.get(player) ?? 0) - amt)
  ⚠ invariant drawn_today >= 0 && drawn_today <= 500 — runtime-checked (computed values): withdraw → drawn_today [drawn_today <= 500] because `drawn_today` has no known range, withdraw → drawn_today [drawn_today >= 0] because `drawn_today` has no known range

12 passed, 3 warnings, 0 failures

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

Builtins used

get_status, map, transition

Source file: /corpus/games_economy/guild_bank.cell · examples/corpus/games_economy/guild_bank.cell