Loan amortization — principal never goes negative, never grows back
Two amortization bugs with real victims: servicers who kept drafting payments after payoff (principal goes negative = the borrower is owed money and usually never told), and the pre-2008 "option ARM" negative amortization, where a payment smaller than the accrued interest silently GREW the principal until the loan ballooned past the house's value. Here `principal >= 0` is a memory invariant — the overdraft write raises — and a payment must at least cover the month's interest, so the balance is monotonically non-increasing. paid_off is terminal and proven.
A Soma program from the escrow_finance part of the verified corpus: 100 lines, cells Loan, LoanTests. It passes soma check with soma 3.2.4.
How it is built
The cell Loan declares 4 signals in its face: originate(id: String, principal: Int, rate_bps: Int) -> Map, monthly_interest(id: String) -> Int, make_payment(id: String, amount: Int) -> Map and balance(id: String) -> Int; keeps 2 memory slots: principal (Map<String, Int>, persistent and consistent) under the invariant principal >= 0 and rate (Map<String, Int>, persistent and consistent) under the invariant rate >= 0 && rate <= 2500. Its state machine loan starts in applied and allows 2 transitions: applied -> active and active -> paid_off. paid_off is terminal. Handlers: originate (line 38), monthly_interest (line 46), make_payment (line 52) and balance (line 71).
The file also holds 1 test cell (LoanTests) with 12 assertions, run by soma test.
The source
loan_amortization.cell
// ============================================================================
// LOAN AMORTIZATION — principal never goes negative, never grows back
// ============================================================================
//
// Two amortization bugs with real victims: servicers who kept drafting
// payments after payoff (principal goes negative = the borrower is owed
// money and usually never told), and the pre-2008 "option ARM" negative
// amortization, where a payment smaller than the accrued interest silently
// GREW the principal until the loan ballooned past the house's value.
// Here `principal >= 0` is a memory invariant — the overdraft write raises
// — and a payment must at least cover the month's interest, so the balance
// is monotonically non-increasing. paid_off is terminal and proven.
// ============================================================================
cell Loan {
face {
signal originate(id: String, principal: Int, rate_bps: Int) -> Map
signal monthly_interest(id: String) -> Int
signal make_payment(id: String, amount: Int) -> Map
signal balance(id: String) -> Int
}
memory {
// the wall: principal cannot be overdrafted below zero
principal: Map<String, Int> [persistent, consistent]
invariant principal >= 0
rate: Map<String, Int> [persistent, consistent]
invariant rate >= 0 && rate <= 2500
}
// paid_off is terminal: a settled loan cannot resume billing.
state loan {
initial: applied
applied -> active
active -> paid_off
}
on originate(id: String, principal_amt: Int, rate_bps: Int) {
if principal_amt <= 0 { return map("ok", false, "reason", "bad principal") }
transition(id, "active")
rate.set(id, rate_bps) // raises if rate is usurious (> 25%)
principal.set(id, principal_amt)
return map("ok", true, "state", get_status(id))
}
on monthly_interest(id: String) {
let p = principal.get(id) ?? 0
let r = rate.get(id) ?? 0
return div_round(p * r, 120000) // bps annual -> one month, rounded HALF_UP (BigDecimal style)
}
on make_payment(id: String, amount: Int) {
if get_status(id) != "active" {
return map("ok", false, "reason", "loan is not active")
}
let interest = monthly_interest(id)
if amount < interest {
// NEGATIVE AMORTIZATION refused: a payment below interest
// would grow the principal — that loan shape does not exist
return map("ok", false, "reason",
"payment {amount} below interest {interest}: would grow principal")
}
let p = principal.get(id) ?? 0
let toward_principal = amount - interest
principal.set(id, p - toward_principal) // raises on overpayment
if p - toward_principal == 0 { transition(id, "paid_off") }
return map("ok", true, "interest", interest,
"principal_reduced", toward_principal, "remaining", p - toward_principal)
}
on balance(id: String) {
return principal.get(id) ?? 0
}
}
cell test LoanTests {
rules {
// 120,000 at 6.00% (600 bps): first month's interest is 600
assert originate("L1", 120000, 600).ok == true
assert monthly_interest("L1") == 600
// NEGATIVE AMORTIZATION: paying less than interest is refused
assert make_payment("L1", 599).ok == false
assert balance("L1") == 120000
// a real payment: 600 interest + 19400 principal
assert make_payment("L1", 20000).remaining == 100600
// OVERDRAFT: paying past payoff would drive principal negative —
// the ledger write itself raises (100600 + 503 interest < 999999)
assert_fails make_payment("L1", 999999)
assert balance("L1") == 100600
assert_fails principal.set("L1", -1)
// exact payoff: balance + this month's interest, to the cent
assert make_payment("L1", 100600 + 503).remaining == 0
// paid_off is terminal: the servicer cannot keep drafting
assert make_payment("L1", 100).ok == false
assert_fails transition("L1", "active")
// usurious rates are rejected at origination by the rate invariant
assert_fails originate("L2", 5000, 9900)
}
}What the toolchain says
$ soma check loan_amortization.cell
warning: parameter 2 of `originate`: the face calls it `principal`, the handler `principal_amt` — an HTTP JSON body is matched by NAME (the handler's), and `soma describe` publishes the face's: use one name
--> loan_amortization.cell:17:9
|
17 | signal originate(id: String, principal: Int, rate_bps: Int) …
| ^
✓ 1 warning(s), no errors.$ soma test loan_amortization.cell
test LoanTests ...
✓ assert originate("L1", 120000, 600).ok == true
✓ assert monthly_interest("L1") == 600
✓ assert make_payment("L1", 599).ok == false
✓ assert balance("L1") == 120000
✓ assert make_payment("L1", 20000).remaining == 100600
✓ assert_fails make_payment("L1", 999999) — raised memory invariant violated on 'principal': principal >= 0 — rejected write of -898896 (key "L1"); the slot is unchanged
✓ assert balance("L1") == 100600
✓ assert_fails principal.set("L1", -1) — raised memory invariant violated on 'principal': principal >= 0 — rejected write of -1 (key "L1"); the slot is unchanged
✓ assert make_payment("L1", 100600 + 503).remaining == 0
✓ assert make_payment("L1", 100).ok == false
✓ assert_fails transition("L1", "active") — raised invalid transition: paid_off → active. Current state: 'paid_off'. Valid targets: []
✓ assert_fails originate("L2", 5000, 9900) — raised memory invariant violated on 'rate': rate >= 0 && rate <= 2500 — rejected write of 9900 (key "L2"); the slot is unchanged
12 tests: 12 passed, 0 failed$ soma verify loan_amortization.cell
Verifying loan_amortization.cell...
State machine 'loan': 3 states, initial 'applied'
States: [active, applied, paid_off]
✓ 3 states, 2 transitions
✓ all states reachable from 'applied'
✓ terminal states: [paid_off]
✓ 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 4 handlers structurally terminate
✓ refinement: handler `originate` ⟶ {active}
✓ refinement: handler `make_payment` ⟶ {paid_off [if p - toward_principal == 0]}
· transition sources: `originate`, `make_payment` 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)
Loan/invariants:
✓ invariant principal >= 0 — writer 'originate' proven by induction (writes principal_amt)
⚠ invariant principal >= 0 — runtime-checked (computed values): make_payment → principal because `toward_principal` = amount - interest — not bounded
⚠ invariant rate >= 0 && rate <= 2500 — runtime-checked (computed values): originate → rate because `rate_bps` is a parameter (narrow it: `require rate_bps >= 0 && rate_bps <= 2500 else …`)
10 passed, 2 warnings, 0 failures
Temporal properties for 'loan':
✓ deadlock_free — no deadlocks in any reachable state
Temporal: 1 passed, 0 failed
VERIFY OK