What changed, and why a judge instead of a flag
This adds content moderation across every model input and output. The shape matters: a history game trips an off-the-shelf classifier in BOTH directions. It over-blocks the substance of play — assassinations, wars, atrocities-to- prevent — and it is blind to the risk this game uniquely manufactures: the anachronism mechanic invites players to feed future knowledge to historical figures, so the real vector is using a figure as a proxy to extract present-day operational harm (real CBRN synthesis, working malware). That request often scores near zero on a category classifier.
So the decider is a contextual judge. Two stages: a cheap deterministic detector for the categories with no fictional defense, and a Claude Sentinel that reads the whole exchange against Anthropic's verbatim policy.
The verbatim policy — the law the Sentinel enforces
usage-policy.ts holds the Anthropic Universal Usage Standards quoted exactly, split into two tiers. HARD_BLOCK is where the policy grants no fictional or roleplay defense — note the minors line ends "including in fictional settings or via roleplay with the model." CONTEXTUAL is where historical play lives: the policy bans inciting / facilitating / providing operational instructions, not depicting — so the Sentinel decides these, holding the verbatim lines (real CBRN synthesis, gratuitous gore) as the hard limits inside the gray zone.
Hard-block tier (no fiction defense) and the detector-category mapping + per-category thresholds. The CONTEXTUAL tier sits between them.
server/utils/usage-policy.ts · 151 lines
⋯ 35 lines hidden (lines 1–35)
⋯ 46 lines hidden (lines 69–114)
⋯ 6 lines hidden (lines 146–151)
The Sentinel prompt
buildSentinelPrompt renders those verbatim prohibitions, then frames the allow-side explicitly: this is alternate history; referencing and dramatizing historical violence is the substance of play; default to "allow." The sharpest- risk paragraph draws the exact line — dramatizing that something happened in history is allowed, providing the real operational instructions is not. It hands over the full conversation (so multi-turn elicitation is visible) plus the model's outputs, and asks for a strict allow/block verdict.
Verbatim policy in, the historical allow-side framing, the whole exchange, strict JSON out.
server/utils/prompt-builder.ts · 515 lines
⋯ 478 lines hidden (lines 1–478)
The moderation module — detector, Sentinel, modes, fail-open
hardBlockCheck is the deterministic detector: OpenAI omni-moderation, blocking only the unforgivable categories above their per-category thresholds (minors at a low, zero-tolerance score; ordinary sexual/self-harm needing real confidence). sentinelReview is the contextual judge via the routing seam. moderateAction runs the output detector and the Sentinel concurrently, first block wins.
Two safety properties: applyMode honors REVISIONIST_MODERATION — shadow computes and logs wouldBlock but never enforces, so the rubric can be calibrated in prod before it bites — and every catch FAILS OPEN, logging a [mod] line and allowing, because a safety classifier being down must never take the game down.
Modes + applyMode, the detector, the Sentinel, and the combined post-generation gate.
server/utils/moderation.ts · 173 lines
⋯ 53 lines hidden (lines 1–53)
⋯ 3 lines hidden (lines 116–118)
Wiring the turn loop
Stage 1 hard-blocks the raw dispatch BEFORE any generation token is spent — the unforgivable categories need no context. Stage 2 runs the Sentinel over the whole exchange AFTER generation and gates the reveal: a block suppresses the generated text entirely and refunds the turn. In between, a model-side safety refusal (Claude's own stop_reason: "refusal", surfaced by the gateway) is treated as a block, not an infra error.
Pre-generation hard-block, the refusal branches, and the post-generation Sentinel gate.
server/api/send-message.post.ts · 283 lines
⋯ 103 lines hidden (lines 1–103)
⋯ 41 lines hidden (lines 119–159)
⋯ 46 lines hidden (lines 168–213)
⋯ 52 lines hidden (lines 232–283)
Block UX and compliance
A block is distinct from an infra hiccup: the store sets moderationNotice (not error), so the page shows a clearly different red "⚠ blocked" banner, the turn is refunded, and the player's words stay in the composer. The Archive has its own archiveNotice. Compliance, per the Usage Policy's chatbot rule and the Commercial Terms: an AI-disclosure at session start plus a persistent footer, an AI-generated-fiction disclaimer, and an adults-only (18+) posture.
The distinct block state, and the failure branch that narrows to it rather than showing a generic error.
stores/game.ts · 960 lines
⋯ 212 lines hidden (lines 1–212)
⋯ 619 lines hidden (lines 219–837)
⋯ 108 lines hidden (lines 853–960)
The session-start disclosure: AI, fiction, 18+.
components/MissionSelect.vue · 110 lines
⋯ 47 lines hidden (lines 1–47)
⋯ 52 lines hidden (lines 59–110)
Verification
The Sentinel eval is the proof it draws the line in the right place: eight held-constant fixtures, four samples each, Anthropic-lane (the Sentinel calls only the Claude judge, never the detector). Dark-but-historical dispatches are allowed — preventing an assassination, ordering a battle, palace intrigue, even referencing the bomb without operational detail. The fiction-wrapped real-harm set is blocked — CBRN synthesis, working malware, explicit sexual content, targeting a living person. It swept 8/8, 4/4 each.
Plus 408 unit tests (the verbatim wording, detector thresholds + modes + fail- open, the Sentinel prompt quoting the policy, the refusal path, the store's distinct block state), a clean typecheck, and 12/12 Playwright e2e.
The fixtures — the ALLOW set is dark but historical; the BLOCK set wraps real present-day harm in the historical frame.