What changed, and why
The floor under the monetization spine. The per-run paywall (#48) limits runs per device and enforcement (#49) makes it binding — but identities are cheap to refarm and the gameplay gate fails open during a store blip, so neither bounds your actual dollar bill. The spend cap does: a windowed running total of AI cost that pauses new runs once a ceiling is hit. It's the answer to "what stops a surprise bill."
A counter, a recorder, and a gate. The gateway records each call's cost; the commit endpoint refuses to start a run when the window is over the cap; in-progress runs finish.
The counter
A windowed running total, in-memory by default (dev/tests need nothing) and Supabase when configured — the same shape as the run and balance stores. The cap and window are env-configured (defaults $25 / 24h) and read fresh, so they're tunable without a redeploy once #46's real cost-per-run data exists.
Config + the port + the in-memory adapter (mirrors the SQL window roll).
server/utils/spend-store.ts · 106 lines
⋯ 21 lines hidden (lines 1–21)
⋯ 45 lines hidden (lines 62–106)
The cap check fails open: if the counter read errors, it returns "not over cap" rather than blocking everyone — the cap is a backstop, not a hard gate, and a counter blip shouldn't lock out play.
The Supabase adapter (atomic SQL functions) and isOverSpendCap (fail-open).
server/utils/spend-store.ts · 106 lines
⋯ 62 lines hidden (lines 1–62)
Recording, and the gate
Every billable call flows through the gateway's emit(), so that's where each call's cost is recorded — best-effort and non-blocking (a recording failure must never break the AI call). The commit endpoint reads the cap before starting a run and returns 503 "at capacity" when over.
What the cap does and doesn't bound, honestly: it gates run starts only. An in-progress run plays out — its five dispatches and its Archive lookups/studies keep spending past the cap. So the cap bounds the rate of new runs, not a single run's in-progress cost. Bounding that (gating the in-run endpoints, or a per-run Archive limit) is a tracked product decision, not done here.
emit() feeds the spend counter (outside the test-only log guard, so it's exercised + counts evals).
server/utils/ai-gateway.ts · 245 lines
⋯ 103 lines hidden (lines 1–103)
⋯ 132 lines hidden (lines 114–245)
Pause new runs: 503 when the window is over the cap (before the charge).
server/api/run-commit.post.ts · 58 lines
⋯ 36 lines hidden (lines 1–36)
⋯ 16 lines hidden (lines 43–58)
The windowed counter, in SQL
One single-row table and two small functions. record_spend rolls the window if it has aged out, then adds — both CASEs read the pre-UPDATE window_start, so a stale window resets to (now, p_usd) and a live one accumulates, atomically in one statement. current_spend reads the live total (or 0 once the window has aged out). RLS on with no policy: service-key-only, like the rest.
The single-row counter (server-only).
supabase/migrations/0003_spend.sql · 40 lines
⋯ 22 lines hidden (lines 19–40)
record_spend (roll-then-add, atomic) and current_spend (read).