tootsies

a discord bot for the tootsies server. ask, recap, discuss, ship features by typing.


Project maintained by mejasonmejason Hosted on GitHub Pages — Theme by mattgraham

Tootsies architecture (the per-surface encyclopedia)

This file inherits CLAUDE.md’s former Architecture section and its discipline, unchanged: before touching a surface, read its section here; when a PR changes a surface’s behavior, update its section here IN THE SAME PR. These paragraphs are load-bearing – they carry every owner steer, incident lesson, and PR cross-reference for each surface, and the numbered (#NNNN) references only work because this record is kept current. CLAUDE.md keeps the always-loaded architecture MAP; this is the full detail, read on demand.

Architecture

Entrypoint: bot.py, boots Discord client, opens DB pool, exposes /health, loads cogs, syncs slash commands per guild on every startup.

Claude API layer: claude_client.py wraps the Anthropic SDK. Optional OpenAI text backend (#openapi-parity): _call routes a model id _is_openai_model() recognizes (a gpt-* / o-series id, label gpt in MODEL_LABELS) OUT to utils.openai_chat (Chat Completions) instead of the Anthropic SDK, so a generation surface can run on GPT to spread token spend onto the under-used OpenAI API bill or fail over when Claude is out of credits — the text twin of the existing OpenAI embeddings + image clients. It reuses the exact assembled system/user prompt (skip_persona, time-context, every per-surface block) and maps the result back onto ClaudeResult, so every downstream guardrail is unchanged. Dispatch by feature: _call_openai sends a tool-free call to Chat Completions and a call with tools / server-side web_search / a client-side tool loop to the Responses API (_call_openai_responses — the only OpenAI path to the built-in web_search + the function_call→handler→function_call_output loop, mapping our Anthropic tool defs onto Responses tools and collecting the web_search source/citation URLs for the link guardrail). Reasoning budget (#2869): a gpt-5 model spends its thinking out of the SAME output budget as the visible message, so openai_reasoning adds _REASONING_TOKEN_RESERVE (1024) to every caller’s budget — enough for a short-form post’s default thinking, live-measured under 192 tokens. A TOOL-using call is a different job (the model reasons over what the tools returned, across rounds), so _call_openai_responses floors the budget at _REASONING_TOOL_TOKEN_FLOOR (16384) instead — the twin of the Anthropic path’s _THINKING_MAX_TOKENS_FLOOR, same number for the same reason. If a turn still comes back with nothing usable (responses_exhausted: status="incomplete", no text, no function call; on the tool-free path, finish_reason="length" with no text) the caller retries that ONE turn at effort: low under _REASONING_RECOVERY_TOKEN_FLOOR (24576) with the same input, and ships the retry’s text; a second exhaustion returns empty as before, so the retry can never loop. Both turns are visible as openai_api detail.reasoning_exhausted / detail.reasoning_recovery. IMAGE input (vision) is served on the Responses path — an assembled Claude vision message maps onto input_image parts via responses_input_content, so a GPT-routed surface with images answers on GPT (no longer a deferred gap / Claude fallback); a missing key / API failure raises OpenAIChatUnavailable, so the surface’s own try/except falls back. A best/cheap PAIR mirroring opus/sonnet: gpt (best = gpt-6-sol, $2/$10, the Opus 5.5 analog) + gpt-cheap (cheap = gpt-5.6-terra, $2/$12, the Sonnet 5 analog — Sonnet 5 is $2/$10; the Haiku-tier gpt-5.6-luna at $0.20/$1.20 is a rung too cheap for a generation surface, available only as an env override), env-overridable. Both are per-surface menu choices (tunables.MODEL_CHOICES = opus/sonnet/gpt/gpt-cheap), offered on every Models-page surface (ask/recap/discourse/music — a new ModelSurface), memory included since 2026-09-07 (#3072: memory was the one row that withheld GPT until its constitutional fence eval held on it; ModelSurface.choices stays so a future model can be gated the same way — see the memory notes for the run). GPT gets its OWN copy of the OPUS prompts: rules_for() hands any OpenAI model _GPT — a replace(_OPUS) COPY of the Opus lean ModelRules (lean ask/discourse cores, lean persona), not the Sonnet-era _LEGACY walls — GPT-5 over-commits + follows literally like Opus, so the Opus rebuild (#849) is the right starting point. It’s forked into its own instance so GPT-specific polish lands on _GPT alone, never on the prompts Opus itself uses; the divergences so far both ride lean_persona: _GPT_FORMAT (scripts/dryrun_openai_surfaces.py found markdown/inline-citation drift Opus doesn’t have) and _GPT_VOICE (the “our replies sound like chat gpt” owner report — GPT’s stock register is the engineered one-liner, mirrored clauses + an abstract-metaphor verdict; measured with a golden-anchored register judge on real wire tweets, scripts/dryrun_x_reply_voice.py, the block took GPT reply drafts 17/18-flagged → 12/18 while the Claude prompts were untouched). Because most generation surfaces never compose lean_persona, the router also appends _GPT_VOICE as an extra system block on every GPT-SERVED, full-persona call (oai_system in _call): it follows the provider actually serving — the direct gpt route AND a reverse Claude→GPT failover get it, a forward GPT→Claude fallback doesn’t — and never touches a skip_persona classifier (the lean surfaces carry it via lean_persona, so it can’t double-apply). GPT keeps a residual AI-register lean its prompts don’t erase (12/18 vs Claude’s 2/18 on the same judge) — that’s a Models-knob tradeoff to weigh when flipping a voice-heavy surface to gpt, not a missing rule. Every surface still defaults to Claude, so the choice is opt-in and changes nothing until a mod picks it. Safe by a BIDIRECTIONAL fallback (#973): _call is now a thin, symmetric router over two sibling provider methods — _call_openai and _call_anthropic (the Anthropic kwargs-build + client-side tool loop + result assembly, split out of _call so the router is a clean two-provider gateway) — each serving the SAME assembled system/user_content (skip_persona, time prefix, every per-surface block, vision included — byte-identical across providers, only the transport differs). The fallback runs BOTH ways: FORWARD, a GPT-routed call that raises OpenAIChatUnavailable (no key / API error / overload) falls back to Claude (#973: the GPT’s cross-provider sibling — gpt-6-sol→Opus, gpt-5.6-terra→Haiku [cheap stays cheap]; an unmapped id → the frontier Opus) — the opt-in safety that makes gpt a per-surface menu choice; REVERSE, a Claude-routed call that fails on credit exhaustion (_is_credit_exhausted — a 400 naming the credit balance, PERMANENT until topped up) or a persistent overload/outage (_is_claude_unavailable — a 529/5xx/429/timeout that already survived _create_with_retry’s retries) falls OVER to OpenAI when it’s provisioned (TIER-PRESERVING: the Claude’s cross-provider sibling — Opus→gpt-6-sol (frontier↔frontier), Sonnet/Haiku→gpt-5.6-terra (cheap↔cheap), with any UNMAPPED model (an env override, an unknown id) defaulting to the FRONTIER gpt-5.6-sol rather than erroring/degrading (owner steer #973); the frontier pair derives from _TIER_ANALOGS, the cheap mappings are explicit overrides; credit is account-wide so the failover always succeeds-or-fails identically across tiers) — credit-exhaustion failover, so the bot keeps talking instead of taking every surface dark at once when the Anthropic balance runs dry. A generic 400/401 is NOT failed over (OpenAI can’t fix a malformed request or a bad key — it re-raises as before, so the surface’s own canned fallback + the claude_api ok=False telemetry still fire). Each direction calls the OTHER provider’s method DIRECTLY (never back through the router), so the two CAN’T ping-pong (a forward GPT→SONNET that then fails just raises; a reverse Claude→GPT_CHEAP that then fails re-raises the ORIGINAL Claude error). Both emit provider_fallback (forward reason=OpenAIChatUnavailable; reverse reason=credit_exhausted/claude_unavailable). generate_song_pool(model=...) + music_post(model=...) were dry-run-validated (scripts/dryrun_openai_song_pool.py, scripts/dryrun_openai_music.py) before the music knob shipped. Model routing: Haiku for the cheap, high-volume classifiers/scorers — mention-intent routing (classify_mention_intent), deflections, the chime-in scorer, and the quiet-room cold-open judge (quiet_room_score, fast/cheap); Haiku also tags each memory note with concept keywords at write time (tag_memory_note), expands a thin conceptual recall query into keyword terms (expand_memory_query), and distills a long/foreign shared-video transcript into a compact English summary (summarize_video, purpose=video_summary) — all cheap, high-volume, no fence exposure (they only categorize/condense already-public text). Sonnet for the answer-generating surfaces — /recap, /discourse, chime-in posts (chimein_post — DEFAULT Sonnet, now a per-guild Models-page COMPOSE-model knob distinct from the Haiku chimein_score classifier), /order pre-flight — and the entire long-term-memory write pipeline (hourly writer + /remember backfill + daily rollup) — plus the /guess song-pool generation (generate_song_pool), moved off Opus to Sonnet after a head-to-head dry run found Sonnet’s recognizable-song recall equal (it’s a breadth-of-canon task, not reasoning, and every line is iTunes-verified) at a fraction of the cost, which matters since the pool regenerates on every refill of an endless game. Per-surface model knobs (the /menu Models page, reached from the experiments page): ask, recap, discourse, music, memory, market drop, and chime-in (the COMPOSE model chimein_post, NOT the Haiku chimein_score classifier — defaults Sonnet, Opus opt-in for sharper room-buffer reading / attribution) each have a per-guild model TUNABLE (utils.tunables.MODEL_SURFACES → surface_model → claude_client.MODEL_LABELS, passed via client.ask/recap/discourse(model=...) / cogs.memory._memory_model → memory_note/memory_rollup(model=...); model=None falls back to Sonnet so eval/dry-run paths are unchanged). The Models view (cogs.models) renders one opus/sonnet select per surface (fully data-driven off MODEL_SURFACES, so a new surface needs no UI change); defaults preserve today’s behavior — ask defaults Opus (#832), recap + discourse + memory default Sonnet (opt-in to Opus per guild, so adding the page changed nothing). The original ask-model knob generalized into this registry. (the Tier-2 room/live surfaces are added as each is evaluated.) The numbers-desk FAMILY BORROWS the market drop knob rather than each getting its own row (#1611): every surface that composes through compose_market_drop but has no menu row — cinema_desk, music_desk, music_news, music_alert, market_alert, and the betting board/alert/value trio — resolves the per-guild market drop model via the shared claude_client.market_drop_model_id(db, guild_id) (label → id, fail-open Sonnet) and threads it as compose_market_drop(model=...), so a guild’s model choice for its flagship market surface carries to the whole desk family (owner steer: none is a discourse-style culture take, they’re all market/numbers reads that align with market drop, not discourse). Before this they silently rode compose_market_drop’s model or SONNET default regardless of the guild’s Opus/GPT choice. Opus is the ask default because the blinded head-to-head (scripts/dryrun_opus_vs_sonnet.py) found it FUNNIER and LESS preachy than Sonnet on real ask traffic, with NO constitution/restraint regression in the safety pass (scripts/dryrun_opus_safety.py). (OPUS is live again via this knob, not just defined.) Memory DEFAULTS Sonnet (Opus opt-in via the knob), and its per-model PROSE follows the ask pattern (ModelRules.memory_note_room/memory_rollup_keep_daily/_task, the exact analog of ask_core, with named _LEGACY_MEMORY_* fallbacks): Opus over-compacts a note on the reductive verbs it follows literally (“Compact”/”Synthesize”/”Drop”), so its memory prose strips them and reframes the note as a RECALL artifact (it’s searched by vector + keyword, so write it name-dense + specific, breadth + depth); Sonnet/Haiku ignore those words, so they keep the proven legacy prose inline. The fence (_MEMORY_FENCE) + token/char caps are SHARED across models (policy/storage, not voice), so the narrowed fence + raised caps reach production (Sonnet) while the rebuilt prose stays Opus-only. Memory defaults Sonnet because the fence is verified on it (scripts/eval_memory_fence.py); the fence was then run on every other Models-page rung on 2026-09-07 (#3072) and held on all of them — gpt-5.6-sol, gpt-5.6-terra, claude-fable-5-1 and gpt-6-astra each passed 7/7 real scenarios (writer + daily/weekly rollups: no address/contact/minor, summarize not quote, attribution matches) with the golden flagged, against a Sonnet 5 control of 6/7 on the same judge (the known soft verbatim-quote nit, no leak), which is why memory now offers the full ladder; a real-recall cross-model dry run (max(prose, tags) cosine over prod daily #1444) found Opus and Sonnet notes recall about equally (means 0.33 vs 0.35, a 5/4 per-query split), and an Opus fence probe held every PII/address/minor hard line at parity with Sonnet (both pick up one soft “verbatim-quote” judge nitpick, no PII leak), so Opus is a safe opt-in rather than a forced default. The writer is low-volume (one call/hour/active-guild) so the cost is modest. Before editing ANY text Opus sees (constitution → persona → per-surface prompts → tool/context descriptions → eval judges), read docs/PROMPT_OPTIMIZATION.md: the standing lesson from Epic #849 is to OPTIMIZE by cutting/rewording the existing text that causes a drift, NOT by instinctively adding a counter-rule (scar tissue) — Opus follows literally and over-commits, so accretion compounds — plus how to evaluate a change (real-pipeline metrics, n≈5 not n=2, validate the metric, report spread, a wash is a result). System prompt is cached via cache_control: ephemeral. Every API call gets the full constitution + persona prepended (~5k tokens, not the ~120 this line used to claim – corrected 2026-08 after a live probe against the running prompt measured cache_creation_input_tokens ~5-7k on a cold write; see docs/OBSERVABILITY.md’s claude_api row and #2659 for the cache-read/write telemetry that made the real size checkable at all). Verified live (#2659): the persona block DOES cache warm for the high-volume Sonnet/Haiku surfaces (cache_read_input_tokens > 5000 on the first probe call); the low-volume Opus /ask surface (~3 calls/day, ~8h gaps) is a genuine cold-cache case no TTL fixes, not a bug. _call also drives a bounded client-side tool loop (tool_handlers): the model emits tool_use, _call runs the handler, feeds back a tool_result, and continues (capped at _MAX_TOOL_ITERS). Thinking budget (2026-09-03): with adaptive thinking on, thinking tokens share max_tokens with the visible answer, so _call_anthropic floors it at _THINKING_MAX_TOKENS_FLOOR (16384; it was 4096, and a 14-poll tally through search_messages spent all of it in thinking, stopped at max_tokens with zero text, and the ask shipped its “lost my train of thought” fallback after 91s). If a thinking turn still stops at max_tokens with no text, the loop retries that ONE turn at effort: low under _THINKING_RECOVERY_MAX_TOKENS (20480; a live Opus probe showed low effort alone re-exhausted the same ceiling, and the SDK refuses a non-streaming call above 21333 tokens, _SDK_NONSTREAMING_MAX_TOKENS) without appending anything (there is nothing to feed back) and ships the retry’s text; a second exhaustion returns empty as before. Both turns are visible as claude_api detail.thinking_exhausted / detail.thinking_recovery. With no handlers it’s a single call, unchanged. (web_search remains a server-side tool, no round-trip.) Integrations as tools, in addition to context: the ask flow pre-fetches and injects markets/Perplexity/memory blocks for the common case AND exposes each as an on-demand tool the model can call when the question lands outside the injected block (the Perplexity pre-fetch is model-gated (cogs.ask._TOOLSIDE_WEB_MODELS): it’s the long pole of the pre-fetch (~5.6s median, up to ~10s) and fully redundant with the research_web tool for a model that reliably reaches for it, so it’s skipped for Opus — which also has server-side web_search and is force-grounded on factual asks, so it pulls web research only when a question needs it instead of paying the latency on every ask — and kept for Sonnet, which doesn’t self-route to the tool dependably and so needs the block as a grounding safety net; the tool is offered to BOTH regardless) — search_memory (semantic-vector-first DB recall, keyword/FTS fallback), lookup_catalog (Apple Music / iTunes durations + tracklists, the fix for “longest song” questions a bare web search collides on, e.g. “Drake” the software), lookup_reference (an authoritative, citable fact from the reference library in utils.reference — an extensible registry of sources, routed by intent: Genius for song credits (producer/writer/featured) + identify-a-song-from-a-lyric (returns the song + a LINK to read the words, never lyric text; token-gated, ToS/copyright-clean), MusicBrainz for release metadata (release date / type / featured artists), Wikidata for date/age factoids (“how old is X”, “when did Y die”, “what year was Z founded”), and Wikipedia for CHART peaks on any major chart (Hot 100, Billboard 200 albums, UK, Canadian; the #335/#292 fix for “how many Drake songs peaked at #2”, “how many #1 albums”) - parsed from the discography table because the column must be picked by its wikilink target (“US” Hot 100 vs “US R&B”) and web search fumbles thin per-spot counts - plus general article summaries as the catch-all (other widely-reported facts, like award totals, ride the summary / web_search rather than a bespoke parser); each returns a citation URL; tool-only, no pre-fetched block; the live/breaking path stays on research_web/web_search), the live market/score feeds broken out one tool per source (so the model routes to the exact feed that answers and can cross-check two): api_sports (live scores + in-match events, World Cup soccer + NBA, the fast primary), sgo (Sports Game Odds live scores + sportsbook betting lines), the_odds_api (traditional sportsbook moneyline payouts), lookup_player_props (a NAMED player’s or NAMED game’s over-under lines — points/assists/rebounds/goals — from SGO; anchored, never a league-wide sweep: it fires only with a player or a game (props are the heaviest SGO call), resolving the live scoreboard to scope leagues + a game’s actual players, then name-filtering the league props to the player(s) asked about; SGO-gated, #390), polymarket + kalshi (the two prediction markets, individually, so a real split reads as “Kalshi 42% vs Polymarket 38%”) — **a numeric “Above N” LADDER renders in the kalshi block as ONE forecast: line plus its rungs in strike order (#3392, markets._kalshi_ladder_blocks → kalshi_ladder.ladder_forecast_line), not as a folded outcomes: field whose percentages sum past 1: the figure is the same 0.50 crossing the desk cards and Kalshi prints, an open board renders “above ", and a board the survival reader refuses (a bucket ladder, or the crossing interpolated across more than `MAX_UNPRICED_GAP` unpriced rungs — the Bass Persuades pure-sales board of 2026-09-17 with 3 of 20 rungs priced) says "no readable forecast" with the priced-rung count so the model quotes no figure at all**; `music_forecast` (the market's tracking-week projection for ONE release by artist/title — first-week Pure Sales / Album-Equivalent Units / Streams off the same Luminate-settled ladders the music desk cards, `fetch_desk_readings` + `music_markets.readings_for_question`, the helper the X mention reply draft shares; needed because a bare `kalshi` search cannot find these from an artist's name — the event doc says "Bass Persuades", not "Miley Cyrus", measured live 2026-09-17 on the FTS; each line is the desk's own grounded blob + the market URL, and a metric the question names that no live ladder answers gets an explicit no-figure line, since a thin book never parses into a reading) — each handler hits ONLY its named source and is provisioning-gated (an unkeyed source isn't offered; Polymarket/Kalshi are public so always on); this replaced the bundled `search_markets`, which hid which feed answered, `research_web` (Perplexity). GitHub/Railway are exposed only through two **narrow, gated** ops tools so Toots can self-serve when a regular reports she's broken (instead of only commenting): `check_deploys` (read-only Railway deploy status + build/runtime logs, so she diagnoses against the real runtime; gated on `RAILWAY_API_TOKEN`/`RAILWAY_SERVICE_ID`) and `file_fix` (file a tracked fix-order for her OWN code — the autonomous counterpart to a mod's `/order`, routed through `order.py:file_autonomous_order`'s preflight + kitchen/pipeline/in-flight gates + a dedicated `AUTO_ORDER_DAILY_CAP` daily budget; the resulting GitHub issue still becomes a reviewed PR, so the human merge stays the backstop). Arbitrary GitHub/Railway **writes** (committing code, redeploys, closing issues) stay off the chat surface — the order pipeline is the one safe write path. On a factual question the grounding classifier forces `tool_choice: {"type": "any"}` (use *some* lookup tool, model routes to the right one) rather than forcing `web_search` specifically; `_call` relaxes the force after the first tool round so the model can still land a final answer. Tool budgets are deliberately generous, not stingy: the client-side loop ceiling (`_MAX_TOOL_ITERS`) and the /ask server-side web_search cap (`_ASK_WEB_SEARCH_MAX_USES`) are both 100, runaway guards rather than "use fewer tools" knobs (a low cap was cutting fact-verification off mid-chase and pushing answers from stale memory, #292); room-post surfaces sit at 25 (`_POST_WEB_SEARCH_MAX_USES`), ample to verify a stat but bounded for their semi-realtime latency.

Model ids + per-model request rules (#model-upgrade): the concrete ids live in ONE place — claude_client.HAIKU / SONNET / OPUS / FABLE and GPT / GPT_CHEAP / GPT_MAX — and reach a surface only through MODEL_LABELS, which the /menu Models page writes as a LABEL per guild (tunables.MODEL_SURFACES). Current ids: Haiku 5.5, Sonnet 5.5, Opus 5.5, Fable 5.1, gpt-5.6-terra / gpt-6-sol / gpt-6-astra. On 2026-10-07 HAIKU moved from Haiku 4.5 to Haiku 5.5 ($0.10/$0.50 per 1M for a prompt of 100K tokens or fewer, against $1/$5); it has no menu label, so the change reaches every Haiku classifier and judge at once. Its request rules changed (see the next paragraph), and its tokenizer counts the same text as about 30% more tokens: classify_abuse’s 4-token cap cut ABUSE to ABUS, so every real abuse case read as OK until the cap went to 16. The tokenizer growth is not uniform: upper-case Kalshi tickers grew 43-54% (26 to 40 tokens on a 43-character market ticker, which then filled its 40-token cap exactly). So _call_anthropic restates every cap through _MAX_TOKENS_SCALE_PCT (Haiku 5.5: 160%, above the worst measured 1.54x) instead of each call site raising its own: callers keep writing caps sized for a length of text, and a model whose tokenizer differs gets one entry there. On 2026-09-28 sonnet moved from Sonnet 5 to Sonnet 5.5 at the same $2/$10 price; its request rules changed (see the next paragraph). On 2026-09-22 (#model-upgrade) opus moved from Opus 5 to Opus 5.5 ($4/$20, down from $5/$25) and gpt moved from gpt-5.6-sol to gpt-6-sol ($2/$10, down from $4/$20); OpenAI shipped no gpt-6-terra, so gpt-cheap stays on gpt-5.6-terra. Earlier (2026-09-07, #cheap-model-pricing: the gpt-5.6 pair replaced gpt-5.4 / gpt-5.5 on the cheap / best rungs, newer AND cheaper on both – $2/$12 against $2.50/$15, $4/$20 against $5/$30 – and gpt-6-astra took the ask-only top rung from gpt-5.6-sol at the same $10/$50 list price as Fable 5.1; the prices sit in one table in utils/openai_chat.py). Every row offers the same five labels (tunables.MODEL_CHOICES = opus / sonnet / gpt-max / gpt / gpt-cheap; ASK_MODEL_CHOICES is now an alias). The Claude top rung fable was retired on 2026-09-23 (#model-upgrade). A blind four-way run on 12 real /ask questions ranked Opus 5.5 at medium effort first on average (2.32), Opus 5.5 at max effort next (2.45, but a 47s median reply and 1.5x Fable’s cost per answer), and Fable 5.1 last (2.64) at about twice Opus’s cost. tunables.RETIRED_MODEL_LABELS maps a stored fable to opus inside surface_model, before the choices check, so the one guild that had it stored (ask + awards) moved to Opus instead of dropping to a sonnet default. FABLE stays defined with its request rules for evals and direct calls; a failed gpt-max call now falls back to the unmapped default, Opus. History: the top rungs fable → Fable 5.1 and gpt-max → gpt-6-astra were ask-only at first (#3028: ask is one answer to one waiting person, while a cadence surface multiplies the same pick by its slot count), and were opened to every row on 2026-09-07 (owner ask: consistent menus). The cadence cost is carried by the menu text (“priciest”, “lands every slot”) instead of by hiding the rung; every surface still defaults to opus or sonnet, so nothing changes until a mod picks one. Memory joined the ladder the same day on data (#3072): its fence eval held on every rung it did not yet cover (see the memory notes below). One row is master-only (ModelSurface.master_only: x replies): cogs.models.visible_surfaces hides it off the master guild, where the surface never runs and the knob would be a dead row (owner ask, 2026-09-07); the setting stays per guild. market drop is NOT hidden even though market_drop itself is master-gated: the betting alert + value alert cogs run in every guild (stage hard-coded PRODUCTION) and borrow its knob through market_drop_model_id, so a non-master guild needs the row to set or revert the model those two post on (#3072 review) — a row can be hidden only once every borrower of its knob is master-gated. And one Fable rule the levelling exposed: the forced-grounding retry on recap / discourse / chime-in (“the happy path ran no search, run it again with web_search forced”) only works thinking-OFF, which an always-thinking model never is, so _can_force_tool skips the retry on Fable and keeps the happy-path result instead of paying a second top-rate call that is discarded (#3072 review).

Changing a model id is never a plain string swap. Request parameters differ per model and fail at the API, not at import. Three sets in claude_client carry the rules: _SAMPLING_MODELS (who still accepts temperature — the whole Claude 5 family answers it with a 400, Haiku 5.5 included, so the allowlist is now EMPTY and every judge runs at the model default; measured on discourse_score, Haiku 5.5 at its default gave sd 0.04 on a strong post and identical scores on weak ones, against sd ~0.20 for Haiku 4.5 at T=1.0), _THINKING_OFF_PARAM (who THINKS when thinking is omitted but can be told to stop, mapped to the value that stops it — Sonnet 5.5 thinks by default, answers disabled with a 400, and takes between_tools; Haiku 5.5 thinks by default, takes disabled, and answers between_tools with a 400 — omitted with max_tokens=40 it spent all 40 tokens thinking and returned no text), _NO_FORCED_TOOL (who answers a forced tool_choice with a 400 even with thinking off — Sonnet 5.5, Opus 5.5, Fable 5.1; _call_anthropic drops the force on these and _can_force_tool skips the recap / discourse / chime-in forced-search retry), and _ALWAYS_THINKING (Fable 5.1 and Opus 5.5, which cannot be told to stop and answers both disabled and a forced tool_choice with a 400 — so it takes the thinking path’s _THINKING_MAX_TOKENS_FLOOR and never reaches the forced-tool branch). Every row in that table was MEASURED against the live API before shipping, except the Sonnet 5.5 column, which comes from the Anthropic migration guide (the 2026-09-28 session could not read the API key) and still needs a live probe; the first draft guarded two things that turned out not to need it. Adding an id means running the same probes, not reading a docs table.

TypeSafe Jev typed judgments (utils/typesafe_client.py, #4310, epic #4309). Jev is a judgment model from TypeSafe. It reads a text or JSON state and answers typed questions in one parallel request: Noul (the probability a statement is true), Choice (one of N labels, with a confidence) and Score (a rubric level, with a confidence). It writes no text. Jev is not a Haiku replacement (owner direction, 2026-10-17). A surface uses it only where Haiku is too slow, or where one Haiku call per question makes too many calls. The reason is latency, not cost: the 2026-10-16 probes measured Jev p50 at 94-128 ms against 622 ms for Haiku, and moving every text judgment would save about $6 a week (ledger #4304).

Persona: persona.py composes the system prompt from constitution.py (hard rules, house rules, calibration) + persona core + voice examples. constitution.py is non-negotiable and cannot be loosened by /order. It also holds GENERATED_LIKENESS_CONDITIONS (#4013): the seven conditions under which the arcade may show a 3D fighter body generated from a member’s PFP or a chart artist’s photo. No model reads that tuple. The arcade model pipeline (#4012) must import it and check every condition in code before it generates anything. An arcade trailer may show member and artist fighters (owner, 2026-10-15): the owner posts it only in the owner’s own Discord servers and on the owner’s own website, never through the bot and never to X or a video host. Opted-out members never appear. The identity-inference rule that the prompts carry does not change.

Database: db.py, raw asyncpg with inline SQL, no ORM. Schema is idempotent CREATE TABLE IF NOT EXISTS statements that run on every startup. Add new tables here; never drop columns without a migration plan. Post-dedup history is generalized: the “have I said this lately” text-dedup memory for every scheduled/wire surface lives in ONE table, post_dedup_history (guild_id, surface, summary, created_at), keyed by the cog’s ScheduledPoster.SURFACE; write via add_post_history(guild_id, surface, summary) and read via recent_post_history(guild_id, surface, *, limit, window_hours=None) (fed to utils.dedup.duplicate_reason). The per-surface add_*_history/recent_*_history names are thin delegators kept for the cogs. One wired prune, prune_post_history, bounds every surface by its POST_HISTORY_RETENTION_HOURS window (with a default-window catch-all so an unmapped surface can’t grow unbounded). A NEW scheduled/wire surface must NOT add its own *_history table — add a POST_HISTORY_RETENTION_HOURS entry and use the generic methods. (discourse_history carries a category and chimein_history doubles as cadence state, so those keep their own tables.) A surface that POSTS but is not a ScheduledPoster must still write here (#2160). cogs/music_alert.py is the worked example of the failure: it is event-driven, so it has no SURFACE constant and no spine to write for it, and it stamped only music_alert_state — a per-ticker BASELINE keyed (guild_id, event_ticker), one row per market, overwritten on every alert. That is not a post log and no dedup path reads it, so an alert was invisible to EVERY topic judge in the repo while crossposting to X off the same Kalshi ladders music_desk reads. Live on 2026-08-08 the alert posted Karol G’s first-week forecast at 16:45 UTC and the desk posted the same market at 17:02; the desk’s judge DID fire on text_similarity and overturned itself, because the history it was handed held a Role Model post and not the alert 17 minutes earlier. The fix is the two halves together — _record_history on both ship paths, and the surface name in WIRE_DEDUP_SURFACES so the desks READ it. The test for “is this surface covered” is not “does it subclass ScheduledPoster”, it is “does it ship text to a room or to X”. #2160 shipped only the WRITE half, and the missing READ half cost a second duplicate (#2189). Being IN WIRE_DEDUP_SURFACES let the desks see the alert cog; it did not make the alert cog look at the desks, and that cog runs no dedup gate of its own because the spine that runs one is the ScheduledPoster base it does not subclass. So it stayed free to restate whatever another surface had just posted: on 2026-08-08/09 music_news posted “Dai Dai is #1 on Spotify’s Global Daily chart” at 17:19, music_alert posted the same fact at 21:27, and music_desk again at 01:02. cogs/music_alert.py:_topic_repeat is the read half — the same recent_post_history(WIRE_DEDUP_SURFACES, limit=WIRE_DEDUP_READ_LIMIT, window_hours=WIRE_DEDUP_WINDOW_HOURS) read and the same topic_duplicate judge, run on BOTH ship paths after the self-gate and before the card render, failing OPEN on every error. Membership in the set is a two-way contract: a surface that appears in WIRE_DEDUP_SURFACES must both write to it and read from it. A write-only member is worse than absent, because the desks’ judges then carry its posts while nothing carries theirs. market_alert was the third surface to fall in this exact hole, and it joined the set on 2026-09-22 (#3638). It is event-driven, so no ScheduledPoster spine wrote for it, and its own market_alert_state table is a per-SERIES baseline keyed (guild_id, series_ticker) — one row per series, overwritten on each alert, read by nothing outside the cog. Its two pacing gates (_blocked for the snapshot-keyed triggers, _forecast_paced for the ladder forecast) both ask their questions of that table, so neither could see another surface. Live on 2026-09-22: music_alert posted Miley Cyrus’s “Bass Persuades” first-week pure-sales forecast at 02:48 UTC (~40.2K) and market_alert posted the SAME Kalshi event (KXPUREALBUMS-BAS26SEP24) at 04:34 UTC (~31.1K). Neither number was wrong — both lanes read the 0.50 crossing (kalshi_ladder.implied_estimate) off the same price rule (kalshi_price.market_yes_price), and a thin release-week ladder really did converge in those two hours — so no number gate could have caught it and only pacing could. cogs/market_alert.py:_topic_repeat is the read half and _record_history the write half. Its REVEAL lanes write but deliberately do not read, on the crown-lane reasoning below: the RT scorecard (_post_reconciliation) and the outcome lanes (_ship_outcome_card, ridden by both the ladder result and the entity winner) report a RESOLUTION, which is new information by construction and cannot be a restatement of the forecast it settles — but the judge compares subjects, so it would read “X is tracking toward 31.1K” and “X sold 28.4K” as one topic and suppress the reveal. They still write, because the desks must see a result the room was told. market_drop was the last HALF member, and joined the same day (#3648). Its WRITE half was always correct and is easy to miss: db.add_market_drop_history is one of the thin delegators described above, so it has always written to post_dedup_history under "market_drop" — which is why POST_HISTORY_RETENTION_HOURS already carried its 72h window. A grep of cogs/market_drop.py for add_post_history finds nothing and is misleading; follow the delegator into db.py. What it lacked was the READ: its _sched_recent_history returned recent_market_drop_history(guild_id, limit=15), its own surface only and 15 rows deep, and the name was absent from the set so nothing read it back. So the scheduled market desk composed Kalshi forecasts off the same ladders music_desk, music_alert and market_alert read, with no judge able to see across — the cinema_desk state again: a dedup gate that exists but structurally cannot catch. It now reads WIRE_DEDUP_SURFACES at the shared limit and window, and being listed also flips ScheduledPoster._shares_history, so its walk takes the serialization gate. The lesson for the next member: check BOTH halves by following the delegators, not by grepping the cog. And a THIRD half, which #3643 closed: the LOCK. utils/history_gate.py:shared_history_gate serializes the history read, the topic judge and the write for one guild (#2758; held per unit since #3828), and ScheduledPoster._shares_history takes it for every spine lane by reading WIRE_DEDUP_SURFACES. A cog that is NOT a ScheduledPoster never reaches that code, and three members are event-driven — music_alert, x_mentions and market_alert. Only market_alert took the lock (#3638), so a desk holding it still raced the other two: both could read the old history, both judge clean, and both post before either wrote. All three now hold the gate across their own walk. The COMPOSE stays OUTSIDE the gate in every one of them, and that is deliberate: GATE_WAIT_SECONDS is 180, while the judge costs about 1.5s (measured claude_api purpose=topic_dedup p50 1536ms), so a lock held across a compose would stall a waiting desk for minutes to serialize a second and a half of work. Each gate block therefore starts at the topic judge and ends after the history write. tests/test_wire_lanes.py pins both facts, so a new event-driven member cannot join the set without taking the lock. A CROWN LANE IS THE ONE EXEMPTION (#2989). music_alert’s four crown lanes (_CROWN_LANES — spotify / apple / apple_album / radio) skip _topic_repeat entirely. A crown fires only when the title JUST took #1 (_chart_crown and _radio_crown_signal both require pos_change > 0), so it is a position CHANGE by construction and cannot be a restatement; the judge cannot see that, because it reads two sentences and compares subjects, and a rank move reads to it as the same song on the same chart. Live on 2026-09-05 apple_crown composed “BbY WOW … has climbed to #1 on Apple Music US songs, up from #2”, scored 0.89, and was suppressed against the desk’s own earlier post of that song at #2 — the post the crown was correcting (that #2 was itself wrong, off a 114-minute-old cached kworb read; see #2988). Replayed against the live judge on that same history the verdict came back duplicate 5 times out of 5, so it is the judge’s steady reading and not a flake. It is not reliably WRONG either, which is why a prompt edit does not fix it: handed the SAME crown take against a ONE-post history the judge called it “a real development: song climbed from #2 to #1” (measured while dry-running the fix). The same take reads as a duplicate or a development depending on what else sits in the window, so on a rank move the gate cannot be trusted in either direction and the lane must not depend on it. Skipping is safe because a crown is already deduped twice, deterministically: _daily_gate allows the lane at most one fire a day and refuses the same song key twice in a row, and the pos_change > 0 gate means a held #1 never fires again — so the same #1 cannot repeat with or without the judge, which added no dedup a rank change needs and subtracted the one story the lane exists to tell. The lane is stamped once a day (_mark_daily runs before the fire), so a single wrong verdict costs the whole day, which is why the fix is a bypass rather than a retry. This exemption is scoped to the crown shape only: every other daily lane (release, the jumps, the risers, the artist-market lanes) still runs the judge. A PERMANENT fact does not ride the text window at all (#market-dedups). The post_dedup_history table also holds EXACT-KEY identities on their own surfaces — a certification (music_news_cert), a streaming milestone (music_news_milestone), and now a WEEKLY-chart DEBUT (wire_chart_debut, music_news.chart_debut_story_key) — read by exact match with no window and 1-year retention, because a settled fact is never news again and the ~14h effective wire window cannot hold it. The CERTIFICATION key’s identity is the TITLE without its feature clause (feat / ft / featuring / f/ / w/ / with, one bracket level deep, a version qualifier after a bare clause kept) plus the LEAD act when that act is on our lists, else the whole credit with its connectors folded, so “Tyler, The Creator” and “Tyler the Creator” key the same (music_news._cert_release_identity, 2026-09-02): RIAA prints a feature in the title (“Up All Night (feat. Nicki Minaj) - Drake”), a wire puts it in the artist (“Up All Night - Drake & Nicki Minaj”, a radio wire “f/”) or drops it, and the RIAA lane’s first day showed the whole-credit key splitting on that (yungenfeatjackharlow|rodwave vs yungen|rodwave), which is a second card for one certification. The milestone, debut and chart-position keys keep the whole credit (_release_identity): a streaming milestone counts per RECORDING, so “bad guy” and “bad guy (with Justin Bieber)” are two counters. The recognition lists change between builds, so a key derived from them alone would change under one certification: the cog writes EVERY form of the key on a ship and reads every form before a spend (cert_story_keys: the corroborated lead, the whole credit, and the pre-2026-09-02 fold; the whole-credit form is the bridge between two list snapshots, and a split-at-every-connector form is deliberately absent because “Earth, Wind & Fire” would book “earth” and suppress a real act named Earth), so the identity does not move with the list snapshot and a key stored under the old fold still matches. A certification’s claim blob also carries the LEVEL’S UNIT COUNT off the body’s own ladder (cert_units_fact, #2871: RIAA Gold 500,000 / Platinum 1,000,000 per multiple / Diamond 10,000,000 per multiple; BPI Silver 200,000 / Gold 400,000 / Platinum 600,000 per multiple; no clause for the RIAA Latin program, an unknown body or a malformed id) on the ONE line the compose, the digit gate and the self-gate read, so a take that converts the level to units (“14x Platinum, 14 million units”) is grounded and a wrong count (“12 million”) is still a number nothing states. Measured 2026-08-31 to 09-03 the self-gate scored five certification takes 0.0-0.2 for a units figure, at least three of them the right arithmetic (Halsey 14x Platinum; a BPI Gold at 400,000; a BPI Platinum at 600,000); the model judge on its own grounds a right count and a wrong one alike (dry run 2026-09-03: a 12-million take scored 0.9 on the same blob), so the digit gate is the refuter and the blob is what it needs. The chart-debut key is the FIRST cross-surface one: music_desk reads Billboard first-party and music_news relays the same debut off @billboardcharts, so both surfaces write it (PRODUCTION delivery only, so a staging audition can’t suppress a real post) and both read it before the compose/research spend. Only a DEBUT keys — a re-entry, a move (“surges to #47”), or a LIVE daily chart that churns all fail open — so a genuine development still ships. Live cost that named it: the #73 “BbY WOW” Hot 100 debut posted from music_desk on Aug 19 and from music_news on Aug 22 (+53h) and Aug 23 (+85h), each past the window, and the topic judge even overturned a shape-only hit against a different Karol G debut still in view. A weekly-chart POSITION that has not moved is the same class of stale re-report, and rides its own high-water store (#chart-repost). A debut settles ONCE, but a POSITION persists for the whole week, so a wire re-posting the same #1 — or a small drift — is one story re-told. The gate is NOT a boolean exact key like the three above; it is a numeric high-water in kv_cache (namespace music_news_chart_hw, keyed music_news.chart_position_key = folded subject + pos + the chart key, per guild, 45-day INACTIVITY TTL — a suppressed repeat refreshes the row so a 10–20-week #1 run cannot expire mid-streak) holding the last position REPORTED. music_news.should_repost_chart_position re-reports only a CLIMB of MUSIC_NEWS_CHART_REPOST_MIN_CLIMB (default 5) spots, a NEW #1 (a crown the last report did not hold), or a RE-ENTRY (the song left the chart and came back — the owner steer “exits post right”, read off the classifier’s chart_event first, bounded to a return wording in the claim or the post, else claims_reentry on the claim, since the settled rewrite strips “re-enters” — the regex read only “re-ent”, so “returns to the Hot 100 at No. 40” was suppressed as a fall, #3418 / the #2523 follow-up); a held or plainly-fallen position never reposts. It runs in music_news only, on a SETTLED reading on ANY known chart — WEEKLY panels AND live DAILIES, because a held #1 repeats on both (a daily #1 shipped eleven times); the deep_chart top-10 floor runs first and trims a deep daily position, so only a top-of-chart daily position reaches the gate. Written on PRODUCTION delivery only. Live cost that named it (Aug 2026): Drake’s “Shabang” shipped at #1 on the US Rhythmic radio chart on Aug 15, 16, 19 AND 24, and Cardi B’s “AH HA” shipped at #18 then #14 (four spots) on the same chart — the owner steer: “duplicates, and a 4-spot jump isn’t big enough to repost”. A genuinely different chart (Shabang #6 on Urban radio) or a real jump still ships. The music_desk radio lane is a separate surface and does not yet share this store — a cross-surface repeat there is a known follow-up. THE READ AND THE WRITE ARE SERIALIZED PER GUILD (#2758). The store is written AFTER the post, so a desk that reads it, judges, posts and only then writes leaves a window of seconds where a sibling desk reads a history that holds neither post. Both judges then say “not a duplicate” and the same story ships twice. It did: on 2026-08-31 sports_desk and pop_desk both composed Messi’s international retirement on one tick and tweeted it at 15:31:48.389 and 15:31:52.396, 4 seconds apart, because pop_desk read the history about 2 seconds before sports_desk’s row existed. The judge was never the problem — handed the row once it existed, it blocked the same story twice that hour (15:37:29 and 15:47:25). utils/history_gate.py:shared_history_gate closes the window with one asyncio.Lock per guild, held across read → judge → write of ONE unit. Every spine lane that ships a unit takes it through the one method ScheduledPoster._deliver_unit: the slot walk (_gated_unit_walk), the breaking lane (EventPoster._ship_event_candidate), the music desk’s source watcher (_off_slot_pass) and the newsroom’s feed watcher (_watch_post_one). So no lane can serialize while another does not. The gate is held PER UNIT, not per walk (#3828). Before #3828 a lane held it for its whole walk. Measured 2026-10-04: one music_desk walk of 20 units held it from 18:55:46 to 18:58:21, and music_alert waited 152s (history_gate reason=waited duration_ms=152245) to judge a post that had nothing to do with the walk. _deliver_unit now takes the gate, reads the history, judges, delivers to the first channel that lands, writes, and releases. asyncio.Lock serves waiters in order, so a waiting lane waits for one unit at most: about 1.5s for a deduped unit, about 38s for a delivered card with its X crosspost. The history is read again inside each hold, so #2758’s guarantee holds per unit. The lines a walk already delivered are put first in each unit’s list, so two units of one slot still dedup against each other when the store lags. The KEY stays the guild, not the story: the history read set is guild-wide, and the Messi duplicate was two desks with two different keys for one story, so a per-story lock could not have serialized them. The dry run (scripts/dryrun_history_lock.py, the real 21-unit walk against the real history and the live judge) measured the alert’s wait at 109.7s before and 1.1s after. GATE_WAIT_SECONDS stays 180: the per-unit hold makes a real wait short, and a lower ceiling only makes a timeout (an unserialized read) more likely. COMPOSE stays OUTSIDE the lock — it is the expensive half (31s and 37s of wire reads in the measured case) and serializing it would stall every desk behind the slowest; the losing desk still pays for a duplicate compose, it just cannot post it. Which surfaces take the gate is DERIVED from WIRE_DEDUP_SURFACES (_shares_history), not declared per cog, so a desk added to the shared read set is gated by that one edit; a surface reading only its own rows has no writer to race and skips it. The gate FAILS OPEN on a GATE_WAIT_SECONDS timeout (proceeding on a fresh read, which is never worse than no gate) and emits history_gate on a real collision or a timeout. An AUDITION never feeds the dedup (#2847). A unit stamped meta["audition"] (a staged source or lane experiment under a PRODUCTION parent: the RIAA feed under the newsroom, a desk lane under the music desk, an artist-market lane under the music alert) reached only #bot-logs, so ScheduledPoster._process_unit books neither its history line nor the in-pass list for it; a plain staged surface still books both and dedups against itself. The newsroom stamps the flag at compose, the music desk once at the slot’s return (lane stage STAGING while graduated_stage is PRODUCTION), and the alert cog’s daily lanes pass it to the firer and skip the permanent event key. Measured 2026-09-02: the four whole-chart presence boards a mod flipped live at 05:48Z were all deduped at 06:08Z against their own 01:50Z auditions.

Models: models.py, plain dataclasses for DB rows and StrEnums for OrderStatus and MoodMode. No ORM behavior.

Cogs (in cogs/):

THE HOUSE POLICY: rap, R&B and pop only, and a record that is not a new release must be by an act we know (owner steer 2026-09-14, after CMAT’s “EURO-COUNTRY” reached @tootsiesbar). utils/music_policy.py holds the rules (pure, with the measured tag vocabulary in its module note); cogs/music.py:_house_gate does the fetching and emits music_house_gate. Four parts, and they are deliberately not all the same part:

THE MODEL CHECK ON AN OFF-LIST TAG (#3443, 2026-10-16). The allow list does not change, and an on-list tag never calls a model. An OFF-list tag used to be a silent refusal; now music_policy.judged_in_house_genre asks one cached Haiku label (claude.house_genre_check, purpose=house_genre, labels RAP / RNB / POP / NONE) with the artist, the title and the tag. Three cases refuse with no call: a blank tag, a blank artist or title, and a tag the owner named as not the genre (_NEVER_RESCUE: Indie Pop, K-Pop, Pop Latino, Pop/Rock). NONE or no verdict refuses, as before. Both call sites use it: the drop gate (_house_gate, which names the catalogue row’s own artist and title) and the newsroom’s new-release lane (_artist_new_release, asked only for the newest in-window off-list album, so one call per slot at most). The answer is cached 14 days in the durable classify cache (kv_cache, #4231). music_house_gate.detail.verdict and music_news_filtered(off_genre).detail.verdict record the step that decided (list / not_asked / model_yes / model_no / model_error). Measured on 103 real off-list records (2026-10-16: the production drop history resolved through iTunes, the three live off_genre album refusals, and 12 known hits): the Soundtrack-tagged classics (Prince “Kiss”, Whitney Houston “I Will Always Love You”, Eminem “Lose Yourself”) pass, and 0 of the 95 records hand-labelled as out passed. A first wording let country crossovers through (Post Malone “I Had Some Help”, Beyoncé “TEXAS HOLD ‘EM”), so the NONE line now says a record that belongs to the tag’s genre stays out when it crossed over to pop radio.

FAIL DIRECTIONS. The genre rule is absolute and fails CLOSED: an unreadable catalogue row is a refusal, because we cannot check what we cannot read. The FAMILIARITY rule fails OPEN on a source outage — an empty watch list is watchlist_source’s documented “everyone is known” (a kworb outage must not silence a lane) and riaa.artist_tallies returns None rather than [] when the site is unreachable. Both open cases still pass the genre rule, so the record the owner rejected stays rejected during any outage. The gate needs its own monitoring (music:house_gate, a gate_dark finding): it runs AFTER the self-gate, so a refused drop has already emitted music_scored, and surface_dark plus the quality average both read judge scores and would see a healthy surface while the channel gets nothing. The music-drop Perplexity pull uses surface="music" (a FRESH-HIT query for the biggest new/hot songs out this week with artist+title, recency=week), distinct from the discourse/news template (which explicitly asks for story pages “not a trending songs this week list”). The priority ORDER is unchanged (most drops are still a classic/deep cut, a fresh hit only when one genuinely fits) — these sources just make the fresh lane land on a real new release when she does reach for it. Links-only channel. Rides on the existing mood schedule. Song-identity dedup (the “Folded keeps re-dropping” fix): the shared music_history dedup keys on the TAKE text + trailing link, which a re-drop of the SAME song under a different take/link form slips past — and the grounded chart sources keep resurfacing a currently-charting song (e.g. Kehlani’s “Folded”), so the model keeps picking it. So music now also keeps a durable SONG reuse block (music_recent_songs, the analog of the guess games’ game_recent_songs): each DELIVERED drop’s canonical song_key(title, artist) — folded from the model’s TRACK: line via drop_song_key, the SAME key the games use, so the two dedup identically — is logged, and a track dropped in the last _SONG_REUSE_DAYS (30) is (1) filtered out of the hot_chart/new_releases material before it reaches the model (filter_blocked_songs, using the FULL block, so a still-charting repeat is never handed back as “fresh” — the direct answer to “is it due to charts?”), (2) surfaced to music_post as a LIGHT prompt nudge — only the freshest ~15 (the block is newest-first), since the full-set filter + hard gate are the real teeth and a long negative list is redundant + can anchor the model toward the songs it lists — and (3) hard-blocked at post time on SCHEDULED drops (same_song gate → one retry → skip the slot; manual /music is a mod deliberately asking so it isn’t hard-blocked, but still benefits from the filtered sources + prompt block). Pure drop_song_key/filter_blocked_songs unit-tested; bounded by prune_music_recent_songs (45d, outlasting the reuse window). Emits music_dedup (decision=song_gate, signal=same_song). The wire union was TESTED here and REJECTED (#2195 / PR #2202). The music drop does NOT read the code-owned wire accounts, and that is a deliberate result, not an omission. A live A/B in the production prompt shape (grounded sources present, 4 composes per arm, scored by the real self-gate) measured 3/4 shipping at mean 0.61 WITHOUT the wire block and 0/4 at mean 0.23 WITH it, all four collapsing onto the same fabricated track. The reason is structural: the drop has no material gap for a wire to fill, because its fresh-hit sources are hot_chart and new_releases, not a social feed. Adding news only gives the model a news SUBJECT, and picking a song to match one invites inventing a song – the ‘prefer absent over invented’ failure. Do not re-add it without a materially different design; the failure was not marginal. The music desk takes this union in _feed_context, the buzz blob behind a projection take: it was feed-channels-only, so a server with no mirror got None and the take had no social signal at all. Same list, same music_wire_accounts knob, block only. Two details are load-bearing there. The feed walk and the wire read run CONCURRENTLY inside the method (a cold wire cache can spend up to FETCH_DEADLINE_S on its live fan-out, and the method is already one leg of the desk’s context gather). And the prompt header changed: it used to read “WHAT THE ROOM’S POSTING”, which would have credited the room for wire accounts nobody there posted — it now names both sources and points at the per-block (feed) / (wire) labels. The X mirror of a drop is the take + the Apple Music link only (owner steer 2026-09-09: “remove the art from music drops and only use the Apple link”): _sched_deliver attaches no album cover image and quotes no other post, so X cards the Apple link itself (or the video link on a video drop, which rides last). apple_music.fetch_cover_art has no live caller now; the manual music-drop skill saves no cover.jpg either. The take names a part of the track, and then it stops (owner report 2026-09-13: “Commentary too ai ish and too long”, #commentary-tone). The flagged post was “Doechii turning a panic spiral into something the whole bar can yell is nasty work.” It was not an outlier. Thirteen drops in a row used one template: an artist name, an abstract scene or transformation, a comma, then a second clause that graded the first. Three named shapes are now banned in the compose prompt – the IMAGINED SCENE (“made this for when the lights come up”), the ABSTRACT TRANSFORMATION (“turning X into Y is nasty work”), and the TACKED-ON VERDICT (“, that hook still knows the assignment”). The length rule is the same fix, not a second one. The prompt used to name a “~80-100 chars sweet spot”, and every flagged drop landed inside that band, so the model was obeying the number. The number is gone. The rule now reads: you are done when you have named the part, and a longer line that names the hook beats a short one that only rates the artist. A char TARGET was tried first and it made the output worse. A 40-70 band squeezed the concrete detail out and left the verdict behind, which is the docs/PROMPT_OPTIMIZATION.md trap: the model obeys a literal number and cuts the wrong thing. Two candidate fixes were measured and REJECTED. The scorer rubric (_MUSIC_SCORE_SYSTEM) awarded its top band for “what it’s good for (the function, a slow night, the drive)”, which is the banned scene shape, so re-cutting the bands looked obvious. Measured against 30 real shipped drops it also skipped the compose’s OWN exemplars (“The Weekend hits different at 1am.” fell 0.62 -> 0.31, under the 0.35 floor) and Haiku swung 0.37 on identical shapes, so the gate was left alone: it cannot carry a register distinction, which is the same limit utils/slop_score.py documents. A deterministic checker was not built either – the shapes carry no banned vocabulary and no reliable regex frame. The EVAL judge (scripts/eval_music_post.py) carries the axis instead, and its live-log pass grades real shipped drops on it. The take no longer describes the sound (owner report 2026-10-18: “our music drops sounds like ai”). The 2026-09-13 fix asked for “something a listener could actually HEAR (a feature, a hook, a verse, a beat switch, what the voice is doing)”. The model has not heard the record, so it wrote a description of the sound in a written register: “Say My Name turns one suspicious phone call into a full group chant over that stuttering beat”, “that slow, whispered groove”. The prompt’s GOOD example was also a Doechii “Anxiety” line, and the bot re-dropped Doechii “Anxiety” as a paraphrase of it. The prompt now asks for the one REASON the track is on (a confirmed hook or a plain opinion) and says not to describe the sound. The Doechii pair and the “whole bar locked in” example are gone. Measured on the real compose (Sonnet 5.5, today’s chart rows, n=12 per arm): sound-portrait or graded-copy lines fell from 6/12 to 0/12, and 0/12 again on a repeat run. The eval judge gained a fourth shape, the SOUND PORTRAIT, with the verbatim Say My Name drop as its golden. The old judge passed it, because its simile clause read a described sound as an image and not as a fault. The take stops at its hook (owner report 2026-10-19: “still sounds like ai, maybe should’ve stopped at stage”). The flagged drop was “Megan took ‘Body’ to the Victoria’s Secret stage, so it’s coming straight to the aux.” Since the guild moved the drop to gpt-cheap (gpt-5.6-terra) on 2026-10-18, every shipped drop ended with a clause that says she is playing the track or that the hook is reason to play it: “so now I’m playing it”, “is plenty reason to clear the bar”, “I’m not fighting the aux”, “is enough for me”. The 2026-10-18 fix was measured on Sonnet only. Three causes sat in the existing prompt: the “SAY WHY IT’S ON … the ONE reason you put this track on” heading asked for a reason to play it; FORMAT line 1 carried the shared _POST_LENGTH with “~20 words”, against the music prompt’s own “there is no target length”; and the FRAMING block opened “You control the aux, you PLAY tracks in the bar”. The GOOD example “Travis hopped on Father and Ye let him cook.” also ran past its fact. The fix rewords the heading to “SAY ONE THING” (a hook OR an opinion, not both; the hook is the whole line), gives the drop its own _DROP_LENGTH with no word count (_POST_LENGTH is byte-identical for every other surface), cuts the aux line from FRAMING, and ends the Travis example at “Father”. Measured on the real compose with the live model and today’s real inputs, n=12 per arm: tailed lines fell from 11/12 to 1/12, and 3/12 on a repeat run; on Sonnet, from 8/12 to 2/12. The arm table is in docs/PROMPT_OPTIMIZATION.md. The eval judge gained a fifth shape, the SELECTION TAIL, with the verbatim Megan drop as its golden; the old judge passed it 3 of 3. The self-gate passed every tailed drop (0.60-0.78), so it is left alone, for the reason the 2026-09-13 note gives. The take names the artist and one fact (owner report 2026-10-20: “bad caption”). The flagged drop was ““Janice STFU” is a mean little record.” It went to X with no artist and no fact. The #4607 prompt allowed both: it asked for “a real hook … OR your plain opinion of it”, said “If you have no hook, say you like it and stop”, and kept a GOOD example that only grades (“That SZA hook on The Weekend is peak petty”). On gpt-5.6-terra, 9 of 12 baseline lines were a bare grade (“Kehlani’s ‘Folded’ is petty in the best way”). The prompt now says the line names the artist and ONE confirmed hook, and that a track with no hook is swapped for one that has a hook. The SZA example is cut. The hot-chart block now says that a song’s place on the list is a reason to pick it, not a hook to post, because the first arm without that line quoted the chart list in 4 of 12 lines. Measured n=12 per arm: clean fact lines went from 3/12 to 8/12, and 8/12 again on a repeat; the arm table is in docs/PROMPT_OPTIMIZATION.md. The artist rule is also checked in code. ship_guard.drop_missing_artist (output_checks.missing_artist) runs on both compose lanes, after the self-correction guard and before the self-gate. A line that names none of the artist’s words skips the slot like a self-corrected one: it emits music_scored phase=shape_reject reason=missing_artist with detail.artist, then music_fallback reason=missing_artist. One name word is enough (“Cardi” names Cardi B), if it is written as a name: “the future of rap” does not name Future. A qualifier cut that takes the artist out drops too (cut_qualifier, matchup lost_artist: ...), and the manual music-drop script ranks its takes by the same check. It flags 5 of 25 real drops in the 3 days to 2026-10-20 (all real misses) and 0 of 48 dry-run lines. The desk_shape_reject panel shows the rejects, and the ops monitor’s degradation finding fires at 3 music_fallback rows with one reason in a window. The eval judge gained a sixth shape, the BARE GRADE. NO ANNIVERSARY OR BIRTHDAY HOOKS (owner decision on #4688, 2026-10-23: “yes i think we can do without these especially if we aren’t generating them ourselves and are copying off the x wire”). Two drops shipped with a date hook in the week to 2026-10-23: “Seventeen years later, “Bad Romance” is still pure theater.” and “Snoop turning 55 today means “Drop It Like It’s Hot” is mandatory.” A third (“Seventeen years later, “Empire State of Mind” …”) scored 0.78 and did not cross-post. utils.retrospective.date_hook reads the composed line and finds an interval with a date word (“N years later / ago / since”), an age (“turning 55 today”, “turns 45 on Friday”), “birthday” / “bday”, an anniversary (not an “anniversary EDITION”, which is a new product), “on this day”, or a throwback tag. The cog removes the drop’s own title (from its TRACK: line, the full title and the title without its “(feat. …)” tail) first, in any case and in any quotes or none, because a title can wear a hook’s words (“Happy Birthday”, ‘Birthday Sex’; Codex review on #4718). It also skips any other text inside double quotes, and it removes the link, because an Apple Music slug holds the title’s words. cogs/music.py:drop_date_hook calls it on the first compose and on the retry. A match takes the empty-compose path: the slot skips, and a manual /music gets the voiced non-answer. It runs before the artist guard, so a dated drop counts under its own reason (music_scored phase=shape_reject reason=date_hook, then music_fallback reason=date_hook). After the hooks it also reads the anchored source frames (_FRAMES: “Today in 2003, …”, “This day in 2016, …”), since #4697. The four wire desks call it on their composed take with look_back_only=True (see the retrospective.py bullet); their source cut is still cut_throwback.

A purely WORDED card ships BARE ART (owner steer 2026-09-15, #3370). This is #3345’s rule reaching music. The newsroom’s bare-art rung was narrow (#3291): a first-party FEED photo, on a TOUR, judged an admat by vision – the judge existed because stripping the card off a press portrait “costs the headline, the byline and the source stamp and returns nothing”. Both cards the owner reported were portraits resolved by the ENTITY resolver rather than a feed, and both were asked to ship bare: “this isn’t a number or award or numbered or percentage or value post, this is purely worded, those are the ones that we tried to cut … we don’t need to duplicatively put the annotation on the card”. The duplication is between the drawn headline and the CAPTION beside it, which repeats it word for word – not between the headline and the picture. So the rung is now ONE condition beyond having art: the card is FIGURELESS. The art source no longer decides, the is_tour limit is gone, and image_is_tour_admat has no caller left (kept, not deleted – removing a capability is the owner’s call). The boundary is the FIGURE, which is exactly the line the owner drew: card_figure is cleared for the release and no_chart kinds and for a rank-word figure, so an empty figure here IS the purely-worded card, while an RIAA “Gold”, a chart-exit “OUT” and a milestone count all keep their full markup. The market and record copies clear their own figure BELOW this gate, so a market card’s odds and a record card’s count keep theirs too. The accepted cost: a bare post keeps the take and the link button, and loses the wordmark, the tag block, the headline and the VERIFIED source stamp; the take still cites the data authority in her own voice. Measured on the real pull, n=3 on six top acts: the widened prompt returned Drake’s Don Toliver Toronto pop-up 3 of 3 where the old prompt returned a sales line; the shipped classifier read it as release 6 of 6 and an audience sighting as none 6 of 6; Grok verified it; the compose self-gated at 0.89-0.95. The pull’s budget is three /menu tune knobs (#2800 line D): searches a slot (default 8, 0 pauses the pull), of which from the watch list’s tail (3), and pull stories admitted to a slot’s slate (4) – tunables.artist_pull_budget, read per guild each slot, fail-open to the defaults. Measured 2026-08-29 to 09-02 before the change: 6 searches a slot found a fresh dated story a third of the time (51-63 hits a day over 126-186 checks) while music_news_posted source=artist_watch ran 1-3 a day, so discovery was not the bottleneck; the same change makes the two silent drops between them visible (music_news_filtered with not_a_story and shortlist_full), which is what the next tuning reads.

Utils (in utils/):

The third reader, markets._kalshi_market_to_snapshot, is now wired in too (#2288). It had re-implemented the rule INLINE and the copy had drifted: on a wide book with an out-of-book last trade it KEPT the untrusted mid where book_yes_price returns None, and its own comment claimed parity (“same thresholds as every other book reader”) — the thresholds matched, the fail direction did not. An existing test asserted the drifted behaviour explicitly, so this was a documented divergence rather than silent rot. What it shipped: an EMPTY Kalshi market quotes bid 1c / ask 99c and has never traded, and the copy midded it to a confident 50% — a fabricated coin flip. Measured over 2043 live open NON-MVE markets: 480 (23.5%) read a price only because of the drift, and of those 480, zero had any 24h volume, any lifetime volume or any resting liquidity (one had open interest); their spreads are EMPTY books, not wide ones — median 94c, p90 99c. So the affected population is entirely markets nobody has ever traded, and every surface that picks a market gates on real volume, so wiring the rule in costs no live coverage. Sampling note for anyone re-measuring this: the raw /markets?status=open listing is ~97% KXMVE combo rows, which the snapshot builder drops for unrelated reasons — sampling it unfiltered measures the MVE filter, not the price rule (it reported a misleading 6.9%). The snapshot builder drops MVE combo/parlay rows (KXMVE tickers) at the per-market boundary; the NON-LEADER chart-board cut is a separate, EVENT-level decision at discovery. Owner steer (2026-08): keep music and sports “#N” boards, cut the rest — the Netflix and Google-search chart runner-ups. _is_cut_runner_up_event(event_title, event_category) decides it in get_events_for_series, and it is keyed on Kalshi’s own EVENT category field (present on the /events?...with_nested_markets=true payload) plus the event title. It cuts an event when the category is Entertainment, the EVENT title names a slot below #1 (_names_non_leader_slot — “runner-up” or “#N” for N ≥ 2), and the title is NOT music (_looks_like_music). This is why it is precise where an earlier per-leg title scan was not (measured against 6000 live open events, 2026-08): SPORTS is a separate category, so a “College Basketball #2 Ranked Team” is never cut here; ELECTIONS is a separate category, so a “Will Initiative #85 pass?” ballot event is never cut; and because the rank must be in the EVENT title, a video-game event (“Video games released this year”) whose only “#N” is a per-leg identifier (“Final Fantasy VII Remake #3”) is not cut. Music stays because every music runner-up event names its platform in the title (“#2 Song on Spotify”, “#2 on the Billboard 200”, “Runner-Up Daily Music Video”), which _looks_like_music matches. Since CB-19 (#3410) the music cue is NOT a fast keep when the Haiku runner-up check is attached: _nominates_runner_up_cut sends every Entertainment non-leader title to confirm_runner_up_cut (purpose=market_runner_up_check), whose task lists music boards as OTHER and carries _MUSIC_MARKET_CUES as examples. With no judge, the cue list keeps the board as before, now on whole words (“Trap” no longer holds “rap”). Against the live catalog this cuts exactly 5 events — Netflix Movie/Show #2 (US + global) and the Google-search #2 — and keeps 19 music runner-ups + every sports rank board + the ballot measures + the video-game list. A SECOND, SURGICAL cut sits in the same get_events_for_series spot (owner steer 2026-08-22): _is_cut_netflix_views_event(event_ticker, event_title, event_category) drops the Netflix VIEW-COUNT boards (reason=netflix_views) — the “how many views will the #1 show/movie have this week” boards (KXNETFLIXTOPVIEWSTV, KXNETFLIXTOPVIEWSMOVIE), a dry number with no narrative. They name no rank, so _names_non_leader_slot never matched them and they posted a viewership guess. The Netflix RANK/leader boards (KXNETFLIXRANKSHOW “Top US Netflix Show this week”, which name the #1 title — e.g. Outer Banks leading at 98%) are KEPT: a real story and a rich card. The cut is keyed on the NETFLIXTOPVIEWS substring in the ticker, with a narrow Entertainment-scoped title fallback (“how many views” + “netflix”) so a rank board is never caught and a Netflix STOCK market (tickered NFLX, in Companies/Financials) is never swept in. It runs BEFORE the runner-up cut and is gated on the CUT_NETFLIX_VIEW_MARKETS env flag (default on; set falsey to let the view-count boards post again). It is why a mod who wants only the Netflix viewership-guess cards gone gets a targeted code cut instead of the /menu topics page, which cannot go finer than the whole Television tag (and would take out the good rank cards with it). A THIRD cut in the same spot is a whole-SHOW blacklist by ticker PREFIX (_BLOCKED_TICKER_PREFIXES + _is_blocked_ticker, reason=blocked_series, 2026-08-26). Big Brother is the seeded case: it kept posting to Entertainment rooms after a mod deselected it, because the /menu subcategory picker is an ALLOW-list (a market posts if it matches ANY one whitelisted tag) and the Big Brother eviction series carries Television alongside Big Brother — so a room that whitelisted Television re-admitted it no matter what. A tag cut cannot fix this: Kalshi tags a show’s series INCONSISTENTLY, and the S27 winner series (KXBIGBROTHER27) carries ONLY Television, so no tag catches every Big Brother market. The STABLE key is the series ticker — every Big Brother series starts with KXBIGBROTHER, so one prefix drops eviction / winner / rank / S27 / Brazil alike (verified against the live catalog 2026-08-26). It cuts at every discovery arm so none bypasses it, each returning [] (clean skip) and emitting market_filtered reason=blocked_series: the series arm — list_series_by_category (so a blacklisted series is never even ranked — matters for the board path, which ranks the whole category by series before resolving markets; this is the emit site for the drops/alerts/trending path, since the series never reaches the fetch) and get_events_for_series (the series fetch); and the event-first arm the /ask + compare/combined tools use — kalshi_search drops blocked events from the FTS candidates before the last-mile picker (the FTS index still holds every blocked event), and, when the caller passes the game’s kickoff (start_ts, every game-line caller since #3560), drops every candidate whose ticker encodes another Eastern date or a start more than 4h away (kalshi_is_other_game, market_filtered reason=other_day), adds the date tokens to the FTS query so the all-terms tier ranks that day first, and names the date in the picker’s query – so a series game (the same two teams on consecutive days) prices off ITS day’s market, and get_event_markets guards the direct event fetch. Big Brother was the seed, but it has since MOVED into the per-guild menu (owner steer 2026-08-26), so _BLOCKED_TICKER_PREFIXES ships EMPTY — the always-on mechanism is kept for a series that must be gone for EVERYONE across ALL surfaces (add a prefix and it cuts drops, alerts, /ask, trending at once), but nothing uses it today. The STEAM CHART BOARDS were briefly entries here (2026-08-31) and are NOT any more. They posted a game title and a price with NO ART — the two cards that prompted the steer were “Counter-Strike 2 51%” and “STAR WARS Zero Company 31%”, both shipped to X as bare number cards. The first response was a global cut; the owner’s steer was to fix the art instead, and put the boards on the menu picker so they can be filtered later by choice rather than by hardcoded cut. So they are a SHOW_CATALOG row (steam_charts, four roots: KXSTEAMTOPSELLER, KXSTEAMWEEKLY, KXTOPSELLERS, KXGAMERANK), UNMUTED by default — and unlike Big Brother there is no seeding migration, because nothing was muted for anyone in the first place. The Steam AWARDS series (KXSTEAMGOTY, KXSTEAMBS, KXSTEAMLOL, …) share the KXSTEAM root and are deliberately NOT in the row: an awards board carries a story a weekly seller rank does not, so muting the charts must not silence the awards. Each root was verified against the live catalog (11,415 series across every category, 2026-08-31) to match exactly ONE series. Note the catalog is now AT 25 entries, which is Discord’s hard cap on select options — a 26th makes _MutedShowsSelect invalid, so adding one means paginating that select first (the subcategory picker already does); test_show_catalog_fits_one_select guards it. A PER-GUILD sibling lets mods self-serve (#mute-shows): the /menu market-topics page has a server-wide “mute shows” picker (_MutedShowsSelect) over markets.SHOW_CATALOG (a stable key -> display name + Kalshi ticker prefixes); a guild’s picks are stored in the muted_series table and resolved to prefixes by muted_prefixes_for. Unlike the global blacklist (which cuts deep in the shared, guild-agnostic client), the per-guild mute is applied at the GUILD-AWARE layer – market_drop._drop_muted_shows filters the pick pool and market_alert filters its candidate pool (both via the pure partition_muted_snapshots), emitting market_filtered reason=muted_series. It is scoped to the AUTOMATED surfaces (drops + alerts) ONLY, never to an explicit /ask – a viewer asking Toots about a show should still get an answer. Big Brother now lives in SHOW_CATALOG (moved off the hardcoded global block, owner steer 2026-08-26): a one-time marker-guarded DB migration (seed_big_brother_mute_v1 in db.py) seeds it muted for every already-configured guild so removing the constant does not let it post again, and it is a normal menu row a mod can toggle. The tradeoff of the move is REACH: the per-guild mute covers drops + alerts, not /ask or trending, where the old global block did. Why per-guild is applied at the cog layer and not the four global cut sites: the shared KalshiClient holds no guild context, and threading it through every discovery path would be a large change for a filter the two scheduled cogs can apply on the already-fetched pool. The pure _names_non_leader_slot rule (with its seed / auction “#N” exemptions, _SEED_RANK_RE/_AUCTION_RANK_RE) is unchanged; it is now read only through _is_cut_runner_up_event. The cut is at discovery (get_events_for_series, the boundary the topic + global pools fan out through), so a Netflix #2 board never enters the trending / movers / ask pools; an explicit single-event or single-ticker lookup, which carries no category, is not filtered. The music desk’s field-market context (music_markets._fetch_field_markets) reads RAW events and, being entirely music, does not filter runner-ups either, so it can show a “#2 Song on Spotify” as “related live markets” odds. Card rank rendering (market_cards.named_rank) is untouched, so a runner-up leg that reaches a card labels “#2”.

TikTok comment stickers (/stickers, #4812)

/stickers link:<tiktok url> size:<chat|sticker|emoji> finds every sticker in a TikTok video’s comments and gives them back as GIFs. Anyone in a server can run it (owner call 2026-10-27: no experiment, no role limit).

Adding a knob to /menu

/menu (cogs/settings.py, a MenuView of pages) is the one mod-config surface. Discord caps a view at 5 action rows, so each page budgets 4 selects + a nav row. Pick the home by the knob’s TYPE:

Wiring a new MenuView select is a 4-touchpoint checklist (miss one and the UI silently lies — saves but reverts on re-render, or doesn’t persist):

  1. class — a discord.ui.Select whose callback sets self.parent_view.selected["<key>"] = ... and await self.parent_view.autosave(interaction, "<key>").
  2. render — add_item(...) it on a page’s _render_page branch (mind the row budget; the nav row is always the last).
  3. state-load — read the saved value in _load_initial_state into state["<key>"].
  4. save-dispatch — an elif key == "<key>" branch in autosave’s persist step (most write db.set_setting).
  5. refresh — a branch in _refresh_select_defaults re-applying the picked value’s default/default_values on re-render (selects revert to construction-time defaults otherwise). (A select on the tune view instead persists in its own callback + refresh(), not these MenuView hooks — see _AskModelTuneSelect.)

Channel select defaults (#2733). Build a channel select’s defaults with self._channel_defaults(...) / self._single_channel_default(...), never a bare guild.get_channel_or_thread. discord.py evicts an ARCHIVED thread from the cache, and a ChannelSelect saves only what it showed, so a cache-only default deleted the thread from config on the next save. /menu resolves every configured id once when it opens (_resolve_configured_channels: cache, then fetch_channel). An id Discord answers NotFound for is dropped on the next save. An id whose fetch failed for any other reason is added back on save (_keep_unresolved_channels), because the select could not show it.

Nav: the menu chain is vibe → channels1 → channels2 → prediction → experiments → models → calendar → tune, walked by more ▸/◂ back. Tune sits LAST (it’s the raw caps/cooldowns/cadence NUMBERS editor, the most advanced page, so it’s placed past the friendlier ones — owner steer). Every config page’s forward nav reaches the experiments hub via experiments ▸ (on the prediction page, whose next page IS experiments, that single button replaces more ▸). The hop experiments→models is its own models ▸ on the experiments page (a _ModelsNavButton — it swaps in the separate Models View via open_models, not a flip_to page change), and the models page’s back is ◂ back (it steps back to experiments — open_models’s return_page = 4). The hop models→calendar is a [calendar ▸] button on the Models page that swaps in the separate calendar view (cogs.calendar_view.build_calendar_view — the scheduled-post day-planner, chill/yaps pages over a 30-minute working-hours grid), whose ◂ back rebuilds the Models page. The final hop calendar→tune is a [tune ▸] button on the calendar that swaps in the separate tune editor (cogs.tune.build_tune_view); tune is terminal (no onward button), and its ◂ back rebuilds the calendar. The page layout + nav contract is documented in the cogs/settings.py module docstring (keep it in sync).

Order status reconciliation

Opening the /order view lazy-reconciles in-flight orders against GitHub on every invocation (the check_fixes /ask tool reconciles too), and a background reconcile_sweep loop (@tasks.loop, ~5 min, started in cog_load) sweeps every configured guild’s in-flight orders so a shipped fix flips to SERVED + pings the reporter promptly after the deploy instead of waiting for someone to poke the order system. The sweep is per-guild gated on the master kill switch (an OFF guild is skipped, leaving its orders in-flight + served-ping pending) and fully fail-open per guild. For any non-terminal row with an issue_number, reconcile discovers its same-repository PR from GitHub’s cross-reference timeline and mirrors draft, review-ready, and merged work as ON THE STOVE, NEEDS A TASTE TEST, and PLATING. A new order is always checked once, including during the process’s first hour. Successful PR reads are then throttled for an hour to protect the GitHub quota; a failed fetch or database write is not cached and retries on the next sweep. A closed issue flips the row to SERVED. The close-on-deploy.yml workflow closes the issue as soon as the fix merges to main (deploy assumed to follow), so a closed issue is the served signal. It closes only an issue with the order label: other PRs also write “Part of #N” to name one part of a tracking issue or an epic, and before this rule the workflow closed 11 of those (epics #4412 and #2874 among them, with sub-issues still open). An unreadable label skips the close with a warning, because the job must never fail while Railway waits for every check suite on main. Reconciliation is read-only on GitHub: it never approves, merges, or closes work. It deliberately does not poll Railway for deploy success: Railway is configured to “wait for CI”, so a CI job that blocks on the deploy reaching SUCCESS deadlocks (Railway waits for the check-suite, the check-suite waits for the deploy — this is exactly what stranded #125/#127/#126). Deploy failures are surfaced via deploy_event / Railway logs, not by holding the order open. This replaces the previous out-of-band scripts/update_order_status.py flow, which broke whenever the GitHub Actions runner couldn’t reach Railway’s Postgres host.

Completing the circle (reporter ping on SERVED): every order stashes the channel it was filed/reported in (orders.reporter_channel_id, captured in _file_order, carried through a retry). When the reconcile flips a row to SERVED, _ping_reporter posts back in that channel tagging the original reporter (<@requester_id>) so the person who flagged the bug hears the fix shipped — the human-facing close to the file_fix → issue → PR → deploy loop. Fires once per order (guarded by orders.served_pinged_at, marked after a successful send OR a terminal can’t-reach so a courtesy ping never retries; an OFF bot leaves it pending). Fully fail-open and honors the master kill switch; emits order_served_ping. Applies to mod /orders and autonomous file_fix orders alike (both share _file_order).

Arcade: generated 3D fighters, the server side (#4012, part of #4005). Every member gets one rigged 3D fighter built from their Discord avatar (owner decision 2026-10-14), and Ring Kings and Deathmatch both show it in place of the procedural body. The pipeline is utils/arcade/fighter_models.py:

  1. The likeness conditions. A body drawn from a picture shows skin tone, body type and gender, so likeness_refusal checks every name in constitution.GENERATED_LIKENESS_CONDITIONS (#4013/#4018) through LIKENESS_CHECKS before anything is paid for: opt_out, own_pfp_only (the avatar URL belongs to that member), chart_artists, game_only, arcade_only, never_described, deletable. No tuple, or a name with no check, means nothing runs.
  2. GPT Image draws a full-body, front-facing, A-pose sheet with the avatar as its reference image (SheetClient). Grok fallback (owner, 2026-10-14: “whatever fails with openai, just retry with grok at highest level possible”): when that one OpenAI call fails for any reason and GROK_API_KEY is set, the same call (same avatar, same prompt) is made on Grok grok-imagine-image-2.0 at medium quality and 2k, and the Grok sheet goes through the same likeness gate. Only a missing avatar is never sent. A Grok failure names xai in its reason (safety_reject_xai is final). A row that failed at the sheet step on OpenAI before the fallback existed gets ONE more run once it is set up (fm.grok_retry_error, mirrored in the SQL of db.begin_fighter_model and db.fighter_backfill_candidates). Before 2026-10-14 there was no failover, on the view that the member agreed to the OpenAI pipeline only; the owner changed that. Likeness is the top requirement (owner, 2026-10-14): the prompt keeps the face, hair, skin tone, glasses, facial hair, accessories, colors and style, and a non-human avatar stays in its own style. The likeness gate (best_sheet) has a vision judge (ClaudeClient.fighter_likeness_score, a 0-1 number only) score each sheet against the avatar. Under 0.7 it draws again, up to 3 times, and keeps the best. When none reaches 0.7 the best is still used and the row is flagged needs_review for the owner (the compose self-gate rule: never drop the member). The pose gate (owner, 2026-10-16): some sheets copied the avatar’s pose (a hand on the face, a third hand on the face, a salute, a hand at the mouth) or drew all-dark eyes, and Tripo builds and rigs exactly that shape, so no client fix can remove it. A second vision judge (ClaudeClient.fighter_pose_score, Sonnet, two 0-1 numbers only, the sheet alone) returns limbs (two arms and two hands, both hands clear of the face, head and body, a full front body) and eyes (open eyes with visible whites). Only limbs is a gate. eyes is a preference among new sheets that pass both gates, and it never blocks a sheet or starts a rebuild: the coordinator’s sweep of all 152 stored sheets (2026-10-16) found 5 clean A-poses that failed only on eyes because the avatar’s own style hides them (a covered face, a blindfold, a face turned up, a glowing outline, eyes closed in a smile), and rebuilding those would cost about 525 credits and could lose the likeness. A sheet must pass likeness AND limbs (fm.SheetPick.passes); drawing stops at the first such sheet whose eyes also reach 0.7, else the one with the best eyes is kept (so an avatar with no visible eyes can cost up to 4 draws); SHEET_TRIES went from 3 to 4 because two gates pass less often on the first draw, and one more draw (one image, two judge calls) costs far less than a 105-credit Tripo build of a bad sheet. When no draw passes both, the one passing more gates is kept (then the higher likeness) and the row is flagged. The prompt now says the A-pose holds “whatever the pose in the picture”, with arms 30 to 45 degrees out, a clear gap between each hand and the thighs, and two open empty hands “clear of the face and head”, and asks for open eyes with visible whites. The kept sheet’s score is stored in arcade_fighter_models.pose.
  3. Tripo builds a v3.1 model (20,000 faces, detailed geometry, detailed PBR textures from the v3.5 texture model; a P1 low-poly model until 2026-10-16), rigs it as a biped with Mixamo bone names, and converts it twice: “hi” (about 20k faces, 2048 px textures; 1024 px until 2026-10-16, which left the face about 100 px across) for Ring Kings and “lo” (about 4k faces, 512 px; 6k until 2026-10-14, cut because 8 skinned fighters sat at the 10% phone rule) for Deathmatch and far views (TripoProvider, the v3 API at openapi.tripo3d.ai). ModelProvider is the interface for a second provider (Meshy).
  4. prepare_glb checks the shared contract: the Mixamo bones exist, at most 30k triangles, at most 6 MB (4 MB until the 2048 px textures), Y-up and 0.3-3 m tall. Rig pose check (2026-10-16, fm.pose_fault): a hand joint within 0.3 of the skeleton height (Head joint over the lower foot joint) of the Head joint, or more than 0.02 of it above its Arm joint, is bad_pose. Measured on the stored models: the two bad ones were at 0.18 and 0.24 (one also 0.06 above its Arm joint), every other at 0.3 or more with both hands below the shoulders. A third hand is not a joint, so only the sheet judge sees that one. A member’s bad_pose retry draws a new sheet (the cog never resumes it: the same rig would fail again), and a second bad_pose for the same avatar is final (run_pipeline(bad_pose_final=True)), so a false positive costs at most two builds; for an artist import the first is final. The coordinator measured the margins on all 185 stored hi GLBs: the check flags only the two hand-at-head models (0.18, 0.24), the closest good model is at 0.405, and every good hand is at least 0.21 of the height below its Arm joint. Pose audit, capped at 30 rebuilds ever (20 until the eyes rebuilds) (Arcade._pose_audit_tick, in the backfill tick): every ready member model with no pose score (built before the gate) is judged once, 4 a tick, the listed members first (fm.judge_order): its stored sheet by the pose judge and its stored lo GLB by the rig check (fm.pose_verdict). The four models the owner named are always rebuilt; they are listed as one-way hashes (fm.POSE_REBUILD_ALWAYS, fm.subject_tag), so no member id is in the repository. Eyes rebuild test (owner, 2026-10-16: “Test 2 first”). A close-up face render of every served model, scored by a vision judge, showed about a quarter with dark or smeared eyes. The owner chose to rebuild two first: fm.EYES_REBUILD (hashes again) names one member whose stored sheet has clear eye whites but whose model lost them, and one whose sheet itself is dark. Each joins the audit although its pose is already scored, until it owns a rebuild slot (so it is rebuilt at most once, under the same cap), and ranks with the listed members. Its rebuild fails closed on the eye score too (run_pipeline(require_eyes=True), sheet: eyes_gate): no new sheet with eye whites, no Tripo spend. The owner then asked to fix all the bad ones: the list now names 18 members (the human-style faces with dark sockets, smears, or closed or blank eyes; normal small eyes and the cartoon or animal styles with no eye whites by design are left out), and fm.POSE_REBUILD_MAX went from 20 to 30 (about 1,900 more credits). First run (2026-10-16): 10 rebuilt, 3 stopped at the eyes gate with no Tripo spend, and 5 waited. #4260 blamed the bot’s member cache, and that was wrong: the slots show the 5 runs started at about 07:05Z and a deploy at 07:12Z cut them off (one had reached the Tripo upload), so each slot read cut and the old rule treated it as a spent rebuild. A cut-off rebuild now starts again (below). The cache fix stays, because a member missing from the cache is a real case: a rebuild reads a member missing from the cache from Discord (_current_member, one REST read per server); a miss counts as an avatar failure, so a member who left is looked up at most _POSE_AVATAR_TRIES times. Each restart also judged the waiting members again (2-4 calls each), because the verdicts lived only in memory: a rebuild verdict is now stored in kv_cache (fighter_pose_verdict, 30 days, <model content hash>|<reason>|<score>) and read before a judge call; it counts only for the model it judged. The REST read happens at most once per _PFP_CHECK_S per member (a found avatar is reused, so a rebuild held back by a cost limit does not read every tick), and its misses have their own counter. Only when every unscored model has a verdict does it rebuild, in fm.rebuild_order (listed, then rig faults, then sheet-only failures by ascending score), so the cap cannot run out before the surest cases. A no_score verdict is asked again after an hour. A failing model is rebuilt through the normal run path (start_model_run(rebuild_pose=...): the cost limits, the run slots, the likeness conditions) with db.begin_fighter_rebuild instead of begin_fighter_model and fm.KeepServedStore, so the row stays ready and keeps serving the old model and sheet until the new model is stored; a failed rebuild writes only its spend and is not retried. A rebuild FAILS CLOSED on the pose gate (run_pipeline(require_pose=True)): when no new sheet passes it, or the judge never answers, the run stops before Tripo (sheet: pose_gate, no credits) and the old model stays; the slot stays used, so the member is not drawn again. An avatar read failure in a rebuild gives the slot back and leaves the row alone (a failed row would start normal runs outside the cap); the verdict goes back into the audit’s memory, so the next try needs no second judge call, and after 3 avatar failures the rebuild is given up (refused reason=pose_no_avatar). A rebuild paused at its start also keeps its verdict. The slot is marked started before the pose score is written, so a crash between the two leaves a slot the next boot reports. Each slot moves <uid> -> <uid>:started (after the start checks and the avatar read) -> <uid>:done; a slot still started at the next boot was cut off by a restart, so it is reported once (pose_audit reason=cut_off, count = cuts so far) and marked cut<n>. That rebuild starts again on the SAME slot (<uid>:retry<n> -> <uid>:started<n>) until it was cut _POSE_CUT_TRIES = 6 times (3 until 2026-10-17); the old model stays served meanwhile. A retry claim that does not start goes back to cut<n>, so the slot is never freed for another member and the cap holds; a slot still retry<n> at boot (a restart between the claim and the start) goes back to cut<n> too, with no cut counted. The audit waits _POSE_BOOT_WAIT_S = 120 s from the first tick that sees a server (the gateway is up) before it reads the slots, so a rebuild the old container is still finishing during a deploy is not read as cut off and paid for twice. A slot value the code does not know reads as a spent rebuild. Before 2026-10-16 a cut rebuild was never retried, and a deploy on a busy day cut 5 eyes rebuilds that way. The boot check records the members with a retryable cut slot (_pose_cut), and they join the audit like the eyes members (one judge call each, then the rebuild). Quality rebuild (owner, 2026-10-16: “top 25 most recently active members”). The owner found the models low in quality. Two causes, measured: P1 is Tripo’s low-poly series, and the hi texture was 1024 px for the whole body. A v3.1 build from one member’s stored sheet gave a clean face with eye whites at the same face count and the same 60 credits, so new builds use v3.1 and a 2048 px hi texture. fm.QUALITY_REBUILD (hashes) names the 25 members with a served model whose last message (member_engagement last_ts) was newest on 2026-10-16. Each joins the audit like an eyes member, gets the verdict quality from the judge’s score, and is rebuilt once on its own slot set (fighter_quality_rebuild, fm.QUALITY_REBUILD_MAX = 25, 35 since 2026-10-17 when the owner asked for 10 more (the next 10 by last message still on P1), then 36 for one member the owner named, then 41 for ranks 36-40; the rest waits on #4372, apart from the pose slots, so a member the pose audit already rebuilt on P1 gets it too, and it neither holds nor uses the pose reserve). The rebuild builds from the STORED sheet when the judge’s limbs score of it passes POSE_FLOOR (run_pipeline(reuse_sheet=...), generate decision=reuse_sheet): the likeness stays and no sheet is drawn. Otherwise it draws new sheets under the pose gate. No eyes gate on this rebuild. The stored sheet is NOT reused when it is known bad (a listed pose, a rig fault, or an eyes-listed member: the verdict’s score is then 0) or when it was drawn from another picture than the one the run stores (the row’s pfp_hash), so the picture check still sees a changed picture (review on #4297). About 105 credits each, about 2,625 for the 25. First run (2026-10-16/17): 20 rebuilt (14 from the stored sheet, 6 from new sheets), 5 given up. Other PRs deployed 8 times in the hour after the merge, so 21 rebuilds were cut off and the 5 reached 3 cuts. Each restart also paid Tripo again, about 1,000 credits wasted: a rebuild kept no state of its own (its task ids went onto the served row, mixed with the old model’s, so they could not be resumed). A rebuild now resumes after a restart: KeepServedStore(on_state=...) reports each saved Tripo task id and the kept sheet, and the cog keeps them in kv_cache (fighter_rebuild_run, 2 days: pfp, tasks, reuse or sheet, likeness, pose; a drawn sheet in base64 under its own key <uid>:sheet, written once). A new task id for a step drops the saved ids of every later step (fm.TRIPO_STEPS), so a remade model is never paired with the old rig. A reuse state builds from the row’s sheet only while the row is still for the same picture. A resumed sheet skips the eyes gate (it passed it when it was drawn). The writes are best effort, and an opt-out deletes the state (review on #4324). A cut-off rebuild of the same picture resumes its finished tasks (fm.Resume, generate reason=resumed), or, when no model task was made yet, builds from the kept sheet with no new draw. The state is deleted when the rebuild ends. _POSE_CUT_TRIES went from 3 to 6, so the 5 given up start again. At most fm.POSE_REBUILD_MAX = 30 rebuilds ever (20 at first, the coordinator’s bound; 30 since the owner asked for the eyes rebuilds; one durable kv_cache slot each, namespace fighter_pose_rebuild), each member at most once, so the spend is at most about 30 x 105 = 3,150 Tripo credits plus up to 4 sheets each. The audit stops on its own once every served model has a score. It shrinks a texture over 2048 px (1024 px until 2026-10-16; the client’s FMODEL.LOD.hi.tex and MAX_BYTES moved with it, and its file cache BUF_MAX went from 8 to 12 MB so two hi files still fit). Ring Kings loads “hi” on a phone too, so there FMODEL.load(..., {maxTex: 1024}) draws each texture into a 1024 px canvas before the GPU upload (shrinkTex): four 2048 px PBR maps are about 64 MB of GPU memory per fighter, and a low-memory phone can lose the WebGL context (review on #4297). It also turns the model to face +Z: the first live Tripo GLB faced +X, so the turn is baked into the vertices, the node transforms and the inverse bind matrices. A GLB that fails a check is not stored.

arcade_fighter_models holds one row per subject: kind member (a Discord user id) or artist (a chart artist, #4016, built later on this interface). The row holds the GLB, the sheet, the Tripo task ids (a re-download without a new paid run), the credits and the avatar key it was made from. A join re-reads it (Arcade._refresh_model): when the guild’s arcade gate is open and a GLB exists, the cog puts the URL in Room.models, and both start messages add "model": "fighter/<id>.glb?v=<hash>" (relative: inside the Activity /<path> maps to /arcade/<path>) to that human fighter. The AI and the bots never get one. GET /arcade/fighter/{id}.glb?v=<hash> serves the GLB (model/gltf-binary). It answers only the exact v hash, and only for a member of a server where the feature is on (the gate is cached 60 s); anything else is a 404, so a Discord id alone fetches nothing. v is checked with a hash-only read before any GLB bytes are loaded. Cache-Control: private, immutable. The cog keeps the last 8 in memory, and an opt-out during a read is never cached. The client appends &lod=lo for the small level of detail; no lod (or any other value) serves “hi”. A server whose arcade gate closes has model cleared from its open rooms on the next backfill tick. The route runs on the arcade thread and reads through to_main.

Runs start from the backfill (Arcade.backfill_once, every minute: the most recently active members first, by each member’s last message member_engagement.stats.state.last_ts (owner, 2026-10-15: “order by last active”; updated_at is the seed time, not activity; and only members whose last message is at most _BACKFILL_ACTIVE_DAYS = 90 days old: the owner paused the older members on 2026-10-15, #4060, and a member of any age still gets a run the first time they join a room), in a server with the feature on, who have no row yet), from a join with no model, and from a new picture at an open. The picture check at an open (owner, 2026-10-15: “detect pfp change on open and fire regeneration of avatar async”). The open IS the join: the socket’s first hello (the Activity and the web link alike) places the member in a room, and _serve calls on_join -> Arcade.joined -> _refresh_model as a background task, so the check never delays the hello or the welcome. There is no earlier moment that knows the member: the Activity’s /arcade/api/token exchange returns a token and names no user. _refresh_model gives the room the stored model URL first, then, at most once per _PFP_CHECK_S (10 min) per member, reads the member from Discord (_current_member: one fetch_member REST read, the gateway cache when the read fails), and starts a run with trigger=pfp_open when the picture key differs from the row’s pfp_hash. The old model shows until _model_ready stores the new one. The fighter is built from fighter_avatar(member): the server avatar when the member set one, else their own avatar, never the default avatar. The lobby and the cards already show display_avatar (server avatar first); until 2026-10-15 the fighter read member.avatar only, so a member with a server avatar got a model of a picture the server never showed, and a new server avatar never started a run (30 days of Axiom: zero join or pfp_change events). fm.avatar_owner reads both URL shapes (/avatars/<id>/ and /guilds/<g>/users/<id>/avatars/), so own_pfp_only holds for a server avatar: the member set it, so it is their own picture. A member in two servers with two server avatars can alternate the key; the 24 h member cooldown limits that to one run a day. A picture change inside the cooldown is refused (refused reason=cooldown trigger=pfp_open) and is checked again at the member’s next open after it. The 90-day backfill pause does not apply: a member who opens the arcade is active. Several runs go at once (owner, 2026-10-15: “all in parallel if needed”). fm.PARALLEL_RUNS (5, a constant: the owner asked for no /menu knob) sets how many: Tripo’s P series runs 5 tasks at once per account, and a full pool’s 429 (code 2000) is waited out with Retry-After, so other jobs on the key queue instead of failing. Arcade._model_runs is a _RunSlots, a semaphore whose size can change without stopping a running run. The cost checks count every run already in flight: the credit cap adds _RUN_CREDITS_EST (105, measured) for each of them plus the new one, and the balance floor takes the same amount off the cached balance, so several starts in one tick cannot all pass on one read. An artist import takes its slot before it begins. Each tick starts runs until that many are queued or running, and skips a member already in flight. Each run builds on its own TripoProvider.fork(): the fork uses the same HTTP sessions, key, breaker and rate limiter, and has its own credits, task ids and last output. Before 2026-10-15 one run went at a time every 3 minutes, and the runner was idle about half the time (a run takes about 3.5 minutes). Before a run is queued AND again when it starts, _global_refusal checks the clients’ breakers, the balance read (a failed read pauses, it never skips the check) and the cost limits; a pause defers the row with no try counted. A failed run is retried after 1 h, 6 h, 24 h, 24 h, then turns final (flagged needs_review); a content refusal (banned, safety_reject) or not_biped is final at once. The retry count and final reset ONLY on a new avatar (or an opt-back-in), so a failure before the first Tripo task cannot retry for ever (#4019 review F; tests/test_arcade_fighter_models_pg.py checks the SQL on a real Postgres). Each Tripo task id is saved when the task is CREATED and its credits when it finishes, so a failed run counts its spend and a retry with the same avatar resumes from those tasks and the stored sheet instead of paying again; a short outage on the re-read of a saved task is a retryable failure, never a second payment, while a saved task that can never be used (expired, refused by Tripo, unfinished after 5 minutes) is paid for again and a saved banned / not-biped task is final at once. A run cancelled mid-task, or a saved task the run drops, still adds that task’s credits when it ends (credits only, never its task id). A join also retries a failed avatar change that kept the old model. A deploy defers the in-flight run for an immediate retry. The backfill skips a member whose refusal is personal (a cooldown, a retry wait) and stops only on a global one. Cost limits are the 3d fighters Tunables, read from the MASTER guild only because one Tripo account serves every server: models a day (1,000) and credits a day (100,000), because the owner set no budget limit (with 5 runs at once the backfill can make about 2,000 a day, so a full day can reach the 1,000 cap; the start check of each run reads both caps again), and a balance floor (100, a pause, not a failure, low so that Tripo’s auto top-up fires first). A member waits 24 h between runs. The usage poll reports source tripo (the balance, and the day’s spend against the cap). The opt-out (owner steer 2026-10-14: no new command; moved INTO THE GAME 2026-10-15, owner: “get rid of my 3d fighter button”, and “add a confirm button” against a delete by mistake): My fighter in the game (web/arcade, #mePanel). It shows the player’s own status and offers Opt out of 3D fighters or Opt back in. Since 2026-10-17 (owner: “put my fighter in fighters when your name pops up”, “so you can opt out, but keep confirmation”) the panel opens from the Fighters page, not from a lobby button: an Opt out button (#rOut) shows in the top left corner of the 3D view when the player’s own tile is picked (under the name until a later 2026-10-17 change; there it made the 3D view about 95 px shorter), and it opens the confirm screen straight away (openMeConfirm, no status request, and its Cancel closes the panel). A My fighter button (#rMe) shows in the page header only when the members came back with no tile of the player’s (no ready model: opted out, no picture, still building), so Opt back in stays reachable. A finished opt-out or opt-in loads the Fighters page again (rosterReload), so a deleted fighter leaves the strip. Opt out opens a confirm screen (“This deletes your 3D fighter and the picture it was made from. …”) with Cancel (the focus, never disabled) and a red Delete my fighter that takes no tap for its first 800 ms. The client talks over the websocket with {"t":"me","op":"status"|"out"|"in"} (ArcadeHub._me): the message carries no member id, so the hub always acts for the socket’s own member; an out without confirm: true is refused (err why=confirm); a per-socket bucket (ME_PER_S 0.2/s, burst ME_BURST 4) answers err why=slow past it. The hook Arcade.fighter_me runs the SAME _opt_out_member / _opt_in_member as the old panel, so the deletion is unchanged: it cancels the member’s queued or running run, deletes the models and the sheet, drops the cached GLB (_drop_glb) and the open rooms’ model (_clear_models), and keeps an opted_out row so the backfill skips the member. The opt-out works even where the server has the feature off (state off). Opt back in turns that row back into a due retry (it keeps the spend history, so the daily caps still count it) and, from the lobby, starts the build at once through _refresh_model (every gate and cost limit applies). The reply is {"t":"me","state":...,"done":...} with state ready / building / none / no_pfp / failed / final / out / off / error (_me_state); the status read runs after the change in its own try, so a failed read still reports a change that happened as done (Codex review on #4154). A welcome while the panel is open asks for the status again, so a reply lost with a dropped socket cannot leave the panel busy. A match start and Escape close the panel. The hub marks its own refusals me: true (and reports a failing hook with emit_error), so only those end the panel’s wait. An in from a member who is not opted out changes nothing and answers without done; a real opt-in clears the member’s picture-check time so the build request runs now (the cost limits and the gap between runs still apply). The old panel’s _status_line reads _me_state, so both surfaces share one state order. The /fight card no longer shows My 3D fighter: lobby_view has no fighters argument, and a card edit drops the button from an old card. FighterButton (custom id arcade:fighter) stays registered, so a button still on an old card opens the private panel (fighter_panel), which now also says where the setting lives and gives its Opt out the same confirm step (_FighterConfirm: Cancel / red Delete my fighter). ring_kings_models is graduated always-on (experiments.GRADUATED_ALWAYS_ON, owner 2026-10-14: “ship on”, “dont need experiment here”) after two live dry runs, so the arcade gate and the master kill switch are its only per-server switches.

Arcade: chart artists as AI fighters, and member models on the AI (#4016, #4030). The owner chose real top rappers and pop stars as generated fighters (#4014, option 3). GPT Image refused to draw real people (5 of 6 calls, public-figure and other categories), so the owner approved Grok (grok-imagine-image-2.0) with each artist’s official photo (the bot’s Deezer art, or its Wikidata portrait where the Deezer photo is an art shot) for fully clothed, arcade-only sheets. The sheets and the Tripo builds were made once, by hand, with scripts/arcade_artist_pilot.py; no image or model of a real person is in the repo. In a bulk pilot run, an error ends only that artist and is recorded in its result; model.json is written as soon as Tripo returns each paid task id, so a crashed run leaves the ids to resume; the task poll and the file download retry a dropped connection (retry_http), and the paid create POSTs do not retry (#4409). The pilot builds on fighter_models.TRIPO_MODEL (v3.1 with the detailed geometry and the v3.5 texture model since 2026-10-17; P1 before that), so a pilot run and a member run use the same settings. artist_roster.PHOTO_SOURCE picks each artist’s reference photo: Deezer by default, Wikidata (WIKIDATA_IDS) where the Deezer photo is an art shot. One artist changed to Wikidata on 2026-10-17 (#4405), because the Deezer photo had the eyes closed. utils/arcade/artist_roster.py holds the roster (the bot’s own chart data), a fixed subject id per artist (one block from 4,016,001 up, far below any Discord snowflake, so is_artist_id and the route tell them apart), and TRIPO_TASKS: each artist’s finished Tripo task ids. fighter_models.import_artist stores an artist by RESUMING those tasks (TripoProvider.build(resume=...), no credits), after the likeness conditions (artist_request: subject kind artist, on the roster). The import is resume-only (build(create=...)): a saved task that expired fails the import with not_reusable instead of paying for a new task, the row turns final, and the artist stays absent. The saved “lo” converts were made at TRIPO_LO_FACES (4,000 faces since #4035); when that differs from fighter_models.LODS, the import drops convert_lo from the resume and converts again (about 10 credits), so a LODS change never serves a stale file. Each artist row is stored under artist_model_key (artist:<slug>:lo<faces>:<TRIPO_TASKS_MODEL>, plus :r<n> when artist_roster.TRIPO_TASKS_REV names that artist). TRIPO_TASKS_MODEL is the Tripo model the saved task set was built on, so a regenerated set changes every key and every artist imports again. TRIPO_TASKS_REV is a per-artist revision, so one rebuilt artist imports again and the others keep their rows. An import only resumes saved tasks, so it costs no credits. A stored row with an older key stays served and is imported again, and a retry resumes a convert an earlier try already paid for (the row’s provider_tasks). Arcade._artist_tick (each 1-minute tick, BEFORE the member backfill and in its own try, so an artist failure never skips the backfill; at most one import per tick, and only when a run slot is free: it takes that slot while member runs go) reads the stored artists into _artist_urls, imports a missing one, and deletes (db.delete_fighter_model) an artist whose newest week in the bot’s chart history (db.chart_history_newest_week_by_credit, one grouped read per credit string, then chart_history._credited, the strict credit match) is more than 12 months old (fighter_models.in_chart_window). A failed read neither imports nor deletes (refused reason=chart_unread; a stored model stays served), a read with no week only skips the import, the newest week is grouped per credit string, so a prolific act’s rows never push a newer featured credit past a limit, a stored artist row whose id is no longer on the roster is deleted (_sweep_departed_artists, reason=off_roster, and is_artist_id stops the route serving it), no Tripo key means no import (reason=unprovisioned), an open Tripo breaker waits (reason=breaker_open) and a Tripo outage during an import is deferred without counting a try, and an opt-out during the AI’s face reads drops that member’s model (_glb_gen). Stored artists load without a Tripo key; only the import needs one. Each artist’s step is isolated (one failure leaves the rest). A full pass holds for 24 h (_ARTIST_RESCAN_H); then the chart window is checked again, so an artist who leaves it while the bot stays up is still deleted. The artist route keeps the member route’s rules: the exact v hash first, the first guild with the feature on is enough, and a delete during the blob read (_glb_gen) is never served or cached. Arcade: the 32-artist roster (#4016, owner approval 2026-10-15). The roster grew from the 16 approved first to 33: the 16 rappers and the 16 pop/R&B singers with the most 12-month chart points in the bot’s own history (Hot 100 (101 - rank) plus Billboard 200 (201 - rank) per week, 2025-10-18 to 2026-10-17, by the strict credit match; ChartArtist.points), plus Lil Wayne. Lil Wayne is outside that top 16 (707 points), but he was live, and leaving the roster makes _sweep_departed_artists delete his model, which cannot be undone; the coordinator kept him. The 17 new artists take ids 4,016,017 to 4,016,033; every shipped id is unchanged. The build was the same as the first 16: a Grok sheet from the official photo, a sheet check, a person’s eye check of each sheet and model (front, side, back, a posed rig test), then Tripo at LODS, with the task ids in TRIPO_TASKS. Two style lines were made explicit after the eye check, because the first sheets showed a bare midriff and an open shirt (Tate McRae, Harry Styles). The pilot now reads the reference photo by the PINNED id (DEEZER_IDS / WIKIDATA_IDS), the same id the portrait route reads, never by a name search, and run builds 2 artists at a time by default, because the member backfill shares the Tripo account (5 tasks at once). Wikidata is the reference for the 7 new artists whose Deezer photo is an art shot, a group photo or too dark (PHOTO_SOURCE). EsDeeKid has no Wikidata photo, and his public photos are masked, so his fighter is masked too. All 17 were inside the 12-month window on 2026-10-15 (oldest: EsDeeKid, 2026-04-11), so in_chart_window admits them; an artist who leaves the window later is deleted as before.

The AI cast (Arcade.pick_ai_fighters, both games): where the guild’s arcade gate is open (ring_kings_models is graduated always-on since #4035, so the arcade gate and the master switch are the only switches), about ARTIST_SHARE (0.5) of the AI fighters are artists with a stored model (artist_slots: the Ring Kings AI is an artist half the time; about half the Deathmatch bots), shown by their real name and their official photo (see the next paragraph) and no lines; the rest wear members’ faces as before. An opted-out member is never a bot face (the face is swapped for another member, or Toots), and a member whose row cannot be read is skipped (fail closed). A member’s face brings that member’s stored model (#4030, owner decision): the cog adds it to room.models only when the member has a stored model, and Room._with_model gives an AI fighter its artist model (room.artist_models) or that member entry. Artist fighters stay in the arcade: no X post and no clip shows one (arcade_only); the lobby and result cards in the server’s own arcade channel name them like any fighter. The Deathmatch client draws models only after #4017.

Arcade: the chart artists’ portraits (#4016 follow-up). An artist fighter first showed only a letter tile where a member shows a Discord avatar (the lobby, the HUD, the scoreboard, the Ring Kings corners, the Deathmatch result card); the owner asked why. utils/arcade/artist_portrait.py now gives each roster artist the official photo each fighter sheet was drawn from: artist_roster.PHOTO_SOURCE first (Wikidata for 9 artists whose Deezer photo is not a usable portrait), then Deezer. The lookup uses PINNED ids (artist_roster.DEEZER_IDS, WIKIDATA_IDS, checked live on 2026-10-15), never a name search, because “Drake” also matches a 174-fan Deezer act. deezer.artist_picture_by_id reads one artist id, and the returned name must still equal the roster name; wikidata.portrait_by_qid reads one item, which must pass is_music_act. A wrong id fails closed. The download reads only the Deezer CDN and Wikimedia hosts (the final host after Commons’ redirect is checked too; thumb.wikimedia.org joined the list on 2026-10-15, because Commons redirects a resized photo there, and without it three Wikidata-first artists silently fell back to their Deezer art shot), caps the size, and to_square_jpeg cuts a 256 px square JPEG; bytes that do not decode are dropped. Arcade._portrait_tick (the 1-minute backfill tick, after _artist_tick, in its own try) fetches the photos of the artists in _artist_urls, at most 4 a tick, and holds them in memory. artist_portrait.resolve also keeps each FOUND portrait in kv_cache (namespace arcade_portrait, 7-day TTL, the JPEG as base64), so a restart reuses it with no outbound read (#4756). Before that, a restart fetched every photo again: on 2026-10-24 the deploy queue booted the bot 12 times in 12 minutes, it read the same 23 Deezer ids 97 times in 30 min, and Deezer then answered 403 to the bot’s IP. A miss is not stored, and the cog waits 30 min (RETRY_AFTER_S) before the next try. pick_ai_fighters puts artist/<id>.jpg?v=<hash> in the artist’s Profile.avatar. It is RELATIVE and same-origin, like model_url, because a Discord Activity blocks a third-party CDN URL that has no URL mapping. GET /arcade/artist/{id}.jpg?v=<hash> serves it (image/jpeg, public, immutable); a non-roster id, a photo not held, or a wrong v is a 404. The client needs no new path: person() / ensureImg in app.js and deathmatch.js load the URL like any avatar. The Discord result card (_load_avatars) takes the held bytes directly, because _fetch_avatar reads only the Discord CDN. The fail direction is absent over invented: with no photo the avatar is “” and the letter tile stays. Constitution: GENERATED_LIKENESS_CONDITIONS covers GENERATED bodies; this is a real, official photo, the same kind of image the bot already posts on its music cards, so the conditions do not apply to it. It stays arcade-only all the same: only the arcade route and the arcade’s own Discord cards show it, and no X post or public clip uses it. constitution.py is unchanged. Telemetry: one fighter_model action=portrait event per source tried (ok, duration_ms, provider deezer|wikidata, reason no_photo|download|bad_image|fetch, bytes), or one cached=True event (in detail) for a portrait read from kv_cache. _turn accepts float noise in a bone’s scale (rtol 1e-3): Tripo wrote 0.99998688 on one axis of a live model, and the default tolerance refused it. This also applies to member models.

Arcade: the Fighters page (#4191, owner ask 2026-10-15: “add it to game menu a page you can see all characters”). A Fighters button in the lobby (#bRoster, next to End game; the My fighter panel opens from inside the page since 2026-10-17) opens a full-screen panel (#rosterPanel, the same pattern as #mePanel) where a player browses every 3D fighter, like a fighting game’s character select. It lives ONLY in the arcade page (game_only / arcade_only): nothing posts to Discord or X, and no public URL lists it.

Arcade: memory cities, and a city per Ring Kings round (owner asks 2026-09-26). Deathmatch adds up to 16 cities (MAX_EXTRA; 8 at first) from the server’s memory (utils/arcade/memory_cities.py). /fight reads the server’s last 400 DAILY memory notes (CITY_NOTES; 90 at first) (Arcade._refresh_cities, at most once per 6 h per server, waits at most 1 s, then the read finishes in the background) and keeps the result in ArcadeHub.guild_cities; each new room copies it to Room.extra_cities. The rules: only daily notes (room-level summaries behind the memory fence; user_facts is never read, so no city is tied to one member); only cities in GAZETTEER (big, well-known cities; names that are also common first names, such as Austin, Charlotte, Brooklyn or Phoenix, are left out so a member’s name never becomes a city); a case-sensitive whole-word match NOMINATES each city, so nothing outside the list is invented; then Haiku CONFIRMS each nomination (CB-32, memory_cities.confirmed_cities, classify purpose=memory_city_check, PLACE / OTHER, cached 30 days per hash of city + sentences): it reads only the sentences of one note that name the city, and OTHER (the name is inside a person’s name or a song, album, film or business title: Memphis Bleek, “Ran to Atlanta”) removes that note from the city’s count; no verdict, no client or a failed step keeps the regex hit (a wrong arena is harmless). Haiku only, never Jev: the input is memory text. Only cities with 2+ regex notes are asked about; at least 2 confirmed notes per city; the 16 built-in cities are skipped. A memory city has the key x-<slug>, the arena “Downtown”, a layout picked from its key, and a skyline painted by paintGen in the client over the look of a plain built-in city (GEN_BASE). The lobby message carries extra ([{k, name}]) and start carries cname. The first read on the live data found Madrid, Atlanta, Liverpool, Chicago, Philadelphia, Barcelona, Milan and Paris for the main server. Ring Kings now fights each round in a different city: Room.start samples one city per round from the same list (built-in plus memory) into Room.rk_cities, the start message carries cities, and the client redraws the backdrop when the round changes (the city’s sky and landmark above the crowd, the city name under TOOTSIES on the banner). Telemetry: arcade_room action=cities (count, duration_ms, and since CB-32 detail.asked = notes the model was asked about and detail.dropped = notes it called OTHER; ok=false + error on a failed read), in the arcade_client panel; the model calls are claude_api purpose=memory_city_check. Owner asks, same day: the Sound and ? buttons are now small icons in the bottom-left corner (.ib), because the Discord bar covers the top of the page. Sound is a speaker drawn in SVG, with waves when on and a cross when off. The Ring Kings city view is bigger: the city fills the top 94 rows at 2/3 scale, and only 2 faded crowd rows (alpha 0.5) sit in front of it. With no cities in start, the old 4-row crowd and dark arena come back.

Arcade: a harder Hard (owner ask 2026-09-26). Live, a member won Ring Kings on Hard by TKO in round 1. The cause: a player’s jab winds up in 5 frames, and the AI reacted 2 to 8 frames into a wind-up, so most jabs landed first. A scripted player who only pressed jab (and power when full) won 200 of 200 simulated fights against Normal and against the old Hard. Hard now has its own Ring Kings brain (FighterSpec.hard, the HARD_* constants in ring_kings.py): it reacts 1 to 3 frames into every wind-up, slips 62% and blocks a jab 28% more, is ready again 3 frames after a clean slip (a player needs 25), punches only into an opening (the player’s recovery or stun), and counters more. Simulated over 200 fights: the jab spammer wins 0; a scripted player with a 200 ms reaction and 85% correct dodges wins 25%. Normal is slightly harder too (owner ask, same day): on 55% of the player’s punches it reacts inside the wind-up (NORMAL_READ), it is ready 7 frames after a clean slip, it punches into an opening at 22% per frame, and it holds 85% of its own punches while the player winds up. Over 200 fights the jab spammer now wins 173 (was 200) and the 200 ms player 177. Deathmatch Hard: BOT_OVR_HARD 95 -> 110, and a Hard bot turns at 6 rad/s (normal 4), waits 0.55 to 1.0 s between shots (normal 0.8 to 1.5), hits at most 80% (normal 72%), and counts a player as 1.6 times closer when it picks a target. A player who stands still against 7 bots now lasts a median 30 s (old Hard 44 s, Normal 68 s). The help box is short and per game (owner ask 2026-09-26): openHint shows only the game in the lobby or in play (Ring Kings, or DM.hintHtml for Deathmatch), only the controls for this device (COARSE: phone or keyboard), and 2 to 4 rule lines. The first help opens once per game (rk_hint_online for Ring Kings, rk_hint_dm for Deathmatch) when that game is in the lobby. Memory cities look different from each other (owner ask 2026-09-26: “some cities too similar”). Before, every memory city had the same skyline and one tall tower. Now paintGen draws a landmark, a sky and a ground for each city. MEM_LOOK in web/arcade/deathmatch.js gives EVERY gazetteer city (166) its own landmark or scene (owner ask, same day: “all cities have a landmark or scene”; the client test fails when a gazetteer city has no entry) (for example Paris the Eiffel Tower, Seattle the Space Needle, St. Louis the Gateway Arch, Memphis the pyramid, Barcelona the Sagrada Familia spires, Las Vegas neon and the Strat), one of 6 skies (SKIES) and one of 7 grounds (flat, river, lake, hills, mountains, beach, desert). The 33 landmark and scene shapes are in MARKS (the last 6: palms, stadium, volcano, atomium, fountain, minar). A key with no entry (only possible if the list grows without one) gets a sky and a ground picked from a hash of its key, and no mark (since 2026-10-15; before, one of 8 plain shapes, which invented a landmark). The walls and floor come from a built-in city that suits the ground (GROUND_BASE). test_every_memory_city_look_is_a_real_city_with_a_painter checks that each mapped key is a gazetteer city and each shape has a painter.

Memory-city marks are at their true size (owner, 2026-10-15: “things like the pyramids or the towers should be true to life”; PR “Arcade: every memory-city landmark at its true size”). Before, each mark was drawn at its painter’s own size, about 60 to 112 strip pixels, whatever the real landmark: the Puerta de Alcalá (19.5 m) stood as tall as the Petronas Towers (452 m).

Deathmatch turns slower and the maps have more cover (owner ask 2026-09-29: the aim swung left to right too fast, and the maps were too open). In web/arcade/deathmatch.js, mouse look is MOUSE_SENS = 0.0016 rad per pixel (was 0.0035) and the arrow/Q/E keys turn at a flat 1.6 rad/s (was 2.6). The flat key rate made a turn-around slow (owner, 2026-09-30), so a held turn key now ramps: it starts at KEY_TURN_MIN = 1.2 rad/s and eases up to KEY_TURN_MAX = 4.2 rad/s over KEY_RAMP_S = 0.5 s. A 0.1 s tap turns about 7 degrees; a held key turns 180 degrees in about 0.9 s. Changing direction or letting go starts the ramp again. Mouse look stays linear. Touch look keeps its old rate through TOUCH_LOOK. In utils/arcade/deathmatch.py, every stage got 2-cell wall pieces: six on beach, harbor, castle, plaza, hills and market, three on downtown and river, one on canals, placed where they cut the longest straight sight lines. Each piece stands free, with open floor on every side, and every stage stays one connected floor. The average sight distance from a floor cell dropped 3-20% per stage (plaza 6.3 to 5.2, beach 6.4 to 5.3). A 10-bot round still ends with one fighter left on every stage. Deathmatch stages are places, not mazes (owner ask 2026-09-28: “more unique features across the stages so it’s not just a generic maze”). The prototype’s four maps were mazes of one-cell walls, shared by all 16 cities. Now there are nine stages in dm.LAYOUTS, one kind of place each: A downtown (NYC, Cleveland), R river (London, Boston), B beach (Miami, Jamaica), W canals (Amsterdam), H harbor (Sydney, Toronto), K castle (Cardiff), P plaza (Dubai; Houston until it moved to the S suburb stage on 2026-10-13), L hills (LA, San Francisco), M market (Lagos, Accra). A memory city gets one of dm.MEMORY_LAYOUTS (A R H P L M) by its name. The cells:

The client (web/arcade/deathmatch.js) reads the same codes (KIND_CH). Each city has a kit that dresses them: the second ground, a water color, two low-cover painters (LOWS), and a prop painter (PROPS). The raycaster passes low cover and draws each box at LOW_H (0.42) of a wall; a sprite behind a box is cut at the box’s top (lowClip). A prop is drawn PROP_H (1.5) walls high, and below row PROP_CLEAR it is only a trunk or pole. The reason: a shot passes a prop, so a prop must never hide a fighter the server lets you hit (the first cut drew a full-height tree that covered a bot completely). Measured with 10 bots, 20 seeds per city: every round ended on “last”, as before, but the median round is 13 to 22 s against 21 to 47 s on the old maps, because water and low cover give longer lines of sight. _reachable now starts at the first open cell, because (1, 1) is water on the harbor stage.

Deathmatch in 3D, with four guns and health (owner ask 2026-10-12: “match Hood Warfare or better”, “our maps and guns need upgrade”). The reference was a viral Unreal-engine FPS set in Chicago blocks: dusk light, fog, real houses, an AK with a red dot, health, magazines and reloads.

Gun Damage Shot gap Magazine Reload Spread hip / ADS (rad)
9mm 34 0.27 s 12 1.25 s 0.025 / 0.006
AK (auto) 25 0.10 s 30 2.2 s 0.03 / 0.008, bloom up to +0.05
Pump 12 x 10, falls to 30% at 9 tiles 0.9 s 6 2.5 s 0.16 / 0.10
Sniper 100 1.4 s 5 2.6 s 0.09 / 0

Deathmatch maps 50% bigger, 3:00 rounds, PFP name tags (owner ask 2026-10-12, #3985: “make the maps like 50% bigger, the games end pretty quickly, very representative of the cities and the avatars, use the PFPs of everyone in the Discord properly, make sure perf is good”).

Axiom showed the problem first: in 201 rounds over 30 days, most were one member against 9 bots, and the median round lasted 19 s. A headless sim found the reason. On the 24x24 maps every fighter spawned about 6 tiles from another, often in sight, so the first kill came the moment the bots’ 2.5 s spawn hold ended.

Map Draw calls before After
Houston (lamps) 324 123
Chicago 404 137
Sydney harbor 570 179

Deathmatch bots hunt (owner, 2026-09-26: “does the AI seek you? a lot of ties or time run out”). Before, a bot with nothing in sight walked to a random cell, so the last few fighters could miss each other until the clock ran out. Live, 12 of 67 finished rounds (18%) ended on time or in a draw. Now, after HUNT_AFTER_S (40 s) or once HUNT_ALIVE (3) or fewer are alive, a bot with nothing in sight walks to the nearest fighter (_hunt_goal), counting a player as HUNT_PREFER (2) times closer. Simulated with a player who hides, 120 rounds each: with 4 fighters, time or draw went from 50 to 0 and the median round from 92 s to 28 s; with 8 fighters, from 60 to 0 and from 117 s to 35 s. /fight takes one optional opponent (owner ask 2026-09-26, “challenge mode”; it shipped with 4 in #3716 and was cut to 1 the next day, because everyone else uses Join game). challenge_targets drops empty options, bots, the caller and repeats. The card carries the ping (“@Bo @Cy, Ana is calling you out. Tap Join game to accept.”) with allowed_mentions limited to those users. Join game is the accept, and it stays open to everyone. When a room is already open in the channel, the caller joins it and the ping goes out as a reply to its card, so there is still one room per channel. A card bump reposts the card without the ping, so nobody is pinged twice. Telemetry: arcade_room action=challenge (count; ok=false + error when the reply failed). Game sound starts OFF (owner, 2026-09-27: the game paused their music). The setting moved to a new localStorage key, rk_sound, which is ‘1’ only after the player taps the speaker; the old rk_mute key was written as ‘0’ on every page load, so it could not tell who had chosen sound. With sound off the page creates no AudioContext, so it never takes the phone’s audio. Discord’s own app can still pause music (for example in a voice call); the page cannot control that.