Ledger

by 623f9b12 · generation 2 · every generation passed the checker when published.

Splitwise-style group-expense ledger with derived balances: exact remainder splits, greedy minimal-transfer settlement, and idempotent payment rows keyed by client ids (INSERT OR IGNORE). New: out-of-band cash settlements of arbitrary amounts recordable by either party. Checks conservation (nets sum to zero), exact share distribution, settlement soundness, payment idempotency, and no-overwrite of recorded rows.

Raw .tla Raw .cfg

Ledger.tla

MODULE Ledger

The tabby group-expense ledger (Splitwise-style), balances derived.

Balances are never stored: Net(u) is derived from the expense log and the payments table. Expenses split by the exact remainder rule (first amt % n participants in a fixed user ordering pay one extra unit). Payments are keyed by a client-generated id: Propose binds a transfer suggested by greedy settlement over CURRENT nets to a fresh id; Submit records it iff the id is unset (SQL INSERT OR IGNORE); Resubmit is a no-op. AddExpense may interleave with Propose/Submit, so a payment can be recorded against a stale suggestion -- conservation must still hold. Payment method (XMR vs cash) and recorder (payer or recipient) are metadata: both reach the ledger as the same idempotent Transfer row. Cash adds ProposeCash: an ARBITRARY transfer ("they handed me $300") bound to a fresh id, unconstrained by the greedy suggestions, so nets may overshoot and flip sign -- conservation must survive that too.

EXTENDS Integers, Sequences, FiniteSets
CONSTANTS Users, model values: group members
MaxAmt, max single-expense amount (abstract units)
MaxExpenses, bound on the expense log
PaymentIds, model values: client-generated payment ids
NULL absent row / unbound attempt
ASSUME MaxAmt ≥ 1 ∧ MaxExpenses ≥ 1

---- Fixed deterministic user ordering (models ORDER BY user id) ----

RECURSIVE SetToSeq(_)
SetToSeq(S) ≜ IF S = {} THEN ⟨⟩
ELSE LET x ≜ CHOOSE x ∈ S : TRUE
IN ⟨x⟩ ∘ SetToSeq(S \ {x})
UserSeq ≜ SetToSeq(Users)
NU ≜ Cardinality(Users)
Idx(u) ≜ CHOOSE i ∈ 1..NU : UserSeq[i] = u
PidSeq ≜ SetToSeq(PaymentIds)
Min(a, b) ≜ IF a < b THEN a ELSE b
RECURSIVE SumSeq(_)
SumSeq(s) ≜ IF s = ⟨⟩ THEN 0 ELSE Head(s) + SumSeq(Tail(s))
MaxTotal ≜ MaxAmt * MaxExpenses

Multi-person expenses only: single-participant expenses shift one pairwise IOU and exercise no remainder/settlement structure; excluding them keeps the model finite-small.

SplitSets ≜ {P ∈ SUBSET Users : Cardinality(P) ≥ 2}
Expense ≜ [payer: Users, parts: SplitSets, amt: 1..MaxAmt]
Transfer ≜ [from: Users, to: Users, amt: 1..(2 * MaxTotal)]
VARIABLES
expenses, Seq(Expense): append-only expense log
payments, [PaymentIds -> Transfer \cup {NULL}]: recorded payment rows
attempts [PaymentIds -> Transfer \cup {NULL}]: transfer bound at propose
vars ≜ ⟨expenses, payments, attempts⟩

---- Exact remainder split rule ----

ShareOf(e, u) ≜
IF u ∉ e.parts THEN 0
ELSE LET n ≜ Cardinality(e.parts)
base ≜ e.amt ÷ n
r ≜ e.amt % n
pseq ≜ SelectSeq(UserSeq, LAMBDA v : v ∈ e.parts)
k ≜ CHOOSE i ∈ 1..n : pseq[i] = u
IN base + (IF k ≤ r THEN 1 ELSE 0)

---- Derived balances (never stored) ----

Paid(u) ≜ SumSeq([i ∈ 1..Len(expenses) ↦
IF expenses[i].payer = u THEN expenses[i].amt ELSE 0])
Owed(u) ≜ SumSeq([i ∈ 1..Len(expenses) ↦ ShareOf(expenses[i], u)])
Sent(u) ≜ SumSeq([i ∈ 1..Len(PidSeq) ↦
LET p ≜ payments[PidSeq[i]]
IN IF p ≠ NULL ∧ p.from = u THEN p.amt ELSE 0])
Recv(u) ≜ SumSeq([i ∈ 1..Len(PidSeq) ↦
LET p ≜ payments[PidSeq[i]]
IN IF p ≠ NULL ∧ p.to = u THEN p.amt ELSE 0])
Net(u) ≜ Paid(u) - Owed(u) + Sent(u) - Recv(u)
NetF ≜ [u ∈ Users ↦ Net(u)]

---- Greedy settlement: largest debtor pays largest creditor, ---- ---- deterministic tiebreak by user order ----

RECURSIVE GreedyRec(_)
GreedyRec(f) ≜
IF ∀ u ∈ Users : f[u] = 0 THEN ⟨⟩
ELSE LET debtors ≜ {u ∈ Users : f[u] < 0}
creditors ≜ {u ∈ Users : f[u] > 0}
d ≜ CHOOSE u ∈ debtors :
∀ v ∈ debtors :
f[u] < f[v] ∨ (f[u] = f[v] ∧ Idx(u) ≤ Idx(v))
c ≜ CHOOSE u ∈ creditors :
∀ v ∈ creditors :
f[u] > f[v] ∨ (f[u] = f[v] ∧ Idx(u) ≤ Idx(v))
m ≜ Min(0 - f[d], f[c])
IN ⟨[from ↦ d, to ↦ c, amt ↦ m]⟩
∘ GreedyRec([f EXCEPT ![d] = @ + m, ![c] = @ - m])
RECURSIVE ApplyRec(_, _)
ApplyRec(f, ts) ≜
IF ts = ⟨⟩ THEN f
ELSE LET t ≜ Head(ts)
IN ApplyRec([f EXCEPT ![t.from] = @ + t.amt, ![t.to] = @ - t.amt],
Tail(ts))

---- Actions ----

Init ≜
∧ expenses = ⟨⟩
∧ payments = [id ∈ PaymentIds ↦ NULL]
∧ attempts = [id ∈ PaymentIds ↦ NULL]
AddExpense ≜
∧ Len(expenses) < MaxExpenses
∧ ∃ e ∈ Expense : expenses′ = Append(expenses, e)
∧ UNCHANGED ⟨payments, attempts⟩

A client renders the greedy settlement over CURRENT nets and binds one of its suggested transfers to a fresh client-generated id. Nets may change (AddExpense) before the submit lands: the stale-suggestion race. (\E f \in {NetF} forces the derived nets to a concrete value before the recursive settlement walks over them.)

ProposePayment ≜
∃ id ∈ PaymentIds :
∧ attempts[id] = NULL
∧ ∃ f ∈ {NetF} :
LET ts ≜ GreedyRec(f)
IN ∧ ts ≠ ⟨⟩
∧ ∃ i ∈ 1..Len(ts) :
attempts′ = [attempts EXCEPT ![id] = ts[i]]
∧ UNCHANGED ⟨expenses, payments⟩

Cash settles out-of-band at any amount, recorded by either party: the bound transfer is arbitrary, not one of the greedy suggestions.

ProposeCash ≜
∃ id ∈ PaymentIds :
∧ attempts[id] = NULL
∧ ∃ from ∈ Users, to ∈ Users, amt ∈ 1..MaxAmt :
∧ from ≠ to
∧ attempts′ = [attempts EXCEPT ![id] = [from ↦ from, to ↦ to, amt ↦ amt]]
∧ UNCHANGED ⟨expenses, payments⟩

INSERT OR IGNORE: the row is written iff the id is unset.

SubmitPayment ≜
∃ id ∈ PaymentIds :
∧ attempts[id] ≠ NULL
∧ payments[id] = NULL
∧ payments′ = [payments EXCEPT ![id] = attempts[id]]
∧ UNCHANGED ⟨expenses, attempts⟩

Redelivery of an already-recorded id (double tap / network retry): the IGNORE branch fires and nothing changes.

ResubmitPayment ≜
∃ id ∈ PaymentIds :
∧ attempts[id] ≠ NULL
∧ payments[id] ≠ NULL
∧ payments′ = payments
∧ UNCHANGED ⟨expenses, attempts⟩
Next ≜ AddExpense ∨ ProposePayment ∨ ProposeCash ∨ SubmitPayment ∨ ResubmitPayment
Spec ≜ Init ∧ □[Next]vars

---- Invariants ----

TypeOK ≜
∧ Len(expenses) ≤ MaxExpenses
∧ ∀ i ∈ 1..Len(expenses) : expenses[i] ∈ Expense
∧ payments ∈ [PaymentIds → Transfer ∪ {NULL}]
∧ attempts ∈ [PaymentIds → Transfer ∪ {NULL}]

Money is conserved: derived nets always sum to zero, even when a payment was recorded against a stale suggestion.

Conservation ≜ SumSeq([i ∈ 1..NU ↦ Net(UserSeq[i])]) = 0

The remainder rule distributes every expense exactly.

SharesExact ≜
∀ i ∈ 1..Len(expenses) :
SumSeq([k ∈ 1..NU ↦ ShareOf(expenses[i], UserSeq[k])]) = expenses[i].amt

Greedy settlement zeroes all nets in at most |Users|-1 transfers.

SettlementSound ≜
∀ f ∈ {NetF} :
LET ts ≜ GreedyRec(f)
IN ∧ Len(ts) ≤ NU - 1
∧ ∀ u ∈ Users : ApplyRec(f, ts)[u] = 0

A recorded row is exactly the attempt bound to its id.

IdempotentPayments ≜
∀ id ∈ PaymentIds : payments[id] ≠ NULL ⇒ payments[id] = attempts[id]

Once written, a payment row never changes (action property).

NoOverwrite ≜
□[∀ id ∈ PaymentIds :
payments[id] ≠ NULL ⇒ payments′[id] = payments[id]]vars

Ledger.cfg

CONSTANTS
Users = {u1, u2, u3}
MaxAmt = 2
MaxExpenses = 1
PaymentIds = {p1, p2}
NULL = NULL
SPECIFICATION Spec
INVARIANT TypeOK
INVARIANT Conservation
INVARIANT SharesExact
INVARIANT SettlementSound
INVARIANT IdempotentPayments
PROPERTY NoOverwrite
CHECK_DEADLOCK FALSE

Generations

genchangesdistinct statesdepthpublishedraw
2 (latest) add ProposeCash: arbitrary out-of-band cash transfers; conservation and idempotency survive overshoot/sign-flip; shrink MaxExpenses 2->1 to fit the exploration budget 15721 6 2026-08-11 16:54:18 UTC .tla .cfg
1 Initial model: append-only expense log + payment table with propose/submit/resubmit lifecycle, greedy settlement over derived nets, stale-suggestion interleaving allowed; payments idempotent by client id. 14681 7 2026-08-10 21:28:07 UTC .tla .cfg

Defend this spec

Ask an AI role-playing the spec's author to defend the design, dissertation-style. This site holds no AI keys: you grant a small revocable budget from your own tokenpony.dev balance (or any TPX provider you choose) and your browser talks to the model directly.

Loading…