Security deposit — deductions can never exceed what the tenant paid
Every US state's tenancy law has the same clause because the same abuse keeps happening: a landlord itemizes $2,400 of "damages" against a $1,500 deposit and bills the tenant for the fantasy difference — or keeps deducting after the deposit was already returned. Here the deposit is a slot that deductions draw down with `deposit >= 0` as the wall: the over-deduction is a REJECTED WRITE. Returning the balance moves the tenancy to a terminal state, so post-return deductions have no edge.
A Soma program from the escrow_finance part of the verified corpus: 99 lines, cells DepositAccount, DepositTests. It passes soma check with soma 3.2.4.
How it is built
The cell DepositAccount declares 5 signals in its face: collect_deposit(id: String, amount: Int) -> Map, begin_moveout(id: String) -> Map, deduct(id: String, reason: String, amount: Int) -> Map, return_balance(id: String) -> Map and deposit_left(id: String) -> Int; keeps 2 memory slots: deposit (Map<String, Int>, persistent and consistent) under the invariant deposit >= 0 and deducted (Map<String, Int>, persistent and consistent) under the invariant deducted >= 0. Its state machine tenancy starts in vacant and allows 3 transitions: vacant -> occupied, occupied -> moveout_review and moveout_review -> closed. closed is terminal. Handlers: collect_deposit (line 39), begin_moveout (line 47), deduct (line 52), return_balance (line 64) and deposit_left (line 71).
The file also holds 1 test cell (DepositTests) with 14 assertions, run by soma test.
The source
security_deposit.cell
// ============================================================================
// SECURITY DEPOSIT — deductions can never exceed what the tenant paid
// ============================================================================
//
// Every US state's tenancy law has the same clause because the same abuse
// keeps happening: a landlord itemizes $2,400 of "damages" against a
// $1,500 deposit and bills the tenant for the fantasy difference — or
// keeps deducting after the deposit was already returned. Here the deposit
// is a slot that deductions draw down with `deposit >= 0` as the wall:
// the over-deduction is a REJECTED WRITE. Returning the balance moves the
// tenancy to a terminal state, so post-return deductions have no edge.
// ============================================================================
cell DepositAccount {
face {
signal collect_deposit(id: String, amount: Int) -> Map
signal begin_moveout(id: String) -> Map
signal deduct(id: String, reason: String, amount: Int) -> Map
signal return_balance(id: String) -> Map
signal deposit_left(id: String) -> Int
}
memory {
deposit: Map<String, Int> [persistent, consistent]
invariant deposit >= 0
deducted: Map<String, Int> [persistent, consistent]
invariant deducted >= 0
}
// closed is terminal: once the balance went back to the tenant,
// there is no state from which to invent another deduction.
state tenancy {
initial: vacant
vacant -> occupied
occupied -> moveout_review
moveout_review -> closed
}
on collect_deposit(id: String, amount: Int) {
if amount <= 0 { return map("ok", false, "reason", "bad amount") }
transition(id, "occupied")
deposit.set(id, amount)
deducted.set(id, 0)
return map("ok", true, "held", amount)
}
on begin_moveout(id: String) {
transition(id, "moveout_review")
return map("ok", true, "state", get_status(id))
}
on deduct(id: String, reason: String, amount: Int) {
let st = get_status(id)
if st != "moveout_review" {
return map("ok", false, "reason", "deductions only during move-out review")
}
if amount <= 0 { return map("ok", false, "reason", "bad amount") }
let held = deposit.get(id) ?? 0
deposit.set(id, held - amount) // RAISES past the deposit
deducted.set(id, (deducted.get(id) ?? 0) + amount)
return map("ok", true, "reason", reason, "left", held - amount)
}
on return_balance(id: String) {
transition(id, "closed") // raises unless in review
let owed = deposit.get(id) ?? 0
deposit.set(id, 0)
return map("ok", true, "returned_to_tenant", owed)
}
on deposit_left(id: String) {
return deposit.get(id) ?? 0
}
}
cell test DepositTests {
rules {
assert collect_deposit("apt4b", 1500).ok == true
// no deductions while the tenant still lives there
assert deduct("apt4b", "carpet", 300).ok == false
assert begin_moveout("apt4b").ok == true
assert deduct("apt4b", "carpet", 300).ok == true
assert deduct("apt4b", "paint", 200).ok == true
assert deposit_left("apt4b") == 1000
// THE FANTASY INVOICE: $2,400 against $1,000 left — rejected write
assert_fails deduct("apt4b", "imaginary water damage", 2400)
assert deposit_left("apt4b") == 1000
assert_fails deposit.set("apt4b", -1)
// the tenant gets exactly what remains, once
assert return_balance("apt4b").returned_to_tenant == 1000
// closed is terminal: post-return deductions have no state to run in
assert deduct("apt4b", "afterthought", 50).ok == false
assert_fails transition("apt4b", "moveout_review")
assert_fails transition("apt4b", "occupied")
// accounting identity: deducted + returned == collected
assert (deducted.get("apt4b") ?? 0) == 500
}
}What the toolchain says
$ soma check security_deposit.cell
✓ All checks passed.$ soma test security_deposit.cell
test DepositTests ...
✓ assert collect_deposit("apt4b", 1500).ok == true
✓ assert deduct("apt4b", "carpet", 300).ok == false
✓ assert begin_moveout("apt4b").ok == true
✓ assert deduct("apt4b", "carpet", 300).ok == true
✓ assert deduct("apt4b", "paint", 200).ok == true
✓ assert deposit_left("apt4b") == 1000
✓ assert_fails deduct("apt4b", "imaginary water damage", 2400) — raised memory invariant violated on 'deposit': deposit >= 0 — rejected write of -1400 (key "apt4b"); the slot is unchanged
✓ assert deposit_left("apt4b") == 1000
✓ assert_fails deposit.set("apt4b", -1) — raised memory invariant violated on 'deposit': deposit >= 0 — rejected write of -1 (key "apt4b"); the slot is unchanged
✓ assert return_balance("apt4b").returned_to_tenant == 1000
✓ assert deduct("apt4b", "afterthought", 50).ok == false
✓ assert_fails transition("apt4b", "moveout_review") — raised invalid transition: closed → moveout_review. Current state: 'closed'. Valid targets: []
✓ assert_fails transition("apt4b", "occupied") — raised invalid transition: closed → occupied. Current state: 'closed'. Valid targets: []
✓ assert (deducted.get("apt4b") ?? 0) == 500
14 tests: 14 passed, 0 failed$ soma verify security_deposit.cell
Verifying security_deposit.cell...
State machine 'tenancy': 4 states, initial 'vacant'
States: [closed, moveout_review, occupied, vacant]
✓ 4 states, 3 transitions
✓ all states reachable from 'vacant'
✓ terminal states: [closed]
✓ no deadlocks
✓ liveness: every state can eventually reach a terminal state
✓ think-isolated: CTL safety properties hold regardless of LLM output (3 handlers, 3 literal transitions, 0 dynamic)
✓ termination: all 5 handlers structurally terminate
✓ refinement: handler `collect_deposit` ⟶ {occupied}
✓ refinement: handler `begin_moveout` ⟶ {moveout_review}
✓ refinement: handler `return_balance` ⟶ {closed}
· transition sources: `collect_deposit`, `begin_moveout`, `return_balance` 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)
DepositAccount/invariants:
✓ invariant deposit >= 0 — writer 'collect_deposit' proven by induction (writes amount)
✓ invariant deposit >= 0 — writer 'return_balance' proven (writes 0)
⚠ invariant deposit >= 0 — runtime-checked (computed values): deduct → deposit
✓ invariant deducted >= 0 — writer 'collect_deposit' proven (writes 0)
✓ invariant deducted >= 0 — writer 'deduct' proven by induction (writes (deducted.get(id) ?? 0) + amount)
14 passed, 1 warnings, 0 failures
Temporal properties for 'tenancy':
✓ deadlock_free — no deadlocks in any reachable state
Temporal: 1 passed, 0 failed
VERIFY OK