What changed, and why
This is the spine that turns a run into a currency. #46 measured cost-per-run; #47 made a run a server row. This makes a run a thing you spend: a new device gets one free run, the balance is charged when the player commits to an objective, and out of runs raises a paywall. It's the heart of "sell runs" — Stripe packs and a global spend cap are the next two vertebrae.
Five small pieces: a balance store (the ledger), an httpOnly device cookie (the trial identity), a commit endpoint (the gate), a gated chooseObjective on the client, and a one-function migration. No accounts, no payment yet.
The ledger — one port, two adapters
The balance store mirrors the run store: in-memory by default (dev + tests need nothing), Supabase when configured. A brand-new device is granted FREE_RUNS; chargeRun spends one run, and charged:false is the paywall. Idempotency is keyed to (device, run), not the run alone — so a second device can't ride another's already-charged run for free (chargedRuns maps a run id to the device that paid).
The port, the free grant, and the in-memory adapter (mirrors the SQL function).
server/utils/balance-store.ts · 116 lines
⋯ 18 lines hidden (lines 1–18)
⋯ 50 lines hidden (lines 67–116)
The Supabase charge does not read-modify-write. It calls one SQL function, spend_run, so the free grant, the idempotency check, and the conditional decrement happen atomically in a single round-trip.
The Supabase adapter: chargeRun is one rpc('spend_run'); ensureDevice tolerates the unique-violation.
server/utils/balance-store.ts · 116 lines
⋯ 67 lines hidden (lines 1–67)
⋯ 15 lines hidden (lines 102–116)
The trial identity — a device cookie
Until accounts arrive, a balance is keyed to an opaque, server-set device id in an httpOnly cookie — the doc's accountless "first run free." Read-or-mint per request, validated against a strict UUID (a tampered value is replaced). A user id will supersede this.
Read-or-mint the device id; minting sets the cookie for next time.
server/utils/device.ts · 27 lines
⋯ 7 lines hidden (lines 1–7)
The gate
The client calls this when the player commits to an objective. It spends one run for the run id and returns the new balance, or 402 when out. A run id that isn't a server-minted UUID means begin-run degraded to a local id (a store outage) — that run was never recorded, so it isn't gated; the client is allowed to play. The honest caveat: this, and a forged id, are uncharged until the AI endpoints verify the run id server-side (a tracked follow-up).
Validate (strict UUID), resolve the device, charge — 402 on out-of-runs, degrade on a non-UUID id.
server/api/run-commit.post.ts · 48 lines
⋯ 23 lines hidden (lines 1–23)
The client — gated commit, paywall on refusal
chooseObjective is now async and gated. It charges the run; if the device is out (402, in any of ofetch's error shapes) it raises the paywall and the run does not start. Any other failure degrades to allowing the run — a store outage must never block play. MissionSelect shows the paywall notice.
Charge at commit; 402 → paywall + don't start; other errors → degrade and start.
stores/game.ts · 1097 lines
⋯ 988 lines hidden (lines 1–988)
⋯ 82 lines hidden (lines 1016–1097)
The table, and the atomic charge
One migration: the balances table (RLS on, no policy → service-key-only, like runs), two nullable columns on runs to record who spent a run and when, and the spend_run function. The function is where correctness lives: it locks the run row (for update) so two commits of the same run serialize — without that, one caller hit a spurious 402 after the other's decrement — and the conditional update ... where runs_remaining > 0 can't lose a race the way a read-then-write would. The idempotent no-op is gated on the owning device.
balances (server-only) and the two run columns.
supabase/migrations/0002_balances.sql · 63 lines
⋯ 48 lines hidden (lines 16–63)
spend_run: free grant, FOR UPDATE serialization, per-(device,run) idempotency, atomic decrement.