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

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.

Generated from soma describe on this file.

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

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

$ 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

Builtins used

get_status, idiv, map, transition

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