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