What changed, and why
"Compose a fresh objective with AI" used to be one model call with no rails: an open prompt invented an objective and the only check was that title and description were non-empty. Nothing stopped it anchoring on living people, contemporary figures, or current events — the same legal and safety risk the figure floor (#72) exists to remove — and the player had no say in what came back.
This change makes the composition bounded and steerable, while keeping the curated pool as both the instant default and the safe floor beneath it.
- Bounds (server). The prompt is constrained to safely-historical objectives, and the output is then independently validated — the prompt is asked to obey the bounds, never trusted to. A year bound, a structured subject check, and the message moderation seam each get a say. Out of bounds → reroll once → curated fallback. - Steer (client). Two closed-enum dials, era and theme, bias the composition. Closed enums only — no free text — so there is no client path that smuggles an unsafe objective past the floor.
Read the shared cutoff first; everything else leans on it.
Bounding the composition: three gates, never trusting the prompt
objectiveWithinBounds is the gate a composition must clear before it can reach the player. The year bound is pure and free: a finite anchor must sit at or before the cutoff (a null anchor — "spans all history" — skips the year gate but is still subject-checked below). Then two independent calls run together: moderation over the generated text, and the structured subject check.
The three gates; first 'out' wins.
server/utils/objective-generator.ts · 232 lines
⋯ 109 lines hidden (lines 1–109)
⋯ 106 lines hidden (lines 127–232)
The structured subject check (a year bound can't read prose)
A year is a number; "rally the world behind a living head of state" is prose. The subject check is the classifier that reads the free text — does this center on living people, contemporary figures, ongoing conflicts, or post-cutoff events? It mirrors the figure-floor pattern (lifespan-estimator.ts): a small structured call, validated by a pure mapper, and it fails closed — an empty answer, junk, or an outage all return false, so an unvetted objective is never shipped.
checkObjectiveSubject (fail-closed) and the pure mapBoundsWire it leans on.
server/utils/objective-generator.ts · 232 lines
⋯ 135 lines hidden (lines 1–135)
⋯ 57 lines hidden (lines 176–232)
The check runs on its own cheap, temp-0 route so its cost and telemetry stay separate from the composition call. It is at most two calls per composition.
The new 'objective-check' task: Haiku, temp 0, off the critical path.
server/utils/ai-routing.ts · 178 lines
⋯ 27 lines hidden (lines 1–27)
⋯ 98 lines hidden (lines 36–133)
⋯ 36 lines hidden (lines 143–178)
Reroll once, then the curated floor
generateObjective is the orchestrator. With useAI, it composes, validates, and on an out-of-bounds result rerolls exactly once before falling back to curated. A transport or parse failure won't heal on a reroll, so it drops straight to curated instead of spending a second call. Curated stays the floor: no path here ever hard-fails the start of a run.
Compose → validate → reroll-once → curated. The whole control flow.
server/utils/objective-generator.ts · 232 lines
⋯ 33 lines hidden (lines 1–33)
⋯ 180 lines hidden (lines 53–232)
Steering: closed enums, one trusted gate
The player biases the composition along two closed enums — an era bucket and a theme. The labels double as the wire values and the prompt phrasing, so there is no value/label map to drift, and every era bucket sits in the past (there is deliberately no "present day" option to pick).
The taxonomies, the ObjectiveSteer type, and coerceSteer — the one trusted gate.
server/utils/objectives.ts · 165 lines
⋯ 124 lines hidden (lines 1–124)
The steer reaches the prompt as a short clause (absent axes simply don't appear), and rides the wire as query params the route re-validates with the same coerceSteer.
The steer injected into the compose prompt; the cutoff is in the ask too.
server/utils/objective-generator.ts · 232 lines
⋯ 193 lines hidden (lines 1–193)
⋯ 24 lines hidden (lines 197–220)
The route coerces era/theme before composing.
server/api/objective.get.ts · 27 lines
⋯ 19 lines hidden (lines 1–19)
The store forwards the steer as query params; absent axes are omitted.
stores/game.ts · 1294 lines
⋯ 1138 lines hidden (lines 1–1138)
⋯ 141 lines hidden (lines 1154–1294)
The UI: two dials, the active steer surfaced
The mission-select screen gains a small "Steer a fresh composition" section: two native selects defaulting to "No preference", styled with the same hairline form language as the rest of the app. The active steer is echoed under the compose button. Nothing else about the preview/commit/reroll flow changes — compose still rerolls, and the fresh objective commits only on Begin.
The two closed-enum dials and the surfaced active steer.
components/MissionSelect.vue · 175 lines
⋯ 33 lines hidden (lines 1–33)
⋯ 13 lines hidden (lines 47–59)
⋯ 112 lines hidden (lines 64–175)
The script: typed steer refs, the active-steer line, and the steer passed on compose.
components/MissionSelect.vue · 175 lines
⋯ 129 lines hidden (lines 1–129)
⋯ 17 lines hidden (lines 159–175)
How it's verified
The bounds logic is the safety-critical part, so it carries the most tests. The generator spec mocks the gateway SDK and drives generateObjective end to end: out-of-bounds compositions (by year, by subject, by moderation) are rejected and rerolled, then fall back to curated; the year boundary is tested at the cutoff, one past it, and at a BC anchor (all derived from the shared constant, never a literal); a null-anchor objective is still subject-checked; and the subject check failing closed on empty content forces a reroll.
coerceSteer has its own spec — valid kept, off-enum and non-string dropped, no present-day era bucket. The component spec proves the chosen era/theme ride the wire as query params (and an axis left at "no preference" is omitted), and an e2e walks steer → compose → begin against mocked routes, asserting the steer reached the route decoded.