Vesting schedule — cliff then linear, and over-vesting is unwritable
Equity vesting disputes are a startup classic: a payroll script with an off-by-one releases shares before the 12-month cliff, or rounds the linear schedule up until the employee has claimed MORE than the grant. Clawing shares back after the fact means lawyers. Here the award pool is a memory slot that only decrements: `remaining >= 0` makes claiming past the total grant a rejected write, the cliff is enforced in the vested math, and a forfeited grant is a TERMINAL state — verified, no path back.
A Soma program from the escrow_finance part of the verified corpus: 118 lines, cells Vesting, VestingTests. It passes soma check with soma 3.2.4.
How it is built
The cell Vesting declares 5 signals in its face: grant_award(id: String, total: Int, cliff: Int, duration: Int) -> Map, vested_at(id: String, month: Int) -> Int, claim_shares(id: String, month: Int, amount: Int) -> Map, forfeit(id: String) -> Map and claimed_so_far(id: String) -> Int; keeps 2 memory slots: remaining (Map<String, Int>, persistent and consistent) under the invariant remaining >= 0 and terms (Map<String, Int>, persistent and consistent). Its state machine award starts in drafted and allows 3 transitions: drafted -> active, active -> forfeited and active -> exhausted. exhausted and forfeited are terminal. Handlers: grant_award (line 40), vested_at (line 52), claim_shares (line 61), forfeit (line 77) and claimed_so_far (line 84).
The file also holds 1 test cell (VestingTests) with 15 assertions, run by soma test.
The source
vesting_schedule.cell
// ============================================================================
// VESTING SCHEDULE — cliff then linear, and over-vesting is unwritable
// ============================================================================
//
// Equity vesting disputes are a startup classic: a payroll script with an
// off-by-one releases shares before the 12-month cliff, or rounds the
// linear schedule up until the employee has claimed MORE than the grant.
// Clawing shares back after the fact means lawyers. Here the award pool is
// a memory slot that only decrements: `remaining >= 0` makes claiming past
// the total grant a rejected write, the cliff is enforced in the vested
// math, and a forfeited grant is a TERMINAL state — verified, no path back.
// ============================================================================
cell Vesting {
face {
signal grant_award(id: String, total: Int, cliff: Int, duration: Int) -> Map
signal vested_at(id: String, month: Int) -> Int
signal claim_shares(id: String, month: Int, amount: Int) -> Map
signal forfeit(id: String) -> Map
signal claimed_so_far(id: String) -> Int
}
memory {
// shares not yet claimed — the wall: it can never go below zero,
// so the sum of claims can never exceed the grant
remaining: Map<String, Int> [persistent, consistent]
invariant remaining >= 0
terms: Map<String, Int> [persistent, consistent]
}
// forfeited and exhausted are terminal: a fired employee's unvested
// shares cannot quietly re-vest, a fully-claimed grant cannot refill.
state award {
initial: drafted
drafted -> active
active -> forfeited
active -> exhausted
}
on grant_award(id: String, total: Int, cliff: Int, duration: Int) {
if total <= 0 || cliff < 0 || duration < cliff {
return map("ok", false, "reason", "bad terms")
}
transition(id, "active")
remaining.set(id, total)
terms.set("{id}:total", total)
terms.set("{id}:cliff", cliff)
terms.set("{id}:duration", duration)
return map("ok", true, "state", get_status(id))
}
on vested_at(id: String, month: Int) {
let total = terms.get("{id}:total") ?? 0
let cliff = terms.get("{id}:cliff") ?? 0
let duration = terms.get("{id}:duration") ?? 1
if month < cliff { return 0 } // before the cliff: zero
if month >= duration { return total } // never more than granted
return idiv(total * month, duration) // linear in between
}
on claim_shares(id: String, month: Int, amount: Int) {
if get_status(id) != "active" {
return map("ok", false, "reason", "grant not active")
}
if amount <= 0 { return map("ok", false, "reason", "amount must be positive") }
let vested = vested_at(id, month)
let already = claimed_so_far(id)
if already + amount > vested {
return map("ok", false, "reason", "only {vested} vested by month {month}")
}
let rem = remaining.get(id) ?? 0
remaining.set(id, rem - amount) // raises if it would over-claim
if rem - amount == 0 { transition(id, "exhausted") }
return map("ok", true, "claimed", amount, "remaining", rem - amount)
}
on forfeit(id: String) {
transition(id, "forfeited")
let lost = remaining.get(id) ?? 0
remaining.set(id, 0)
return map("ok", true, "forfeited_shares", lost)
}
on claimed_so_far(id: String) {
let total = terms.get("{id}:total") ?? 0
let rem = remaining.get(id) ?? total
return total - rem
}
}
cell test VestingTests {
rules {
// 4800 shares, 12-month cliff, 48-month duration
assert grant_award("g1", 4800, 12, 48).ok == true
// before the cliff: nothing vests, claims refused
assert vested_at("g1", 11) == 0
assert claim_shares("g1", 11, 1).ok == false
// at the cliff: 12/48 = 1200 shares
assert vested_at("g1", 12) == 1200
assert claim_shares("g1", 12, 1200).ok == true
// OVER-VEST: month 24 vests 2400 total; claiming 1300 more = 2500
assert claim_shares("g1", 24, 1300).ok == false
assert claimed_so_far("g1") == 1200
// the pool itself refuses to go negative — even if the math broke
assert_fails remaining.set("g1", -1)
// never beyond the grant, no matter the month
assert vested_at("g1", 999) == 4800
// forfeiture is forever: no transition out, no re-vest
assert forfeit("g1").forfeited_shares == 3600
assert_fails transition("g1", "active")
assert claim_shares("g1", 48, 1).ok == false
// full life on a second grant: claim everything -> terminal
assert grant_award("g2", 100, 0, 10).ok == true
assert claim_shares("g2", 10, 100).ok == true
assert_fails transition("g2", "active")
}
}What the toolchain says
$ soma check vesting_schedule.cell
✓ All checks passed.$ soma test vesting_schedule.cell
test VestingTests ...
✓ assert grant_award("g1", 4800, 12, 48).ok == true
✓ assert vested_at("g1", 11) == 0
✓ assert claim_shares("g1", 11, 1).ok == false
✓ assert vested_at("g1", 12) == 1200
✓ assert claim_shares("g1", 12, 1200).ok == true
✓ assert claim_shares("g1", 24, 1300).ok == false
✓ assert claimed_so_far("g1") == 1200
✓ assert_fails remaining.set("g1", -1) — raised memory invariant violated on 'remaining': remaining >= 0 — rejected write of -1 (key "g1"); the slot is unchanged
✓ assert vested_at("g1", 999) == 4800
✓ assert forfeit("g1").forfeited_shares == 3600
✓ assert_fails transition("g1", "active") — raised invalid transition: forfeited → active. Current state: 'forfeited'. Valid targets: []
✓ assert claim_shares("g1", 48, 1).ok == false
✓ assert grant_award("g2", 100, 0, 10).ok == true
✓ assert claim_shares("g2", 10, 100).ok == true
✓ assert_fails transition("g2", "active") — raised invalid transition: exhausted → active. Current state: 'exhausted'. Valid targets: []
15 tests: 15 passed, 0 failed$ soma verify vesting_schedule.cell
Verifying vesting_schedule.cell...
State machine 'award': 4 states, initial 'drafted'
States: [active, drafted, exhausted, forfeited]
✓ 4 states, 3 transitions
✓ all states reachable from 'drafted'
✓ terminal states: [forfeited, exhausted]
✓ 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 `grant_award` ⟶ {active}
✓ refinement: handler `claim_shares` ⟶ {exhausted [if rem - amount == 0]}
✓ refinement: handler `forfeit` ⟶ {forfeited}
· transition sources: `grant_award`, `claim_shares`, `forfeit` 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)
Vesting/invariants:
✓ invariant remaining >= 0 — writer 'forfeit' proven (writes 0)
✓ invariant remaining >= 0 — writer 'grant_award' proven by induction (writes total)
⚠ invariant remaining >= 0 — runtime-checked (computed values): claim_shares → remaining
12 passed, 1 warnings, 0 failures
Temporal properties for 'award':
✓ deadlock_free — no deadlocks in any reachable state
Temporal: 1 passed, 0 failed
VERIFY OK