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

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.

Generated from soma describe on this file.

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

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

$ 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

Builtins used

get_status, map, transition

Source file: /corpus/escrow_finance/security_deposit.cell · examples/corpus/escrow_finance/security_deposit.cell