What changed, and why
Three mission-select routes call the model before a run is charged: /api/objective composes a grand objective, /api/suggestions runs a tool-using agent to suggest figures, and /api/figure grounds a name and can ask a disambiguation model which person you meant. They sit ahead of the paywall and the per-run budgets from #212, so the only thing bounding them was the global 24h spend cap — and that cap gates run starts, not these pre-run generations.
So a scripted client with no account could hammer all three without limit: spend the cap records but never stops. Worse, once the cap trips, every free-tier player's next run gets a 503. (Surfaced in the #212 review; #211 carries the independently-verified evidence and was closed as a duplicate of #304.)
This PR adds a soft bound built from pieces that already exist: a per-subject rate window (the same mechanism reroll and begin-run use) and the spend-cap read. When either says "over budget", each route serves its existing curated or deterministic result instead of paying the model. There is no 429 — a limited client still gets a usable mission-select, exactly as it would during a model outage, and the front end already renders these shapes. Blast radius is small: one new server util, a short gate at the top of each route, and tests.
The gate: a rate window OR the spend cap
pre-run-limit.ts is the whole policy. The window is pinned from subjectRateLimit, the shared sliding-window primitive (not a new mechanism), at 60 calls per 60 seconds per subject. withinPreRunLimit records an attempt and reports whether the subject is under the cap; isPreRunSubjectTracked exists for the eviction test.
The routes call one function: preRunAiOverBudget. It resolves the subject — the account id, or the device-cookie id for a no-account caller, the same key balances and runs already use — then returns true if that subject is over the window OR the spend cap is tripped.
The pinned window, the recorded-attempt check, and the composed policy.
server/utils/pre-run-limit.ts · 52 lines
⋯ 26 lines hidden (lines 1–26)
objective: fall back to the curated pool
This route already had a curated floor. generateObjective(false) draws from the fixed pool with no model call, the same path it takes when the model is unavailable. The gate reuses it: over budget, compose with false; under budget, compose with true.
The gate, then the two compose paths it chooses between.
server/api/objective.get.ts · 73 lines
⋯ 60 lines hidden (lines 1–60)
⋯ 1 line hidden (lines 73–73)
fellBackToCurated stays honest. It is true only when the player steered (picked an era or theme) and got curated anyway — their explicit intent was dropped, and the UI shows a quiet notice for it. A degraded steered request sets it; an unsteered one stays silent, since the player never asked for anything specific.
suggestions: skip the agent and the moderation call
The suggester is the costliest pre-run path: a tool-using agent that makes several model calls per request, and the route runs an OpenAI moderation check on the objective text before it. Over budget, the gate returns before either. fallbackSuggestions() is a plain copy of five hardcoded figures, the same list the suggester falls back to on its own failure.
The gate returns above readBody and the moderation import.
server/api/suggestions.post.ts · 70 lines
⋯ 22 lines hidden (lines 1–22)
⋯ 34 lines hidden (lines 37–70)
figure: skip the model pick, keep the grounding
figure is different: it is a lookup, not a generator, and it has no curated pool to substitute. Its floor is the deterministic Wikipedia/Wikidata grounding, which must run — contact is deceased-only (#73), and the client blocks an ungrounded figure. So the gate can't skip grounding. It skips only the paid part: the contextual disambiguation model.
Ground first (always), then gate only the contextual pick.
server/api/figure.get.ts · 82 lines
⋯ 43 lines hidden (lines 1–43)
⋯ 16 lines hidden (lines 67–82)
Grounding and the namesake probe run first, in parallel. If there is nothing to disambiguate the route already returns the grounded figure. Only past that point, where it would otherwise call the disambiguator model, does the gate fire — returning the deterministic grounding with its override list attached. That is the same shape applyContextualPick returns when the model abstains, so the "not who you meant?" picker still works; the caller just loses the AI auto-pick.
Tests: the gate short-circuits before spend
The load-bearing assertion in every route test is that the model function is not called on the degraded path — proof the gate cuts spend, not just the response shape. Each of these fails on the pre-fix code, which always calls the model.
Over budget composes with useAI=false, never the live path.
tests/unit/server/api/objective.spec.ts · 225 lines
⋯ 150 lines hidden (lines 1–150)
⋯ 53 lines hidden (lines 173–225)
Over budget returns the grounded pick plus overrides; the disambiguator is never called.
tests/unit/server/api/figure.spec.ts · 113 lines
⋯ 97 lines hidden (lines 1–97)
The composed policy: over the rail, over the cap, and the short-circuit that skips the cap read once rate-limited.