MOHITH GAJJELA SYSTEM / 01
DEPLOYED · SIX SERVICES · TWO TENANTS

A volleyball tournament app
and the platform under it.

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.

TYPESCRIPT · POSTGRESHONO + NEXT.JS DOUBLE-ENTRY LEDGER7 INVARIANTS, RECONCILED 960 TESTSMULTI-TENANT
01THE PROBLEM

One team's phone
should not decide a match.

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.

Sideout's home screen: live matches, one disputed, and what each event has raised
LIVE PLAY · TWO EVENTS ON THE SAND, ONE RESULT CONTESTED
The tournament bracket
BRACKET · WINNERS ADVANCED IN THE SAME TRANSACTION AS THE AGREEMENT
The impact screen showing money raised for the beneficiary
IMPACT · CHARITY DOLLARS, A LEDGER THAT NEVER MEETS THE CONTEST
02THE FOUR RULES

Two services, and one boundary
I could break without noticing.

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.

RULE 01

Separate databases.

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.

RULE 02

The secret key never reaches a browser.

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.

RULE 03

Sideout owns the outcome.
Purse owns the settlement.

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.

RULE 04

Every mutation is idempotent.

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.

03THE LEDGER

Append-only is not a convention here.
The database role cannot do anything else.

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.

I1The journal nets to zero per asset.
I2Every entry balances.
I3No user wallet is negative.
I4Settled and voided contests hold zero escrow.
I5A settled contest's payouts equal what it escrowed.
I6Every balance snapshot equals its derived balance.
I7Every 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.

7
Ledger invariants
reconciled every 15 min
10,000
Random ledger ops
per CI run
960
Tests
across 125 files
21
Purse migrations
applied in order
2
Tenants live
on one platform
04THE SECOND TENANT

It is only a platform
if something else can run on it.

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 ↗

05WHAT I DID NOT BUILD

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.

Home