Sideout runs charity beach volleyball tournaments. Purse is the platform underneath it that tracks scores and payouts. Both are live: open the app below and settle a match as a team captain.
If a platform pays out a prize, it is accepting a score it has no way to check. Forward whatever one team typed in and you have let one team decide a match that moves money. So in Sideout a score only becomes a result when two independent parties say the same thing, and the server is what decides whether they did, never the phone.
Both teams submit from their own side. The submission is canonicalised and hashed, and agreement is equality of hashes, never a comparison of who said what. A disagreement is not an error, it is just a state the match sits in, and only an organizer can move it out.
awaiting_first
└─ one team submits ─────────────────► awaiting_second
├─ the other submits the same hash ──► agreed
└─ the other submits a different hash ► disputed
disputed ──── organizer resolves ──────────► agreed
agreed ────── Purse accepts the scores ────► pushed_to_purse
pushed_to_purse ── Purse's replay confirms ► confirmed
Only agreed may push, and only under the idempotency key minted on entering it. The last step is my favourite part: confirmed is literally a replay. The proof that Purse is really holding the score is that sending it a second time changes nothing.
It is all one repository, so nothing physically stops me from reaching across that boundary one evening and never noticing. That is why every rule below has a test attached that keeps it true.
Two logical databases, two connection strings never loaded into the same process, no foreign key across the line. Sideout holds Purse ids as opaque strings and nothing Purse-shaped otherwise: no column of Sideout's holds a POINTS amount. A test proves each app's loader ignores the other's URL even when both are present.
Secret keys are used server to server; the publishable key only bootstraps the iframe. The build greps its own client bundle for sk_ and fails. Exactly one component imports the SDK, and a lint rule plus a test hold it there.
Sideout decides what the score is with its own consensus rules and pushes the result. Purse decides what it pays, and it refuses to settle a contest that no human has closed behind a frozen preview of the payouts.
Every write takes an idempotency key. A replay returns the stored response and creates nothing; the same key with a different body is a conflict. This is what makes a phone on bad signal safe to retry from.
Every entry has at least two lines, in one asset, and debits equal credits. Balances are derived by summing lines, so the balance at any past instant is one WHERE posted_at <= $1 away. Nothing is ever updated or deleted, and that is not just a rule I follow. The API runs as a role that holds SELECT and INSERT on the journal and nothing else. A test fires an UPDATE and expects Postgres to refuse it, and the API will not boot on a role that could. Corrections are reversing entries.
Seven invariants are checked every fifteen minutes, in CI against the seeded database, and from the console. A failing invariant makes the health endpoint answer 503, so any uptime check on the status code alone pages, and a deploy will not switch onto a ledger that does not reconcile.
| I1 | The journal nets to zero per asset. |
| I2 | Every entry balances. |
| I3 | No user wallet is negative. |
| I4 | Settled and voided contests hold zero escrow. |
| I5 | A settled contest's payouts equal what it escrowed. |
| I6 | Every balance snapshot equals its derived balance. |
| I7 | Every contest entry links to a matching escrow entry. |
The test I would read first is the randomised one: ten thousand seeded random operations covering issue, escrow, refund, settle, void, replay, reversal, attempted overdraft and every contest operation, a lot of them fired concurrently, all checked against an independent replay of every accepted line, after which reconcile has to come back clean. CI refuses to run fewer than that.
Settlement itself is a pure function: no database, no clock, no randomness, and the same input gives the same output byte for byte whatever order the entrants arrive in. Shares are floored in bigint and the remainder handed out one unit at a time to the best placement first, so 100 points split three ways is 34 / 33 / 33, never 33 / 33 / 33 with a point quietly missing. Conservation, non-negativity, placement monotonicity and determinism under permutation are property tests over thousands of generated contests, not examples.
So I put a second tenant on it that has nothing to do with volleyball: an office ping-pong ladder. Sign in with a name and an office code, challenge someone up to three places above you, agree the score, climb. Each season is one contest on Purse, so the stake, the eligibility check and the payout all happen there, through the same public API and the same embedded frame, with its own database and its own keys.
pingpong-production-bc24.up.railway.app ↗
Where I could not build something properly, I built the seam for it and named the vendor rather than faking it. Identity, geolocation and risk are interfaces with development implementations behind them, standing in for Persona, GeoComply and Sardine; a production process refuses to start on a development provider unless it is told to on purpose.
And there is no real money. The contest currency is closed-loop: free-to-enter points and sponsor-funded credit, no cash prizes, no withdrawal, no peer-to-peer wagering. The ledger, the escrow model and the settlement arithmetic are the ones a real-money system would use, so swapping the asset for actual currency is a licensing problem rather than an architecture one. The charity dollars are the one real ledger, and they never touch the contest, so a prize can never be computed out of donation revenue.
Built September 2026. Two apps, four Next.js surfaces, one Hono API, six services on Railway. The public demo resets itself nightly, so anything you break is gone by morning.