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-5.6-sol, $4/$20, the Opus 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-5.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-5.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_SURFACESsurface_modelclaude_client.MODEL_LABELS, passed via client.ask/recap/discourse(model=...) / cogs.memory._memory_modelmemory_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%”) — 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 4.5, Sonnet 5, Opus 5, Fable 5.1, gpt-5.6-terra / gpt-5.6-sol / gpt-6-astra (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 six labels (tunables.MODEL_CHOICES = fable / opus / sonnet / gpt-max / gpt / gpt-cheap; ASK_MODEL_CHOICES is now an alias): 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, so the allowlist keeps it on the Haiku judges and drops it for the Sonnet ones), _THINKING_ON_BY_DEFAULT (who THINKS when thinking is omitted — Opus 5 does, Sonnet 5 does not, so the thinking-OFF path says disabled out loud for Opus alone), and _ALWAYS_THINKING (Fable 5.1, 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; 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.

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.

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. 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 original wire wording via claims_reentry since the settled rewrite strips “re-enters”); 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 by BOTH lanes that ship a unit (ScheduledPoster._gated_unit_walk and EventPoster._maybe_event_post, which take it through the one _history_gate helper so they cannot drift). 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:

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.

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. 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 armlist_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 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”.

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. renderadd_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.)

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. 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).