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
server/utils/spend-store.ts106 lines · TypeScript
⋯ 21 lines hidden (lines 1–21)
1/**
2 * The spend cap — a global, windowed running total of AI cost (USD) that pauses
3 * NEW runs when a configured ceiling is hit. It's the backstop behind the per-run
4 * paywall: identities are cheap to refarm and the gameplay gate fails open during
5 * a store blip, but the dollar cap bounds the total regardless. The gateway
6 * records each AI call's cost; the commit gate reads isOverSpendCap() before
7 * starting a run.
8 *
9 * The counter lives in Supabase so it's consistent across workers (an in-process
10 * counter would let each replica spend a full cap). In-memory by default, so
11 * `npm run dev` and the tests need nothing. Cap + window are env-configured and
12 * tunable post-deploy once real cost-per-run data exists.
13 */
14import { createClient, type SupabaseClient } from '@supabase/supabase-js'
15import { supabaseConfig } from './supabase'
16 
17const DEFAULT_CAP_USD = 25
18const DEFAULT_WINDOW_SECONDS = 86_400 // 24h
19 
20function positiveEnv(name: string, fallback: number): number {
21 const n = process.env[name] ? Number(process.env[name]) : NaN
22 return Number.isFinite(n) && n > 0 ? n : fallback
24 
25export function spendCapUsd(): number {
26 return positiveEnv('REVISIONIST_SPEND_CAP_USD', DEFAULT_CAP_USD)
28export function spendWindowSeconds(): number {
29 return positiveEnv('REVISIONIST_SPEND_WINDOW_SECONDS', DEFAULT_WINDOW_SECONDS)
31 
32export interface SpendStore {
33 /** Add a call's cost to the current window. Best-effort — never throws. */
34 record(usd: number): Promise<void>
35 /** The current window's spend (USD). */
36 currentSpend(): Promise<number>
38 
39/** Dev default — process-local, single-window counter mirroring the SQL functions. */
40export class InMemorySpendStore implements SpendStore {
41 private windowStart = Date.now()
42 private total = 0
43 
44 async record(usd: number): Promise<void> {
45 this.roll()
46 this.total += usd
47 }
48 
49 async currentSpend(): Promise<number> {
50 // Read-only, like the SQL current_spend: report 0 once the window has aged
51 // out, WITHOUT re-anchoring it — the next record() does the real roll.
52 return Date.now() - this.windowStart >= spendWindowSeconds() * 1000 ? 0 : this.total
53 }
54 
55 private roll(): void {
56 if (Date.now() - this.windowStart >= spendWindowSeconds() * 1000) {
57 this.windowStart = Date.now()
58 this.total = 0
59 }
60 }
⋯ 45 lines hidden (lines 62–106)
62 
63/** Service-key access to the windowed counter via the atomic SQL functions. */
64export class SupabaseSpendStore implements SpendStore {
65 constructor(private readonly client: SupabaseClient) {}
66 
67 async record(usd: number): Promise<void> {
68 try {
69 await this.client.rpc('record_spend', { p_usd: usd, p_window_seconds: spendWindowSeconds() })
70 } catch {
71 // Best-effort: a recording failure must never break the AI call.
72 }
73 }
74 
75 async currentSpend(): Promise<number> {
76 const { data, error } = await this.client.rpc('current_spend', { p_window_seconds: spendWindowSeconds() })
77 if (error) throw new Error(`current_spend failed: ${error.message}`)
78 return typeof data === 'number' ? data : Number(data ?? 0)
79 }
81 
82let cached: SpendStore | null = null
83 
84export function spendStore(): SpendStore {
85 if (cached) return cached
86 const cfg = supabaseConfig()
87 cached = cfg
88 ? new SupabaseSpendStore(createClient(cfg.url, cfg.secretKey, { auth: { persistSession: false } }))
89 : new InMemorySpendStore()
90 return cached
92 
93/** Test seam: clear the memoized store so the next spendStore() re-reads config. */
94export function resetSpendStore(): void {
95 cached = null
97 
98/** Is the current window over the cap? Best-effort: a read failure fails OPEN —
99 * don't block players on a counter blip; the cap is a backstop, not a hard gate. */
100export async function isOverSpendCap(): Promise<boolean> {
101 try {
102 return (await spendStore().currentSpend()) >= spendCapUsd()
103 } catch {
104 return false
105 }

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
server/utils/spend-store.ts106 lines · TypeScript
⋯ 62 lines hidden (lines 1–62)
1/**
2 * The spend cap — a global, windowed running total of AI cost (USD) that pauses
3 * NEW runs when a configured ceiling is hit. It's the backstop behind the per-run
4 * paywall: identities are cheap to refarm and the gameplay gate fails open during
5 * a store blip, but the dollar cap bounds the total regardless. The gateway
6 * records each AI call's cost; the commit gate reads isOverSpendCap() before
7 * starting a run.
8 *
9 * The counter lives in Supabase so it's consistent across workers (an in-process
10 * counter would let each replica spend a full cap). In-memory by default, so
11 * `npm run dev` and the tests need nothing. Cap + window are env-configured and
12 * tunable post-deploy once real cost-per-run data exists.
13 */
14import { createClient, type SupabaseClient } from '@supabase/supabase-js'
15import { supabaseConfig } from './supabase'
16 
17const DEFAULT_CAP_USD = 25
18const DEFAULT_WINDOW_SECONDS = 86_400 // 24h
19 
20function positiveEnv(name: string, fallback: number): number {
21 const n = process.env[name] ? Number(process.env[name]) : NaN
22 return Number.isFinite(n) && n > 0 ? n : fallback
24 
25export function spendCapUsd(): number {
26 return positiveEnv('REVISIONIST_SPEND_CAP_USD', DEFAULT_CAP_USD)
28export function spendWindowSeconds(): number {
29 return positiveEnv('REVISIONIST_SPEND_WINDOW_SECONDS', DEFAULT_WINDOW_SECONDS)
31 
32export interface SpendStore {
33 /** Add a call's cost to the current window. Best-effort — never throws. */
34 record(usd: number): Promise<void>
35 /** The current window's spend (USD). */
36 currentSpend(): Promise<number>
38 
39/** Dev default — process-local, single-window counter mirroring the SQL functions. */
40export class InMemorySpendStore implements SpendStore {
41 private windowStart = Date.now()
42 private total = 0
43 
44 async record(usd: number): Promise<void> {
45 this.roll()
46 this.total += usd
47 }
48 
49 async currentSpend(): Promise<number> {
50 // Read-only, like the SQL current_spend: report 0 once the window has aged
51 // out, WITHOUT re-anchoring it — the next record() does the real roll.
52 return Date.now() - this.windowStart >= spendWindowSeconds() * 1000 ? 0 : this.total
53 }
54 
55 private roll(): void {
56 if (Date.now() - this.windowStart >= spendWindowSeconds() * 1000) {
57 this.windowStart = Date.now()
58 this.total = 0
59 }
60 }
62 
63/** Service-key access to the windowed counter via the atomic SQL functions. */
64export class SupabaseSpendStore implements SpendStore {
65 constructor(private readonly client: SupabaseClient) {}
66 
67 async record(usd: number): Promise<void> {
68 try {
69 await this.client.rpc('record_spend', { p_usd: usd, p_window_seconds: spendWindowSeconds() })
70 } catch {
71 // Best-effort: a recording failure must never break the AI call.
72 }
73 }
74 
75 async currentSpend(): Promise<number> {
76 const { data, error } = await this.client.rpc('current_spend', { p_window_seconds: spendWindowSeconds() })
77 if (error) throw new Error(`current_spend failed: ${error.message}`)
78 return typeof data === 'number' ? data : Number(data ?? 0)
79 }
81 
82let cached: SpendStore | null = null
83 
84export function spendStore(): SpendStore {
85 if (cached) return cached
86 const cfg = supabaseConfig()
87 cached = cfg
88 ? new SupabaseSpendStore(createClient(cfg.url, cfg.secretKey, { auth: { persistSession: false } }))
89 : new InMemorySpendStore()
90 return cached
92 
93/** Test seam: clear the memoized store so the next spendStore() re-reads config. */
94export function resetSpendStore(): void {
95 cached = null
97 
98/** Is the current window over the cap? Best-effort: a read failure fails OPEN —
99 * don't block players on a counter blip; the cap is a backstop, not a hard gate. */
100export async function isOverSpendCap(): Promise<boolean> {
101 try {
102 return (await spendStore().currentSpend()) >= spendCapUsd()
103 } catch {
104 return false
⋯ 2 lines hidden (lines 105–106)

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
server/utils/ai-gateway.ts245 lines · TypeScript
⋯ 103 lines hidden (lines 1–103)
1/**
2 * The AI gateway — one transport for every structured model call, dispatched
3 * per task by the routing table (ai-routing.ts). Callers keep their own prompts,
4 * schemas, parsing, guards, and never-throw envelopes; the gateway only decides
5 * WHICH provider runs the request and HOW (model + parameters), and emits the
6 * cost telemetry the go-to-market plan's dispatch 1 requires.
7 *
8 * Telemetry: every call logs one structured line (task, lane, model, tokens
9 * in/out, latency, ok) — intentional production output, suppressed under vitest.
10 * The eval harness subscribes via setAiCallObserver to price bake-off lanes.
11 */
12import OpenAI from 'openai'
13import Anthropic from '@anthropic-ai/sdk'
14import { routeFor, type AiTask, type AiLane } from './ai-routing'
15import { currentRunId } from './run-context'
16import { costUsd } from './cost'
17import { spendStore } from './spend-store'
18 
19let openaiClient: OpenAI | null = null
20let anthropicClient: Anthropic | null = null
21 
22/** Reads a runtime-config key, falling back to raw env outside a Nuxt context
23 * (the eval harness exercises these utils directly in node). */
24function configuredKey(name: 'openaiApiKey' | 'anthropicApiKey', envName: string): string | undefined {
25 let key: string | undefined
26 try {
27 key = useRuntimeConfig()[name] as string | undefined
28 } catch {
29 key = undefined
30 }
31 return key || process.env[envName]
33 
34export function getOpenAIClient(): OpenAI {
35 if (!openaiClient) {
36 const apiKey = configuredKey('openaiApiKey', 'OPENAI_API_KEY')
37 if (!apiKey) throw new Error('OpenAI API key is not configured')
38 openaiClient = new OpenAI({
39 apiKey,
40 // Fail fast rather than hang on the SDK's 10-minute default if a
41 // call stalls (e.g. a bad key or network hiccup).
42 timeout: 30_000,
43 maxRetries: 1
44 })
45 }
46 return openaiClient
48 
49export function getAnthropicClient(): Anthropic {
50 if (!anthropicClient) {
51 const apiKey = configuredKey('anthropicApiKey', 'ANTHROPIC_API_KEY')
52 if (!apiKey) throw new Error('Anthropic API key is not configured')
53 anthropicClient = new Anthropic({
54 apiKey,
55 // The epilogue's adaptive thinking can run long; everything else is
56 // seconds. 60s bounds the worst case without hanging a turn forever.
57 timeout: 60_000,
58 maxRetries: 1
59 })
60 }
61 return anthropicClient
63 
64export interface AiUsage {
65 inputTokens: number
66 outputTokens: number
67 cacheReadTokens: number
69 
70export interface AiCallTelemetry {
71 task: AiTask
72 lane: AiLane
73 model: string
74 ms: number
75 ok: boolean
76 usage?: AiUsage
77 /** The game run this call belongs to (from the request's x-run-id); absent
78 * outside a run-scoped request. The key that groups calls into a run. */
79 runId?: string
80 /** The call's dollar cost from the rate card; absent when usage is missing
81 * (a failed call) or the model has no listed rate. */
82 usd?: number
84 
85type AiCallObserver = (t: AiCallTelemetry) => void
86let observer: AiCallObserver | null = null
87 
88/** The eval harness (and future dashboards) subscribe here; null to clear. */
89export function setAiCallObserver(fn: AiCallObserver | null): void {
90 observer = fn
92 
93function emit(t: AiCallTelemetry): void {
94 // Enrich once, here, so both the success and failure call sites get the run
95 // id, and so the observer (the eval harness) sees the same priced line the
96 // log does. usd needs usage, so it rides only on calls that reported tokens.
97 const enriched: AiCallTelemetry = {
98 ...t,
99 runId: t.runId ?? currentRunId(),
100 usd: t.usd ?? (t.usage ? costUsd(t.model, t.usage, t.lane) : undefined)
101 }
102 observer?.(enriched)
103 // One greppable line per call — the GTM's "structured log line is enough to
104 // start". Intentional production telemetry; quiet under the test runner.
105 if (!process.env.VITEST) {
106 console.info(`[ai] ${JSON.stringify(enriched)}`)
107 }
108 // Feed the spend cap: record this call's cost (best-effort, non-blocking).
109 // Outside the VITEST guard — unlike the log line, recording must happen under
110 // test (so the wiring is exercised) and for eval traffic (which spends real
111 // money), not just in production.
112 if (enriched.usd) void spendStore().record(enriched.usd)
⋯ 132 lines hidden (lines 114–245)
114 
115export interface ChatTurn {
116 role: 'user' | 'assistant'
117 content: string
119 
120/**
121 * Thrown when Claude's own safety classifiers decline a request mid-flight
122 * (Claude 4 returns stop_reason "refusal"). It is NOT an infra error and NOT an
123 * empty answer — it's a model-side block, so callers surface it as a moderation
124 * block, not a "try again" refund. (See Anthropic's streaming-refusals guide.)
125 */
126export class ModelRefusalError extends Error {
127 constructor() {
128 super('The model declined this request (safety refusal).')
129 this.name = 'ModelRefusalError'
130 }
132 
133export interface StructuredRequest {
134 task: AiTask
135 /** The built prompt. Single-shot tasks send it as the whole request; with
136 * `turns` it becomes the system prompt over the conversation. */
137 system: string
138 turns?: ChatTurn[]
139 /** OpenAI requires a schema name; Anthropic ignores it. */
140 schemaName: string
141 schema: Record<string, unknown>
143 
144/**
145 * Runs one structured completion on the task's routed lane and returns the raw
146 * JSON text (trimmed), or null on an empty answer. Throws on transport errors —
147 * callers own their never-throw envelopes, exactly as before the seam.
148 */
149export async function completeStructured(req: StructuredRequest): Promise<string | null> {
150 const { lane, route } = routeFor(req.task)
151 const model = lane === 'anthropic' ? route.anthropic.model : route.openai.model
152 const started = Date.now()
153 try {
154 const result = lane === 'anthropic' ? await viaAnthropic(req) : await viaOpenAI(req)
155 emit({ task: req.task, lane, model, ms: Date.now() - started, ok: true, usage: result.usage })
156 return result.content
157 } catch (error) {
158 emit({ task: req.task, lane, model, ms: Date.now() - started, ok: false })
159 throw error
160 }
162 
163interface LaneResult {
164 content: string | null
165 usage?: AiUsage
167 
168async function viaOpenAI(req: StructuredRequest): Promise<LaneResult> {
169 const { route } = routeFor(req.task)
170 const params = route.openai
171 const client = getOpenAIClient()
172 const completion = await client.chat.completions.create({
173 model: params.model,
174 messages: [
175 { role: 'system' as const, content: req.system },
176 ...(req.turns ?? [])
177 ],
178 // gpt-5.5 is a reasoning model: omit temperature, keep effort low, leave
179 // headroom for hidden reasoning tokens inside the output cap.
180 ...(params.reasoningEffort ? { reasoning_effort: params.reasoningEffort } : {}),
181 max_completion_tokens: params.maxTokens,
182 response_format: {
183 type: 'json_schema',
184 json_schema: { name: req.schemaName, strict: true, schema: req.schema }
185 }
186 })
187 const usage = completion.usage
188 ? {
189 inputTokens: completion.usage.prompt_tokens ?? 0,
190 outputTokens: completion.usage.completion_tokens ?? 0,
191 cacheReadTokens: completion.usage.prompt_tokens_details?.cached_tokens ?? 0
192 }
193 : undefined
194 return { content: completion.choices?.[0]?.message?.content?.trim() || null, usage }
196 
197async function viaAnthropic(req: StructuredRequest): Promise<LaneResult> {
198 const { route } = routeFor(req.task)
199 const params = route.anthropic
200 const client = getAnthropicClient()
201 
202 // Anthropic requires the first message to be a user turn. Single-shot tasks
203 // send the whole built prompt AS the user message (no system param) — for
204 // one-off structured generation the two are equivalent, and it avoids a
205 // contentless "Proceed." turn. Conversational tasks keep system + turns.
206 const turns = req.turns ?? []
207 const system = turns.length ? req.system : undefined
208 const messages: Anthropic.MessageParam[] = turns.length
209 ? (turns[0].role === 'user' ? turns : [{ role: 'user' as const, content: '(The conversation resumes.)' }, ...turns])
210 : [{ role: 'user' as const, content: req.system }]
211 
212 const response = await client.messages.create({
213 model: params.model,
214 max_tokens: params.maxTokens,
215 ...(system ? { system } : {}),
216 messages,
217 // Thinking rejects non-default sampling params — temperature only rides
218 // on thinking-off calls (the family rule the routing table can't express).
219 ...(params.temperature !== undefined && params.thinking !== 'adaptive' ? { temperature: params.temperature } : {}),
220 ...(params.thinking === 'adaptive' ? { thinking: { type: 'adaptive' as const } } : {}),
221 output_config: {
222 ...(params.effort ? { effort: params.effort } : {}),
223 format: { type: 'json_schema' as const, schema: req.schema }
224 }
225 })
226 
227 // Claude's streaming classifiers can decline mid-flight (stop_reason
228 // "refusal"); that's a model-side block, surfaced distinctly so callers
229 // don't mistake it for an empty/infra response. Cast: the union may predate
230 // this SDK's type for the value.
231 if ((response.stop_reason as string) === 'refusal') {
232 throw new ModelRefusalError()
233 }
234 
235 // Thinking blocks (epilogue) precede the text block; take the text.
236 const text = response.content.find(
237 (b): b is Anthropic.TextBlock => b.type === 'text'
238 )?.text?.trim()
239 const usage: AiUsage = {
240 inputTokens: response.usage?.input_tokens ?? 0,
241 outputTokens: response.usage?.output_tokens ?? 0,
242 cacheReadTokens: response.usage?.cache_read_input_tokens ?? 0
243 }
244 return { content: text || null, usage }

Pause new runs: 503 when the window is over the cap (before the charge).

server/api/run-commit.post.ts · 58 lines
server/api/run-commit.post.ts58 lines · TypeScript
⋯ 36 lines hidden (lines 1–36)
1/**
2 * POST /api/run-commit — the paywall + capacity gate. The client calls this when
3 * the player commits to an objective. It spends one run from the device's balance
4 * for this runId (once, idempotently), granting the free trial on the device's
5 * first sight. Returns { runsRemaining } when charged, 402 when out of runs, or
6 * 503 when the global spend cap has paused new runs.
7 *
8 * Two gates, both at run START only (in-progress runs keep going): the spend cap
9 * (isOverSpendCap) pauses NEW runs while the window is over the dollar ceiling,
10 * and the per-device balance gates on runs remaining. NOTE the cap bounds run
11 * STARTS, not a run's in-progress AI cost — an in-progress run's dispatches and
12 * its Archive lookups/studies are not cap-gated (a tracked gap).
13 *
14 * A run id that isn't a server-minted UUID means begin-run degraded to a local id;
15 * that run was never recorded, so it isn't gated here.
16 */
17import { deviceId } from '~/server/utils/device'
18import { balanceStore } from '~/server/utils/balance-store'
19import { isOverSpendCap } from '~/server/utils/spend-store'
20import { isUuid } from '~/server/utils/uuid'
21 
22export default defineEventHandler(async (event) => {
23 if (getMethod(event) !== 'POST') {
24 throw createError({ statusCode: 405, statusMessage: 'Method Not Allowed' })
25 }
26 const body = await readBody(event)
27 const runId = typeof body?.runId === 'string' ? body.runId.replace(/[^a-zA-Z0-9-]/g, '').slice(0, 64) : ''
28 if (!runId) {
29 throw createError({ statusCode: 400, statusMessage: 'Bad Request: runId is required' })
30 }
31 
32 const device = deviceId(event)
33 // A degraded (non-UUID) run was never recorded server-side — don't gate it.
34 if (!isUuid(runId)) return { runsRemaining: -1, degraded: true }
35 
36 // Spend cap: pause NEW runs while the window is over the global cap. Only run
37 // starts are gated — in-progress runs keep going and finish. Best-effort
38 // (fails open on a counter read error; the cap is a backstop, not a hard gate).
39 if (await isOverSpendCap()) {
40 throw createError({ statusCode: 503, statusMessage: 'At capacity', data: { atCapacity: true } })
41 }
42 
⋯ 16 lines hidden (lines 43–58)
43 let result
44 try {
45 result = await balanceStore().chargeRun(device, runId)
46 } catch (error) {
47 console.error('Run commit failed:', error)
48 throw createError({ statusCode: 500, statusMessage: 'Internal Server Error' })
49 }
50 if (!result.charged) {
51 throw createError({
52 statusCode: 402,
53 statusMessage: 'Out of runs',
54 data: { runsRemaining: result.runsRemaining }
55 })
56 }
57 return { runsRemaining: result.runsRemaining }
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
supabase/migrations/0003_spend.sql40 lines · Transact-SQL
1-- spend_counter: a single-row, windowed running total of AI spend (USD) — the
2-- global backstop behind the per-run paywall. The gateway records each AI call's
3-- cost; the commit gate pauses NEW runs when the current window is over the cap.
4-- A consistent counter across workers is why this lives in the DB, not in process
5-- memory. RLS on with no policy: service-key-only, like the other tables.
6create table if not exists public.spend_counter (
7 id int primary key default 1,
8 window_start timestamptz not null default now(),
9 total_usd numeric not null default 0,
10 constraint spend_counter_singleton check (id = 1)
11);
12 
13alter table public.spend_counter enable row level security;
14 
15insert into public.spend_counter(id) values (1) on conflict (id) do nothing;
16 
17-- record_spend: roll the window if it has aged past p_window_seconds, then add
18-- p_usd. Returns the new window total. Atomic — both CASEs read the pre-UPDATE
⋯ 22 lines hidden (lines 19–40)
19-- window_start, so a stale window resets to (now, p_usd) and a live one adds.
20create or replace function public.record_spend(p_usd numeric, p_window_seconds int)
21returns numeric
22language sql as $$
23 update public.spend_counter set
24 window_start = case when now() - window_start >= make_interval(secs => p_window_seconds)
25 then now() else window_start end,
26 total_usd = case when now() - window_start >= make_interval(secs => p_window_seconds)
27 then p_usd else total_usd + p_usd end
28 where id = 1
29 returning total_usd;
30$$;
31 
32-- current_spend: the live window's total, or 0 once the window has aged out
33-- (read-only; the next record_spend rolls it for real).
34create or replace function public.current_spend(p_window_seconds int)
35returns numeric
36language sql as $$
37 select case when now() - window_start >= make_interval(secs => p_window_seconds)
38 then 0::numeric else total_usd end
39 from public.spend_counter where id = 1;
40$$;

record_spend (roll-then-add, atomic) and current_spend (read).

supabase/migrations/0003_spend.sql · 40 lines
supabase/migrations/0003_spend.sql40 lines · Transact-SQL
⋯ 19 lines hidden (lines 1–19)
1-- spend_counter: a single-row, windowed running total of AI spend (USD) — the
2-- global backstop behind the per-run paywall. The gateway records each AI call's
3-- cost; the commit gate pauses NEW runs when the current window is over the cap.
4-- A consistent counter across workers is why this lives in the DB, not in process
5-- memory. RLS on with no policy: service-key-only, like the other tables.
6create table if not exists public.spend_counter (
7 id int primary key default 1,
8 window_start timestamptz not null default now(),
9 total_usd numeric not null default 0,
10 constraint spend_counter_singleton check (id = 1)
11);
12 
13alter table public.spend_counter enable row level security;
14 
15insert into public.spend_counter(id) values (1) on conflict (id) do nothing;
16 
17-- record_spend: roll the window if it has aged past p_window_seconds, then add
18-- p_usd. Returns the new window total. Atomic — both CASEs read the pre-UPDATE
19-- window_start, so a stale window resets to (now, p_usd) and a live one adds.
20create or replace function public.record_spend(p_usd numeric, p_window_seconds int)
21returns numeric
22language sql as $$
23 update public.spend_counter set
24 window_start = case when now() - window_start >= make_interval(secs => p_window_seconds)
25 then now() else window_start end,
26 total_usd = case when now() - window_start >= make_interval(secs => p_window_seconds)
27 then p_usd else total_usd + p_usd end
28 where id = 1
29 returning total_usd;
30$$;
31 
32-- current_spend: the live window's total, or 0 once the window has aged out
33-- (read-only; the next record_spend rolls it for real).
34create or replace function public.current_spend(p_window_seconds int)
35returns numeric
36language sql as $$
37 select case when now() - window_start >= make_interval(secs => p_window_seconds)
38 then 0::numeric else total_usd end
39 from public.spend_counter where id = 1;
40$$;