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
server/utils/pre-run-limit.ts52 lines · TypeScript
⋯ 26 lines hidden (lines 1–26)
1/**
2 * The pre-run AI gate (issue #304). The mission-select / figure-picker routes compose
3 * with the model BEFORE a run is charged, so they sit upstream of the paywall
4 * (`runIsActive`) and the per-run budgets (#212) — only the global 24h spend cap and this
5 * soft per-subject rail bound them. A scripted client could otherwise spam objective
6 * composition + the suggester agent + the disambiguator anonymously, burning spend the
7 * cap records but never stops (and a tripped cap 503s legit free-tier run starts).
8 *
9 * Two complementary bounds, both degrading to the FREE curated/deterministic path rather
10 * than a 429 — so a limited client still gets a usable mission-select (#304), exactly as a
11 * model outage already would:
12 * 1. A per-subject sliding window (shared with reroll #117 / begin-run #254 via
13 * `subjectRateLimit`): the anti-hammer rail. Best-effort + in-process, so NOT a
14 * security boundary (a client that drops its device cookie mints a fresh key).
15 * 2. `isOverSpendCap()`: once the window's dollar ceiling is hit, every pre-run route
16 * serves curated, so pre-run generation can't pile onto an already-tripped cap.
17 *
18 * Pure + `now`-injected window, so the rail is directly unit-testable; the cap read is
19 * best-effort and fails open (see spend-store). The cap is generous: real mission-select
20 * play stays well under it and only automation trips it.
21 */
22import type { H3Event } from "h3";
23import { currentSubject } from "./auth";
24import { isOverSpendCap } from "./spend-store";
25import { subjectRateLimit } from "./rate-limit";
26 
27export const PRE_RUN_WINDOW_MS = 60_000;
28export const MAX_PRE_RUN_PER_WINDOW = 60;
29 
30const limit = subjectRateLimit(PRE_RUN_WINDOW_MS, MAX_PRE_RUN_PER_WINDOW);
31 
32/** True if this subject is under the per-window pre-run cap (and records the attempt). */
33export function withinPreRunLimit(subject: string, now: number): boolean {
34 return limit.within(subject, now);
36 
37/** Whether a subject currently has an entry in the limiter map — exposed for tests /
38 * observability of the eviction (a fully-expired subject is swept on the next call). */
39export function isPreRunSubjectTracked(subject: string): boolean {
40 return limit.isTracked(subject);
42 
43/**
44 * True when a pre-run AI route should serve its curated/deterministic path instead of
45 * spending: the subject is over the per-window rail OR the global spend cap is tripped.
46 * Evaluated left-to-right, so the rate-limit attempt is always recorded and the async
47 * spend-cap read (best-effort, fails open) is skipped once already rate-limited.
48 */
49export async function preRunAiOverBudget(event: H3Event): Promise<boolean> {
50 const subject = await currentSubject(event);
51 return !withinPreRunLimit(subject, Date.now()) || (await isOverSpendCap());

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
server/api/objective.get.ts73 lines · TypeScript
⋯ 60 lines hidden (lines 1–60)
1import { generateObjective } from "../utils/objective-generator";
2import { coerceSteer } from "../utils/objectives";
3import { coerceAvoidTitles } from "../utils/validate";
4import { currentUser } from "~/server/utils/auth";
5import { preRunAiOverBudget } from "~/server/utils/pre-run-limit";
6 
7/**
8 * GET /api/objective?era=…&theme=…&avoid=…&avoid=… — composes a fresh grand
9 * objective with the model, optionally steered along two closed enums (#71) and
10 * away from titles already rolled this session (#95).
11 *
12 * Objective generation MUST run server-side: it needs OPENAI_API_KEY, which only
13 * exists on the server (runtimeConfig.openaiApiKey). Calling the generator from
14 * the client would silently fall back to the curated pool every time. The curated
15 * pool itself is plain data the client already has, so this route exists purely to
16 * unlock the *live* AI path.
17 *
18 * Custom composition is a signed-in privilege: an anonymous player gets the fixed
19 * starter sampler in the briefing (see listStarterObjectives) and no compose button,
20 * and this route refuses them (401) BEFORE any model call — the client gate is UX, the
21 * server is the authority (mirrors checkout.post.ts). currentUser THROWS on a real auth
22 * error (vs null for no session), so a Supabase blip fails the compose rather than
23 * silently treating a signed-in caller as anonymous; the client surfaces that as the
24 * ordinary "pick one above" compose-error, and the curated pool is always the floor.
25 *
26 * The steer is coerced against the closed enums HERE, the single trusted gate:
27 * anything off-enum drops to "no preference" rather than reaching the prompt, so
28 * the closed-enum safety guarantee has no wire-side bypass. The avoid-list is the
29 * untrusted client boundary too — coerced to a bounded array of trimmed titles so a
30 * crafted request can't stuff the prompt (the curated half of the de-dup set is
31 * server-built, never trusted from the wire).
32 *
33 * generateObjective(true) self-heals — out-of-bounds or model-unavailable, it
34 * returns a curated objective rather than throwing — so the player is never left
35 * without one, and an out-of-bounds composition is never shipped.
36 *
37 * `fellBackToCurated` (#127) tells a half-truth's worth of honesty: it is true ONLY
38 * when the player steered (a coerced era/theme survived) AND the AI path fell back
39 * to curated — i.e. their explicit intent was silently dropped. A no-steer fallback
40 * stays quiet (false), so the common path the player never notices makes no noise.
41 */
42export default defineEventHandler(async (event) => {
43 // Custom composition requires an account (design: anonymous progress doesn't carry
44 // over, and the live AI path is a signed-in perk). Refuse anonymous callers before
45 // spending any tokens — the briefing already hides compose for them, so this is the
46 // authoritative gate against a direct request. No session (device-fallback mode) is
47 // NOT anonymous and composes as before.
48 const user = await currentUser(event);
49 if (user?.isAnonymous) {
50 throw createError({
51 statusCode: 401,
52 statusMessage: "Sign in to compose a custom objective",
53 });
54 }
55 
56 const q = getQuery(event);
57 const steer = coerceSteer(q.era, q.theme);
58 const avoid = coerceAvoidTitles(q.avoid);
59 const steered = Boolean(steer.era || steer.theme);
60 
61 // Pre-run AI gate (#304): this route composes with the model before a run is charged,
62 // so only the global spend cap and a soft per-subject rail bound it. When either trips,
63 // serve the curated pool (generateObjective(false) — free, no model call) so
64 // mission-select stays usable, exactly as a model outage already falls back. A steered
65 // request that degrades still flags fellBackToCurated (its explicit intent was dropped).
66 if (await preRunAiOverBudget(event)) {
67 const { objective } = await generateObjective(false, steer, avoid);
68 return { success: true, objective, fellBackToCurated: steered };
69 }
70 
71 const { objective, composed } = await generateObjective(true, steer, avoid);
72 return { success: true, objective, fellBackToCurated: steered && !composed };
⋯ 1 line hidden (lines 73–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
server/api/suggestions.post.ts70 lines · TypeScript
⋯ 22 lines hidden (lines 1–22)
1import {
2 suggestFigures,
3 buildLedgerBrief,
4 fallbackSuggestions,
5} from "../utils/figure-suggester";
6import {
7 cleanString,
8 MAX_ERA_CHARS,
9 MAX_OBJECTIVE_DESC_CHARS,
10 MAX_OBJECTIVE_TITLE_CHARS,
11} from "../utils/validate";
12import { preRunAiOverBudget } from "~/server/utils/pre-run-limit";
13 
14/**
15 * POST /api/suggestions — given the run's objective (and the changes landed so far),
16 * returns era-relevant historical figures (name + one-line reason) to seed the
17 * contact step. Runs the tool-using suggester agent server-side; always succeeds
18 * (curated fallback) so the player is never left without ideas. The landed ledger
19 * lets a blurb re-narrate against the altered timeline rather than asserting a fate
20 * the player has already overturned (issue #131).
21 */
22export default defineEventHandler(async (event) => {
23 // Pre-run AI gate (#304): the suggester is the most expensive pre-run path (a
24 // tool-using agent, multiple model calls per request), and it runs before a run is
25 // charged. When over the soft per-subject rail or the global spend cap, serve the
26 // curated fallback list — free, skipping both the moderation call and the suggester —
27 // so the contact step still has ideas (the same shape the suggester's outage fallback
28 // returns).
29 if (await preRunAiOverBudget(event))
30 return {
31 success: true,
32 suggestions: fallbackSuggestions(),
33 fallback: true,
34 };
35 
36 const body = await readBody(event);
⋯ 34 lines hidden (lines 37–70)
37 const objective = body?.objective ?? {};
38 // Untrusted body → the tool-using suggester agent's prompt: coerce + bound each
39 // field. Empty → undefined so the suggester's own fallbacks still apply.
40 const title =
41 cleanString(objective.title, MAX_OBJECTIVE_TITLE_CHARS) || undefined;
42 const description =
43 cleanString(objective.description, MAX_OBJECTIVE_DESC_CHARS) || undefined;
44 const era = cleanString(objective.era, MAX_ERA_CHARS) || undefined;
45 
46 // The landed ledger (issue #131): era-stamped headlines of the changes the player
47 // has already made, so the suggester re-narrates each blurb against the altered
48 // timeline. Untrusted body → buildLedgerBrief caps + coerces + bounds it.
49 const worldBrief = buildLedgerBrief(body?.ledger);
50 
51 // A custom objective is untrusted text reaching a model — deterministically
52 // hard-block the unforgivable categories before the suggester acts on it.
53 // (The full Sentinel re-reviews the objective in context on every turn.)
54 const { hardBlockCheck } = await import("~/server/utils/moderation");
55 const objectiveBlock = await hardBlockCheck(
56 [title, description, era].filter(Boolean).join(" — "),
57 "suggestions",
58 "input",
59 );
60 if (objectiveBlock.blocked)
61 return {
62 success: true,
63 suggestions: [],
64 blocked: true,
65 reason: objectiveBlock.block.reason,
66 };
67 
68 const result = await suggestFigures({ title, description, era, worldBrief });
69 return { success: true, ...result };
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
server/api/figure.get.ts82 lines · TypeScript
⋯ 43 lines hidden (lines 1–43)
1import { groundFigure, findNameCandidates } from "../utils/figure-grounding";
2import { applyContextualPick } from "../utils/figure-disambiguator";
3import { preRunAiOverBudget } from "~/server/utils/pre-run-limit";
4import {
5 cleanString,
6 clampInt,
7 MAX_FIGURE_NAME_CHARS,
8 MAX_OBJECTIVE_TITLE_CHARS,
9 MAX_OBJECTIVE_DESC_CHARS,
10 MAX_ERA_CHARS,
11 MAX_SIGNED_YEAR,
12} from "../utils/validate";
13 
14/** How many same-name overrides to surface in the "not who you meant?" list. */
15const MAX_OVERRIDES = 5;
16 
17/**
18 * GET /api/figure?name=… — resolves a historical figure to grounded facts (lifespan,
19 * role, blurb, portrait) for the contact step. Runs server-side to keep the external
20 * lookups + cache off the client; never throws — an unresolved figure comes back as
21 * `{ resolved: false }` so the UI can show the unresolved guide and gate the contact
22 * (#73 require-grounding).
23 *
24 * Ambiguous names (#186): the grounding floor auto-picks the most-notable person. This
25 * interactive picker route adds two things on top, in parallel with grounding so the
26 * common case isn't slowed:
27 * 1. A same-name PROBE (`findNameCandidates`) so even a name that resolves CLEANLY to
28 * one article ("William Congreve" → the playwright) still surfaces its namesakes
29 * (the 2nd Baronet) as override candidates — the silent-wrong-person case.
30 * 2. A contextual AI pick over those candidates, steered by the run's objective (passed
31 * as query params), so "Congreve" near a rocketry goal resolves to the rocket
32 * pioneer. The deterministic pick stands if the model abstains or fails.
33 * The contact gate and suggester stay model-free — only this route, where a human is
34 * looking, pays for the probe + contextual pick.
35 */
36export default defineEventHandler(async (event) => {
37 const q = getQuery(event);
38 // Trim + length-cap the untrusted name (it feeds groundFigure and the disambiguator
39 // prompt) — the same bound the contact gate applies to figureName.
40 const name = cleanString(q.name, MAX_FIGURE_NAME_CHARS);
41 if (!name) return { name: "", resolved: false };
42 
43 // Ground and probe for namesakes concurrently — the probe overlaps grounding, so an
44 // unambiguous name pays only the slower of the two, not their sum.
45 const [grounded, probed] = await Promise.all([
46 groundFigure(name),
47 findNameCandidates(name),
48 ]);
49 if (!grounded.resolved) return grounded;
50 
51 // The override pool: a rescued name already carries it; a clean resolve takes it from
52 // the probe (minus the resolved figure itself).
53 const pool = (
54 grounded.candidates?.length
55 ? grounded.candidates
56 : probed.filter((c) => c.name !== grounded.name)
57 ).slice(0, MAX_OVERRIDES);
58 if (!pool.length) return grounded; // truly unambiguous — nothing to choose between
59 
60 // Pre-run AI gate (#304): only the contextual pick below spends (the disambiguator
61 // model), and it runs before a run is charged. When over the soft per-subject rail or
62 // the global spend cap, skip just that pick and return the deterministic grounding with
63 // its override pool — contact still resolves (#73 require-grounding) and the "not who
64 // you meant?" list still shows; only the AI auto-pick is dropped. (The grounding above,
65 // including its rare lifespan bridge, is the required factual floor and always runs.)
66 if (await preRunAiOverBudget(event)) return { ...grounded, candidates: pool };
⋯ 16 lines hidden (lines 67–82)
67 
68 const anchor = Number(q.anchorYear);
69 const objective = {
70 title:
71 cleanString(q.objectiveTitle, MAX_OBJECTIVE_TITLE_CHARS) || undefined,
72 description:
73 cleanString(q.objectiveDescription, MAX_OBJECTIVE_DESC_CHARS) ||
74 undefined,
75 era: cleanString(q.objectiveEra, MAX_ERA_CHARS) || undefined,
76 anchorYear:
77 Number.isFinite(anchor) && anchor !== 0
78 ? clampInt(anchor, MAX_SIGNED_YEAR)
79 : undefined,
80 };
81 return applyContextualPick(name, grounded, pool, objective, groundFigure);
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
tests/unit/server/api/objective.spec.ts225 lines · TypeScript
⋯ 150 lines hidden (lines 1–150)
1import { describe, it, expect, vi, beforeEach } from "vitest";
2import { existsSync } from "fs";
3import { resolve } from "path";
4import {
5 coerceAvoidTitles,
6 MAX_AVOID_TITLES,
7 MAX_OBJECTIVE_TITLE_CHARS,
8} from "~/server/utils/validate";
9// vitest hoists the vi.hoisted / vi.mock blocks below above every import, so the h3
10// globals + mocks are in place before this handler module evaluates (cf. checkout.spec).
11import handler from "~/server/api/objective.get";
12 
13/**
14 * The gate that matters here: custom composition is a signed-in privilege, so an
15 * anonymous caller is 401'd BEFORE any model call (a direct request can't burn tokens),
16 * mirroring checkout.post.ts. currentUser + the generator are mocked so the handler can
17 * be driven in the node env; the curated safety net it leans on is checked with the
18 * REAL generator via importActual.
19 */
20const { generateObjective, currentUser, preRunAiOverBudget } = vi.hoisted(
21 () => {
22 // @ts-expect-error — Nitro/h3 globals the handler calls.
23 globalThis.defineEventHandler = (fn: unknown) => fn;
24 // @ts-expect-error
25 globalThis.getQuery = (e: { query?: Record<string, unknown> }) =>
26 e.query ?? {};
27 // @ts-expect-error
28 globalThis.createError = (o: {
29 statusCode: number;
30 statusMessage?: string;
31 }) => Object.assign(new Error(o.statusMessage ?? "error"), o);
32 const generateObjective = vi.fn(
33 (): Promise<{ objective: unknown; composed: boolean }> =>
34 Promise.resolve({
35 objective: {
36 title: "Composed",
37 description: "…",
38 era: "…",
39 icon: "★",
40 },
41 composed: true,
42 }),
43 );
44 const currentUser = vi.fn(
45 (): Promise<{ id?: string; isAnonymous?: boolean } | null> =>
46 Promise.resolve(null),
47 );
48 const preRunAiOverBudget = vi.fn((): Promise<boolean> =>
49 Promise.resolve(false),
50 );
51 return { generateObjective, currentUser, preRunAiOverBudget };
52 },
53);
54vi.mock("~/server/utils/objective-generator", () => ({ generateObjective }));
55vi.mock("~/server/utils/auth", () => ({ currentUser }));
56vi.mock("~/server/utils/pre-run-limit", () => ({ preRunAiOverBudget }));
57 
58const run = (e: unknown) =>
59 (handler as unknown as (e: unknown) => Promise<unknown>)(e);
60const event = (query: Record<string, unknown> = {}) => ({ query });
61 
62beforeEach(() => {
63 generateObjective.mockReset().mockResolvedValue({
64 objective: { title: "Composed", description: "…", era: "…", icon: "★" },
65 composed: true,
66 });
67 currentUser.mockReset().mockResolvedValue(null);
68 preRunAiOverBudget.mockReset().mockResolvedValue(false);
69});
70 
71/**
72 * /api/objective composes a fresh grand objective server-side — the only place the
73 * OpenAI key exists. Its route contract and the generator safety net it leans on are
74 * covered here; the full-stack drive belongs to the e2e suite.
75 */
76describe("/api/objective endpoint", () => {
77 it("exists as a GET route (filename convention)", () => {
78 expect(
79 existsSync(resolve(process.cwd(), "server/api/objective.get.ts")),
80 ).toBe(true);
81 });
82 
83 it("relies on a generator that always yields a well-formed objective", async () => {
84 // The REAL generator (importActual bypasses the file-wide mock): the same
85 // self-healing curated fallback the route depends on when the model is unavailable.
86 const { generateObjective: realGenerate } = (await vi.importActual(
87 "~/server/utils/objective-generator",
88 )) as typeof import("~/server/utils/objective-generator");
89 const { objective } = await realGenerate();
90 
91 expect(typeof objective.title).toBe("string");
92 expect(objective.title.length).toBeGreaterThan(0);
93 expect(typeof objective.description).toBe("string");
94 expect(objective.description.length).toBeGreaterThan(10);
95 expect(typeof objective.era).toBe("string");
96 expect(typeof objective.icon).toBe("string");
97 expect(objective.icon.length).toBeGreaterThan(0);
98 });
99});
100 
101/**
102 * The anonymous compose gate: custom composition is a signed-in perk (anonymous
103 * progress deliberately doesn't carry over), so the route refuses an anonymous caller
104 * before spending any tokens. The briefing already hides compose for them; this is the
105 * authoritative gate against a direct request. Mirrors checkout.post.ts.
106 */
107describe("/api/objective — anonymous compose gate", () => {
108 it("401s an anonymous caller BEFORE any model call", async () => {
109 currentUser.mockResolvedValue({ id: "anon-1", isAnonymous: true });
110 await expect(run(event())).rejects.toMatchObject({ statusCode: 401 });
111 expect(generateObjective).not.toHaveBeenCalled();
112 });
113 
114 it("composes for a signed-in caller", async () => {
115 currentUser.mockResolvedValue({ id: "user-1", isAnonymous: false });
116 const res = (await run(event())) as {
117 success: boolean;
118 objective: unknown;
119 };
120 expect(res.success).toBe(true);
121 expect(generateObjective).toHaveBeenCalledOnce();
122 });
123 
124 it("composes in device-fallback mode (no session is NOT anonymous)", async () => {
125 currentUser.mockResolvedValue(null);
126 const res = (await run(event())) as { success: boolean };
127 expect(res.success).toBe(true);
128 expect(generateObjective).toHaveBeenCalledOnce();
129 });
130});
131 
132/**
133 * The pre-run budget gate (#304): before a run is charged this route composes with the
134 * model, bounded only by the global spend cap + a soft per-subject rail. When either
135 * trips (preRunAiOverBudget → true) it must serve the curated pool WITHOUT the model call
136 * — generateObjective(false) — so mission-select stays usable, mirroring a model outage.
137 */
138describe("/api/objective — pre-run budget gate (#304)", () => {
139 it("composes with the model when under budget", async () => {
140 currentUser.mockResolvedValue({ id: "user-1", isAnonymous: false });
141 preRunAiOverBudget.mockResolvedValue(false);
142 await run(event());
143 // useAI=true — the live compose path.
144 expect(generateObjective).toHaveBeenCalledWith(
145 true,
146 expect.anything(),
147 expect.anything(),
148 );
149 });
150 
151 it("serves the curated pool WITHOUT a model call when over budget", async () => {
152 currentUser.mockResolvedValue({ id: "user-1", isAnonymous: false });
153 preRunAiOverBudget.mockResolvedValue(true);
154 generateObjective.mockResolvedValue({
155 objective: { title: "Curated", description: "…", era: "…", icon: "★" },
156 composed: false,
157 });
158 const res = (await run(event())) as {
159 success: boolean;
160 objective: { title: string };
161 };
162 expect(res.success).toBe(true);
163 expect(res.objective.title).toBe("Curated");
164 // Discriminating: the curated fallback runs the generator with useAI=FALSE, so no
165 // model is called — the gate short-circuits the spend, not just the response shape.
166 expect(generateObjective).toHaveBeenCalledOnce();
167 expect(generateObjective).toHaveBeenCalledWith(
168 false,
169 expect.anything(),
170 expect.anything(),
171 );
172 });
⋯ 53 lines hidden (lines 173–225)
173 
174 it("still flags fellBackToCurated when a STEERED request degrades", async () => {
175 currentUser.mockResolvedValue({ id: "user-1", isAnonymous: false });
176 preRunAiOverBudget.mockResolvedValue(true);
177 const res = (await run(event({ era: "Antiquity" }))) as {
178 fellBackToCurated: boolean;
179 };
180 expect(res.fellBackToCurated).toBe(true);
181 });
182});
183 
184/**
185 * coerceAvoidTitles is the untrusted-wire gate for the session avoid-list (#95): the
186 * route hands it straight to the compose prompt, so a crafted request must not be
187 * able to stuff or malform it. Its bounding behavior is pinned here.
188 */
189describe("coerceAvoidTitles — the avoid-list wire gate (#95)", () => {
190 it("reads a repeated query key (array) and trims each title", () => {
191 expect(
192 coerceAvoidTitles([" Reach the Moon by 1900 ", "Break the Black Death"]),
193 ).toEqual(["Reach the Moon by 1900", "Break the Black Death"]);
194 });
195 
196 it("wraps a single query value (string) into a one-title list", () => {
197 expect(coerceAvoidTitles("Save the Library of Alexandria")).toEqual([
198 "Save the Library of Alexandria",
199 ]);
200 });
201 
202 it("returns an empty list for a missing / unusable param", () => {
203 expect(coerceAvoidTitles(undefined)).toEqual([]);
204 expect(coerceAvoidTitles(null)).toEqual([]);
205 });
206 
207 it("drops empties and non-string entries without throwing", () => {
208 expect(coerceAvoidTitles(["", " ", 42, null, "Real Title"])).toEqual([
209 "Real Title",
210 ]);
211 });
212 
213 it("caps the count and the per-title length (abuse guard)", () => {
214 const many = Array.from(
215 { length: MAX_AVOID_TITLES + 20 },
216 (_, i) => `Title ${i}`,
217 );
218 expect(coerceAvoidTitles(many)).toHaveLength(MAX_AVOID_TITLES);
219 
220 const huge = "x".repeat(MAX_OBJECTIVE_TITLE_CHARS + 50);
221 expect(coerceAvoidTitles([huge])[0]).toHaveLength(
222 MAX_OBJECTIVE_TITLE_CHARS,
223 );
224 });
225});

Over budget returns the grounded pick plus overrides; the disambiguator is never called.

tests/unit/server/api/figure.spec.ts · 113 lines
tests/unit/server/api/figure.spec.ts113 lines · TypeScript
⋯ 97 lines hidden (lines 1–97)
1import { describe, it, expect, vi, beforeEach } from "vitest";
2import { existsSync } from "fs";
3import { resolve } from "path";
4// vitest hoists the vi.hoisted / vi.mock blocks below above every import, so the h3
5// globals + mocks are in place before this handler module evaluates (cf. objective.spec).
6import handler from "~/server/api/figure.get";
7 
8const {
9 groundFigure,
10 findNameCandidates,
11 applyContextualPick,
12 preRunAiOverBudget,
13} = vi.hoisted(() => {
14 // @ts-expect-error — Nitro/h3 globals the handler calls.
15 globalThis.defineEventHandler = (fn: unknown) => fn;
16 // @ts-expect-error
17 globalThis.getQuery = (e: { query?: Record<string, unknown> }) =>
18 e.query ?? {};
19 return {
20 groundFigure: vi.fn(() =>
21 Promise.resolve({ name: "Congreve", resolved: true }),
22 ),
23 // A same-name namesake plus the resolved figure itself (the route filters the
24 // latter out), so the override pool is non-empty and the contextual pick is reached.
25 findNameCandidates: vi.fn(() =>
26 Promise.resolve([
27 { name: "Congreve (2nd Baronet)" },
28 { name: "Congreve" },
29 ]),
30 ),
31 applyContextualPick: vi.fn(() =>
32 Promise.resolve({ name: "Congreve (picked)", resolved: true }),
33 ),
34 preRunAiOverBudget: vi.fn((): Promise<boolean> => Promise.resolve(false)),
35 };
36});
37vi.mock("~/server/utils/figure-grounding", () => ({
38 groundFigure,
39 findNameCandidates,
40}));
41vi.mock("~/server/utils/figure-disambiguator", () => ({ applyContextualPick }));
42vi.mock("~/server/utils/pre-run-limit", () => ({ preRunAiOverBudget }));
43 
44const run = (query: Record<string, unknown> = { name: "Congreve" }) =>
45 (handler as unknown as (e: unknown) => Promise<unknown>)({ query });
46 
47beforeEach(() => {
48 groundFigure
49 .mockReset()
50 .mockResolvedValue({ name: "Congreve", resolved: true });
51 findNameCandidates
52 .mockReset()
53 .mockResolvedValue([
54 { name: "Congreve (2nd Baronet)" },
55 { name: "Congreve" },
56 ]);
57 applyContextualPick
58 .mockReset()
59 .mockResolvedValue({ name: "Congreve (picked)", resolved: true });
60 preRunAiOverBudget.mockReset().mockResolvedValue(false);
61});
62 
63/**
64 * /api/figure resolves a name to grounded facts via Wikidata/Wikipedia. The live
65 * lookups belong to e2e (mocked); here we cover the route contract + the util surface.
66 */
67describe("/api/figure endpoint", () => {
68 it("exists as a GET route (filename convention)", () => {
69 expect(existsSync(resolve(process.cwd(), "server/api/figure.get.ts"))).toBe(
70 true,
71 );
72 });
73 
74 it("exposes the grounding util it leans on", async () => {
75 const mod = await vi.importActual<
76 typeof import("~/server/utils/figure-grounding")
77 >("~/server/utils/figure-grounding");
78 expect(typeof mod.groundFigure).toBe("function");
79 expect(typeof mod.parseWikidataYear).toBe("function");
80 });
81});
82 
83/**
84 * The pre-run budget gate (#304): grounding is the required, key-less factual floor and
85 * always runs (contact is deceased-only, #73) — only the contextual disambiguator model
86 * spends. When over the per-subject rail or the global spend cap, the route must skip just
87 * that pick and return the deterministic grounding with its override pool, so contact
88 * still resolves and the "not who you meant?" list still shows.
89 */
90describe("/api/figure — pre-run budget gate (#304)", () => {
91 it("runs the contextual pick when under budget", async () => {
92 preRunAiOverBudget.mockResolvedValue(false);
93 const res = (await run()) as { name: string };
94 expect(applyContextualPick).toHaveBeenCalledOnce();
95 expect(res.name).toBe("Congreve (picked)");
96 });
97 
98 it("skips the disambiguator model when over budget, keeping the grounded pick + overrides", async () => {
99 preRunAiOverBudget.mockResolvedValue(true);
100 const res = (await run()) as {
101 name: string;
102 resolved: boolean;
103 candidates: { name: string }[];
104 };
105 // The deterministic grounding stands (contact still resolves — #73).
106 expect(res.name).toBe("Congreve");
107 expect(res.resolved).toBe(true);
108 // The override pool (the namesake, minus the resolved figure) is still surfaced.
109 expect(res.candidates).toEqual([{ name: "Congreve (2nd Baronet)" }]);
110 // Discriminating: the AI auto-pick is short-circuited before any model spend.
111 expect(applyContextualPick).not.toHaveBeenCalled();
⋯ 2 lines hidden (lines 112–113)
112 });
113});

The composed policy: over the rail, over the cap, and the short-circuit that skips the cap read once rate-limited.

tests/unit/server/utils/pre-run-limit.spec.ts · 105 lines
tests/unit/server/utils/pre-run-limit.spec.ts105 lines · TypeScript
⋯ 83 lines hidden (lines 1–83)
1import { describe, it, expect, vi, beforeEach } from "vitest";
2import {
3 withinPreRunLimit,
4 isPreRunSubjectTracked,
5 preRunAiOverBudget,
6 MAX_PRE_RUN_PER_WINDOW,
7 PRE_RUN_WINDOW_MS,
8} from "~/server/utils/pre-run-limit";
9 
10// The composed policy leans on currentSubject + the spend cap; the pure sliding window
11// below leans on neither, so mocking these boundaries is harmless to those tests.
12const { currentSubject, isOverSpendCap } = vi.hoisted(() => ({
13 currentSubject: vi.fn((): Promise<string> => Promise.resolve("subject-x")),
14 isOverSpendCap: vi.fn((): Promise<boolean> => Promise.resolve(false)),
15}));
16vi.mock("~/server/utils/auth", () => ({ currentSubject }));
17vi.mock("~/server/utils/spend-store", () => ({ isOverSpendCap }));
18 
19/**
20 * The soft pre-run AI rate limit (issue #304): a per-subject, in-process guard rail on the
21 * routes that compose with the model before a run is charged. It allows a burst up to the
22 * cap, refuses beyond it, and recovers once the window rolls off. `now` is injected, so
23 * this is deterministic. Each test uses a unique subject — the limiter's map is
24 * module-level (shared with the real routes), so distinct keys keep tests independent.
25 */
26describe("pre-run rate limit (issue #304)", () => {
27 it("allows a burst up to the cap, then refuses", () => {
28 const subject = "pre-run-burst";
29 for (let i = 0; i < MAX_PRE_RUN_PER_WINDOW; i++) {
30 expect(withinPreRunLimit(subject, 1000)).toBe(true);
31 }
32 // The (cap+1)th within the same window is refused.
33 expect(withinPreRunLimit(subject, 1000)).toBe(false);
34 });
35 
36 it("recovers once the window has rolled off", () => {
37 const subject = "pre-run-window";
38 for (let i = 0; i < MAX_PRE_RUN_PER_WINDOW; i++)
39 withinPreRunLimit(subject, 1000);
40 expect(withinPreRunLimit(subject, 1000)).toBe(false); // capped now
41 // A call after the full window has elapsed sees the old stamps expire → allowed.
42 expect(withinPreRunLimit(subject, 1000 + PRE_RUN_WINDOW_MS + 1)).toBe(true);
43 });
44 
45 it("keys per subject — one subject hitting the cap never blocks another", () => {
46 const busy = "pre-run-busy";
47 for (let i = 0; i < MAX_PRE_RUN_PER_WINDOW; i++)
48 withinPreRunLimit(busy, 1000);
49 expect(withinPreRunLimit(busy, 1000)).toBe(false);
50 expect(withinPreRunLimit("pre-run-fresh", 1000)).toBe(true);
51 });
52 
53 it("evicts a subject once its whole window has expired, so the map does not grow unbounded", () => {
54 withinPreRunLimit("pre-run-evict", 1000);
55 expect(isPreRunSubjectTracked("pre-run-evict")).toBe(true);
56 // A later call past the window triggers the eviction sweep, dropping the stale entry.
57 withinPreRunLimit("pre-run-sweeper", 1000 + PRE_RUN_WINDOW_MS + 1);
58 expect(isPreRunSubjectTracked("pre-run-evict")).toBe(false);
59 });
60});
61 
62/**
63 * preRunAiOverBudget is the policy the routes call: degrade to curated when over the
64 * per-subject rail OR over the global spend cap. It always records the rate-limit attempt,
65 * and reads the spend cap only when still under the rail (left-to-right short-circuit).
66 */
67describe("preRunAiOverBudget — the composed pre-run gate", () => {
68 beforeEach(() => {
69 currentSubject.mockReset().mockResolvedValue("subject-x");
70 isOverSpendCap.mockReset().mockResolvedValue(false);
71 });
72 
73 it("is false under both the rail and the cap", async () => {
74 currentSubject.mockResolvedValue("gate-under");
75 expect(await preRunAiOverBudget({} as never)).toBe(false);
76 });
77 
78 it("records the subject's attempt against the rail", async () => {
79 currentSubject.mockResolvedValue("gate-records");
80 await preRunAiOverBudget({} as never);
81 expect(isPreRunSubjectTracked("gate-records")).toBe(true);
82 });
83 
84 it("is true once the subject is over the per-window rail", async () => {
85 currentSubject.mockResolvedValue("gate-rail");
86 for (let i = 0; i < MAX_PRE_RUN_PER_WINDOW; i++)
87 await preRunAiOverBudget({} as never);
88 expect(await preRunAiOverBudget({} as never)).toBe(true);
89 });
90 
91 it("is true when the spend cap is tripped even though the subject is under the rail", async () => {
92 currentSubject.mockResolvedValue("gate-cap");
93 isOverSpendCap.mockResolvedValue(true);
94 expect(await preRunAiOverBudget({} as never)).toBe(true);
95 });
96 
97 it("skips the spend-cap read once already rate-limited (short-circuit)", async () => {
98 currentSubject.mockResolvedValue("gate-shortcircuit");
99 for (let i = 0; i < MAX_PRE_RUN_PER_WINDOW; i++)
100 await preRunAiOverBudget({} as never);
101 isOverSpendCap.mockClear();
102 expect(await preRunAiOverBudget({} as never)).toBe(true);
103 expect(isOverSpendCap).not.toHaveBeenCalled();
104 });
⋯ 1 line hidden (lines 105–105)
105});