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 xCHOOSE xS : TRUE
INx⟩ ∘ SetToSeq(S \ {x})
UserSeqSetToSeq(Users)
NUCardinality(Users)
Idx(u) ≜ CHOOSE i ∈ 1..NU : UserSeq[i] = u
PidSeqSetToSeq(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))
MaxTotalMaxAmt * 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 ≜ {PSUBSET 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 ue.parts THEN 0
ELSE LET nCardinality(e.parts)
basee.amt ÷ n
re.amt % n
pseqSelectSeq(UserSeq, LAMBDA v : ve.parts)
kCHOOSE i ∈ 1..n : pseq[i] = u
IN base + (IF kr 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 ppayments[PidSeq[i]]
IN IF pNULLp.from = u THEN p.amt ELSE 0])
Recv(u) ≜ SumSeq([i ∈ 1..Len(PidSeq) ↦
LET ppayments[PidSeq[i]]
IN IF pNULLp.to = u THEN p.amt ELSE 0])
Net(u) ≜ Paid(u) - Owed(u) + Sent(u) - Recv(u)
NetF ≜ [uUsersNet(u)]

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

RECURSIVE GreedyRec(_)
GreedyRec(f) ≜
IFuUsers : f[u] = 0 THEN ⟨⟩
ELSE LET debtors ≜ {uUsers : f[u] < 0}
creditors ≜ {uUsers : f[u] > 0}
dCHOOSE udebtors :
vdebtors :
f[u] < f[v] ∨ (f[u] = f[v] ∧ Idx(u) ≤ Idx(v))
cCHOOSE ucreditors :
vcreditors :
f[u] > f[v] ∨ (f[u] = f[v] ∧ Idx(u) ≤ Idx(v))
mMin(0 - f[d], f[c])
IN ⟨[fromd, toc, amtm]⟩
GreedyRec([f EXCEPT ![d] = @ + m, ![c] = @ - m])
RECURSIVE ApplyRec(_, _)
ApplyRec(f, ts) ≜
IF ts = ⟨⟩ THEN f
ELSE LET tHead(ts)
IN ApplyRec([f EXCEPT ![t.from] = @ + t.amt, ![t.to] = @ - t.amt],
Tail(ts))

---- Actions ----

Init
expenses = ⟨⟩
payments = [idPaymentIdsNULL]
attempts = [idPaymentIdsNULL]
AddExpense
Len(expenses) < MaxExpenses
∧ ∃ eExpense : expenses = Append(expenses, e)
UNCHANGEDpayments, 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
idPaymentIds :
attempts[id] = NULL
∧ ∃ f ∈ {NetF} :
LET tsGreedyRec(f)
INts ≠ ⟨⟩
∧ ∃ i ∈ 1..Len(ts) :
attempts = [attempts EXCEPT ![id] = ts[i]]
UNCHANGEDexpenses, 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
idPaymentIds :
attempts[id] = NULL
∧ ∃ fromUsers, toUsers, amt ∈ 1..MaxAmt :
fromto
attempts = [attempts EXCEPT ![id] = [fromfrom, toto, amtamt]]
UNCHANGEDexpenses, payments

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

SubmitPayment
idPaymentIds :
attempts[id] ≠ NULL
payments[id] = NULL
payments = [payments EXCEPT ![id] = attempts[id]]
UNCHANGEDexpenses, attempts

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

ResubmitPayment
idPaymentIds :
attempts[id] ≠ NULL
payments[id] ≠ NULL
payments = payments
UNCHANGEDexpenses, attempts
NextAddExpenseProposePaymentProposeCashSubmitPaymentResubmitPayment
SpecInit ∧ □[Next]vars

---- Invariants ----

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

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

ConservationSumSeq([i ∈ 1..NUNet(UserSeq[i])]) = 0

The remainder rule distributes every expense exactly.

SharesExact
i ∈ 1..Len(expenses) :
SumSeq([k ∈ 1..NUShareOf(expenses[i], UserSeq[k])]) = expenses[i].amt

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

SettlementSound
f ∈ {NetF} :
LET tsGreedyRec(f)
INLen(ts) ≤ NU - 1
∧ ∀ uUsers : ApplyRec(f, ts)[u] = 0

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

IdempotentPayments
idPaymentIds : payments[id] ≠ NULLpayments[id] = attempts[id]

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

NoOverwrite
□[∀ idPaymentIds :
payments[id] ≠ NULLpayments[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…