a discord bot for the tootsies server. ask, recap, discuss, ship features by typing.
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.
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_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%”) — 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/):
ask.py, the @Toots mention handler (the /ask slash command was removed — mentions, plus voice notes via cogs.voice, are the ask surface now; both run the same produce_answer/_answer pipeline). Uses the per-user ask rate-limit counter. Fail-open on DB errors (better to answer than go silent). Natural-language routing: a plain typed mention with words (not a reply, not pointed at an image) that clears a cheap local cue pre-filter (_looks_routable, a substring scan that gates the paid call so ordinary questions/banter never pay for it) is classified by Haiku (claude_client.classify_mention_intent) into ask|recap|discourse|icebreaker|music — so “@toots catch me up on the last hour” runs recap (period extracted), “@toots whats the discourse” / “@toots give us a conversation starter” run discourse/icebreaker, “@toots give me another recommendation” / “@toots recommend me a song” runs the music drop (a track rec with a link, replied in place) — instead of hunting for the matching slash command. Routing the music intent up front (not letting the ask path answer + voice it) is deliberate: a “recommend me a track” mention is a music pick, not a question, and answering it through /ask was producing voiced replies to typed requests (the #over-voicing report). The classifier owns the path choice so a rec request lands on the music surface instead. Routed intents reuse the destination cog’s own pipeline via get_cog (recap’s produce_recap, discourse’s produce_discourse / produce_icebreaker, music’s _compose public entry points) and charge the destination’s rate-limit bucket (recap → per-user recap; discourse/icebreaker → server-wide discourse; music → per-user ask, since music has no bucket of its own and the mention would otherwise have been an ask), mirroring the slash commands, which still exist. Abuse detection (_maybe_handle_abuse) gates routed intents too, so harassment can’t slip past the warn/silence escalation by phrasing itself as a recap. The classifier defaults HARD to ask and fails open to ask, so a real question/banter (or a Haiku outage) always just answers. Emits mention_routed. Channel pings (2026-09-03): on_message swaps each <#id> token in the question for #name (_resolve_channel_mentions) so the router and the search_messages tool’s in_channel filter see the channel’s name, and a recap verdict on a message that pings ANOTHER channel (_names_other_channel) is overridden to ask (emits mention_routed intent=ask reason=other_channel): recap only summarizes the current channel, so “look at the polls in #main-stage and make a graphic” went to recap twice and answered “can’t see into #main-stage from here”. The router prompt carries the same rule (another channel, or BUILD something from the chat = ask); the override is the deterministic backstop. Injects a fixed long-term-memory slice (the tier mix) plus the PROACTIVE [relevant memory] block (_relevant_memory: a vector search over the whole corpus keyed on the incoming message). Every proactive line carries the note’s date range WITH the year (_memory_when, shared with the search_memory formatter, #2181): the block is recency-agnostic by design, so undated lines left the model unable to place a note on the room’s timeline — the season-1 fabrication (2026-08-08) happened over an undated block with 23 real early-era notes in view, and the model riffed instead of retelling. The prompt side of the same fix: _OPUS_MEMORY (and legacy rule 4b) now names memory as the ONLY source for the room’s own past — retell what the notes say anchored to their dates, say plainly when memory runs out, never invent a room event (“a room event no note backs is a fabrication, however good the bit”). AND hands the model a suite of on-demand tools to dig past the injected context: search_memory (guild-bound recall over the guild’s notes, semantic-vector first: _vector_recall embeds the query (text-embedding-3-small) and cosine-ranks the embedded notes in-process, so a conceptual query like “the messiest thing” lands near a drama/beef note natively even though the fenced prose never says “messy”. A strong/numerous vector result stands on its own; a thin one (fewer than _RECALL_MIN_HITS) still gets the durable daily-arc backfill the keyword path uses, so a single stray semantic hit never leaves the model with less material than the old lexical path would. When vectors are unprovisioned/empty it falls back to the keyword/FTS path — full-text over note prose + concept keywords, on a thin literal match Haiku-expands the concept into keyword terms and re-searches, then backfills the durable daily arc. Emits memory_search with a vector field marking which path answered), recall_detail (the hour-grained drill-down companion to search_memory — same vector-first recall but over the kept hourly notes including rolled-up ones that search_memory dedups away, so after a day-level hit the model can zoom into the blow-by-blow; emits memory_search with detail=True), lookup_catalog (Apple Music / iTunes catalog; emits catalog_lookup), lookup_reference (an authoritative, citable fact from the reference library utils.reference — an intent-routed registry of sources, each returning a citation URL: Genius (utils.genius song credits — producer/writer/featured — and identify-a-song-from-a-lyric via the official API, returning the song + a LINK to read the words, never lyric text, ToS/copyright-clean; provisioning-gated on GENIUS_ACCESS_TOKEN, dormant + falls through to Wikipedia prose when unset; first in the registry so credit/lyric intent routes here), MusicBrainz (utils.musicbrainz release-group metadata: release date / type / featured artists, e.g. “when did Scorpion come out”; the model is told to include the artist in the query so a bare title doesn’t match the wrong act; producer/writer credits are deliberately left to Genius since MB’s recording-search ranking + credit coverage are unreliable), Wikidata (utils.wikidata typed date/age factoids: born/died/founded + computed age), and Wikipedia (utils.chart_data) parses per-position CHART peaks for any major chart (Hot 100, Billboard 200 albums, UK, Canadian, detected from the question; the #335/#292 ground truth, e.g. “how many Drake songs peaked at #2”, “how many #1 albums”), routed by chart/discography intent - the one place we keep a bespoke parser, since the column is identified by its wikilink target and web search fumbles per-spot counts - plus the general article summary as the catch-all (award totals and other widely-reported facts ride the summary / web_search)); specific sources match first and fall through to Wikipedia on a miss, fail-closed to a hedge when nothing clean is found; tool-only with no pre-fetched block; emits reference_lookup), song_credits (Genius promoted into its own crisp tool, #847 — utils.genius.lookup direct: producer/writer/featured credits, what a track samples/interpolates, what a line/song means, identify-a-song-from-a-lyric; returns a LINK, never lyric text; token-gated via genius.provisioned() so a dead tool is never taught; Genius STAYS in the reference registry as the wider fallback; emits the shared reference_lookup event with source=Genius|none so its integration-health telemetry survives bypassing the reference dispatcher), read_media (on-demand read/transcribe of a specific video or X/Twitter URL the model hasn’t seen in context, #847 — the ambient ingest is async so a just-posted clip is bare on first read; CACHE-FIRST via video_ingest.cached_video returns a resolved clip’s metadata + transcript instantly, and on a miss it kicks the SAME video_ingest.kick_fetch background fetch the ambient path uses (cached for every later reader) + optionally awaits a short bounded timeout for a fast captions/fxtwitter resolve, NEVER blocking on a long-clip STT; gated on video_ingest.should_fetch() (VIDEO_TRANSCRIPTION kill switch + yt-dlp); emits read_media with the cache_hit|fetched_inline|pending|not_media disposition. Accepts a raw uploaded video file too (video_fetch.is_direct_media_url — a direct .mp4/.mov/.webm URL like a Discord attachment, host-agnostic by extension), routed through fetch_video’s generic yt-dlp path and keyed by canonical_key so it caches — so an OLD uploaded clip found via search is transcribable on demand, not only the recently-buffered ones the ambient path covers), 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). DOWN-vs-no-data signaling (the fabricated-slate fix): sgo and the_odds_api now report an EMPTY result differently depending on their circuit-breaker .degraded state — a DOWN feed returns an explicit OUTAGE string (pointing to the peer feed), distinct from a feed that’s UP-but-empty (a genuine “no line, cross-check the peer”), so the model never reads a downed feed as “no games” and invents a slate (the Opus World Cup fabrication: SGO 100% down + (no SGO lines) read as “no games”). The descriptions bake in the capability matrix (utils.sportsdata.sources.CAPABILITIES): sgo/the_odds_api are PEERS on the same games (SGO reprices IN-PLAY, the_odds_api is PRE-MATCH-only → for a live number prefer sgo or the prediction markets, which DO move in-play); api_sports has NO odds and empty = nothing LIVE, not “no games today”. Cross-provider scoreboard trio (the core fix): live_scoreboard (every LIVE game MERGED across ALL providers via sports_hub.live_games(), the cross-provider “what’s on” the single-source tools miss), game_schedule (today/tonight’s UPCOMING fixtures via upcoming_games, since api_sports only sees live + just-finished so the schedule lived nowhere), and recent_finals (today’s/just-FINISHED games + final scores via recent_finals(), the “what were today’s scores / who won today” answer that was only reachable through api_sports’ named-game-miss fallback) — all three append _FEED_DOWN_NOTE on an empty result IFF a primary feed is degraded (_scores_degraded), emit scoreboard_lookup (phase=live|schedule|finals). API-Sports DEPTH tools (api_sports-gated, each resolving a named game on the live/upcoming/finals slate via _resolve_slate_game): standings (the group/league table → game_standings → format_standings), matchup_preview (formations/form/H2H → game_pregame → format_pregame), box_score (leaders + team shape → game_leaders/game_team_stats); plus list_sports (the sportsbook coverage catalog via the_odds_api.list_sports(), so “do you have odds for X” is answered from the real catalog) and match_highlights (an on-demand post-game video clip for a named game via highlightly.get_highlights, the user-asked counterpart to the commentator’s auto-posted clips; Highlightly-gated, emits highlight_lookup). Prediction-market GAME discovery: kalshi_game_markets / polymarket_game_markets sweep the matchup PLUS each team name (and a resolved game’s real names) so a bare keyword search that whiffs (the Kalshi “world cup” → 0 hits incident) still finds the matchup markets (emit game_markets_lookup). lookup_player_props (a NAMED player’s or NAMED game’s over-under lines — points/assists/rebounds/goals — from SGO, #390; anchored, never a league-wide sweep: it refuses with no player/game (props are the heaviest SGO call), reads the live scoreboard to scope the prop-covered leagues + a named game’s actual players (sports_hub box-score leaders + goal scorers), then name-filters the league props via match_player_props/format_player_props to just the player(s) asked about; SGO-gated, reuses the commentator’s prop matchers, fail-open to a soft string; instrumented via the underlying market_fetch event), break_down_board (#622: ONE named game’s FULL market board — game lines (moneyline/3-way/spread/total/BTTS/1st+2nd-half totals), game props (corners, first-to-score), and player props, each selection carrying the book’s implied % — so she TRANSLATES the interesting edge in plain words (“ignore the wall, the live one is over 0.5 1H goals at 75%”) instead of dumping a board of ¢ prices. Anchored to a named game like lookup_player_props (resolves the live/upcoming scoreboard → the SGO-covered league → matches the board by team name); SGO-gated, soccer-deep / NBA-thin (SGO barely carries NBA odds depth). The whole odds tree is a flat {statID}-{entity}-{period}-{betType}-{side} dict already in the payload, parsed by the pure utils.sportsdata.board.parse_game_board (standalone — inlines its own implied-prob math to avoid the markets↔sportsdata import cycle) + rendered by format_game_board, fetched via SportsGameOddsClient.get_game_board; emits board_lookup with the per-category market counts as depth telemetry. When SGO is degraded (#725) the board falls back to the Odds API deep board: a FREE /events resolve + ONE metered get_event_board (/event-odds over ODDS_BOARD_MARKET_KEYS — totals/BTTS/corners/team-totals/halves) parsed by the pure parse_odds_api_board into the SAME GameBoard so format_game_board renders unchanged, budget-guarded by the_odds_api.has_enhancement_budget (credits above the 5,000 _ENHANCEMENT_RESERVE) so the credits the /bet SGO-down backstop depends on are never blown), 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, gated on it being configured), and the ops self-service pair so a “you’re broken” report gets diagnosed + fixed instead of only commiserated with: check_deploys (read-only Railway deploy status + build/runtime logs via utils.railway.RailwayClient.diagnose, gated on RAILWAY_API_TOKEN/RAILWAY_SERVICE_ID; emits deploy_check) and file_fix (file a fix-order for her own code off a regular’s bug report, routed through cogs.order.Order.file_autonomous_order — preflight + kitchen/pipeline/in-flight gates + AUTO_ORDER_DAILY_CAP, attributed to the reporting regular; emits auto_order. Prompt-orchestrated dedup (not a code gate): the file_fix tool description + COMMAND_GUIDE instruct her to check_fixes FIRST and only file a NEW order if nothing open already covers it — an existing in-flight match routes to update_fix (add the new detail) or, with nothing new, to a “it’s already tracked” reassurance, so she doesn’t double-file), plus the customer-service-desk pair so she’s her own help desk: check_fixes (read the status of this server’s filed fix-orders via cogs.order.Order.recent_fix_status — reconciled against GitHub, and merges in raw open GitHub issues filed outside the order pipeline so a directly-filed bug isn’t invisible, scoped to user-facing labels (order/bug) with internal/ops issues (auto-eval/feedback) excluded, deduped by issue # against the order rows, user-facing fields only, so she reassures a reporter their bug is tracked/shipped instead of double-filing) and end_game (end a stuck /guess game in the channel on the asker’s behalf via cogs.games.Games.end_game_for; any user can end the game, matching the open-access End button on the game embed); both emit self_serve. Plus update_fix (the counterpart to file_fix, cogs.order.Order.add_fix_update): when a regular adds new detail to a bug already reported (a follow-up reply), it appends their note as a plain attributed comment onto the existing in-flight order’s GitHub issue so the extra context reaches the fix — no @claude, so it records without spawning a duplicate PR run; scoped to this guild’s non-terminal orders (a shipped/closed one routes back to file_fix), runaway-guarded by the shared AUTO_ORDER_DAILY_CAP (one ceiling for both regular-driven order ops, file_fix + update_fix, with separate daily counters); emits fix_update. Plus note_feedback — the lighter lane beside file_fix (cogs.order.Order.file_feedback): a suggestion/wish/sentiment that isn’t a concrete code change gets logged as a comment on ONE rolling feedback-log GitHub issue (label feedback, plain body so claude-code-action ignores it) for mods to triage, gated only by a per-guild FEEDBACK_DAILY_CAP (no preflight/kitchen/pipeline); emits feedback. The /ask surface also always carries a COMMAND_GUIDE (mirrors cogs/help.py) so she points people to the exact real command rather than inventing one. Each is the on-demand counterpart to a block the cog also pre-fetches (except lookup_reference, which is tool-only). The utils.reference _SOURCES/_EXTRACTORS registries are the extension points for adding more authoritative sites/table shapes (utils.reference_types holds the shared ReferenceResult + fetch_json to break the import cycle); the live/breaking path stays on research_web/web_search. KTT2 forum tools (#ktt2, utils.ktt2): search_ktt2 (what the music FORUM is arguing about on a topic) and ktt2_breaking (the threads filling fastest right now, by replies per hour, plus the board’s trending artists) — the discourse + early-signal pair beside search_reddit. KTT2 has no API: both read one section page and parse the Apollo cache it server-renders into __NEXT_DATA__, guarded by a limiter/breaker/retry + a 10min cache so a burst of tool calls in one answer collapses onto one fetch. Never linked, quoted or credited — their terms bar public display, so KttThread carries no url/slug/id field at all and post_text strips every URL out of post bodies (same shape as the Reddit steer). Ranking is the load-bearing part: breaking excludes PINNED threads (the megathreads have five-figure lifetime counts and would win forever), and discourse ties break on most-recent-reply rather than reply count (a lifetime total put a 238,932-reply album thread above that day’s actual Drake stories on the live board). Emits ktt2_fetch. Tier lens (#2230 follow-up): ktt2_breaking joins each thread’s artists to the desk’s WATCHED list (utils/watchlist_source.py — the ONE async builder for the recognition lists, which MusicDesk._watchlist now delegates to) and marks those threads [ours], ranked first, so “what are our artists blowing up about” is answerable. The join is on KTT2’s OWN resolved artist entities via artist_watch.Watchlist.contains, never on title words. An empty watchlist marks nothing and degrades to plain velocity order. The watched block also states that these are FORUM CLAIMS — she may report the board is arguing about something, never restate the claim as fact (the constitution’s room-is-not-a-source rule). Voice-out on a typed mention: the mention answer runs with allow_voice=True, and a voice-nominated answer (the <voice>/<sing> tag) routes through cogs.voice’s shared deliver (user_sent_voice=False) so a typed @Toots can come back spoken or sung when voice is provisioned (bot.tts); a plain answer (the common case) just text-replies, skipping the voice_reply event. Text stays the default and the tag is stripped from any text reply. This is the reliable directed channel for a voiced answer, since a Discord voice note can’t carry an @mention. Image-out on a typed mention: Results graphics (2026-09-03): the _IMAGE_GENERATE directive tells the model that a scoreboard / tally / bracket / list-of-winners picture is drawn from the words INSIDE the tag only, so it must write every row’s title, numbers and winner into the tag in order (the picture model cannot see the caption or the chat; in the poll-tally dry run it filled undescribed rows with invented songs and vote counts, 842 vs 512, while the caption was right). utils/image_codec.MAX_PROMPT_CHARS went 1000 -> 4000 for the same reason: the 18-round board’s prompt is ~1700 chars and the old backstop rejected exactly the prompt that carried the real data. the mention answer also runs with allow_image=True when the image client is provisioned (OPENAI_API_KEY set), so the model can nominate a generated picture by wrapping its description in an <image>...</image> tag (parsed by utils.image_signal); _deliver_image then renders it via utils.image_gen (OpenAI GPT Image) and replies with a native PNG attachment + the caption (the text outside the tag). It’s the rare case (a “draw me X” / visual-gag answer), checked before the voice/text branch and fully fail-open: no key → tag never taught; generation or send fails → it degrades to the caption as a plain text reply, so a nominated image always falls back to its words. Emits image_reply (cog, delivery outcome) + image_generated (the API call). Hardened with a budget (#326): image is a permanent surface gated by provisioning (OPENAI_API_KEY/bot.image) AND a per-guild daily budget (image_daily_cap, default 20 to match the server-wide daily cap, DB-backed via the server-rate counter, restart-proof, checked before the paid generate/edit so a capped image costs nothing). The cap bounds cost in both stages — every room send AND every staging audition counts against it — because image has no per-channel cooldown, so the guild cap is its only budget guardrail (image keeps its budget because a render is dear ~$0.07; gif, being near-free, has no cap at all and is paced by the model’s own nomination + vision gate). A capped/failed nomination always degrades to the caption text. Remix (image edit): when there’s a source image in the immediate context (an image attached to the replied-to message OR to the mention itself, found via _first_image_attachment), the model also gets a <remix>instruction</remix> tag that edits that image instead of generating fresh (<image> = new from scratch, <remix> = change the one that’s here). _deliver_remix reads the source attachment bytes and runs utils.image_gen’s edit (OpenAI images/edits, multipart upload), sharing the same _send_image delivery + fail-open contract as generation (unsupported source / read miss / edit fail → caption text). allow_remix is only passed (and the tag only taught) when both the image client AND a source image are present, so she’s never told to remix nothing. File edits (<media op=...>, the mechanical half): with a clip or a photo attached (found via _first_media_attachment, which unlike the remix finder accepts video and returns the KIND), the model also gets one tag that edits the FILE rather than what it shows: gif, mp4, trim, speed, frame, mute, reverse on a clip; resize, rotate, flip, convert on a photo. The model CLASSIFIES the ask in the same call that composes the line – the tag IS the classifier, so there is no second pass and no added latency – and _deliver_media runs it through utils.media_edit.run_edit on ffmpeg + Pillow, both already in the image. That makes it the one file surface with no API cost and so no daily cap: the per-user mention limit is the pace, and the costs are bounded by an input-size refusal, a per-op output ceiling, and the guild’s own upload limit (a too-big encode retries ONCE at a smaller shape, then gives up). media_kind is keyed per kind so the model is never taught an op that cannot run on what is attached. The split against remix is the rule to keep: <remix> changes what a picture SHOWS (paid, a model redraws it), <media> changes the file – container, length, size, speed. Fail-open throughout: any miss degrades to the caption, so a broken edit costs a file and never the reply. Source priority: the replied-to image first, else the mention’s own attachment. Gif-out on a typed mention: the mention answer also runs with allow_gif=True (only when bot.gifs is provisioned), letting the model nominate a reaction gif with a <gif: query> tag (parsed off by utils.gif_signal, stripped before any text/voice delivery so it never leaks). The gif rides alongside the answer (it doesn’t replace the delivery channel, so it can stack with a voiced reply) as a follow-up message, gated only by Giphy provisioning (GIPHY_API_KEY/bot.gifs): when provisioned she sends the Giphy gif to the room. There is no daily cap or per-channel cooldown — the model’s own nomination + vision pick are the pace (the redundant pacing knobs were removed). A bare <gif:> nomination with no words never sends an empty message (the prompt also tells her to always write a text line); if a gif is the only thing and it misses, a quip covers it so a summon is never left silent. She vision-picks the gif by her own taste rather than sending the top text-relevance hit: the Giphy search returns candidates (each with a small preview), and claude.pick_gif (a cheap Haiku vision call, the outbound twin of the inbound gif vision she uses to narrate gifs posted in chat) looks at the preview frames (plus the actual moment — her reply + what she’s replying to — so a deadpan line gets a sarcastic gif, not an earnest one) and picks the one she’d actually send — it gates on “does it read as the reaction” AND “does it fit the moment” AND “does it fit her voice (sharp/ironic, not corny/basic)”, biases hard toward none fit (skip rather than send a mid gif), and falls back to the top hit only if the vision call errors. Pacing/budget: there is no daily cap or per-channel cooldown — the gif ships only when the MODEL nominated one (a <gif:> tag) AND the vision pick judges a candidate fits, so her own judgment IS the pace (the redundant cap+cooldown knobs were removed; the vision gate biases hard toward none fit, which keeps gifs occasional on its own). Any gif miss (no result, none fit, send failure) is fail-soft and never touches the already-delivered answer. Emits gif_search (the Giphy call) + gif_reply (the disposition, incl. the vision field). Consented memory (remember_about_you, the user_facts store): a regular can explicitly teach Toots a fact about THEMSELVES — a nickname, how to be addressed, a light preference (“call me X”, “don’t call me Y”, “remember this”) — and she stores it per-(guild, user) (db.add_user_fact, case-insensitively deduped via a SQL existence check; storage is uncapped since rows are cheap, while the per-reply read-back is bounded to the newest USER_FACTS_READBACK) and reads the asker’s OWN facts back into her reply context via the privileged <about_them> block (_user_facts_context), so she talks to that person the way they asked. This is user-directed and consented, distinct from the observational, constitution-fenced memory_notes pyramid (which captures observed public behavior and can’t store a volunteered “call me X”): a user’s facts only personalize HER replies to THAT user, are never disclosed to or about anyone else, and the tool steers AWAY from sensitive categories (health, identity, religion, politics, location, contact, anyone else) — those get a warm in-chat ack but no store. The constitution still gates everything she says. /forget wipes a user’s facts alongside their notes (db.delete_user_facts). Emits user_fact (cogs.ask._make_remember). Context-stage remap (the /ask latency work): every independent context fetch — the rich buffer render, long-term-memory recall (_memory_context, the vector pass included), girls/user-facts lookups, link enrichment, the Perplexity grounding pre-fetch (when the model keeps it — see _TOOLSIDE_WEB_MODELS), and the markets snapshot — runs in ONE asyncio.gather under the ask.parallel_fetch span (previously that span covered only enrich+pplx+markets while the buffer/memory/girls/facts awaited SERIALLY ahead of it). Axiom showed the serialized chain at ~29s p50 end-to-end with feeds.resolve_reactors alone at ~16s p50; with everything overlapped the pre-model stage costs only its slowest member — and since the #2175 follow-up /ask skips the reactor-name fan-out entirely (reactors={}; the aggregate [N reactions] counts are free), so the stage’s long pole is memory recall (~3s), not the reactions rate bucket. URL extraction for enrichment reads the RAW message content + forward snapshots (not the rendered buffer, whose 200-char truncation could clip a URL mid-string), which is what frees enrichment to join the same gather. return_exceptions=True + isinstance-narrowing keeps every fetch fail-open.discourse.py, the discourse surface: the mood scheduler background task (proactive posts) + the mention-routed entry points (produce_discourse/produce_icebreaker, reached via @Toots what's the discourse / give us a conversation starter, routed by cogs.ask). The manual /discourse slash command was removed — discourse is proactive + mention-routed now (like /ask), so there’s no standalone command; schedule control lives on /menu. The shared _compose pipeline + the quiet-slot linkless icebreaker fallback (_icebreaker_fallback, material-only invariant kept) are unchanged. Trending-clip variant (#1272): SOME scheduled slots are a “did you see this” REACTION to a fresh trending social clip instead of a news take. Chosen by QUALITY, not a dice roll (owner steer — “the best discourse we have as an option, not a crap shoot every time”, and don’t STARVE trending): _try_trending (called first in _compose_with_retry, gated only on ScrapeCreators provisioned) gathers topic-scoped trending clips (_gather_trending_clips: DISCOVERS the SPECIFIC current viral moments that fit the channel’s vibe via _trending_search_phrases — Perplexity (build_search_query(surface="trending"), theme-scoped, this-week) names what’s actually blowing up RIGHT NOW as short searchable phrases (a celebrity drama, a meme, a release), cleaned by social_search.parse_trending_phrases — then social_search.search_socials searches EACH phrase across all socials + merges. This is the fix for the old raw-topic-string query, which fed the channel’s Discord description straight to ScrapeCreators’ dumb keyword search: the broad “anything goes / pop culture” hub matched literal junk (an 8-year-old “Similes in Pop Culture (For Kids)”, Bing Crosby’s Anything Goes) and never surfaced the current drama; Perplexity discovery live-returns e.g. “BBL Drizzy Drake meme” / “Travis Kelce Taylor Swift honeymoon” → each surfaces fresh on-moment clips. A broad room catches the actual pop-culture drama, a niche room stays on its niche (the topic steers Perplexity); falls back to the general discover_trending feed when discovery yields nothing. The recency gate is 7 days (_TRENDING_MAX_AGE_DAYS, “is it really trending if it’s 2 weeks ago” — with Perplexity surfacing this-week moments the clips come back fresh, so the tight window cleanly drops stragglers). The pool is BALANCED per platform via social_search.balance_by_platform — a round-robin over each platform’s engagement-ranked list, NOT a raw-engagement top-N, so YouTube’s million-view magnitudes don’t crowd TikTok/IG out of the slate she picks from, the “normalize engagement” fix), hands them to claude.react_to_trending — an ISOLATED client method (its own prompt, NO web_search grounding pass so she reacts to the given clip instead of wandering to a news topic — the exact failure a plain-discourse dry run showed) that picks the ONE clip that’d spark THIS room, reacts in voice, and ends with its link. She SEES the clip, not just its caption: each candidate carries a cover still frame (SocialResult.frames — TikTok video.*.uri covers + a clean unsigned YouTube hqdefault.jpg; Instagram’s signed CDN isn’t Anthropic-vision-fetchable so IG clips stay caption-only) attached as VISION on the react (labeled clip N, bounded to the API’s 10-image cap), so a hashtag-soup caption (#fyp #dog #funny) she’d otherwise decline EMPTY becomes a real reaction to what’s on screen — a dry-run A/B confirmed frames flip held visual clips to ship (a dog-launch bit EMPTY→0.80) and catch a caption↔video mismatch, where a transcript A/B did nothing (0/4, TikTok transcripts are absent or garbled). She ends with the clip’s link (allowlisted so the guardrail keeps it; no verify_live_links HEAD check since a datacenter-IP HEAD to tiktok/yt false-strips a real link) — then ships it ONLY when the reaction clears a HIGH bar ON ITS OWN (_TRENDING_SHIP_FLOOR≈0.7, ABOVE the normal 0.6 floor), scored on the surface="trending" REACTION rubric (discourse_score’s carve-out like icebreaker/music: grades whether the REACTION lands, NOT whether it argues a take — the bare take rubric craters a “did you see this” drop as a hollow/soft take for not picking a side, live-confirmed cratering good reacts to 0.25-0.31). A great clip reaction wins the slot on its own merit (never starved by a marginally-higher news take — the head-to-head compare that starves it); a mediocre one returns “” and the normal _compose runs instead (so the news pipeline is skipped whenever trending wins — the cheaper path). The bar is live-calibrated: good reactions cluster ~0.78, weak ones ~0.25-0.31, so 0.7 cleanly separates them with margin for judge noise. Material-only + self-declining: it returns “” (→ the slot falls through to a normal discourse post) when unprovisioned, no on-theme clip fits, the model declined (dry-run-verified: correctly returns EMPTY on the noisy GENERAL trending feed, reacts + links on a topic-scoped one), or the reaction is under the high bar. Movie/TV grounding (#cinema-facts, owner steer “tool she reaches for”): when the cog wires a reference_lookup handler (utils.reference.lookup_reference), react_to_trending is handed the on-demand lookup_reference TOOL + a movies/TV grounding prompt block, so a clip about a specific FILM/SHOW (a trailer, a box-office moment, casting, reviews) lets her pull the REAL facts (director/cast/RT+Metacritic/box office/awards off the #1561 TMDB+OMDb movie source) and work the ONE sharpest detail into her reaction — a dry run flipped a Sinners react from a vibes take to “$370M worldwide, four Oscars including Best Actor for MBJ…”. It’s the ONE tool on this surface (no web_search wander); optional (only when the handler’s wired), never an invented figure, threaded through the leak-repair retry. Emits discourse_scored (category="trending") + claude_api/link_stripped (purpose="discourse_trending"). Market routing reads the recent conversation (not just the channel name) so the Haiku classifier can spot a sport in content (“SGA is cooking” → NBA); when it does, markets.get_context pulls the whole live scoreboard (get_live_scoreboard, every covered league, live games only) so the post can cite a real score. Icebreaker context is ONE gather (#1950 Class B): _icebreaker_fallback’s four sources — the distilled memory mix, the icebreaker-tuned Perplexity hook (~6s and the dominant cost), the sports-gated market line, and her own recent openers — are mutually independent and used to run as four serial round-trips ahead of the compose; they now share an asyncio.gather, each keeping its OWN try/except so per-source fail-open behavior is byte-identical (a plain gather, not return_exceptions, precisely because nothing in it is allowed to raise). Precedence is unchanged and pinned by tests: a caller-passed market line is reused rather than refetched, a fresh hook overrides a passed one, and a FAILED hook fetch falls back to the passed one. The gather does hoist recent_discourse_all above the material-only gate, so a no-material skip pays one extra cheap local read — deliberate, since that gate exists to skip the SONNET call, not an indexed read. The account set is a UNION of feed channels + the CODE-OWNED wire lists (#wire). Discourse used to read its raw material only from the guild’s feed_channels (the Discord channels a MEE6-style mirror posts X accounts into). That made every new server do mirror setup before discourse had anything to link, and the dependency was real: over three days, every post that cleared the 0.6 floor linked a tweet the feed channel had carried 25-90 minutes earlier. utils/wire_sources.py now also reads the desks’ own vetted lists (SPORTS_WIRE_ACCOUNTS / POP_WIRE_ACCOUNTS / MUSIC_WIRE_ACCOUNTS / CINEMA_NEWS_WIRE_ACCOUNTS) straight off twitterapi.io via bot.xprovider, and ADDS them to whatever the feed channels gave it — so a server with a mirror gains the accounts its mirror lacks, and a server with NO feed channel still gets real, linkable material. The module is PURE except fetch_wire_posts (which takes the provider as an argument, so tests pass a stub). Four design points worth keeping: (1) it joins the SAME asyncio.gather as the feed + local pulls, so it adds no serial leg (#1950’s lesson); (2) POP_WIRE_ACCOUNTS ends with MUSIC_WIRE_ACCOUNTS concatenated on, so pop is sliced back to its own handles and selection is ROUND-ROBIN across the four lists — otherwise a top-N slice reaches no music handle at all, and discourse is music-heavy in practice; (3) a wire post whose tweet id already appears in the feed URLs is DROPPED (new_posts), matching across embed-fixer hosts, because two copies of one tweet read to the model as two sources agreeing; (4) the wire links are rendered as their OWN prompt block and never merged into hot_urls — that list ranks by Discord reaction count (0-5) while these carry X engagement in the thousands, so one merged sort would bury every feed link — but both lists DO join the enforce_source_links allowlist, or a wire link is stripped as hallucinated. The cost story is the CACHE, and it is the load-bearing part. twitterapi.io allows 10 requests a minute with a burst of 1 and every surface shares ONE provider, so a compose fetching 32 accounts itself takes ~3 minutes and holds the limiter for all of it — starving the desks. It is also redundant: the desks already pull each of these handles ~50 times a day (~every 29 minutes, measured in Axiom). So the desks now publish what they fetch into a process-local _CACHE (remember, one line in each of sports_desk / pop_desk / cinema_news), discourse reads it (cached_posts, 60-min TTL), and only genuinely MISSING handles are fetched live under a hard LIVE_FETCH_BUDGET (6). In steady state the union costs zero extra API calls; only a cold start (fresh boot, no desk tick yet) fetches at all. The live fan-out uses gather_deadline, NOT a wait_for around one big gather — the latter throws away every handle’s work when one is slow, which under a 10/min limiter is the NORMAL case, and a live dry run confirmed it returned zero posts every time. Breadth (discourse_wire_accounts, default 8 per list) is therefore a PROMPT-SIZE dial, not a credit dial; 0 turns the direct read off and restores feed-channels-only. The music DESK reads the same union now (#2195) — cogs/music_desk.py:_feed_context; the music DROP was tested and rejected, see music.py below — so every caller goes through the SAME pair of entry points rather than repeating the steps: gather_wire (select handles, read, emit) and wire_material (dedup against the feed, rank, render). They are two calls and not one because each caller runs the fetch inside an asyncio.gather with its Discord pulls, and the feed URLs to dedup against are not known until that gather returns. Emits wire_read (surface, count handles, kept posts, ok, duration_ms) — a surface-neutral kind stamped with the caller, per the one-kind-per-shared-integration rule, so one dashboard panel and one ops-monitor health branch (keyed wire_read_<surface>) cover all three.order.py, the /order feature pipeline. One command: /order <feature> files now (pre-flight checked); bare /order opens a management view — a filterable status list, a + new-order button (modal), retry/cancel on a picked order, and a kitchen open/close toggle. This replaced the old /order new|status|retry|cancel subcommand group and absorbed the standalone /close + /open. The interaction-free cores (_file_order / _retry_order / _cancel_order) are shared by the slash arg, the modal, and the buttons. Pre-flight sanity check, in-flight cap, pipeline-red blocking, per-user cooldown. Mod-only via _mod_gate.music.py, /music setup (channel picker) + /music drop (manual post) + scheduled music-lounge posts (track recs with Apple Music links). Sources: feed channels (Twitter/social), Perplexity, channel activity, web_search, plus the two GROUNDED fresh-hit sources the priority-1 “fresh hit” lane picks from so a drop names a REAL current/new song instead of fabricating a “just dropped”: hot_chart (utils.music, the currently-CHARTING top songs, Deezer-led + iTunes RSS) AND new_releases (utils.music, Apple’s most-played albums feed filtered to the release window — the JUST-DROPPED releases the chart lags, since a hit charts for weeks after release, so this is what makes a New Music Friday drop name a genuine new release rather than a catalog classic). new_releases read a DEAD feed for three weeks (fixed 2026-09-14). It called Deezer’s editorial /releases, which Deezer retired: measured 2026-08-20, 2026-09-04 and 2026-09-14, every genre id answers {"data":[],"total":0}. utils/apple_releases.py had already recorded that retirement when the releases BOARD hit it (#2950) and moved to Apple’s feed, but nobody swept the other consumer, so the drop’s just-dropped list was empty on every call — the class-sweep miss, and it mattered because that lane is the only one that can post an act on neither familiarity list. Both readers now share the one source, on different entities: the board reads albums (a release day is an album day), the drop reads songs. That split is load-bearing, not cosmetic — a drop must end in a track link, and resolve_apple_music_url searches entity="song", so an album whose name is not also a track name resolves to nothing and the links-only gate discards the drop. Measured 2026-09-14 on the album feed, only 3 of 10 candidates resolved to a postable link, so seven in ten rows of grounded material could not be used. The drop’s window is its own knob (JUST_DROPPED_DAYS, 7) and deliberately NOT the policy’s 30-day familiarity window: the prompt block is headed “JUST DROPPED THIS WEEK” and tells the model the rows just came out, so a 30-day window put month-old records under that heading and invited a false timeline claim. One row per artist, because the feed is a play chart and one album release floods it (12 of 17 in-window rows were a single act’s).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:
_MUSIC_GENRES; afrobeats and neo-soul used to sit there and told the prompt to lean into them), and utils.music._HOUSE_DEFAULT_CHARTS no longer carries Deezer’s GLOBAL chart. That global row was the cross-genre breadth net, and it is what fed the drop records the bar does not play: measured on the live blend the morning the steer landed, five of twelve rows were Country (Ella Langley twice, STELLA LEFTY, a Taylor Swift soundtrack cut) or Alternative (Tame Impala), all from that row. The chart rows are still not genre-pure and nothing cheap makes them so — Deezer files US country under its POP chart, and a Deezer chart row carries no genre, so filtering there would cost a per-row iTunes lookup (up to 24 searches a drop against a host that already throttles us, #371). The source half only has to stop feeding whole off-genre CHARTS.docs/PROMPT_OPTIMIZATION.md): the taste profile’s genre line used to read “also pop, indie, rock, electronic, Latin, country (new gen)”, which licensed exactly what shipped, and the DEEP CUT lane invited “an underrated track” with no bar on the ARTIST. Both were rewritten in place; nothing was added./music too — the genre rule is editorial, not a dedup convenience. It grades the record the LINK points at (apple_music.track_row), falling back to a name search (resolve_music_row) only when the drop carries no link yet — the two disagree on a FEATURED credit, because pick_apple_music_url deliberately accepts Travis Scott’s “SICKO MODE” for “Drake - SICKO MODE” (its documented artist-absent fallback) while the search path finds no Drake-credited row and answers None. Grading the search result refused drops whose link had already resolved: measured 2026-09-14, 2 of 5 real feature-led queries. Then the kworb watch list, then RIAA — each read only when it can still change the verdict, so an off-genre pick or a genuine new release costs one catalogue read and no familiarity read at all (the kworb outage build is deliberately not memoized, so a needless call re-hits it every drop).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.
/menu has three separate music channel selects, and a mod who empties the wrong one does not stop the surface they meant to stop. The split: 🎧 music (music_channels) is the SONG-REC room – music.py’s drops read it, and games.py:_room_tracks samples it for the guess-the-song pool’s taste signal. 📊 music market (music_market_channels) is the NUMBERS room – music_desk.py (scheduled) AND music_alert.py (event-driven) both read it. 📰 music news (music_news_channels) is the newsroom – music_news.py alone. The rule that decides the split is the READ, not the topic: a Kalshi/Luminate figure is a market read and belongs with the desk, so the alert rides the desk’s picker. Until #2234 music_alert.py read music_channels instead, so a mod who emptied the music-market picker still got MUSIC ALERT cards in the song-rec room, with no knob that explained why (this is exactly how the owner hit it). None of the three has a FALLBACK: an empty picker means OFF in Discord for every cog that reads it. Empty does NOT stop X – x_only_active keeps the compose alive for the X-only lane (#2137), so “off in discord” is literal. cogs/x_reply_draft.py:_CHANNEL_SOURCES is the cross-check: it lists one getter per surface family, and it already named get_music_market_channels as “music_desk + music_alert” before the code did. Artist-market listings (2026-09-02, #2800 line E): a daily lane (_artist_market_signal) reads the Kalshi open-event catalog the client already walks hourly, matches event titles to the watchlist (music_markets.title_names_act, whole-token runs on folded names), skips anything Kalshi files under a sport and the numeric ladder families the desk already reads (ARTIST_MARKET_SKIP_SERIES: chart position, YouTube views, streams in the year), and posts ONE new listing a day: the earliest market opened inside 10 days, total volume over 50 contracts, the leading outcome priced between 5% and 95% (artist_market_listing, prefer absent over invented). Blob = the market, its listing date, the leader and its price, the field size, the volume (artist_market_blob); card = the leader’s Kalshi snapshot with a Kalshi source pill. Its own experiment music_artist_markets (STAGING) and permanent per-event dedup in post_dedup_history (music_alert_artist_market, 1-year retention), never the song reuse block. Measured 2026-09-02: 70 open Kalshi events named a top-40 act, 54 with real volume, 32 listed inside two weeks, none reaching any surface (the swing lane’s two-week horizon and the drop’s hottest-event pick exclude them by construction). Artist-market moves (2026-09-02, #2833): the second read on the same catalog, _artist_market_move_signal, on the same experiment. For each act event (same skips, plus any event the listing lane posted in the last 3 days) it reads the raw markets, keeps the live ones with 300+ contracts, and reads their DAILY candlesticks in ONE batch call (KalshiClient.get_daily_mids over /markets/candlesticks?market_tickers=, 168h lookback; a mover is not always the busiest market today, so every liquid market is read) – so the week-ago baseline comes from the API and nothing of our own is stored. music_markets.artist_market_move picks the biggest move by absolute points whose current price sits inside 5%-95% and whose move reaches 15 points; the lane posts the single biggest move across events, once per market per direction (music_alert_artist_market_move key, <market>:<up|down>, 30-day retention). Per tick at most 20 events, two fetches each. Blob = the market, the outcome, the price a week ago and now, the move in points, the volume and the close date (artist_market_move_blob). Measured 2026-09-02 across 168 open act markets: 3 moved 20+ points in a week (Drake to feature on Travis Scott’s album 4%→37%, Miley Cyrus album before Nov 1 26%→55%), 7 moved 10+. Nested DATE boards are not races (#3194, 2026-09-12). market_alert resolves a rung-shaped event through _resolve_ladders, and is_threshold_ladder refuses every cumulative DATE board: it strips a rung’s numbers and compares the residue, so “Before Oct 1, 2026” and “Before Dec 1, 2026” leave “oct” and “dec” and read as two subjects. That is right for guarding a survival integral over ONE quantity, and it left the board falling through to the RACE path, where _event_leader crowned the FURTHEST window – live, the Drake album board’s “Before Jan 1, 2027” at 57%, which prices highest only because it CONTAINS every earlier window – and _apply_leader_rule dropped every rung that actually moved behind it as a trailing leg. _LadderInfo.nested_dates (kalshi_ladder.is_date_ladder) closes it: a date board now suppresses its rungs like any ladder and reports no leader. It is a SEPARATE flag from is_ladder on purpose – a date board answers only one of the two questions a ladder answers. Kalshi ships these with no strike metadata, so ladder_forecast and ladder_is_decided both read floor_strike and return None/False on every one (measured on the live board); folding it into is_ladder would claim a forecast capability the board does not have. market_drop needed no change – its own _is_priced_threshold_ladder already says “numeric- or date-thresholds” and returns True on the same board (measured), and its binary path guards on the sibling COUNT. Artist-market STANDING (2026-09-12, owner steer “separate card for the leader”): the fourth read, _artist_market_leader_signal, on the same experiment and spine – the companion to a move. The move lane reports ONE leg’s price change and never says what the board as a whole says, so the Drake album-date event (KXALBUMRELEASEDATE-NEWDRA) shipped four cards in nine days – before Jan 1, 2027 at 57%, before Nov 1 at 44%, before Sep 19 at 8%, before Oct 1 at 29% – four rungs of ONE ladder, each a bare number, and the room was never told which window leads. It is a QUEUE, not a second catalog sweep: a shipped move post books its event in music_alert_artist_market_leader_queue (<event>:<moved market>, 5-day retention) and this lane posts that board’s standing. It is registered BEFORE the move lane, so within one sweep it cannot see the row that sweep is about to write and the NEXT TICK (10 minutes) it can – the standing card follows its mover by one slot, not by a day; only the one-a-day cap every lane keeps can push it further. The move lane in turn skips a board whose standing card ran in the last 3 days, the mirror of its listing rule. music_markets.artist_market_leader is the read. Every candidate is BID-SUPPORTED (kalshi_price.bid_supported, the conviction test – the same Drake board’s furthest rung quotes bid 25c / ask 98c and last traded 57c, a price the book’s own bid values at 25 cents), and the read is withheld on fewer than two priced legs, under 50 contracts, or a hero outside 5%-95%. A named FIELD heroes its top price with the runner-up as the gap. A cumulative DATE board heroes its MIDPOINT instead – the first window, in DEADLINE order (kalshi_ladder.date_rung_deadline, the labels’ own dates, never the legs’ close times), that the market prices at even money or better – because a nested board’s top price is its furthest window by construction and heroing it answers nothing about when. With no window at even money the furthest one leads and the answer is “not likely even by then”. Judging the band on the hero is what lets a healthy date board keep a far rung over 95% (6 live boards quote one, two of them album releases) while a board whose midpoint IS its soonest window, at 97%, still reads as the settled fact it is. A cumulative DATE board is a DIFFERENT claim and is flagged as one (kalshi_ladder.is_date_ladder, a new tell: is_threshold_ladder refuses a date board, because stripping the numbers off “Before Oct 1, 2026” leaves the month as the rung’s subject and two months read as two subjects). Nested windows rise with the date by construction, so no rung is the favourite date; the blob states the soonest window, the first at even money (live on 2026-09-12: before Nov 1 at 50%) and the furthest the BOOK STANDS BEHIND – never “the top price”, which is the same rung only when the board prices in perfect order (the Drake board’s Jan 1 rung is priced off a 57c print under a 25c/98c book, so the furthest window anyone bids near is Dec 1). Each window is named once, since one rung is often two of the three. A named field keeps the plain leader-and-runner-up shape. Keyed <event>:<leading label> (music_alert_artist_market_leader, 30 days), and the dedup compares the board’s LAST posted standing, not every one the window holds – a board that goes A -> B -> A has changed lead twice and the second change is as much a story as the first. Three more guards, all fail-closed: a board the LISTING lane posted in the last 3 days is skipped (the lanes run in one sweep, so without it a queued board could take both cards on one day); a board whose raw read comes back a FULL page is refused, since get_event_markets_raw reads one page and does not follow the cursor and the leader of a page is not the leader of the board (the widest live act board runs 35 legs against the 1000-leg page); and is_date_ladder accepts BEFORE/BY only – “after” windows nest the other way and a mixed board nests neither way, so a blob that says “the chance it happens BEFORE that date” can never run over one. The shape is read over every LIVE leg, not the priced ones: a thin three-rung date board with two quotes is still nested, and rendering two windows as rivals is the same wrong sentence. The label forms are a full date, a month-year (“Before Nov 2026” – 7 live boards) and a bare year, one regex classifying and parsing both. Artist-market resolutions (#2800 line E): the third read, _artist_market_settle_signal, on the same experiment and the same daily-lane spine: the follow-through on a market the listing lane announced. The roster is the listing surface’s own event tickers (music_alert_artist_market, held a year), so it never scans the catalog; each event is re-read once a tick at most 12 a tick, and music_markets.artist_market_settle reads it only when EVERY market is finalized/settled inside 7 days (the utils.luminate whole-event rule, so a mid-period threshold crossing is never a result): the YES outcome, or a single market’s NO. A settled event leaves Kalshi’s open catalog, so the act is re-read off the resolved market’s title with title_names_act, and a title naming no watched act is skipped. The card is the big-number shape with the verdict (YES / NO) as the hero and the outcome + settle date under it, source pill Kalshi; each resolution books its event ticker on music_alert_artist_market_settle (permanent) and never the song block. The same change gave the mover lane its Kalshi pill (it wore Spotify’s).cinema_desk.py, the cinema desk (#cinema-desk): the movie/TV sibling of the music-numbers desk (#1461). A ScheduledPoster subclass that each slot sweeps four FREE sources — Box Office Mojo (domestic weekend grosses, utils.box_office), Netflix Top 10 (weekly hours-viewed + views, utils.netflix_top10), TMDB (new theatrical/streaming/TV releases + title metadata, utils.tmdb), and OMDb (a film’s critical scores — RT % + Metacritic + IMDb, utils.omdb) — ranks them by freshness/story (utils.cinema_numbers.fetch_cinema_stories → rank_stories), and posts the top story that hasn’t posted this week into the guild’s cinema channels (its OWN /menu picker + page, db.get_cinema_channels, NOT reusing music channels — owner steer). The lanes each answer a different question (box_office/streaming/release/scores + the two Perplexity-driven NEWS lanes awards/tv_news); the CRITICAL-SCORES lane is the one differentiated fact market_drop can’t do (#cinema-scores, owner steer: market_drop posts the ODDS on a movie market, this posts the FACT — “reviews are in: 94% on RT”). Kalshi’s own RT/score markets are too THIN to read a number off (verified: 0 of the top-200 traded Entertainment events are score markets), so scores come from OMDb directly; the streaming numbers the desk would have wanted from Kalshi it already gets from the Netflix TSV, so the whole Kalshi-numbers route is a dead end (music works on Kalshi only because those markets actually trade). The movie beat is RELEASE-CENTERED (box_office_story leads with the biggest NEW opener via the first-weekend total≈gross heuristic, not an evergreen hold — owner steer). On a weekend with NO big opener it leads with a notable HOLD/DROP trajectory instead of a flat “#1 led with $X” (#cinema-facts owner steer “sharper box-office angles”): parse_chart now captures BOM’s %± (weekend-over-weekend change) + weeks-in-release columns (position-anchored off the Gross + Total cells, live-verified), and _notable_holdover surfaces the sharpest legible trajectory among the holdovers — a strong HOLD (dropped ≤35%) or a CLIFF (≥55%), gated by a _NOTABLE_MIN_GROSS ($2M) floor so a real film’s trajectory carries it, not a tiny arthouse expansion. Every blob line now carries its %±/week so the take reads trajectory even on an opener weekend (“Toy Story 5 only dropping 28% through five weekends”). Worldwide context (#cinema-facts): resolve_lead also surfaces the lead film’s TMDB lifetime WORLDWIDE gross (LeadInfo.worldwide), folded into the box-office blob as context (≥$50M) so a domestic-weekend take can situate it globally (“$40m domestic, already $310m worldwide”) — never a headline number, the card still leads with the domestic weekend. Unlike the music desk there’s NO gatekept scoop (box office is public), so the value is her curated take — she’s the CONSOLIDATED movie/TV NUMBERS + RELEASES source; the prediction-market ODDS layer (reality-TV winners, casting/speculation) is DELIBERATELY left on market_drop (owner steer). Per-STORY weekly dedup (cinema_desk_events, one box-office/streaming/release story per week) so the weekly cadence emerges even on a daily slot pool. Compose reuses the shared compose_market_drop voice (a per-kind context — the NUMBER lanes lead with the figure, the release lane is a “what’s out this week” roundup); self-gated by claude_client.cinema_desk_score (0.6 floor, fail-CLOSED — the sibling of music_desk_score, NOT the betting drop_score), which is kind-aware: the number lanes (box_office/streaming/scores) are graded on the figure + a sharp read (a critical score IS a valid number), while the release lane is graded on a “worth-watching heads-up” rubric (NOT docked for having no number — the #1555 fix for a lane the numbers rubric was silently killing). The release blob carries a one-line SYNOPSIS per title (_synopsis/_release_line off the free TMDB overview, not just bare names + dates), so the take can say what a title IS rather than hedging (“worth a look depending on what it is”) — the telemetry-driven fix for the lane chronically self-gating OUT (prod: 5/5 dropped at avg 0.15; the enriched blob ships at 0.62–0.78). Gated on its OWN cinema_desk experiment (production → the cinema channels, staging → a #bot-logs audition, off → skip) + master switch + mood; cadence on the /menu calendar (cinema_desk is a calendar surface). All three sources are free + unmetered (no quota poll) but fully guarded (rate limiter + circuit breaker + retry + a TTL cache) and instrumented (tmdb_fetch/box_office_fetch/netflix_top10_fetch/omdb_fetch per-call + integration-health, which surfaces a silent BOM IP-block / TMDB key death / Netflix TSV shape change / OMDb key death). Delivery is the UNIFIED BIG-NUMBER card — the SAME schema as the music desk (#cinema-facts, owner steer “big numbers like the music desk, with the art” + “unified design schema”): the film NUMBER lanes (box_office/scores) ship the shared utils.market_chart.render_number_card PNG (the exact music-desk card: a small-caps dot-path eyebrow CINEMA · BOX OFFICE, the film title, the BIG GREEN figure — the weekend gross / the RT % or Metacritic score — a sub-caption, and a status pill REPORTED/CONFIRMED), now with the film’s TMDB POSTER composited top-right via render_number_card’s new optional artwork_png (a backward-compatible param the music desk can also use for album art). It rides in the SAME Components-V2 LayoutView the music desk uses, assembled by the new public utils.market_cards.build_number_card (her take as a TextDisplay + the card image + a native link Button), delivered via send_market_card. The button links a Kalshi market for that film when one genuinely exists (CinemaDesk._kalshi_link → markets.kalshi_search with a film-scoped intent, thin coverage so usually absent) ELSE the film’s TMDB page. The card also folds in a lazily-resolved CREDITS grounding line (director + top cast off cinema_numbers.resolve_lead, one guarded TMDB lookup for the CHOSEN story only) so the take can name the makers (“Ryan Coogler’s Sinners”). The STREAMING lane cards too (#1578): the Netflix #1 is often a TV SHOW search_movie can’t resolve, so resolve_lead(media_kind="tv") pulls the poster + TMDB tv page via tmdb.search_tv (a show carries no director/cast credits line — creators, out of scope). Only a release (multi-title roundup) story, or any figure/render miss, degrades fail-open to a plain-text post. The card renders at 2400×760 (a _OUT_SCALE-2× higher-res emit, #1578) with a w780 composited poster, and carries NO status pill (dropped — the verdict lives in her voice). The card build runs off-thread (asyncio.to_thread) and the poster fetch is best-effort. X crosspost (#1581): a shipped card mirrors to the @tootsiesbar timeline (take + card image + Kalshi/TMDB link) via maybe_crosspost (surface="cinema_desk"), gated by the x_crosspost experiment. Live context beat (#cinema-facts): before composing, the desk pulls utils.context_beat.gather_context_beat (Grok X-crowd + Perplexity web/socials buzz, purpose="cinema_desk") and passes it as compose_market_drop’s x_context so the take carries the sharp CURRENT angle (the fandom read, a critical split, a second-season-slump framing) the bare number can’t — sentiment/context only, hard numbers still from the story blob; provisioning-gated + fail-open. News lanes (#cinema-facts, awards + tv_news): two Perplexity-driven NEWS beats beyond the scraped numbers — awards_story (Oscar/Globe/Emmy/festival nominations + wins + snubs) and tv_news_story (a show renewed/cancelled/greenlit). fetch_cinema_stories takes the perplexity client and runs one fresh-week search per lane; the Perplexity text IS the grounding (like the release lane’s TMDB titles — the compose quotes its specifics, never invents an award/count/renewal). Each self-declines (_clean_news → no story) when the pull returns the NONE marker / a “nothing this week” punt, so awards stays dormant most of the year (seasonal) and TV news only fires on a real renewal week. Scored on the release-style rubric (cinema_desk_score routes awards/tv_news there — no number required, an award COUNT is fine), weekly-deduped (aw:/tv:). Raw ART on the bare-text lanes (#everything-has-art → #thin-ahead-art): the news/release lanes (tv_news/awards/release) no longer ship bare — after the take composes, the ONE unified resolver runs on it (resolve_desk_image(line), the same ranked-subject walk every desk uses: a film/show take → its TMDB poster, a person-centric awards take → their photo), and the resolved bytes ship attached to the take (NOT a custom card — the big-number card format is for a figure, not a block of prose; the raw art IS the image). A multi-title roundup ships a poster PER show named (#individual-art, owner steer “attach photos of all shows mentioned”): the release “what’s out this week” lane names two or three titles, so the bare-text lanes resolve art through resolve_desk_images(line) — the multi twin of resolve_desk_image that walks the SAME ranked image_subjects list but keeps ONE poster for EACH subject (not just the first-with-art), byte-deduped and capped at 4 (X’s media ceiling; Discord matches so both surfaces show the same set). The release lane hands the resolver the EXACT posters TMDB already gave each title (release_story builds a {norm_subject(title): poster_url} map into meta["posters"], passed as resolve_desk_images(..., title_posters=…)): the blob gives the model only titles, so a film/tv subject would otherwise be re-searched by NAME — and a generic title (“Desire”) matches a popularity-ranked HOMONYM, not the current release the roundup is about, which the delivery coherence gate then drops, so the post shipped one art short (owner report — the Desire/Pyaar Prema Kalyanam roundup went out with one poster instead of two). Using the poster the story was built from removes that miss; a title not in the map (or a failed download) falls back to the normal search path, and the news/awards/tracking lanes carry no map so they are unchanged. _sched_deliver attaches them all — Discord channel.send(files=…), the X crosspost crosspost_with_quote(images=…) → maybe_crosspost uploads each into the tweet’s media_ids, the per-photo coherence gate dropping a bad one without sinking the rest. A single-title news/awards take just yields one, and the number lanes stay single (the card is one film). The LEAD keeps its any-kind art (a person-centric awards take still gets that person’s photo); the EXTRA posters are film/tv TITLES only — a live dry run of the Lanterns take showed the classifier kinds the CHARACTERS “John Stewart”/”Hal Jordan” as person, whose TMDB person search returns a NAMESAKE’s headshot, so a non-title extra is dropped (absent over invented). resolve_desk_images returns (bytes, source, kind) and the cog keeps index 0 plus the film/tv extras. This replaced the bespoke title-only claude.cinema_news_hero extractor (deleted — it went bare on any take without a single film/TV title). _sched_deliver routes a lane with a card_number (the box_office/scores/streaming NUMBER lanes) to the unified build_number_card, a lane with only resolved art to the raw discord.File + take, and a lane with neither to plain text; the crosspost mirrors the art bytes to X. Resolved from the composed TAKE (not the story) so the image matches what she actually led with; fully fail-open (no art → the bare-text post, never skipped). The NUMBER lanes take that resolved image as the card’s BACKGROUND too (owner steer “even for cards we can use the images as background”): the resolve is no longer gated to the bare-text lanes, so a number lane whose lead has no TMDB poster falls through to resolve_desk_image and the card ships a real full-bleed background instead of the dark panel. The art RUNG travels with the bytes into build_number_card(artwork_source=) → _art_fit, so a logo is fitted whole rather than cover-cropped. Emits desk_art (surface=cinema_desk) so an art-less card is a queryable rate rather than something spotted by eye — carded only ever said a card was BUILT. Pure builders/parsers/ranker in utils/cinema_numbers.py + the shared render_number_card/build_number_card + the three client modules (unit-tested tests/test_cinema_desk.py); the guarded fetch lives in the clients. Emits cinema_desk_posted (with carded) / cinema_desk_scored. RANKED BOARDS (owner ask 2026-08-10, epic #2266): the desk also draws ranked BOARD cards off the same Box Office Mojo pages, on its own cinema_boards experiment (PRODUCTION by default, on the music publication-boards precedent – a weekend chart is perishable and the rows are a faithful reprint of a page BOM already published). Three of them: the weekend top 10, the per-screen average (where a three-screen platform release out-earns a blockbuster and the top 10 hides it), and the year-to-date race. A fourth, holds-and-drops (holdovers ranked by weekend-over-weekend change, best hold to hardest cliff), was cut on 2026-09-13 (#3241) because it got no engagement on X — the owner reported 7 impressions on a shipped card. It was the desk’s smallest board by volume (Axiom, 30 days to 2026-09-13: 5 built, 5 shipped, against 99 for the weekend top 10), and the trajectory read it carried still runs on the weekend top-10 board, where _notable_holdover picks the sharpest hold or cliff to lead a debut-less weekend. Pure builders in utils/cinema_boards.py; the lane in the cog is the fetch + pick + compose + dedup shell, the same split the music desk uses. A board’s payload is a LIST, so it renders through market_cards.build_board_card (surface="cinema", so the wordmark reads tootsies cinema) rather than the big-number card, and each board key has its own chart_cards.ACCENTS entry in a warm band so a box-office card is never mistaken for a music one. Weekly dedup keys (cbo:<kind>:<weekend>), _MAX_BOARDS_PER_SLOT so a first slot does not empty the week’s slate, and the SAME deterministic shape guards the music board lanes run (_board_unshippable: self-correction, row arithmetic, row tally, all-time claim) in front of the 0. The CRITICS board posts ONCE PER MOVIE (owner steer 2026-08-23 “these should only post once per movie”). The weekly key alone re-shipped the same holdovers’ scores every weekend they held (prod: The Odyssey led the critics board on Aug 11 AND Aug 22, same 88 Metacritic), so a film’s critic score is now a one-off. Every film on a SHIPPED critics board is stamped in the shared songstats_milestone_state store under metric critics_board (_record_critics_films, entity crit:<norm-subject>), and _critics_board drops any film already stamped, walking DEEPER into the chart to fill the board with films NEW to it. get_milestone_rung’s read-bumps-seen_at keep-alive holds a film’s stamp for the life of its chart run; the 21-day prune clears it only after the film has left, where it is no longer a candidate anyway (the durable “seen for its whole run” the 14-day cinema_desk_events table cannot give). The board’s headline is framed as “this week’s new critics scores” (NOT “the weekend’s top 10”), because a higher-scored holdover that is already stamped may sit off the board — a top-10 claim would be false. Staging never stamps (a #bot-logs audition must still be able to reach the room). So the board reappears only when NEW films are reviewed. The ranked BOARD needs TWO new films (_MIN_SCORE_ROWS, owner steer 2026-08-23 “make the bar 2”); a SINGLE new reviewed film ships as the big-NUMBER card instead of a one-row board (owner steer “for single do a number card”). The single card is a single=True CinemaBoard that rides the same board pipeline — the once-per-movie stamp and the cbo:critics_board:<weekend> weekly key work identically (the one row still carries the packed MC / RT cell the stamp reads the film off) — but CinemaDesk._sched_deliver draws it with build_number_card (RT %/Metacritic headline, the film poster as background) and _compose_board_unit hands it the number-desk _CONTEXT rather than _BOARD_CONTEXT, because it is one figure and not a list. critics_scoreboard returns the single card when exactly one new film clears the gross floor; the coverage floor does not gate it (a single card makes no “best of the weekend” claim). 6 self-gate. Those guards are not theoretical here: the first live dry run shipped four takes at 0.92 and two of them broke a fence stated in capitals – a summed ‘every other film in the top 10 combined’ and a subtracted ‘$184M gap’. Both are caught deterministically, and the fences now name the exact failed sentences. The numeric backstop reads a STRIPPED copy of the block (#2311/#2344): ungrounded_numbers whitelists every number the source states, so numbering the rows (per_screen since #2279, the critics board since #2344) quietly grounded 1..N – a fabricated score of 8, or a wrong “#2”, cleared the one check that does not depend on the model noticing. cinema_boards.grounding_source removes the row markers before the check, and cinema_boards.strip_position_claims removes position claims from the TAKE for the same check, BOUNDED to the board’s row count so a real figure past the last row (“Rotten Tomatoes number 98”) stays checked as a figure. The two strips are a PAIR: stripping only the source holds a CORRECT “#1” for quoting a number the block no longer states. A position is not a figure the membership check can adjudicate – it cannot tell a right rank from a wrong one – so the cinema_desk_score judge owns it under its WRONG PLACE rule, reading the take against the still-numbered block. Related: the judge’s UNGROUNDED rule now asks for a figure on the line the block gives THAT film, so every single-film block (the worldwide context line, scores_story, gross_milestone, budget_cross) names its film on the figure line rather than scoping it by a header or an “It”. Two NUMBER-CARD lanes off the same weekend chart (#2266): weekend_champ, the weekend’s #1 film as one hero figure, and gross_milestone, a film crossing a round DOMESTIC-GROSS rung ($100M, $200M, …). The milestone rides the SHARED utils.milestones high-water spine under a new domestic_gross ladder and the existing songstats_milestone_state table (entity bo:<release-slug>), so no new table and no new detection code: a film seen for the first time is SEEDED at its current total and never announces a rung it passed before we were watching, and the table’s 21-day prune is exactly right (a film off the chart for three weeks is done, and a re-entry re-seeds). The champion card scores BELOW the box_office lane on purpose – both describe the same weekend, and rank_stories dedups by subject, so the lower score is what makes the champion yield when the box-office lane already leads with the #1 film. It survives exactly when that lane led elsewhere (a new opener, or a holdover’s trajectory). Both lanes run the deterministic SHAPE guard before the self-gate (_SHAPE_GUARDED_KINDS); the news/awards/release lanes deliberately do NOT, because their ground truth is a live web pull that can genuinely carry a first-ever. The BUDGET-CROSSING card (budget_cross) fires once per film when its running domestic gross passes its TMDB-reported production budget. It rides the same songstats_milestone_state store as the gross ladder under metric budget_cross, but it is NOT a ladder – one threshold per film, the film’s own budget – so the stored rung is a FLAG (0 seen-and-below, 1 fired) and the first sighting seeds the honest answer, which is what stops a film already past its budget announcing a crossing that happened before we were watching. TMDB leaves budget at 0 for most titles, and an absent budget is not a small one, so a figure under _MIN_REPORTED_BUDGET means no card rather than a crossing at zero. The card must never say the film is profitable: marketing spend is not public and the industry rule of thumb is roughly twice the budget worldwide before a film profits, so passing the production number is a milestone and not break-even. That is stated in the ground truth in those words, because it is the one claim this card invites and gets wrong.pop_desk.py, the pop desk (#pop-desk): the celebrity/pop-culture sibling of the cinema desk. A ScheduledPoster subclass that each slot reads what the pop-culture WIRE accounts (utils.pop_stories.POP_WIRE_ACCOUNTS — CODE-OWNED, the same trusted-account model music_news._TRUSTED uses, NOT a per-guild /menu picker, so a server needs zero config and the list grows by evidence in one place; seeded PopCrave/PopBase/TheePopCore + more). The pop desk IS the music CULTURE wire desk: there is deliberately no separate music moment/culture wire surface (the music-news desk lane-orders its own NUMBERS slate with the same utils.wire_lanes orderers — biggest + breaking leaders compose first, then the multi-item slate — but stays a numbers desk) — a breaking music moment rides these two lanes, so the list carries the music CULTURE wires — the owner-vetted 2026-08 music-list pull (Rolling Stone/XXL/Pitchfork/RapTV/NME/Stereogum/Consequence/The FADER + the fast urban wires FearedBuck/Kurrco + the music_news._TRUSTED culture accounts popx/thepopstuff; every handle probed live before landing, dormant ones — hiphopdx, strappedhh — skipped on evidence), while the numbers AUTHORITIES (chartdata/billboardcharts/touringdata/…) stay OUT: a chart/consumption number is the music-news desk’s beat, and polling them here would only manufacture redundant supply. The whole music wire list now feeds BOTH desks (#2031, owner steer “move all music related accounts to music news too”) — it used to be six dual-role accounts (billboard/talkofthecharts/hitsdd/chartmastersorg/headlineplanet/dailyrapfacts). Measured cause: the newsroom polled trusted_handles(numbers_only=True), i.e. 13 numbers authorities, so a chart stat that ONLY an aggregator carried could not reach it at all. On 2026-08-05 @Rap posted “Drake ranked as Spotify’s #1 most-streamed artist in the US yesterday” and “Drake’s ‘ICEMAN’ has returned to being the #1 Hip-Hop album on Apple Music US”; @chartdata posted the GLOBAL artist chart instead and @billboardcharts carried neither, so the newsroom never saw either claim. The pop desk DID see them and ranked them #51 and #79 of 380 candidates against a top-4 compose window — so neither desk could reach a story both were nominally covering. Every MUSIC_WIRE_ACCOUNTS handle is now a _TRUSTED row (guarded by a containment test, since the two lists are hand-maintained), joining as CULTURE (relay_number=False): an aggregator’s chart claim is a LEAD the claim settler (#1890) checks against the live chart we already fetch, never a number relayed as fact. The settler now covers STREAM claims too (#2771, _live_stream_total). #1890 wired kworb into the CHART branch and did not sweep the MILESTONE branch, which kept verifying on the open web – and both web legs (Perplexity + Grok) inherit aggregators that LAG Spotify, so a TRUE claim came back refuted. A wire said Rihanna had 8 songs over 2 billion Spotify streams; the judge answered “her highest is ‘Stay’ at 1.972B; no songs confirmed over 2B” and the story was dropped three times, while kworb’s artist songs page – the page the watch sweep already fetches ~20x a day – listed exactly 8 at or above the rung, Stay at 2,026,849,516 and her highest at 2,647,680,489. The judge’s counter-figure was wrong twice over. Drake’s “140 billion Spotify streams” was dropped four times across four days against a leaderboard reading 140,255,100,000. Three shapes settle off pages already cached: CATALOG counts the artist’s titles at or above the rung, TRACK reads one title’s total (matching through kworb’s feature ASTERISK, which on Rihanna is two of the eight qualifying songs), CAREER reads the leaderboard. stream_claim scopes it to a claim naming BOTH Spotify and a stream metric, because kworb counts Spotify and nothing else – answering an Apple Music claim with Spotify numbers would be worse than the bug. The reading OVERRIDES a contradicting web verdict, the same call #1890 made for charts rather than a second rule, and it also goes to the judge as evidence so the telemetry stops reporting a refutation we have disproved. The judge row’s PRECEDENCE CLAUSE is load-bearing and measured: the verify prompt weighs its research rows equally, so two web legs outvote one first-party row, and a label ABLATION on the real judge (n=5/arm, evidence text held constant) scored “first-party” 0/5 verified, “the data authority” 0/5, plus “not a report of them” 0/5, and only “when this and the web disagree, this is the correct number” 5/5 – while every arm still contradicted a FALSE claim 5/5, so it is not a rubber stamp. Fail direction is ABSENT: a kworb miss returns nothing and the web path runs unchanged, because an unreachable page says nothing about whether a claim is true, and ok=False means READ-and-did-not-bear-out rather than refuted (a wire count can be one ahead on the day a song crosses). WHICH CHARTS the newsroom may report is now a list too (#2127, owner cut 2026-08-07). Every other chart surface names its charts in code — the movement lane reads MUSIC_DESK_CHART_LANE, the boards read MUSIC_DESK_STANDINGS_CHARTS, the genre boards read billboard.GENRE_CHARTS — and the newsroom was the one that did not: its gate is the ACCOUNT plus the number cue, so it reported whatever chart a trusted account happened to post about. Measured over the 30 days to 2026-08-07, its 135 chart stories named ~20 chart families, including ARIA, the U.K. Official Albums Chart, Billboard Japan, Bubbling Under, Kid Albums, Top Movie Songs and a Dabeme fan tally of “Top 100 Male K-Pop Vocalists”. music_news.KEPT_PATTERNS / CUT_PATTERNS is the owner’s reviewed scope (kept: Hot 100, Billboard 200, Global 200, Streaming Songs; the rap + R&B/hip-hop + Latin genre charts, songs and albums alike; the pop / adult pop / dance / urban / rhythmic radio formats; Spotify daily + Top Artists, Apple Music, iTunes), applied by _chart_in_scope right after classify so an out-of-scope story costs no verify and no compose. A cert must be a US (RIAA) or UK (BPI) one (owner cut 2026-08-21, “we mostly only care about US certifications”, plus “keep BPI, it’s popular”): the desk read a certification from any authority a wire or the artist pull named — RIAA (US), BPI (UK), SNEP (France), ARIA (Australia) — and a BPI silver reached X (an Ariana Grande “petal” UK silver, the report that prompted this cut; BPI is kept, so that one is fine — the cut removes the rest). _cert_in_scope (sibling of _chart_in_scope, same right-after-classify slot, music_news_filtered reason=cert_out_of_scope) keeps only RIAA + BPI certs via music_news.cert_reportable, which reads the model’s cert body first (a named authority ships only when it is riaa or bpi) and the prose second when the body is empty (a named foreign authority — snep/aria/… — or an “in utils/industry_feeds.py + utils/pollstar_charts.py, the first-party industry sources (#reporting-oversight): the trade press read DIRECTLY, so a number is in hand rather than chased through a Perplexity/Grok web-confirm that lags a fast story. This is the fix for the R&B Tour gross case — the pop wire said $153.4M/20 shows, the web-confirm returned the last PUBLISHED figure ($101.9M/13 shows), and Toots posted the stale one. Two readers, both keyless, fail-open, and DARK until a desk consumes them (a later change adds a reserved first-party LANE + its own slots to the desks). (1) industry_feeds reads four public RSS feeds (FEEDS: Pollstar news, IQ Magazine, Music Business Worldwide, Billboard music-news), one namespace-driven parser, mapping each item to the same SourcePost the X provider yields (so the desks rank/gate/compose/dedup it through the pipeline they already run); each source has its OWN breaker + limiter so one host refusing us doesn’t blind the others; feed_fetch per read. (2) pollstar_charts reads Pollstar’s public chart JSON (data.pollstar.com) — the live-music numbers Billboard does NOT publish (Live 75, Global Concert Pulse, New Tours, Artist Power Index, 24 Mediabase radio formats), anon free-tier only (no auth ever sent, only the preview rows the endpoint returns), pollstar_fetch per read. A Pollstar chart id names one WEEK’S ISSUE, not the chart (#3357): every issue gets its own id and none is ever updated, so the registry id is a SEED and the client resolves the current issue off the chart’s config endpoint before each read. Pinning it instead froze the touring + radio lanes for 15 days at HTTP 200, which is why lane_stale_upstream now watches the freshness fence. ToS one-way-door (owner-accepted, epic issue): Pollstar’s terms forbid redistributing chart content and stripping attribution, so every posted Pollstar number MUST carry PollstarChart.attribution (“Pollstar utils/riaa.py, the RIAA first-party certification feed (#2839, line B of #2800, owner ask 2026-09-02): RIAA’s own Gold & Platinum database read directly, feeding the newsroom’s EXISTING cert lane. The gap it closes, measured 2026-08-20 to 09-01: the wire cert lane shipped 22 cards against ~180 awards RIAA issued (one in eight), and a Julia Michaels 2x Platinum reached no wire we poll. The site is keyless and structured (a plain table, 30 rows a page, a JSON show-more call, a per-award timeline); the ONE thing that mattered was the user-agent – Cloudflare refuses a bare Mozilla/5.0 (the earlier ‘RIAA is blocked’ finding was that probe’s own UA) and accepts a browser-shaped string, which the client and the riaa probe share; the probe READS the page and requires award rows, so a challenge page that answers 200 without a table reads as a failure on the sweep, not as healthy. The design: RiaaClient.recent(until=) walks the newest-first feed until a page’s oldest award is older than the caller’s window, capped at 8 pages (2h durable cache, ~15 awards land a day, 58 in one burst); cogs/music_news.py:_gather_riaa_pool keeps the rows whose credited act is on the WATCHED or KNOWN list (the shared corroborated splitter aw.credit_parties over the alias-aware keys, so ‘Lil Nas X’ stays whole and a ‘YE’ credit resolves to Kanye West; the watchlist’s own spelling becomes the display name), dated inside 3 days, not yet seen (the award id is the seen-key), at most 5 a slot, reads each award’s timeline (7d cache) for the previous level, and hands the newsroom a SourcePost shaped like a wire post (platform=riaa, handle riaa). A slot’s places go ROUND-ROBIN across the acts with news, cards per act per slot each (riaa.fair_slot; the /menu tune knob music_news_cert_cards_per_act, default 1, owner call 2026-09-03 “i don’t want to limit cards but i want to diversify so one artist doesn’t block”): round one is each act’s best unseen award, acts by that award’s level, round two each act’s second-best while places remain; nothing is stamped seen, an award that missed a slot stays eligible until the 3-day window drops it, and the desk’s plaques board lists the batch (music_news_filtered reason=cert_batch_deferred, count the act’s awards waiting). An act that took a card sits out for cert_act_cooldown_hours (the /menu tune knob music_news_cert_act_cooldown_hours, default 24, 0 = off; _acts_on_cert_cooldown, 2026-09-05). The per-slot share alone did nothing ACROSS slots: an act with a catalog batch got its next-best award every hourly slot until the 3-day window dropped the batch. Measured 2026-09-04: RIAA certified 15 Nickelback titles in one day, the lane composed one an hour from 15:14 to 02:14 UTC (Rockstar 12x, How You Remind Me 9x, Far Away 5x, …), five reached the room (“Animals”, “Savin’ Me”, “Gotta Be Somebody”, “Burn It to the Ground”, “This Afternoon”) and six more were stopped only by the text dedup (post_dedup content_overlap / text_similarity / topic_restated, which is luck of the wording, not a rule); the owner reported them as duplicate posts. The signal is the lane’s own seen stamp: music_news_seen is read again at the cooldown width, and an act credited on any window award stamped inside it takes no card this slot (music_news_filtered reason=cert_act_cooldown, count the awards held, detail.cooldown_hours); a collaboration stays eligible under its other act. A stamp is any attempt – composed, gate-failed or dedup-retired – so an act whose headline card the dedup retired (Rockstar, against the desk’s plaques board) does not get a second-best card an hour later; the board is where the rest of the batch belongs. Fail-open: a failed seen read is no cooldown. Measured 2026-09-02 before the fold: RIAA certified 59 Rod Wave titles on 08-31 and the lane posted 14 cards to the room in one afternoon, about three a slot, while the wire had already carried the batch as one @complexmusic story the day before. The first fold (#2861) kept ONE headline card and stamped the rest; the owner asked for five, then for diversity instead of a cap; the RSS and RIAA pools are ONE first-party pool for the slate, ranked by source authority then freshness (_first_party_rank, RIAA 1.0 above Pollstar 0.95) and capped once, because the slate classifies in order and stops at six real stories – except an RIAA row still AUDITIONING (its source not yet PRODUCTION), which goes to the END of the slate so it never takes a shortlist seat from a room-bound story. The seen-key is riaa:<award id>:<level>: the award id is per TITLE (its timeline lives under the same id), so a later upgrade is its own post. The sentence keeps every credited party (‘Post Malone & New Artist’), spelled the desk’s way where known (riaa.display_credit). From there the row is a first-party feed candidate: _feed_source resolves the handle to the display name + relay_number=True (the same trust path as the trade-press RSS: the authority’s own figure is settled on source trust, never routed back through the web-verify hop), the classifier returns cert_id <n>xplatinum|riaa, and cert_story_key dedups it against the same certification from any wire. The card heroes the award NAME, never the id’s bare multiplier (music_news.cert_level_label, applied in claude_client._parse_music_news). The classifier answered a level with “Nx”, because the one certification example in its prompt did. The RIAA counts multiples on the PLATINUM rung only. So a hero of “1x” over a Gold states 1,000,000 units where the award is 500,000 (owner report 2026-09-15, the Jennie RUBY card). Measured over the 30 days to that date: 217 of 283 certification classifications answered a bare “Nx”, and 54 of those sat on a rung that carries no multiplier – 29 Gold, 15 Diamond, 10 Silver. The prompt example now carries the whole award name, and the parser reads the level off the validated cert_id as the deterministic backstop; measured on the real classifier, 24 of 24 wires went from “1x”/”2x” to “Gold”/”2x Platinum”. A BATCH figure is a COUNT of awards (“53 certifications”), so it keeps its count and still takes the ranked plaques board (_cert_batch_card). Its own key music_riaa_certs – GRADUATED 2026-09-07 (master-only, the third pass; owner steer “graduate these to on for master guild”), so _riaa_stage is graduated_stage: PRODUCTION in the master guild (rides the parent newsroom), OFF elsewhere (skips the read), never STAGING. While it was a trial, anything but an explicit PRODUCTION routed an RIAA card to #bot-logs WHATEVER the parent newsroom’s stage (a new SOURCE auditioned first, unlike the graduated overlays that ride the parent); that audition route is still in the code (meta.audition, _is_riaa_audition) but can no longer occur. The stage is resolved ONCE at gather time and rides the post (riaa.STAGE_KEY), so a failed re-read at compose can never promote a card; and such an AUDITION (meta.audition) is recorded as seen but never books the settled cert key – a card that only reached #bot-logs must not silence the wire’s later production story (review findings on #2840). With no recognition lists at all the lane skips the slot and says so in an error row: the watchlist is this source’s discovery filter, and relaying every act RIAA certifies (~15 a day) would be a flood, not a fail-open. Fail directions: every read fails open to [] with riaa_fetch ok=false + an error row; the parser skips a row missing its id, badge or date; a title’s casing is derived from RIAA’s caps (display_title, so ‘SZA’ reads ‘Sza’ – a visible mis-case over a wrong title). Not yet read: the awards-by-artist tallies (Drake: 333 awards, 11 Diamond) and the per-title ranking (Sunflower 20x), the source for the derived facts, on #2839. An audition composes LAST, in its own audition lane after the reserved lanes and the tail, and its take never enters the dedup history (neither the base’s in-pass list nor post_dedup_history), so a room-bound wire story of the same certification is never retired by a card that only reached #bot-logs. A connector-named act inside a collaboration credit (“AC/DC & NEW ARTIST”) is recognised whole by the shared splitter’s run match (artist_watch.credit_parties, #2846). The certified-units and Platinum-singles boards rank RIAA’s own artist ranking (#2918, 2026-09-03). Those two boards cannot read a ready-made RIAA list the way the Diamond boards (#2915) and the year board (#2871) do: RIAA publishes no “every act’s certified units” page and no list of every Platinum award. So they need a CANDIDATE set, and it used to be the top of the kworb Spotify watch list. That list is ordered by global streaming while the boards count US certifications, so the two never lined up – Garth Brooks (200.5M) and The Beatles (207.5M) sit inside RIAA’s own top ten and on no streaming list at all. A reply on X caught the same defect from the other side: “this is not accurate if you counting feats” on a units card that was missing Lil Wayne, who holds 217.0M and is the ninth-biggest. The candidates now come from riaa.artist_rankings – the awards-by-artist tab with an empty name, RIAA’s own ranking by certified units. The population is the one RIAA defines, and the cut has a RULE, because the ranking is ordered by the very metric the board reports; a name list (the 1301-name known list was the other option) would have needed one invented. Those rows count SOLO titles while the boards count features too, so the ranking picks WHO to read and each act’s own award search still decides the order; four pages of 30 is 120 acts down to 62.5M solo units, and the deepest feature multiplier measured is Lil Wayne’s 1.81x, so an act cut at 62.5M would need 3.4x to reach the tenth row. Measured against the acts most likely to break that: Wiz Khalifa 83.5M inclusive, Young Thug 76.0M, Ty Dolla $ign 65.5M, Gucci Mane 60.5M, Swae Lee 48.5M, Quavo 47.5M – none within 125M of the cut. The FASTEST-to-Diamond board reads the same roster, so its population moved too, and all three boards’ fences now say “RIAA’s biggest acts” instead of “today’s biggest streaming acts” – including the model-facing scope, which had told the composer that Garth Brooks and Elvis Presley are NOT on the board, now false. Names arrive in RIAA’s capitals and are spelled through the desk’s recognition lists, falling back to riaa.own_credit (the ONE definition of “RIAA’s own artist cell as the act”, shared with credited_on(name_cell=True)). PREFER ABSENT: a short ranking read is no candidates, so the boards wait. Rows RIAA files WITHOUT a credited act (_OMITTED_CREDITS, 2026-09-03). RIAA’s convention puts a recording’s guests in the title cell (“STAY (FT. MIKKY EKKO)”), which the crediting rule reads; a minority of rows break it, naming the lead alone with the guests on neither the artist cell nor the title. Such a guest is UNREACHABLE, not merely uncounted: the award search matches the artist cell and the title, so no search for the guest’s own name returns the row. The trigger was a live card – a fan graphic said Miley Cyrus held 91M certified single units, our card said 87.5M, and the whole gap was “23” (Mike WiLL Made-It feat. Miley Cyrus, Wiz Khalifa & Juicy J), filed as artist “MIKE WILL MADE-IT” and invisible to all three guests. The class sweep (scripts/riaa_credit_audit.py, the whole Diamond singles population against Deezer’s independent contributors cast) found it is about one Diamond single in twenty: Umbrella hides JAY-Z, Yeah! hides Lil Jon and Ludacris, Closer hides Halsey, Dark Horse hides Juicy J, Party Rock Anthem hides Lauren Bennett and GoonRock, Moves Like Jagger hides Christina Aguilera, Just Dance hides Colby O’Donis, Pursuit of Happiness hides MGMT and Ratatat, Broccoli hides Lil Yachty – so the “most Diamond singles” board was under-counting JAY-Z and Halsey by a whole Diamond each. _OMITTED_CREDITS names the rows by hand, keyed by (artist cell, title_identity) so one entry covers every award id RIAA files the recording under (Party Rock Anthem has two). parse_rows and the cache inverse _cert_obj both patch the credit cell back into RIAA’s own “LEAD FEAT. GUESTS” shape, so every tally, board and display counts the guest with no special case; awards_for_act also searches the LEAD (omitted_credit_names), which is the only way the row is reachable, and a short read there makes the whole catalog absent like any other name. The map is DATA, not inference: the audit prints candidates to read by hand (Deezer’s search is fuzzy – it matched “The Box” to a different song), and a wrong entry hands an act a plaque they do not hold. Fail direction: an entry whose key stops matching (RIAA re-filed the row) is a silent under-count, so awards_for_act checks that every promised row came back credited and emits a recoverable error row when one did not. The map is now HALF GENERATED (#2944, 2026-09-04). Curating by hand does not scale: a sweep of only the top 60 LEAD acts found 169 candidates, and Drake alone accounts for about twenty (“Forever” hides Kanye West, Lil Wayne and Eminem; “Crew Love” hides The Weeknd). So scripts/riaa_credit_audit.py --write writes its accepted rows to utils/data/riaa_omitted_credits.json, and riaa._OMITTED_CREDITS merges that file under the curated entries – curated LAST, so a hand-read row always wins, because those carry judgement the sweep cannot make (a collective’s per-track cast). Three things keep the generated half honest. The audit’s accept() rejects a candidate whose Deezer match is a DIFFERENT RECORDING (a remix or a re-recording; version qualifiers like “(Album Version)” are normalised away first, “Remix” and “Live” deliberately are NOT), whose Deezer lead is not RIAA’s own artist cell, or whose “guest” reads as a production credit – 46 of the first 169, every rejection correct (a “Rihanna Cover Band” recording of “We Found Love”, “Monster Inside Me” answering Taylor Swift’s “ME!”, Florida Georgia Line’s “Up Down” answering Morgan Wallen’s row). Deezer is a BUILD-TIME source only, so no card’s number depends on it at run time. And the file is committed, so an entry arrives as a reviewable diff. Measured after the first generation: 123 accepted entries, and Lil Wayne moves from 202.0M across 76 singles to 218.0M across 84, 244.0M career. The reachability searches are additive and their failure is NOT fatal. Each mapped row needs the LEAD searched to be reachable at all, and the generated half took the busiest act from one search to ten – so one flaky read would black out the boards exactly as the “Ye” alias did. An act’s OWN names are still fatal when short (they ARE the catalog); an omitted-credit lead that comes back short costs only the rows filed under it, which is where the act stood before the map existed, and the staleness check names every mapped row that did not come back.music_desk.py’s catalog-count sweep (_catalog_stat_units + utils/catalog_stats.py, #2772, owner question 2026-08-31 “why didn’t we proactively find different stats about artists and genres?”). WHAT IT FIXES: every other music lane waits for a TRIGGER — a chart week prints, or one scalar crosses a round number (utils/milestones.py). A stat like “Rihanna now has 8 songs past 2 billion Spotify streams” is NEITHER: it is a standing fact about a CATALOG, produced by counting rows on a page the bot already fetches and ranking acts against each other. Measured on 14 days of music_desk_posted (2026-08-31): ~650 posts, of which about 1% were a derived stat and the rest a ranking somebody else published, redrawn. So the story reached us only as a wire tweet — which the newsroom then REFUTED off a stale web aggregator (#2771), the sibling bug. HOW: each slot a rotated window of watched artists (aw.rotate, the same bounded pattern the milestone sweep uses; MUSIC_DESK_CATALOG_FETCHES=6) has its kworb SONGS page read once — the artist id rides the ranking page the watch sweep already caches, so no search — and cst.counts_for_rungs counts the rows at or above each rung in CATALOG_RUNGS (1B and 2B). Every read UPSERTS into artist_catalog_counts, the GLOBAL (not per-guild, like kv_cache) field a rank is taken over: a catalog count is a fact about the world, and two guilds must not rank the same act differently. The corpus is not the change detector — that stays the shared songstats_milestone_state high-water rung under a new metric catalog_count keyed catalog:<act>:<rung>, so seed-on-first-sight holds (an act first seen at 8 never breaks a stale “reached 3”), a rung breaks once, and an undelivered crossing retries next slot. The RUNG lives in the entity key rather than the metric, so adding a threshold needs no ladder change and no migration. The count ladder is every integer from 3 up: each new song past the mark moves the count by one and THAT tick is the story; the floor is 3 because a first or second song past a rung is the per-track streams milestone the other ladder already breaks, and counting it twice would post the same fact twice. Rung choice is calibrated, not guessed (12 top artists, 2026-08-31): 100M counts run 69–322 per act so a tick marks nothing, where 1B runs 10–35 and 2B runs 1–11 — a number a reader can hold. THREE HONESTY GATES, all pure: (1) BOUNDED — a kworb songs page is capped, so if every row we hold clears the rung, rows past the cap may too and the count is a LOWER BOUND; count_catalog decides this off the SMALLEST row held (below the rung ⇒ we have seen every qualifying row ⇒ exact) rather than a page-size constant, and an unbounded count is never stored or stated. (2) THE RANK NAMES THE ACTS AHEAD, AND IS THE SHIP GATE — rank_clause says “Among Spotify’s biggest artists, only The Weeknd, Bruno Mars and Justin Bieber have more”, never an ordinal over our roster size. The first draft said “tying for 4th most among the 30 artists tracked” and the owner rejected it on sight: naming our sweep size describes plumbing rather than music, invites “only 30?”, and makes a real stat read as a small-pond placing. Naming the acts is also MORE honest — specific, checkable, self-bounding. The clause is ALSO the lane’s ship gate, and that is the load-bearing part: a card with no clause hands the compose a VACUUM and the compose fills it — measured over 6 real composes of a bare count, 2 invented a cross-artist record (“the only artist in Spotify history”, “the only artist alive”) and the shared ranking guard caught only ONE of the two. Chasing each phrasing in a shared guard is whack-a-mole, so the lane never composes without a true clause in hand (MAX_ACTS_AHEAD=3, i.e. rank 4 or better); a deeper act yields no clause and therefore NO CARD, which is the right scope anyway (the 9th-deepest catalog at a rung is not a story). With a clause present the fabrication rate halves and the residual drift (“fourth-most all time”) is caught and dropped — the correct fail direction, since the rung is recorded on delivery so the card retries next slot. The measured miss ALSO fixed the shared guard: output_checks._RANKING_CONTEXT’s in history arm now allows an intervening qualifier (“in SPOTIFY history”), matching the _ALLTIME_CLAIM_RE sibling that always did. coverage_ok needs 90% of the roster swept before ANY rank — the clause names a REAL population, and that naming is only truthful once we have read nearly all of it — and catalog_counts_at_rung drops rows older than 30 days so a stalled sweep cannot rank an act on a number that has since moved. This is the catalog twin of the chart_records rule that a year claim needs every week of the year. (3) NO IDENTITY INFERENCE — the wire framed this story as “the most by a female artist”; constitution.py forbids identity inference, gender explicitly, so the core carries NO artist attribute at all and the identity-qualified claim is structurally inexpressible (regression-tested). A LEAD/FEATURE split is carried because they are different claims: kworb marks a feature credit “*” (its own summary table names the mark) and Rihanna’s 8 include 2 features, so the blob states both. NO NEW MACHINERY: it rides _milestone_candidate for the compose, the self-gate, the number card and the rung recording, adding only three optional overrides (headline_override/topic_override/eyebrow) for wording the shared ladder cannot reach — the rung is in the entity key, so “8 songs past 2 billion Spotify streams” is unreachable from LADDERS alone. The card heroes the COUNT (“8”), not the streams, because the unit is the point; art_kind="album" + the artist ranking strip make it read as an artist card. Gated on the music_catalog_stats experiment (STAGING default — the sweep still fills the corpus while the take auditions in #bot-logs; OFF skips the page reads entirely), capped at MUSIC_DESK_MAX_CATALOG_STATS=1 a slot. Emits catalog_sweep; count near 0 is the NORMAL case (a catalog tick is rare by design), so health is read off detail.read and detail.readings, never count — see docs/OBSERVABILITY.md. DAILY HITS (#2800, line A of the dig-every-slot epic): the SAME sweep now counts the page’s other column. ArtistSong.daily was already parsed and cached and count_catalog ignored it; count_daily_hits counts an act’s songs over a DAILY-streams rung (DAILY_RUNGS, one rung: a million a day) with the same boundedness rule, the same integer ladder (daily_hits, floor 3), its own field table (artist_daily_hit_counts, so a daily rung never shares a field with a total rung), its own daily: entity keys, and its own experiment music_daily_hits (STAGING — a new fact type with a cross-artist rank auditions before it rides the parent’s production flip). Zero new fetches. WHY: measured 2026-09-01, the round-number ladders delivered 0–17 milestones/day off ~6,000 candidates (zero on two of seven days) with the ship cap never binding — detection-bound — while the one FINE ladder, catalog_count, was the best lane on the desk at 2.9/day. A count ticks; a threshold crawls. The daily column ticks every day. Capped at MUSIC_DESK_MAX_DAILY_HITS=1 a slot, separate from the total cap so neither crowds the other. Emits catalog_sweep with detail.mode=daily. Second daily rung (2026-09-02, #2800 line C): DAILY_RUNGS is (500_000, 1_000_000); measured on the top 45 acts, 11 held no song over a million a day but three or more over half a million, so the half-million count is the only daily fact for the tier below the million-tier acts, and for the million-tier acts it is the bigger, faster-ticking number. Every rung is counted off the one page read, each rung fills its own field (artist_daily_hit_counts by rung) and its own daily:<act>:<rung> state, and the card path breaks the biggest rung that ticked first under the one-per-slot cap. daily_rung_phrase speaks 500K as ‘half a million’. The ROUND-COUNT path (owner ask 2026-09-10, off Pop Crave’s “Doja Cat becomes the first female rapper in history to have 10 songs with over 1 billion streams each on Spotify” – “let’s add these to the metrics we watch”). The lane already counted this metric; it could not post it. The rank clause is the ship gate and it fires only for the four deepest catalogs at a rung, so measured on 2026-09-10 the TOTAL sweep had run 224 times in 14 days and shipped 0 cards (the daily twin shipped 6), and Doja Cat sat 27th at 1B with 9 songs in the corpus – her 10th could never post. The fix is one more trigger, not a new lane: a count that lands on a ROUND number (cst.is_round_count, every multiple of five – the counts chart accounts post on their own) with NO rank clause goes to _milestone_candidate with the web qualifier REQUIRED (require_qualifier=True, skip_qualifier=False, no lane grounding). The candidate runs the same verified_qualifier path every per-track milestone uses (Perplexity lookup, light second read, the qualifier_verify judge), and composes ONLY when a reported ranking verified and attached – the vacuum rule is unchanged, the clause just comes from a source instead of the field. A SETTLED negative (no_qualifier / unverified / cached_none) ADVANCES the rung with no card (db.advance_milestone_rung, no alert clock), so a crossing nobody reported never pays for a second lookup: kworb lags the crossing by about a day and the chart accounts post within hours of it, so a ranking not reported by the time we see the tick will not be. A TRANSIENT miss (error, unprovisioned, the gate OFF) leaves the rung open and the next slot retries. Every miss emits qualifier with reason=required_missing (detail.settled, detail.lookup, detail.rung). TOTAL counts only – a daily count moves every day and no outlet reports a ranking for it. Cost is bounded by construction: a first sight SEEDS (no lookup), a non-round tick is skipped before the candidate, and the live 1B field held 3 acts at exactly 10, 2 at 15 and 1 at 20, so a tick onto a round number is rare. On the identity rule: the core still carries no artist attribute and builds no cohort claim; what changes is that the lane may RESTATE a cohort ranking a credible source reports, which is what the verified-qualifier path was built for (its own docstring names “the first female rapper” as the target shape, and the per-track lane has shipped “the biggest streaming year by any Asian act on Spotify”). Reported and checked is not inferred. The earlier skip_qualifier note that a web lookup for a catalog stat “returns a claim we must refuse” is superseded by this.music_desk.py’s chart-tenure pass (_chart_tenure_units, #2800 line C, owner steer 2026-09-01 “dig deep on every slot”). The platform-chart sweep’s cached rows carry a days column (cumulative days on the chart) that fed nothing — chart_weeks counts Billboard WEEKS. A watched artist’s song crossing a days rung (milestones._CHART_DAY_RUNGS: hundreds plus 365/730/1095) on a DAY-period chart whose rows carry days (the Spotify Global + US dailies; Apple/iTunes rows have none, the weeklies count weeks) is a chart_tenure card: figure = the day count, grounding = the row itself (position, peak, today’s streams), the metered qualifier skipped like the catalog lane. Shared high-water rung per (chart, normalized song), so a song first seen at 1,200 days seeds silently, a row under the first rung still seeds (so its 100th day fires), and two mixes of one release share one state. The tenure: rows (with bbrun:/bbpeak:) are exempt from the 21-day milestone-state prune and never expire (bounded by the pairs a watched act ever charted), because a song can leave a chart for months and re-enter – a pruned row would read the return as first sight and swallow the rung. Gated on music_chart_tenure – GRADUATED 2026-09-07 (master-only), so the gate is graduated_stage and the key only labels the unit – capped at MUSIC_DESK_MAX_CHART_TENURE=1 a slot. Measured 2026-09-01: 25 rows within a week of a rung across the two charts. Emits artist_watch with detail.mode=tenure.music_desk.py’s platform-chart TOP-RUNG entries (artist_watch.chart_stories, owner ask 2026-09-05 “entering top 5 and top 3 should generate a card like 10”). The platform-chart sweep derives one story per row off kworb’s Spotify / Apple / iTunes / radio pages: new_entry (dated by the cog to debut / reentry), crown, a top<N> rung entry and jump. The rung entry used to be ONE line, the top 10 (#2047), so a #7 -> #4 climb crossed the top 5 and fired nothing: four spots is under jump_min, and #7 was already inside the top 10. DEFAULT_ENTER_RUNGS is now (3, 5, 10), checked TIGHTEST FIRST: a row that was outside a rung last period and is inside it now is a top3 / top5 / top10 story, and a move that crosses several (#12 -> #2) is told as the tightest one. top_rung(kind) reads the N back out, and the compose blob (“just entered the chart’s TOP 5, at #4”) and the card eyebrow (<chart> - Top 5) are derived from it, so every rung shares one shape. top_for_all widens every rung to unwatched acts, on the top-10 reasoning (a top 5 or top 3 entry sits inside the top 10). Dedup: a top 5 / top 3 story adds its rung to the card key (kc:<chart>:<song>:top5), because the bare kc:<chart>:<song> key folds every later story about that song into the first for the 9-day window, and each line crossed is its own card; the top-10 entry keeps the bare key it always had (its stored keys are in that shape, and a debut-then-top-10 pair inside one window is the repeat the bare key exists to stop). Same compose path, 0.6 gate, watch_chart story tag, per-slot cap and board fold as before – the rung entry is a new KIND, not a new lane, so no new event. The take states the release’s AGE only where the blob does (owner ask 2026-09-06, “cut those last sentences on age of the record”). Two shipped X posts closed with “a track over a decade old” and “a catalog cut over four decades old still finding new spikes”; Axiom showed 20 watch-chart takes in 30 days with the same tail (“thirteen years deep”, “sixteen years after its release”). The cause was not in chart_story_inputs but one layer up: the shared _CONTEXT block’s AGE / STAGE paragraph opened “the head names the release’s milestone” as a fact, written for the Luminate tracking head (catalog, out 4.2 years), and every chart lane hands the model that paragraph beside a row with no age in it, so the model supplied the age itself. The paragraph is now a CONDITION (“WHEN the head states one … when the material states NO age or milestone, say nothing about how old the title is”), a reword of the causing text rather than a stacked counter-rule (docs/PROMPT_OPTIMIZATION.md). A real-model n=6 dry run on the Bruno Mars blob went 3/6 -> 2/6 age clauses, so the prompt alone does not zero it; output_checks.ungrounded_age_claim is the deterministic backstop, wired into _drop_ungrounded on both cogs (reason=ungrounded_age, docs/OBSERVABILITY.md). It is the age-in-words twin of the #2081 digit check (“14 years old” was already dropped, so the model wrote “fourteen years deep”), and it stands down whenever the source states any age at all.music_desk.py’s CAREER STANDING on a thin chart row (_career_context, #3363, off the engagement measurement in that issue). WHAT IT FIXES: the chart lane said WHERE a title sits (“debuts at No. 81 on the Billboard Hot 100 this week”) and stopped. Measured over 1,034 of our own posts (2026-09-08..15), a bare chart position is the WORST-performing post type on all 45 wire accounts we read (0.69x their own median) and 37% of our output, while a post stating a record or a career standing is the BEST (1.30x) and 1% of ours; @chartdata’s “LISA ties JENNIE … the most entries in Hot 100 history (7 each)” took 8,899 engagements against 0 for our own bare card on the same chart week. NO NEW MACHINERY: the newsroom already derives this off Billboard’s per-artist chart-history page and already grounds it, so the desk calls the SAME pure ladder in utils.music_news through the SAME billboard.artist_entries client – “their Nth entry” keeps one definition instead of growing a second that could drift. The block joins _number_grounding as well as the context, because a fact the model may state must be a fact the ungrounded-number gate accepts. TWO GATES, both measured, both narrowing: (1) only a debut/reentry move – the A/B improved 4 of 4 thin rows but REGRESSED a row that already had its own hook (Luke Combs’ “a new peak 24 weeks into its run” 0.92 -> “his 43rd career entry” 0.88), and on a jump the model ignored the block outright; (2) music_news.is_latest_entry must confirm the title is the act’s NEWEST on that chart – entry_ordinal_fact is always arithmetically right, but a chronological ordinal on an older title implies a recency it lacks (live on the Hot 100 dated 2026-09-19, Ariana Grande’s “Hate That I Made You Love Me” is her 99th entry with ELEVEN charted after it). Gate (1) lands on the same condition the newsroom already uses for this rung (_debut_confirmed), which is the sign the narrowing is real rather than fitted. peak_history_fact and chart_run_fact are deliberately NOT rungs here: the run fact restates weeks_on, already on the chart row, so four of four A/B pairs came back identical. Only hot100 and billboard200 have a readable history page, so every other chart returns “” with no fetch. Fail-open throughout – every miss ships the card exactly as before. Emits music_desk_career.music_desk.py’s CAREER MILESTONE on a projected debut (_projected_career_fact + music_news.projected_entry_fact, #3363, owner steer “if we have any scar tissue related to career milestones, please remove those”). The HITS projection lane could state a rank, a unit count and a sales split and NOTHING about the act, because its framing banned career claims outright. THE BAN’S PREMISE EXPIRED and the ban was not doing its job anyway. Its premise was that “a projection row carries rank, units, the sales split and the label – never career history”, which held only while nothing else was handed in. And measured live on the 2026-09-19 projection, the flat-ban arm wrote “a solid new entry with no chart history to lean on yet” about DOLLY PARTON, who has fifty-one Billboard 200 entries: a ban on career claims does not produce silence about a career, it produces an ungrounded guess in the other direction. The lane now resolves the act’s billboard200 chart history and passes hits.compose_inputs(career_fact=...), which SWAPS the ban for that one fact (“State that and no other career fact”); with no fact resolved the flat ban still stands, which is the default. projected_entry_fact owns the guards and is pure: the title must NOT already be on the history page (Beyonce’s “B’Day” and Kanye’s “Graduation” both project onto next week’s chart and both already sit there – catalog re-entries, not debuts, and “would be” about a printed entry is simply wrong); the page must be NON-EMPTY (a guessed slug that 404s and a genuine first-timer are indistinguishable, and calling a veteran’s tenth album their first entry is the worse error); every date must parse. The wording is “would be” by construction, so a release that misses the chart leaves the sentence true as stated. Live result: “Dolly Parton’s greatest-hits comp is tracking for #21 on next week’s Billboard 200 … and it’d be her 52nd entry on that chart.” The CAVEATS are gone too (owner steer, “don’t give any of those caveats, please, ever”): the framing no longer says the number “is not a chart position, not a settled number, and not a market price”, and projection_standing no longer appends “– a FORECAST, not a printed position”. The tense rule survives as one clause. The chart lane’s own ban is conditional the same way (billboard.compose_inputs(has_career_block=...)): since #3365 the desk hands it a grounded CAREER STANDING block, so the flat ban was telling the model not to make a claim and then handing it one to make. Fail-open everywhere – a miss keeps the ban and the post we already make. Emits music_desk_career with kind=projected_entry.music_desk.py’s whole-chart presence board (chart_boards.platform_presence, #2800 line C, the second fact). The act holding the MOST entries on a platform chart today, once it clears chart_boards.presence_floor (8 entries or 5% of the chart, whichever is higher) with an outright lead (a tie yields nothing) on a COMPLETE read (chart_boards.PLATFORM_CHART_ROWS, 95% of the printed size, positions past it dropped, else no claim), is a platform_presence board on the platform-board rotation: rows attributed to the LEAD act of the credit, placeholder credits skipped, acts bucketed by normalized name, distinct releases by normalized title (the top-10 count’s own rule), the act’s best-placed rows drawn, the whole count in the headline and figure; a joint credit groups under its first party only when the known-artist list corroborates both parts (lead_party, the watch boards’ rule). Any act, not only the watchlist. Story board_presence, its own key music_board_presence (GRADUATED 2026-09-07, master-only – the same graduated_stage gate as the platform lane, no settings read; the lane still hands that stage over as extra meta so its keys stay stage-scoped), posts on a NEW HIGH for the (chart, act) – the count is read against the shared milestone store under entries:<stage>:<chart>:<act> (metric board_entries) and recorded on DELIVERY (_record_ship / the dedup close-out, carried as presence_state meta; the high, the act key and the daily key are all stage-scoped so a #bot-logs audition never advances the room’s high or spends its daily turn), so a steady leader (Drake’s ~10 albums on the Apple albums chart every day) is one card and not one a week; the store’s 21-day prune is the episode reset (only the chart’s leader is read each sweep, so an act that stops leading for three weeks reads as first sight next time) – with the 9-day pfbp: act key as the belt, counted against the platform lane’s day ceiling. Measured 2026-09-02: Rod Wave 23 of Apple’s 100, Dolly Parton 17 of iTunes’ 100, Olivia Rodrigo 10 of Spotify US’s 200; a quiet Spotify Global leader holds 6 and is held out. Emits music_desk_scored with phase=board_presence.music_desk.py’s X crowd-read withhold (_withhold_crowd_read, owner report 2026-09-06). The desk composes with an X crowd read (market_topic_pulse) beside its number. That block buys ONE angle: the crowd against a live market question. A take that REPORTS a number has no such question, but the block still licenses its own clause, so the model wrote the reception instead of the number – the desk shipped ‘“Bass Persuades” is on pace for ~18.5M Luminate streams in its debut week, and X is already treating it as a Miley win’. The “X is already treating it” clause is that block talking. So the read is now fetched ONLY for a live units/sales market: the projection lane, any STREAMS read (a pace or a count – the card carries no market link, so there is no market to argue with) and any SETTLED reveal (the question is answered) all skip it, and skip it BEFORE the fetch, so the Grok call is not paid for and discarded. This is the same rule market_alert.py’s skip_crowd_read and market_drop.py’s already run, after the same failure (a forecast take that read the timeline instead of the forecast); withholding the input beats banning the output (#1140). Measured on Sonnet against the real composer, n=9 per arm, same reading and context in both: a crowd clause in 9/9 takes with the block, 0/9 without, and every take without it reports the figure and stops.music_desk.py’s tracking gate on a live streams ladder (music_markets.tracking_gate, run inside rank_desk; owner report 2026-09-07). The reveal/tracking lane took every live ladder the sweep returned. On 2026-09-06 it shipped ‘“Bass Persuades” is on pace for ~18.5M Luminate streams in its debut week’ off KXALBUMSTREAMS-BAS26SEP24, the ALBUM’s first-week streams event for the week of September 18-24, twelve days before that week opened. The 0.50 crossing sat between the 15M rung (30 lifetime contracts) and the 20M rung (20 contracts), and the ladder had no 0.25 crossing at all. A reader on X took the number as the same-titled SINGLE’s count for the week in progress (it had ~1.1M US Spotify streams over two days) and asked where it came from. The units lane (rank_debut_projections) already refuses both halves of that shape: a window more than _PROJECTION_WINDOW_LEAD_DAYS out, and a band is_confident_projection rejects. tracking_gate applies the same two checks to every live tracking read; a measured read (settled, or a partial-resolve value_so_far) passes untouched, and a yearless period (the weekly artist-streams family) fails open on the window check. The second half of the fix is the AGE: resolve_reading_age_days used to send a KXALBUMSTREAMS reading to the Deezer date lookup, which matched the single (out Sep 4) and framed the album’s ladder ‘debut week’ (and spent the metered Songstats read on the single). The event title says ‘First Week’ (every event in that series does, measured 2026-09-07: 29/29 open, 3/3 settled), and parse_event_reading now carries that as LuminateReading.first_week; a first-week streams reading rides the same window-framed branch as KXPUREALBUMS (#2519): None before the window, the days-in count inside it. A weekly-streams event whose title does not say first week (a catalog album’s tracking week) keeps the lookup. Replayed against the live sweep on 2026-09-07 (see the PR): the gate dropped every pre-window album-streams ladder and kept the in-window ones with a bounded band. The gate is silent by design, like rank_debut_projections; a blackout still shows as surface_dark.music_desk.py’s one board per chart week, and a deduped move closes out (2026-09-11, owner report on the 2026-09-12 Billboard week: “a lot of our boards are being duplicated … the chart ones that should be weekly”). Two repeats, two causes, both fixed at the class. (1) A fact key that reads the DRAWN rows changes inside a chart week. The individual-artist board keys on the act’s ranks (_artist_catalog_dedup_key, bbac:<chart>:<act>:<ranks>) and the standings boards on the subject + count (_standings_dedup_key); #3147 raised the card’s row cap from 10 to 11, so Drake’s Billboard 200 board keyed on 10 ranks on 09-09 and 11 ranks on 09-11 and posted twice, word for word. Each lane now carries a per-chart-week key beside the fact key (_artist_catalog_week_key = bbac:<chart>:<act>:wk:<week>, _standings_week_key = bbst:<chart>:<kind>:<subject>:wk:<week>): a board is skipped when EITHER is in the window (_artist_catalog_seen / _standings_seen) and both stamp on ship (extra_dedup_keys). The fact key still keeps a stable catalog from re-boarding across weeks; the week key is the invariant – one board per act per chart per printed week, whatever the rows look like. (2) A move the dedup gate rejected kept carry-over and re-composed every slot until the wording window passed, then shipped as a repeat. _sched_on_dedup closed out the publication boards only, on the #2037 reading that #1982’s carry-over (“an unposted move stays a candidate”) covered the movement lane; but #1982 is about a move the SLOT CAP skipped, which never reaches the hook, and a move the gate REJECTED is a story the room already has. Measured: the top-ten board’s take said “Don’t Look Down debuts at No. 1” on 09-09 18:14; the move card composed and passed the self-gate on 35 hourly slots, was dropped as a duplicate each time (post_dedup phase=chart), and at 09-11 08:11 – 36h later, WIRE_DEDUP_WINDOW_HOURS exactly – it shipped; the act board and the census board did the same. chart, standings and artist_catalog now close out with the boards (every key the unit carries stamps; a production DEBUT books its cross-surface key through _book_chart_debut, the same rule _record_ship applies). The wording gate is a 36h window by design (WIRE_DEDUP_SURFACES), so it is the LAST net, never the one a weekly board relies on. Tests: test_a_deduped_board_or_move_is_closed_out, test_artist_catalog_week_key_blocks_a_re_keyed_board_in_the_same_week, test_standings_week_key_guards_one_board_per_chart_week.music_news.py’s proactive watched-artist pull (owner steer 2026-08-20): the newsroom is otherwise REACTIVE — it reports what a followed account posts. This lane inverts it so Toots drives the TOP artists’ major updates herself. Each slot _gather_artist_pull rotates a bounded set of the desk’s watched list (top ~25 by daily streams swept fast + the ~75 tail slow — owner steer “prioritize the 25, still pull all 100, the 75 less so”; rotation is a consecutive window (_rotate_window, per_sweep names) whose start is a PRNG offset SEEDED by the wall-clock 30-min slot index — deterministic per slot (shared across guilds so the discovery memo hits) and RESTART-STABLE (no process state), while the PRNG removes any arithmetic tie between the schedule and the start, so no cadence — however sparse (the whole-hour CHILL_TIMES fallback) — can residue-lock coverage onto a fixed subset. The top-25 are swept every slot (always well-covered); the tail-75 is covered over a longer horizon — the “the 75 less so” bias by design (guaranteed bounded-time tail coverage would need a persisted sweep counter, deferred to keep the lane stateless); budget MUSIC_NEWS_ARTIST_PULL_PER_SLOT=6 / _TAIL=2), asks Perplexity for each one’s biggest NEW CERTIFICATION / TOUR development in the past week (_pull_artist_news, one call per artist, memoized ~1 slot in-process so a multi-guild tick spends each search once; a NONE/hedge answer skips the artist), and wraps a real answer as a synthetic SourcePost (platform=artistpull, source tag artist_watch, id hashed from the folded FIRST-SENTENCE claim so a reworded/re-cited response does not re-post). Recency gate (#2488): the pull has NO source-tweet time — its synthetic post is stamped now — so the _RECENCY_HOURS window cannot catch a STALE event the model surfaced. That is how the Weeknd’s $1B tour record (crossed Nov 2025) shipped as a “breaking” card in Aug 2026: “the single most significant news” pulled the biggest LIFETIME number, and Grok verified the fact TRUE (never that it was NEW). So the prompt now asks for a fresh past-week EVENT and forbids a standing all-time record, and makes the model LEAD its answer with the event date in ISO form; _gather_artist_pull reads that date back (mn.parse_pull_event_date) and DROPS a story whose event is older than _ARTIST_PULL_MAX_EVENT_AGE_DAYS (10) or carries no date at all (fail CLOSED — the stated date is the pull’s only freshness signal), counted as detail.stale. The date prefix is stripped before the text reaches the classifier or the card. Those flow through the SAME _gather_candidates slate → classify → _artist_in_tier → _compose_one verify → self-gate → seen-set/TOPIC_DEDUP pipeline as a wire post, with a RESERVED lane (_order_artist_pull, composed first, like the first-party feed lane) so a zero-engagement pull story is not crowded out; the pull’s slate share is capped (_ARTIST_PULL_MAX_STORIES=3) so it can never fill the whole _MAX_COMPOSE_ATTEMPTS shortlist and starve the wire. The engagement lanes also BLEND in the recognition tier (owner steer 2026-08-21): after a story is classified (subject known), its engagement score is multiplied by utils.music_news.tier_weight of its subject artist’s artist_watch.recognition_tier (watched 1.6 > known 1.15 > unknown 1.0) before order_by_freshness / order_for_breaking sort — so at similar engagement the bigger name leads both lanes, but it stays a BLEND (a multiplier, not a hard tier sort), so a genuinely viral unknown story still wins on raw engagement. Fail-OPEN: if the tier lists are unavailable the blend is skipped and the lanes rank on raw engagement. The source tag is untrusted (may_relay_number false), so a pull story ships ONLY when independently verified (grok X + Songstats) — the same verify-or-drop fence every wire story passes. It drives release/cert/tour updates only: the DESK owns streaming MILESTONES (_watch_milestone_units + songstats_milestone_state) AND chart POSITIONS (its platform-chart sweep), so a milestone/chart classification off a pull is dropped in _pick_stories (music_news_filtered reason=artistpull_desk_owns) — dropping chart also keeps the pull clear of the live-daily-chart staleness guard, which needs an event time this read cannot supply. No new cog and no new calendar (owner steer “minimize the machinery I manage”): the pull rides the newsroom’s existing slot, channel picker and X crosspost toggle; gated on the music_news_artistpull overlay (STAGING default, OFF skips the watchlist read and all Perplexity spend) and RIDES THE PARENT newsroom’s staging-vs-production routing like the first-party/no-chart overlays (the overlay gates the lane’s activation; the parent stage decides room-vs-#bot-logs), so a staged audition can never pollute the production dedup list. Because the pull’s Perplexity discovery IS the claim, the verify judge is fed only the INDEPENDENT signals (Grok + Songstats), never a Perplexity re-confirming a Perplexity. Emits artist_news per sweep (detail.mode=pull). Sibling lane — deterministic NEW RELEASES (_gather_new_releases, owner steer 2026-08-21): the SAME tiered rotation reads each watched artist’s Apple Music discography (apple_music.artist_albums, keyless + free, and it carries a pre-release with real cover so a same-day drop is caught), and turns an ALBUM drop (singles/remixes filtered via the catalog’s own _is_single_or_short, EPs kept — precision over recall) dated within _NEW_RELEASE_WINDOW_DAYS (7) into a release candidate with a STABLE newrelease:<collection_id> id (a drop posts once). A re-dated reissue is NOT a new drop: Apple’s releaseDate moves to an anniversary edition / remaster / catalog re-upload while the phonogram COPYRIGHT year stays original, so _recent_release skips a row whose apple_music.copyright_year is _NEW_RELEASE_REISSUE_LAG_YEARS (2)+ years before its release year (the loose 2y clears a Dec-mastered/Jan-released album AND a re-recording, which carries a FRESH copyright and stays genuine; a missing/unparseable copyright is never flagged — the window + self-gate remain the backstop, prefer a miss to a fabricated drop). The candidate flows through the SAME pipeline with its OWN reserved compose lane (_order_new_release, composed FIRST in _sched_compose_units — a zero-engagement, noon-timestamped drop could never win the engagement-ranked breaking/biggest lanes, so it leads via a reserved slot, like the pull and first-party lanes; carrying the catalog row’s real cover art so a fresh drop’s card is never coverless, and collapsing clean/explicit editions so one drop posts once). The two proactive lanes share one slate cap _PROACTIVE_MAX_IN_SLATE=4 so they can’t starve the wire; the Apple fan-out is semaphore-bounded + memoized ~1 slot (the RAW discography) to avoid tripping the shared Apple breaker. So Apple OWNS releases and the pull no longer asks for them — the two divide cleanly (Apple = releases, pull = certs/tours), a deterministic read a web search can miss: the top ~25 are swept every slot (caught for sure), the ~75 tail best-effort (the same probabilistic PRNG coverage as the pull; a guaranteed full-tail sweep is the same deferred persisted cursor). Emits artist_news detail.mode=newrelease; gated on the music_news_newrelease overlay (OFF default — dark until a mod opts in, since it rides the parent stage for routing). Overlap to weigh: the broad nightly new-releases BOARD (below) already lists ALL fresh drops as a roster in the rec channel — this lane instead gives a WATCHED artist’s album its own newsroom NEWS take; keep both only if that focused take earns its place. A SALES-unit carve-out of the desk owns it backstop was tried and reverted (#2789, review round four). The desk’s ladders have no RIAA-units rung, so a pulled sales total was refused to a lane that does not exist — but the pull’s discovery prompt refuses lifetime totals, so the carve-out could never fire, and a units milestone has no permanent dedup key. The whole story is #2793: discovery, ladder, key, then the backstop, in that order. Live moments (2026-09-02, #2800 line D): the pull’s beats widened to a tour or festival announcement or new dates added and a surprise guest appearance or unannounced pop-up performance the artist PLAYED (not attended; not a routine stop on an announced tour), the classifier’s release kind covers a LIVE MOMENT (an audience sighting -> none), the record compose steer and the release score rubric name it, and _claim_is_live gives the card a music · live kicker with photo-first art and no listen link (the tour treatment). The first card shape regressed (owner report 2026-09-02, the Troye Sivan / Olivia Rodrigo cards): #2831 wrote the live subject as the bare ACT and the whole event sentence as the claim, so the card heroed “Troye Sivan” over a two-line, cut-off caption (“why title their names and not the event subject”, “subtitles way too long”, “i’d rather nothing on the photos”). Now the classifier writes a live subject as <the show or set it happened at> - Artist (“Don Toliver’s Toronto show - Drake”), _live_card_copy heroes the show, bylines the act and draws NO caption (the take under the card carries the sentence), and _artwork_and_link(is_live=True) resolves the photo from the ARTIST CREDIT alone, because the show name is another act’s and the desk resolver anchors its search on the title (the #2472 class). The live test runs BEFORE the tour test in _build_card: a live claim names the show it happened at, and “concert” is a tour cue. The Rodrigo card also wore the RELEASE tag because “performed an intimate GRAMMY Museum set” matched no live phrase, so the cues now cover an intimate / acoustic / secret / one-off set PLAYED, with future-tense anti-cues (“announces”, “will perform”, “tickets”) so a show announced for later stays a tour-shaped announcement. A performance on a named stage or broadcast (owner report 2026-09-15, #3363): LE SSERAFIM’s “Made My Night” on The Jennifer Hudson Show matched no live phrase either, and no phrase COULD reach it – the wire writes the song between the verb and the venue (“performed ‘Made My Night’ ON The Jennifer Hudson Show”), so even a “performed on” cue misses, and naming the talk shows would be a vocabulary to maintain that still misses the awards stage. The story therefore stayed a plain release: it wore a NEW RELEASE kicker for a TV performance, and _artwork_and_link resolved art off the WHOLE subject, so image_subjects kinded “The Jennifer Hudson Show” as a tv title and returned its TMDB poster. The delivery coherence gate caught that (“image shows Jennifer Hudson herself, not LE SSERAFIM”) and dropped the art, so the post shipped a coverless text card. _played_at_subject reads the SHAPE the classifier already writes instead of more phrases: a performance verb in the past or present, PLUS the subject’s own title following “on” / “at” / “during” in the claim. It runs inside _claim_is_live after the anti-cues, so a booked-for-later show and an audience sighting are vetoed exactly as before, and a tour announcement can never match (its claim lists dates and never names its own subject title as a place the act played). One test fixes all three wrong signals at once: the kicker becomes music · live, _artwork_and_link(is_live=True) anchors the photo on the ARTIST CREDIT alone, and is_drop goes false so no listen link is attempted. The first cut matched too tightly (owner report the same day, the Olivia Rodrigo BMI Troubadour card): it required the subject’s title straight after “on”/”at”/”during” plus an optional “the”, and the claim read “… in Nashville at A BMI Troubadour Award dinner honouring Crow”, so that card still wore NEW RELEASE. A short bounded run of words (an article, a possessive, a city) is allowed between the preposition and the title now. Replayed over 219 real release stories from one week: 6 re-lane, 0 false flips, with the tight and the wide span alike.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.
music_news.py’s wire coverage of the SALES and MARKET beats (2026-09-01, owner report “our artist sweep isn’t greedy enough”). Five Drake stories ran in one day and the desk posted none. Measured cause, per story, against the live code: (a) RIAA sales units had no source. @hiphopdataa posts unit totals (five in three hours on 2026-09-01: “‘Up All Night’ has now sold over 3 million RIAA units”, “‘Passionfruit’ has now sold over 11 million (@RIAA) units”) and was not on _TRUSTED, so the desk polled 41 handles and saw none of them. It is added as CULTURE (relay_number=False), so the claim settler checks the number rather than relaying it. @Genius is added on the same evidence for the CATALOG beat — its on-this-day posts state real chart facts (“today in 2014, ilovemakonnen dropped ‘tuesday’ featuring drake. the track peaked at no. 2 on the billboard hot r&b chart”). That post needed two supporting fixes to reach compose at all, both found by the #2789 review, and both are the kind of gap that only shows up when a NEW source’s house style meets old detectors. (i) A hot r&b chart recognition was tried and REVERTED after four review rounds. chart_reportable knows only the R&B/Hip-Hop Songs spelling, so “billboard hot r&b chart” is cut as unnamed. Adding the shorthand leaked three ways in succession: it passed the gate but fell through the RESOLVER (_CHART_PATTERNS, read by _live_chart_position) to the Hot 100; then it matched “Hot R&B/Hip-Hop Airplay”; then it matched an ADJECTIVE — “The hot R&B singer earns her first Billboard chart entry” passed the fail-closed allowlist as genre_rnb with no chart named. That last one is disqualifying: the chart list is the owner’s cut, and a genre word must never pass it. Two lessons stay in the code comments: the scope gate and the resolver are two hand-maintained lists that must name the same charts, and a resolver row placed before the generic airplay exclusion must not be matchable by an airplay chart’s name. The @Genius catalog posts that ship are the ones naming a chart in full (“debuted at no. 3 on the billboard 200”). (ii) retrospective_note accepted this day in <year> and on this day in <year> but NOT @Genius’s today in <year>, so it returned None. A chart story skips the stale-recap gate, so an undetected throwback could have been stated as a CURRENT chart position — the exact class utils/retrospective.py exists to stop. The frame is added, and its year guard is now keyed on the captured year group rather than on one frame’s LABEL, so a year-bearing frame added later cannot skip the future-date check. (b) A market PRICE ranked below every chart claim. _CHART_CUES named the venues (kalshi, polymarket) but not the price, so @Kurrco’s “Kalshi currently has the odds at 61%” scored 1 — the weakest strength_buckets bucket, competing for 12 _MAX_CLASSIFY culture places against a pool measured at 57–154 per slot. A market PRICE cue was tried here and REVERTED over three review rounds (#2789). Every form of it admitted ordinary prose, because _CHART_CUES is not only a ranking signal — strength_buckets drops a zero-strength culture post, so a cue changes ADMISSION for a post carrying no other domain cue: bare odds scored “the odds are against him” 1; odds at scored “the odds at this point are against Drake” 1; odds at|of \d scored “the odds of 10 artists appearing are slim” 1. Each spends one of the 12 _MAX_CLASSIFY places on a post naming no price. It was reverted on PROPORTION as much as on the leaks: the motivating post is ALREADY admitted at strength 1 by the kalshi cue, so the change only ever bought a 1-to-2 ranking nudge for posts that already get in — not worth a cue that kept leaking. The real lever on a market post’s rank is the classify BUDGET, measured in #2792. A test pins the revert, so re-adding the cue fails. What this change does NOT fix, and why: the other two stories carried no number at all — a surprise guest appearance (Drake brought out at Don Toliver’s Toronto show) and a teaser note (“2026 FOMO”). The newsroom is a NUMBERS desk by design and the pop desk owns music culture, so neither is a newsroom miss. They are a pop-desk RANKING limit: it ranks the whole entertainment wire by engagement and composes _COMPOSE_CANDIDATES = 4. See the follow-up issues on the culture-moment lane, the on-this-day lane, and the culture classify budget.music_alert.py:_rank_snapshot builds every rank-hero lane’s card (the Apple crown + jump + the two Apple debuts + the radio entry; _is_rank_figure is what routes the crown, because the Spotify crown heroes a STREAM COUNT and a count keeps its caption – without “streams today” the number has no unit), and music_news.py:_build_card’s chart branch (the tag is data_source – the settled chart’s label, else the label of the ONE readable chart the claim names, else the one the source post names (mn.unsettled_chart_label, 2026-09-08: the live read misses whenever kworb’s cache is behind the wire (#3191 cut that flat 3h hold to a 2-minute revalidation, so the window is now narrow), and Ashe’s “Stop the Wedding!” card shipped tagged “SPOTIFY” under a take that said “Spotify’s Global Daily chart”; the classifier’s terse “debuts at No. 199” form can leave the chart in the post alone; a bare “Spotify” or “Apple Music” claim names two charts and keeps the wire’s authority, and a FORECAST (“projected to debut at No. 1 on the Billboard 200”) never takes the chart’s name because the release has not entered it – so the tag never guesses; the deep-chart floor reads the post’s chart the same way when the claim names none), else the wire’s authority – and the generic kicker only when all three are blank; chart left _KIND_ACCENT_KEYS). The verified source PILL then repeated the chart under the credit line (owner, same day: “sub is getting redundant”), so the renderer now skips the stamp whenever the tag already carries the source’s name as a whole word (market_chart.stamp_is_redundant, case-insensitive): “APPLE MUSIC US SONGS” draws no “✓ Apple”, “BILLBOARD 200 - NEW PEAK” no “✓ Billboard”, while “MUSIC · CERT” keeps its “✓ RIAA”. One rule in the renderer, so every surface’s card obeys it. The music desk’s chart cards (artist_watch.chart_story_card_fields, the weekly peak / chart-run milestones) are deliberately NOT changed: they already tag the chart label, and their caption carries the ARTIST (the title is the card’s headline on its own), so cutting it would drop the act from the card. The MARKET number card follows the same rule (owner steer 2026-09-08, “our market blocks should do the same: put the chart in the block and remove the duplicate sub”). A market whose own words name the CHART it settles on wears that chart as its accent block, and a LIVE rank hero then carries no caption: the reported card read “ENTERTAINMENT / #2 / Dai Dai / 20% · Runner-Up Daily Music Video Global on YouTube: Sep 8, 2026” beside a music card reading “APPLE MUSIC US SONGS / #16 / the cure - Olivia Rodrigo”; it now reads “YOUTUBE GLOBAL MUSIC VIDEOS / #2 / Dai Dai”, with the take under it carrying the odds. One home, market_cards.chart_card_fields (called once in build_market_card, so the drop, the alert and every other market surface get it), over a registry of the exchange’s own chart phrasings in market_chart_tag: the four YouTube daily music-video series, Billboard 200 / Hot 100, Spotify’s year lists (region + year, since the list is annual), Netflix’s weekly lists, a registered rank authority (TelevisionStats most popular movies), the Luminate count metrics (“Ow by Sylvan Esso: First Week Streams” tags FIRST WEEK STREAMS and titles the card “Ow by Sylvan Esso”), and the artist YouTube view-count family (“Ariana Grande: Highest daily YouTube view count (August 24 - August 30, 2026)” tags YOUTUBE DAILY VIEWS and titles the card “Ariana Grande” – the most-posted music market family of the 30 days to 2026-09-08, 30 alerts, and every one of them titled its card with the whole event title). Three deliberate keeps: a PROJECTED rank keeps its caption (“projected chart position” is what says the rank is a forecast, not a repeat of the tag); a COUNT hero keeps its odds qualifier (“22% to clear 2M” is not the chart); and the context STRIP (“#1 on YouTube today · 7.2M views”) stays, since it is a different fact from the market’s slot. A market whose words name no chart (a sports seed board, a draft pick) is untouched: the category ladder tags it and its caption stays, so nothing is cut where nothing moved into the block. Measured on the live board (8,000 open events, 2026-09-08): 146 tag, all Entertainment. Telemetry: market_card_hero.detail.chart. The same-day SWEEP over every music card (owner: “do a sweep for all music cards and entertainment music sub category cards”, then “keep the ranks on Spotify cards”) found five more repeats and fixed each at its source: (1) the music NEWSROOM’s streaming-milestone card (music_news._milestone_card_fields) read “MUSIC · MILESTONE / 30.1B / Harry Styles / 30,098,500,000 Spotify streams” with no rank anywhere – it now tags “music_news.catalog_count_tag -> _milestone_card_fields): “SONGS PAST 1B SPOTIFY STREAMS”. The wording is the DESK’s own catalog_stats.catalog_tag, the block music_desk already draws on its catalog card off the same kworb count – the desk had this rule and the newsroom did not, so one home now serves both. The tag applies only when the card’s figure IS the claimed count, so a block never labels a hero it does not describe. The Spotify RANK STRIP also returns to these cards: it used to share one early return with the metric tag, so a story whose milestone_id carried no readable metric lost the strip too – and those are the cards that need it most, because their block says the least. The strip hangs on the SOURCE alone now; a missing metric says nothing about whether the act ranks on Spotify.last-modified), that premise is wrong for most of what we read: ten of the fourteen pages refresh INSIDE a day – US iTunes was 0 minutes old at the probe, the Spotify all-time songs ranking 7, YouTube trending 10, the Spotify artist ranking 12, Apple Music songs 18, Apple Music albums 28, the YouTube video page 64, US radio 196, Spotify listeners 261, the US YouTube weekly 357. Only the four /spotify/country/ charts are daily (all four about 1370 minutes old, so they refresh once a day near 18:30 UTC). That flat hold sat on exactly the pages the CROWN lanes read, so a title could take #1 and wait up to 3h for our next real read, even though the alert tick runs every 10 minutes – the cache, not the tick, set how late we posted. Every kworb page serves BOTH etag and last-modified, so _fetch now re-asks CONDITIONALLY once _REVALIDATE_AFTER_SECONDS (2 min) passes: an unchanged page answers 304 with no body and no re-parse, and a changed one arrives within one tick. The conditional headers ride along ONLY when a cached parse exists, so a 304 can never leave a caller with nothing, and a page serving no validator keeps the old flat hold rather than re-scraping a full body every window. Only a whole-CHART or RANKING page revalidates. A PER-ENTITY page (_PER_ENTITY_QUERIES: track_, artist_songs_, artist_albums_ – the same three shapes ops_monitor._OPAQUE_QUERY_PREFIXES collapses, for the same reason) keeps the flat hold: its number moves daily, no lane reports it as breaking, and there is one path per entity. That distinction is measured, not assumed – on the live Axiom data (24h to 2026-09-12) the per-entity reads are 337 of 540 chart_fetch events (track_* 168, artist_songs_* 154, artist_albums_* ~15) against 14 or fewer a day for each real chart page, so revalidating them would have put the biggest and most unbounded group of reads onto a two-minute timer. A 304 is applied only while the cache entry is still the one that call read (the GENERATION GUARD, Codex on #3193): bot.kworb is ONE client shared by the alert tick and the desk slots, so two callers can revalidate one path at once, and a 304 that lands after a concurrent fresh 200 would otherwise write its stale rows over the new ones. That rollback does not self-heal – a 304 carrying no ETag keeps the NEWER validator, so every later revalidation is answered 304 against a page we never parsed and the new #1 stays invisible until kworb changes the page again. The losing 304 returns the fresher rows and marks itself superseded on its chart_fetch event. A 304 is a SUCCESS (ok=true, reason=not_modified, http_status=304, cached=true): counting it as a failure would trip the breaker on a perfectly healthy page. ONE reader comes with it: refreshed_at(query), the page’s OWN refresh time (its last-modified, never our fetch time). It is the first timestamp in this repo that says when a CHART changed rather than when we looked; utils/chart_presence.py stores only our first sighting. A changed_on_last_read companion was written and CUT before merge – nothing called it. A lane that wants to fire on the refresh itself should add it back WITH its caller, so the signal and its consumer land together. The four crown lanes stamp both onto chart_crown as refreshed_at + refresh_age_s. That is the DETECTION half: the event fires while the lane BUILDS the signal, before the daily gate, the self-gate and the repeat check, so a crown counted there may never have posted (Codex on #3193). The DELIVERED lead time – the “how fast did users see it” number the crown_lead_time panel reads – rides music_alert_posted.refresh_age_s, which only a shipped post emits; _CROWN_LANE_PAGE is the ONE home mapping a crown lane to the kworb page it reads, so the two cannot name different pages. Keeping both splits a slow READ from a slow COMPOSE. the RADIO crown lane emits that event for the first time, since it composes its own blob and so never rode _chart_crown. Deliberately NOT done here: the card states no time. A chart’s refresh minute is when the PAGE published the change, not the minute the title reached #1, and printing the one as the other would be the kalshi_price fail direction – prefer absent over invented. The same revalidation is not yet wired into the other scrape clients (Billboard, box office, HITS, Pollstar); that is the follow-up, and the helper is shaped to take them.BILLBOARD SONGS OF THE SUMMER / Most Songs of the Summer top 10s / Rihanna. The classifier names the record as outcome – the same field the market kind uses for the thing it prices, extended in the prompt line that defined it (“for a RECORD … the record itself, with the chart or body it is set on”), and _parse_music_news no longer scopes it to the market kind (a release, a miss and none still carry none). The layout keys on that field, never on the kind: the classifier’s kind for a record drifts between milestone, novelty and chart on the same wire (the Rihanna wire came back novelty before the #3135 prompt reword and milestone after). music_news.py:_record_card_copy builds the copy (title-cased headline, the artist split off the subject as the byline, data_source as the tag, else the generic kicker so nothing is invented for the block); _build_card applies it after the market copy and before the tour/live/announcement shapes, counts the headline as a hero for the no-card rule, and the milestone and chart branches stand down for it; _sched_compose clears card_figure for a record where the hero is settled, so the music_news_verified event reports no hero rather than a count the card never draws, and scripts/dryrun_music_news.py prints the record headline. The stamp follows the existing redundancy rule (stamp_is_redundant): a block that names the source draws no <check> pill. Rendering it found a caser bug: title_case_headline wrote “Top 10S”, because _WORD_RE starts a word at a letter and the “s” after “10” was a word to it; a token that follows a digit with no space is now left as written (“10s”, “3rd”). Dry-run on the real wire n=5: every sample returned the outcome “Most Songs of the Summer top 10s” with source “Billboard Songs of the Summer”; the count controls (a #135 debut, a first-week total) returned none.feed_photo). On a tour announcement that image is the ADMAT the publisher filed with the story, and an admat already prints the tour name, the act and every date. The card then drew all three again: the purple TOUR · ANNOUNCEMENT tag, “Do That Again Tour” as the headline, “Malcolm Todd” as the byline, the ✓ Pollstar stamp, and a bottom scrim that covered the date list the admat exists to show. market_cards.build_bare_art_card is the third card rung beside build_number_card and build_board_card: it takes her take and the picture, and draws nothing on the picture. market_chart.bound_bare_art only re-encodes the image to PNG and bounds its long side, with a byte budget so a busy photo admat cannot cross the 5MB limit that makes X reject an upload — it never crops and never stamps, because shipping the image as its publisher made it is the point. The rung is CANDIDATE-then-CHECK. Three cheap conditions pick the candidate: feed_photo art (the only rung that can say the picture belongs to THIS announcement — the catalog cover and the Deezer portrait that dress most tour cards are not the story’s own image at all), a TOUR, and no figure (a card that heroes a number of ours keeps its markup; a tour GROSS is the touring kind and never reaches is_tour). None of the three says the picture IS an admat, and that is what the check is for (Codex review on #3291). industry_feeds._first_image takes the enclosure or the first <img>, and a tour article leads as readily with a press portrait or a live shot; stripping the card off one of those costs the headline, the byline and the source stamp and returns nothing. So claude_client.image_is_tour_admat reads the image on HAIKU and answers ONE question: does the image itself list shows — dates, cities or venues — FOR THE ACT THE ANNOUNCEMENT NAMES. The act match is the second half and it was added for the same reason as the first: a feed article can lead with an OLDER or a merely related poster, and a judge given only the image accepts any schedule at all, so the rung would delete the card carrying the real announcement and ship an unrelated set of dates — the fabrication direction this repo refuses by default. The date list is the condition, not “does this look like a poster”: it is the one thing an admat has that a photo does not, it is exactly what the card’s scrim was covering, and a model can read it off the image instead of judging a style. The judge fails CLOSED, the opposite direction from the delivery-time media gates: doubt keeps the CARD, which is the shipped, branded behaviour. Measured live on every tour and festival image in the Pollstar feed of 2026-09-14, three samples each, all unanimous, with each image under its own announcement: the reported Malcolm Todd admat YES 3/3; the Stagecoach lineup poster YES 3/3; the Big Ears lineup poster YES 3/3 (it prints “APRIL 1-4 2027 KNOXVILLE” over a dense lineup our card would have covered); the Tim McGraw, Ed Sheeran and Oasis live shots NO 3/3; the Dave Chappelle tour graphic NO 3/3 (it names the act and the tour but lists no shows, so the card buries nothing). Four of those live-photo and no-date cases would have shipped unbranded under the unchecked gate. The MISMATCH pair was measured too and is what the act match buys: the Malcolm Todd admat under a Stagecoach announcement NO 3/3 (“not Riley Green”), and the Stagecoach poster under the Malcolm Todd announcement NO 3/3. Note that the context also CORRECTED a false refusal — without the announcement the judge read Big Ears as “lists artists but not individual show dates” and said NO 3/3. The cost is that the post ships UNBRANDED: no wordmark, no tag, no source stamp. desk_art carries bare as a TRI-STATE so that share is countable (see docs/OBSERVABILITY.md): true shipped bare, false was a candidate and kept its card, absent was never a candidate. It also carries delivered, because the card is built before _sched_deliver branches on the stage — read delivered == 'room' so a STAGING audition, which only reaches #bot-logs, does not count as an unbranded post. It records what SHIPPED, never what was attempted — the judge can say no, and download_image_bytes checks only the HTTP status and the body length, so a non-image body reaches the builder and comes back None. The take still names the source (“per Pollstar”). A middle option was NOT taken and is the one-line change if the owner wants it: keep the corner wordmark and drop only the headline block.✓ Kalshi stamp under that – over a photo of Jean Smart. The post around the picture already prints the eyebrow and the title in its heading, and Toots’ take states the odds, so the card said all of it a second time. Two pure functions own the rule, both in utils/market_cards.py. figure_is_number asks TWO things: does the hero carry a digit, and do its letters stay within _FIGURE_MAX_LETTERS (8). ships_bare_art requires four: real art, a non-music lane, a non-empty figure, and a hero that is not a number. Both tests are MEASURED, not invented. The digit test replaced a pattern that scripts/dryrun_number_cards.py broke on the live builders inside one run – the Billboard climb record heroes “+34”, the Netflix card “80.6M hrs”, the forecast ladder “~17M” and the companion projection “~61.4K”, each read as a word, and each would have lost the drawn card it should have kept. The LETTER CAP then came off the live hero distribution (market_card_hero, 888 cards, 400 distinct over 30 days), which splits cleanly at 8: at or under it every hero is a number wearing a unit (“27.9M hrs”, “14,806 units”, “957,000 copies”, “100 entries”, “13 concerts”), above it every hero is a phrase the caption says better (“1x Platinum”, “53 certifications”, “22 weeks at No. 1”, “Death of the Pastor’s Wife: Season 1”). Re-measure the cap rather than nudge it. MUSIC IS EXEMPT WHOLE (is_music_lane, reading the wordmark lane the card prints on itself, so the test cannot disagree with the card). The music lanes hero a number nearly always, and their few word heroes are facts the artwork does not carry: the chart-exit “OUT” and the RIAA level “Gold”, which the owner reads as a number card in its own right. Dropping the markup there would lose the story; dropping “Emmy Winner” off a photo of the person it names only removes a repeat of what the caption already says. An earlier revision of this rule stripped them and also split the RIAA lane in half – “8x Platinum” kept a card while “Gold” lost one – which is the incoherence the exemption removes. Both market surfaces brand a music chart the same way. is_music_chart_market used to live in cogs/market_drop, where only the drop could reach it, so a Billboard or Spotify chart market branded tootsies music from the drop and tootsies predictions from the alert – one market, two wordmarks. The bare-art rule reads the wordmark, so the alert’s copy was not recognised as a music card and a word hero on it would have been cut. The test now sits in market_cards beside is_music_lane, and market_alert stamps the brand on its base figure dict, so every figure shape it builds carries it (owner steer 2026-09-15, “just keep music”). The rule is wired at both funnels – build_market_card’s number rung (where the Emmy card is built) and build_number_card (the desk / newsroom / cinema lanes) – and each passes the SAME lane it hands the renderer, so the exemption and the wordmark always agree. Unlike the tour-admat rung above there is NO vision judge and no feed_photo condition: the test is what the card would DRAW, not what the picture happens to contain, so it needs no model. The post keeps its heading, its take and its market button – build_bare_art_card takes url + button_label so a card that drops its markup does not also drop the reader’s way through to the market. A build that returns None (bytes that do not decode) falls through to the full card. Measured reach: 92 of 912 heroed cards a month (10%) ship their picture BARE, all on the market surfaces (alert 48, predictions 26, drop 18) and NONE on a music lane – the awards, film, festival and TV markets whose hero is a nominee’s name, a show’s name, “Emmy Winner”, or a settled YES/NO/Tie. Every FIGURELESS card is untouched: that is the announcement shape, and music_news decides it per story with the judge above. Telemetry: market_image gains the bare_art value on image_source, and market_card_hero the bare_art value on rung, so the mix is queryable with no new field against the Axiom budget.utils/music_news.py, four pure guards, each the deterministic backstop under a line in the classify_music_news prompt). figure_is_rank_word drops a ranking word with no number behind it (“most”, Cardi B’s Diamond-singles card, 2026-08-30, #lame-most). collapse_rank_run names a run of positions from #1 down as its block (“#1, #2, #3” -> “Top 3”, Drake’s Billboard 200 lockout, 2026-09-04). lead_figure keeps the FIRST of two or more magnitudes joined by a slash or a semicolon (“282k / 496k” -> “282k”: BTS’ four sold-out SoFi Stadium shows, 2026-09-08, owner report “makes no sense and card number is wrong”). The wire stated a four-show count AND an eight-show total, the classifier wrote both into figure with a slash, and a slash between two numbers reads as a fraction on the card. The first side is the number the claim leads with and the take is told to quote, so the card and the prose agree; a side that is a word, a list of chart positions (left to collapse_rank_run), a range (“120-130k”), a comma inside a number (“282,000”) a composite measurement joined by “and” or a comma (“1 year and 6 months”) and a ratio of two small bare integers (“3/3 albums”; both from the Codex review on #3099) are never split. All three run in _sched_compose where card_figure is settled (so the music_news_verified event reports the hero the card draws), again in _build_card as the renderer-side backstop, and in scripts/dryrun_music_news.py. A THRESHOLD the wire states is a rung the count passed, not the count (figure_is_passed_threshold + count_past_threshold, Rod Wave’s Hot 100 card, 2026-09-09, owner report “Should be 107”): the wire read “16 songs from ‘Don’t Look Down’ chart on the Hot 100, surpasses 100 career entries”, the classifier quoted “100 entries” as the figure (its prompt says to quote the post’s number), the verify judge confirmed the story and returned “16 songs, 107 career entries”, the take quoted 107 off the research, and the card heroed 100. The media gate read the disagreement and kept the image by design (a copy verdict presumes the card is the correct half; here it was not). The rule is a fourth pair: the first helper reads the WIRE TEXT (the classifier’s claim plus the post) and says whether the figure’s number sits behind a passing verb or a floor phrase there (“surpasses”, “passes”, “crosses”, “exceeds”, “tops”, “over”, “more than”, “past”, “at least”) or carries a “+”; the bare adjective “top” is not a cue, so “top 10” stays a block. The second picks the count out of the judge’s figure: the segment that shares the wire figure’s unit word (“entries”) and is LARGER than the rung; a figure with no unit word (“100M”) takes a single-segment answer only; anything else returns “” and the rung stays, because the rung is at least true. It runs only on a VERIFIED verdict and only when the wire figure is a rung, so a number the post states as a count still wins, exactly as before. Settled in _sched_compose and mirrored in scripts/dryrun_music_news.py; _build_card cannot backstop it (it has no research). The prompt line was REWORDED, not extended: “the number the story is ABOUT” became “ONE number … the headline figure the claim leads with … never two totals side by side” (docs/PROMPT_OPTIMIZATION.md). The CAPTION is one fact too (lead_clause, the same BTS card, owner report “why is the sub so long”): the caption is the classifier’s claim, the claim rule quoted the post’s exact figure, so a wire with two totals gave a two-clause claim that the renderer’s two-line cap ellipsized. The claim rule now says ONE fact (“a second total the post also states stays out of it”), and until the caption went (2026-09-10, below) _build_card passed lead_clause(claim, figure) as the card’s sub: on a STAT card (a figure heroes), the clause before the first semicolon when the text after it carries a second TOTAL (a k/M/B or million/billion/thousand scale, a currency sign, or a thousands comma), else the claim whole, so a date, a week count or a show count after the semicolon never cuts (“34-date APOP World Tour; opens Oct 12 in Paris” stays whole even under a “34 dates” hero), and a tail that QUALIFIES the hero (“10 albums; each sold 1 million copies”, opening with each/every/all/both/including/of which/averaging, or “with” followed by a number or a distributive word; “per” and a bare “with figure_is_phrase, Rihanna’s Songs of the Summer card, 2026-09-09): the wire (@chartdata) stated the record with the count in a second sentence (“Ten of her songs have made the season-end lists”), the classifier wrote figure “10 Songs of the Summer top 10s” (30 characters; its prompt said a count takes the noun for the thing it counts, and the model put the whole chart name into the noun), figure_is_rank_word passed it (“top” is followed by a digit), the digit test passed it, and the renderer shrank it to its floor, could not fit it, and ellipsized: the card shipped heroing “10 Songs of the Summer to…”. The verify judge had returned “10 top 10s” for the same claim; it never reached the card because the research fallback fires only on an EMPTY or ranking-word wire figure. The rule is the hero’s SHAPE: a figure longer than _FIGURE_MAX_CHARS (24) is a phrase, not a hero – the verify judge’s own prompt already bounds its answer at “about 25”, and this is the same bound under both models. It was first measured on the renderer (2026-09-10, the 60pt floor of the day: 23 characters set whole, 25 were cut); #3131 then moved the figure’s last-resort ellipsis down to 44pt, where about 32 characters fit, so the bound is no longer where the ellipsis lands but where the figure has shrunk to the small end of its range and stopped reading as a hero. _sched_compose runs the two list guards on the wire figure FIRST (so a long run of positions from #1 down is judged by its block name), then treats a phrase exactly like a ranking word: the VERIFIED research’s count heroes when it carries a digit and fits, else the figure goes empty and the card falls to headline mode. _build_card drops a phrase as the renderer-side backstop, and scripts/dryrun_music_news.py mirrors both. The classifier’s prompt line was extended in the sentence that caused it (“the noun is the UNIT, a word or two … under about 24 characters … a chart’s name is not the unit, it is the source”), dry-run on the real wire n=5 each: before 5/5 wrote the phrase, after 5/5 wrote “10 top 10s”; a tighter wording that only named the chart-name rule (no character bound) still wrote the phrase 5/5, so the bound is the part that lands. The verify judge’s prompt already carried the same bound (“keep it under about 25”). Four refinements from the Codex review on #3135: the guard measures the figure as the RENDERER draws it (abbrev_grouped first, so “1,234,567 Spotify streams” is not a phrase); the researched figure gets the two list guards before the fit test (a judge’s “#1, … #7” heroes “Top 7”); a phrase that is also a passed RUNG keeps the rung’s unit-matched replacement (count_past_threshold), never whichever short number the judge also stated, and goes empty with no unit match; and the FINAL card_figure is fit-tested after every rung has settled it, so a phrase-length threshold replacement or live reading is cleared where the hero is settled and the music_news_verified event, the meta and the card agree. The 30-day market_card_hero sweep found one sibling on this surface, “35 wins, 99 nominations” (23 characters, 2026-09-04), which the renderer set whole at its floor; it is two counts joined by a comma, which lead_figure deliberately does not split, and it is left as is. A COUNT about an ACT draws the BOARD, not the count (_chart_count_card + chart_boards.artist_count_board, owner report 2026-09-13, #3224). A wire states “Lil Durk has 6 titles inside the top 100 on the US Apple Music albums chart”, the settler reads the live chart and rewrites the claim, and the card heroed the settled “6” over a Lil Durk photo with no titles under it. The count says nothing about WHICH titles are on the chart. resolve_artist_count already keeps the (position, title) pairs it counted, so _sched_compose stamps that reading on the unit and the card draws those rows: the take and the board come from ONE count and cannot disagree (this is why it needs no counts-agree gate, unlike the certification sweep on line 95). The value column is blank – an entry carries a position and a title and nothing else. A COLLABORATION counts as the act’s title (owner rule 2026-09-13, “we never do solo only”): the count matched the credit EXACTLY and dropped every joint one, so on the live charts that day it said 6 of Lil Durk’s 7 albums in the Apple albums top 100 and 18 of Jhene Aiko’s 19 songs – both cards short by the row a reader sees on the page. It now reads _artist_agrees, the credit rule resolve_chart_position in the same module already used, so one module holds one answer to “is this row theirs”; the subset rule still refuses an act who is not credited. The joint row prints NO credit beside its title, per the 2026-09-10 steer that governs every act board. A FEATURE counts too, and that half is bigger (owner rule the same day, “features and collabs always count”): the streaming charts name a feature in the TITLE and credit the row to the lead alone, so a guest spot was invisible to EVERY lane at once – Kendrick Lamar sat at #3 on “So Good (feat. Kendrick Lamar)” and neither the newsroom count nor his own act board carried it. A row’s identity is its credit AND its title, so artist_watch.row_credit is the ONE definition of the whole credit a row carries (credit_matches/_artist_agrees both take a credit string, and feeding both the whole credit fixes both without merging two matching rules that exist for different reasons). row_is_theirs is the listing rule; every counting/listing lane reads it – chart_boards.watch_artist_rows (so every act board), the newsroom’s count and position resolvers, artist_watch.standing_block, the X-mentions units and scripts/artist_sweep.py. It says who is ON the record, never whose record it is: attribution stays the row’s own credit line, so a single-entry card about a guest spot still names the lead act (the #1911 misattribution class). RIAA already counted features and is untouched. Two skips keep the number card: the card’s settled hero is no longer that count (other_figure, a market story heroes its own leg), or the count is one title (under_floor, MIN_COUNT_ROWS). Telemetry: the count_board event. A CATALOG streaming milestone gets the same treatment, but ADDITIVE (_catalog_board_png + chart_boards.catalog_rung_board, owner steer 2026-09-13 “can we make the board additional”, #3225). “Drake has 30 songs past 1 billion Spotify streams” heroed a bare 30; #3131 made that card say WHAT it counts but never WHICH songs. Here the board does NOT replace the card – the hero is the crossing the story is about and the list is the evidence under it – so it ships as a SECOND image in the same post: one MediaGallery with two items on Discord (build_number_card(extra_png=...) -> MarketCard.extra_file/extra_png), two media in one tweet on X (crosspost_with_quote images, each gated separately, a failing one dropped without sinking the other). StreamReading.entries carries the titles the resolver already selected, so it costs no read; the value column is each title’s lifetime total, and kworb’s feature “” is stripped for display by catalog_stats.clean_title (the live dry run drew “* Work”). Scoped to the CATALOG shape – a track or career reading is one number with no list. Telemetry: the catalog_board event. A CAREER chart-ENTRY count is the third of these, and the same ADDITIVE shape (_live_entry_history + _entries_board_png + chart_boards.career_entries_board, owner report 2026-09-15, #3331). “LISA ties JENNIE with 7 Billboard Hot 100 entries” heroed a bare “7” with none of the seven titles on it: the newsroom had NO reading for this claim shape at all – _live_artist_count settles a top-N window off the live chart, and a career total is not on that page – so the take shipped on the wire’s word and the card had no list to draw. music_news.entry_count_claim scopes it to a claim naming ONE Billboard chart whose per-artist chart-history page we read (billboard.CHART_HISTORY_CODES, the two flagships) and stating a count; every other story pays nothing. The wrong-artist guard is the COUNT (resolve_entry_history): every other consumer of a chart-history page checks that the reported TITLE is among the entries, and a career count names no title, so the page must hold exactly as many entries as the claim states – a page that counts differently is either another act’s or behind the chart, and both draw nothing. The board ranks on the PEAK and carries the chart-DEBUT YEAR as its value column, both read off the page, and the BLOCK names the column (“BILLBOARD HOT 100 PEAKS”) because the card draws no descriptive caption – without it a bare “#68” reads as this week’s position on a card about a whole career. The reading it depends on had a silent bug of its own (same PR): billboard.artist_slug GUESSES the artist page from the credit, and the guess does not merely 404, it lands on ANOTHER ACT – LISA is filed as lisa-of-blackpink, so “lisa” returned a 1980s act’s three Hot 100 entries from 1982-84 and the DEBUT guard (line 87 of docs/OBSERVABILITY.md) had been reading that page as hers. BillboardRow.history_slug now carries the slug Billboard prints in the row’s own “Chart History” link and BillboardClient._history_slug reads it out of the cached week, so the guess is the fallback rather than the rule; a joint credit yields no slug (its bare artist links are not in credit order) and an act on no week we hold still falls back. The board draws the story’s own ART (owner steer 2026-09-15, “show with art”): the cover or photo the number card beside it already resolved, passed through _extra_board_png rather than resolved a second time – one resolve means the two images of one post cannot show two different acts, and the crosspost’s media gate reads one art_source for both. It builds through market_cards.build_board_card, the ONE definition of what a board looks like (owner report 2026-09-15, “that’s not how we normally do it”): that builder pins art_anchor="corner" and the board bloom, so the art bleeds from the bottom-right and closes the list off. Calling chart_cards.render_board_portrait directly took its default art_anchor="top" – the HEAD BAND, the picture where the type starts – and the one board then looked like no other board we post. A second caller of the low-level renderer is how a look drifts, so there is one caller of the look. _catalog_board_png still calls the renderer directly; it passes no art, so the anchor never showed, but it does miss the board bloom – worth folding in next time that card is touched. The catalog board (#3225) is left as it shipped. Telemetry: the entries_board event + billboard_artist_history detail.slug_source.event_poster.py + utils/event_trigger.py, the EventPoster spine + the off-slot BREAKING lane (#news-breaking-speed, the CADENCE half of #1742): the wire desks’ breaking lane was already fresh-first (utils.wire_lanes.order_for_breaking), but it only ran when a calendar slot fired, so measured break latency sat at a 37–43 min median per desk (p90 ~55 min, 7 days of prod) against the owner’s 5–15 min target. EventPoster (cogs/event_poster.py) is the TRIGGER-driven sibling of ScheduledPoster, and the two differ in exactly ONE step — an inversion of control: a SCHEDULED post decides BEFORE it looks at the material (the clock says go, then find the best thing to say), an EVENT post lets the material decide (look at what exists, a bar says whether anything is worth saying). That is why their state cannot merge either: a clock trigger must know how many slots it OWES, an event trigger must know when it LAST FIRED and how often today. Everything else is shared BY CONSTRUCTION — the standing gates (ScheduledPoster._surface_gate: kill switch + mood + experiment/provisioning), the room lookup (_surface_channels, incl. the X-only sentinel), and the whole dedup/deliver/history tail (_process_unit) — so a trigger-driven lane cannot drift into honouring fewer gates than a slot does. Identity is the one thing NOT duck-typed: pick_by_velocity takes a key callable rather than reading .dedup_key, because the three wire desks carry that attribute and the music newsroom’s LaneCandidate does not (its identity is the source post id) — reading identity off an attribute would have made the spine unusable on that desk. A single Poster with a pluggable trigger (SlotTrigger / VelocityTrigger) is the honest END state, deliberately NOT built yet: ScheduledPoster serves ~8 live surfaces, and two triggers do not earn a registry — a third would. It is a mixin over ScheduledPoster (pop_desk / sports_desk / cinema_news; music_news is deliberately OUT — its stories need a paid classify per candidate and the pop desk already carries the music CULTURE wires, so a big music moment reaches the fast path through pop): between slots each desk polls the HEAD of its wire list (top BREAKING_SOURCE_ACCOUNTS=3 handles — the lists are priority-ordered and the top handles carry nearly every genuine break) on an ALIGNED 15-minute cadence (poll_bucket: wall-clock :00/:15/:30/:45, half the calendar’s 30-min slot grid, so the two paths INTERLEAVE and something can post every quarter hour; at :00/:30 both run in one tick and the slot goes FIRST, so its post is in history before the poll’s dedup reads it — bucketing by wall clock rather than by elapsed time also stops the cadence drifting off the grid and self-corrects after a late tick), and when a moment clears the pure trigger — parseable timestamp, age ≤ BREAKING_MAX_AGE_MINUTES=60, engagement VELOCITY (score/minute of age) ≥ BREAKING_MIN_VELOCITY=100 — it composes + ships IMMEDIATELY through the desk’s NORMAL breaking pipeline: the same _compose_one(lane="breaking") (news-age gate, grounding, self-gate) and the same _process_unit (the per-unit dedup/deliver walk, factored out of the slot loop for exactly this reuse), so the fast path changes WHEN a break ships, never WHAT ships. Velocity is the right TRIGGER here even though it was rejected as a lane RANKING (ranking mid-window stories needed ~2h of accrual; a yes/no “is this minutes-old post already moving” reads clean at 10 min — a real break on a top wire runs 200–500+/min, routine posts idle at ~30–60). Guardrails, all BEFORE any credits: the slot gates (kill switch / mood / experiment / provisioning), the ONE /tune “breaking posts” knob (breaking_gap_min: the minimum GAP between two breaking posts on a desk, default 30 min, 0 disables the lane — its only off switch). It replaced a daily CAP + cooldown pair, and the reasoning generalizes: a cap answers “how many today”, a number nobody can pick honestly because the honest answer depends on how much news broke; a GAP answers “how often at most”, the same question the /menu calendar already asks of every other posting surface, so a mod reads it without learning a new idea. Dropping the cap also removed the class’s one durability problem — a cap must survive a redeploy to mean anything (a reset count re-opens the whole day’s budget, and Railway redeploys on every push to main), while a reset gap costs at most ONE post shipped early, which the next record re-imposes. So the pacer stays in memory with no table, no migration, no cleanup. Sized against measured volume: pop posts ~15 breaking takes/day over a ~16h span (one per ~64 min), cinema ~13, sports ~6 — so 60 min reproduces today’s volume almost exactly and the 30-min default leaves headroom for a busy news day, with the velocity floor keeping a quiet day quiet. The gap is a CEILING, not a target, and within_calendar_span (fast posts only between the day’s first and last slot + 30 min grace — the calendar stays the owner’s quiet-hours contract). A compose DECLINE is remembered in-process so the next poll doesn’t re-pay for it; the durable per-moment dedup means a fast-shipped story is simply seen to the slot lanes. The WINDOW and the FLOOR do two different jobs on purpose: the window says how stale a break may be, the floor says whether it is a break at all. 60 min matches the slot lane’s own breaking window, so no story CLASS is lost when breaking moves off the slot — and at a 15-min cadence a story inside it gets FOUR looks, so a break that builds speed slowly can still qualify. The first cut was 20 min and was wrong: it gave a slow-building break ONE look, survivable only while the slot’s breaking lane still ran as a backstop. Cost: 3 handles × 3 desks every 15 min ≈ 576 twitterapi.io requests/day over the calendar span, a THIRD of the 5-min cadence — and that saving is what pays for widening the handle list, which is how the 60-min window keeps today’s breaking VOLUME (baseline ~4.4k/day, limiter capacity 14.4k/day); the fetch publishes via wire_sources.remember so discourse’s union rides it free. Telemetry rides existing events — wire_read category=breaking for the poll, the desk’s *_posted with lane=breaking + a folded fast=true + source_age_seconds for a ship — so the ops monitor’s Break latency table and breaking_lane_stale finding measure it unchanged (and double as its silent-death watchdog: a dead fast path drifts the p50 back to the ~40m slot baseline). Pure trigger/pacer/span in utils/event_trigger.py (tests/test_event_trigger.py); the wiring pinned by tests/test_event_poster.py.commentator.py, the live sports commentator loop: Toots calls live games (World Cup soccer + NBA) in a dedicated sports channel (configured on /menu page 1, beside music channels). Pure orchestration over the already-tested sportsdata backbone — bot.sports_hub.live_games() (all providers MERGED, deduped by matchup, #435 breadth: API-Sports covers football+basketball, SGO covers everything else it carries - NFL/MLB/NHL/UFC/tennis - so the loop calls every major sport SGO scores, not just soccer+NBA; the first provider per matchup wins so a game both carry keeps API-Sports’ richer-depth row; PLUS a LAST-resort TheOddsApiScoreProvider (#725, utils/sportsdata/providers.py) that keeps SGO-only-sport scores (NFL/MLB/NHL/UFC) alive off The Odds API /scores when SGO craters — registered LAST so it only fills games the primaries missed, gated on sgo.degraded (the SGO circuit-breaker state) so it spends a metered credit ONLY while SGO is down, deliberately NOT the API-Sports-covered World Cup/NBA (redundant + wasteful), 5-min cached, off-season sports return empty/unbilled) for “what’s live”, utils.sportsdata.cadence.decide_post for the per-game “post now? on what trigger?” decision (goals, quarter/period boundaries, halftimes, finals, a ~10-min heartbeat tighter in clutch time — the heartbeat + clutch intervals are mod-tunable per guild via /menu’s tune page, resolved by tunables.commentary_cadence into the per-guild CadenceConfig), and claude_client.commentate (SONNET on every phase — quality over cost, a cheap-model-on-heartbeat routing was tried and pulled; the LIVE DATA is always injected, never from a tool — see the narrow slow-trigger background tool below) for the voice, with a per-phase format: a pregame rundown / goal reaction / a calm interval “where it stands” read (NOT a goal-style reaction) / a final recap beat, length scaled to the moment. Box-score depth (#432): NBA has no per-play events, so bot.sports_hub.game_leaders(game) pulls the live box-score leaders (API-Sports /games/statistics/players, top scorers parsed to PlayerLine by _basketball_leaders, rendered by format_leaders into a WHO’S COOKING block) and feeds them to commentate (stats_blob) so she calls a standout out by name (“brunson’s got 24, wemby near a double-double”) instead of just the scoreline; grounded (names/numbers given only, never invented) and fail-open (a stats miss → scoreline as before). Soccer gets the same WHO’S COOKING depth (game_leaders is now per-sport, not basketball-only): _football_leaders parses API-Sports /fixtures/players (goals/assists/shots/key-passes/rating, scored by goal-involvement-then-rating to the top few) so she calls a soccer standout by their real line (“wirtz with 2 goals and an 8.4 rating”), beyond just the goal-event scorer name. The commentate prompt leads with WHO’S COOKING (standout player + numbers) AHEAD of any market/odds talk (players + action are the story, the line is secondary color), and per-trigger length is a ceiling not a target (one sharp line is fine; never pad to reach 2-3 sentences). Odds-aware (#476): a matched betting line — sportsbook moneyline (utils.the_odds_api) + Kalshi/Polymarket implied % (bot.markets), matched to the live game by team name (utils.sportsdata.format.match_event_odds/match_prediction_snapshot) and rendered by format_commentary_odds — is injected on every trigger EXCEPT the goal (#1140: a score change lags the market, so the odds/market block is withheld from the goal compose ENTIRELY via _ODDS_SUPPRESSED_TRIGGERS — nothing for her to invent/misread from, and the #507 no-line forbid rule then fires; _odds_blob is still computed so odds_health telemetry is unaffected, only the text handed to the model + self-gate is withheld. The prompt already said the line “never appears on a goal”, but prod showed her inventing a Polymarket flip / handicap line off the still-injected block ~half the time and the self-gate dropping the whole post — ~56% of goal reactions shipped nothing; withholding the block structurally enforces what the rule only asked, validated n=5 on the real card+goal feed), but it is secondary to the game, not the lead (#603 follow-up: the prior “speak on the line at the first opportunity” framing made commentary market-LED, reading as a call on the odds instead of the game — and the market lags a goal, so a goal post citing a not-yet-repriced % read as wrong). The reorder is priority, not suppression (owner steer: “game/player/team focused, but equip our bettors with fresh market info always”): the prompt leads HARD with the GAME on EVERY phase (a top “THE GAME IS THE STORY” rule naming pregame/in-game/final + the rule order puts players/shape/props/standings AHEAD of the line), then hands the bettors the fresh line as a secondary beat so they stay on current numbers — it FOLLOWS the game, NEVER leads, and NEVER appears on a goal (stale-relative-to-the-event). A pure-game post with no line is fine when the game’s the whole story. Still grounded: she quotes the numbers given and never invents/shades a line. Fail-open per source (a miss omits that source; the Bookie surface, #435, goes deeper into line-shopping + user bets + leaderboards). SGO-sourced games (NFL/MLB/NHL/UFC/tennis, #435) get their OWN moneyline surfaced via format_sgo_odds_line (the snapshot already carries it) in the $100 wins $X convention, home/away-paired to avoid the prior misread footgun, used as the sportsbook line when The Odds API has no match - so combat/etc. get “who’s favored” even with no other odds source. Beat-the-line player props (#390): SGO player props (bot.markets.sgo.get_player_props(league), INTERNATIONAL_SOCCER/NBA) are pulled scoped to THIS game’s SGO event (get_player_props(league, event_id=...), #SGO-eff — the caller already knows the game, so it bills one event’s props via the eventID filter instead of the whole league’s limit-event slate, a ~10x entity cut live-verified against SGO; the /ask lookup_player_props path is event-scoped the same way) and matched to the players actually in this game by name (format.match_player_props, a fuzzy person-name match against the box-score leaders + goal scorers — surname-first Brunson Jalen ties to a prop’s Jalen Brunson, a different game’s player is dropped, and a bare initial never false-matches) then rendered by format_player_props into a props_blob fed to commentate — the beat is a player’s ACTUAL box-score number vs the book’s o/u (“brunson’s at 34, smoked his 26.5”), grounded (quote the line exactly, never invent/shade one) and fail-open down the whole chain (no SGO key / unmapped sport / no in-game players / fetch miss / no match → no props, scoreline as before). The SGO prop snapshot carries structured player/market_type on meta so the matcher keys off clean fields, not the title. NOTE: SGO’s playerProps shape is best-effort/version-varying and not yet live-verified end to end (amateur-tier quota + uncertain payload), so watch the commentary_posted has_props rate to confirm props actually land in prod. Market-edge beat (#622 slice 3): the deeper-market siblings of the moneyline — a total / both-teams-to-score / 1st-or-2nd-half total / corners line, the “interesting” market the moneyline doesn’t say — are surfaced via the full SGO board (bot.markets.sgo.get_game_board(league) – the entity-heaviest SGO call, so it and the player props now read a cross-tick TTL cache (_SGO_SECONDARY_TTL_SECS, 4min, #sgo-burn) instead of re-pulling every 60s tick: a slow-beat secondary doesn’t need per-tick freshness the way the live moneyline does, which cut ~75% of these two beats’ SGO calls) matched to this game by team name (_board_for_game, accent-folded _team_match, either orientation), then the single standout is picked by the pure utils.sportsdata.board.pick_standout_market (the book’s most confident READABLE call that isn’t a foregone conclusion — highest top-selection implied % at or under a 0.90 near-lock ceiling; moneyline/spread/3-way + player props excluded since other beats cover them) and rendered as a compact one-liner (“over 0.5 1st-half goals at 75%”) into a market_edge_blob fed to commentate. SLOW triggers only (pregame/interval — never a goal/clutch reaction, where the market lags the event). It is an ALTERNATIVE source for her ONE market beat, not a second one (#603 game-leads discipline preserved): the prompt tells her to use the deeper line instead of the moneyline when it’s the sharper tag, still one clause, still behind the game, still skipped when the game’s the whole story; the no-line grounding guard (#507) now also clears when a market edge is present. Grounded (quote the % exactly, never invent) and fail-open down the chain (no SGO / unmapped sport / no board match / no standout → no edge). When SGO is degraded (#725) the edge falls back to the Odds API deep board (_odds_api_market_edge): a FREE get_events resolve + ONE metered get_event_board (ODDS_BOARD_MARKET_KEYS — totals/BTTS/corners/team-totals/halves) → parse_odds_api_board → the same format_market_edges, gated on sgo.degraded (zero spend when SGO is healthy) AND the_odds_api.has_enhancement_budget (credits above the 5,000 _ENHANCEMENT_RESERVE, so the /bet SGO-down backstop credits are preserved) AND an 8-min per-game cache, so it’s a slow-trigger-only, ~5-credit-per-game-per-window enhancement that never threatens the bookie budget. Dry-run confirmed she works it in as secondary color (“Spain’s been the better side all night… the ‘no both teams to score’ at 61% tracks, Germany’s barely gotten looks”) and ignores a stale/irrelevant edge rather than forcing it. Watch commentary_posted has_market_edge. Bookie tie-in (#623): on the bookend beats she works the room’s play-money bets in via _bets_blob → the Bookie cog (get_cog("Bookie")). PREGAME folds in who’s got open action on this game (Bookie.open_bets_blob → db.open_bookie_bets_for_game, names not pings, biggest stake first, capped at _BETS_READOUT_MAX); FINAL settles the game (Bookie.settle_and_roast_blob → the shared idempotent _settle_game, which ALSO fires the per-channel payout embed, so whoever reaches a finished game first — this or the 5-min settle loop — settles it and the other no-ops) and hands back the W/L for a light roast (format_bets_results). The roast prompt rule is don’t force the joke (only when there’s a real angle, else read it straight), text-only, names never invented. Bookie-experiment-gated + fail-open to “” (a bookie blip never breaks commentary); settlement itself still runs via the loop regardless. Pure formatters (format_bets_action/format_bets_results) are unit-tested. Watch commentary_posted has_bets. (A scheduled follow-up surfaces the same open_bookie_bets_for_game data as a user-facing “everyone’s bets on a game” command, open + closed.) Shape-of-the-game depth (#432): soccer had only score+goals+market% to call on (which is why prod check-ins read as one-liners), so bot.sports_hub.game_team_stats(game) pulls live team match stats (API-Sports /fixtures/statistics: possession / total shots / shots on target / corners / cards, parsed to a MatchStats by _football_team_stats, rendered by format_match_stats into a MATCH STATS block) and feeds them to commentate (team_stats_blob) so she calls the shape (“germany’s on 64% and 14 shots to 3, one-way traffic”) not just the score; grounded (home-away numbers given only, never invented), fail-open (a stats miss → scoreline as before), cached per game per tick. NBA gets the same shape depth (game_team_stats is now per-sport): _basketball_team_stats parses API-Sports /games/statistics/teams (FG% / 3PT% / rebounds / assists / turnovers, mapped to home/away by team id since the stats block carries team.id only, names from the snapshot) so an NBA check-in reads “knicks shooting 39% and getting crushed on the glass 47-38” not just the score; format_match_stats renders both sports’ labels from one _STAT_ORDER. Watch the commentary_posted has_team_stats rate to confirm the depth lands. Pregame depth (#432): on the pregame trigger only (where it belongs, so live reactions never pay for it), bot.sports_hub.game_pregame(game) pulls API-Sports formations + coaches (/fixtures/lineups), each side’s recent form (/fixtures?team=&last=, rendered “WWDLW” newest-first by _form_string), and the head-to-head record (/fixtures/headtohead), assembled into a PregameContext and rendered by format_pregame into a PREGAME CONTEXT block fed to commentate (pregame_blob) so the rundown sets the table with a real hook (a hot/cold run, a formation mismatch, the history); grounded (quote, never invent), fail-open per source, cached per game per tick, team-ids carried on the snapshot meta (home_id/away_id) so no name→id resolve is needed. Watch commentary_posted has_pregame. (Highlightly’s own data endpoints — lineups/H2H/standings/predictions — are a deferred follow-up pending live response-shape verification; the commentator’s pregame depth uses API-Sports, whose shapes are verified.) The commentate prompt was also reshaped for depth-not-length: one sharp sentence is the norm with a HARD two-sentence ceiling (never three), a “DEPTH OVER LENGTH” guard (every sentence carries a real stat/name/run from the feed), a “ONE ANGLE, not a stat dump” rule (the data is there so she PICKS the sharpest beat, never lists ‘three yellows AND outshot AND 55% AND a 7.3 rating’), and a “no wrap-up line” rule (no ‘that’s the story in a sentence’ meta) - the deepen-not-LENGTHEN tightening after the depth pass over-corrected into long stat-sheet posts in prod. The depth still lands, just as DIFFERENT angles across SEPARATE posts (rotated via the ‘story so far’ context - a player in one take, the shape in another, the line in another), never crammed into one and never all at once (the #502 stagger spaces them). Post-game highlights (#432 dim 3): the commentator drops a video clip for a finished game via utils.highlightly (the HighlightlyClient, key-gated on HIGHLIGHTLY_API_KEY — Highlightly covers BOTH soccer + NBA, the one source for our whole coverage, free Basic tier 100 req/day). Because clips land 0-48h after the whistle (the final recap fires the instant a game ends, too soon for a clip) AND a finished game leaves recent_finals() at UTC midnight, the cog persists a pending-clip window: when a game we called finishes, db.mark_game_for_highlight stamps its matchup + highlight_finished_ts onto sports_post_state, and a separate per-tick pass (_process_pending_highlights, run BEFORE the live-games early-return so a next-day clip still posts when nothing’s live) re-queries Highlightly for any game db.due_highlight_games returns — finished within _HIGHLIGHT_WINDOW_SECS (~48h), not yet posted, last checked over _HIGHLIGHT_RECHECK_SECS ago (60min, free-tier-budget-safe; the window/pace/dedup are all enforced in SQL). It posts the first VERIFIED clip (the API’s own verified title + url, which Discord unfurls — real API data, nothing fabricated) and marks the game done. Once per game, durable dedup + pacing + the 48h window via sports_post_state columns (highlight_posted_ts/highlight_checked_ts/highlight_finished_ts + the persisted matchup, durable so frequent redeploys don’t re-post or re-burn budget and a clip landing the day AFTER the game still gets caught). Gated by the same live_scores experiment (production → room, staging → #bot-logs audition) + fail-open throughout (no key / no clip yet / send failure → leaves it pending for the next window). Emits highlight_fetch (the API call) + highlight_posted (the disposition). Phase 2 (deferred, tracked on #432): an X-accounts cog pulling clips from curated accounts (@BleacherReport, @NBACentral) — blocked on a timeline source (fxtwitter does single tweets, not timelines). Odds grounding (#507): when NO odds block is matched/injected, the prompt forbids her mentioning a moneyline/spread/implied-%/Polymarket/Kalshi at all (the inverse of the odds rule) plus an always-on no-bet-sizing rule — prod soccer logs showed her inventing market %s from nothing when the match missed (the self-gate caught most, but it’s a real grounding hole). Right-market-info-at-the-right-time (the capability matrix applied to commentary): The Odds API h2h moneyline is pre-match only (utils.sportsdata.sources.provides_live_odds("the_odds_api") is False — it does NOT reprice in play), so on any in-play trigger (everything but pregame) format_commentary_odds(live_game=...) frames its line as the OPENING line (“where they opened”, not the current price) and points the model to the prediction markets for the LIVE read — otherwise she’d cite a stale pre-match number as live (the Bookie stale-odds bug, in the commentary voice; acute while SGO’s monthly entity cap is exhausted, since the Odds API is then the only sportsbook line). The SGO line (when present) IS live-capable, so it’s never relabeled; the prediction markets are always the live read. live_game flows from the trigger through _odds_blob. Cross-provider matching (#508): so the odds/props blocks actually LAND, format._team_match/_name_match accent-fold via NFKD (utils.sportsdata.format._fold — the Türkiye→trkiye prod miss was the matcher deleting diacritics) and match on accent-folded exact-or-containment ONLY (the fragile English-club suffix list + bare last-token fallback were dropped: containment covers the real cases and biasing toward a miss over a wrong match is safe now that a miss = no line, not an invented one). Game-state awareness (#508): on the SLOW triggers only (pregame/interval, via _context_tools), commentate is handed ONE narrow read-only tool — lookup_background (utils.reference.lookup_background, the Wikipedia-article-reader slice only, deliberately NOT the full lookup_reference so it’s not bundled with Genius/MusicBrainz/chart sources that are noise for calling a game) — for background/stakes/history. Never on goal/clutch (a reaction can’t wait on a round-trip), never for the live score/odds (those are injected, real-time, authoritative — a search there would only risk stale data); live-data tools stay off the commentator. Fail-open (a lookup miss → call it without). Each composed line is self-gated (commentate_score, a Haiku classifier like discourse_score) and dropped under the 0.6 ship floor (fail-open on a scorer error so a Haiku blip can’t silence the surface). The self-gate is handed the live FEED the line was built from (score + events + team/player stats + the line) so it verifies the line’s numbers are real (in the feed) rather than false-failing injected depth - player match ratings, possession %, key passes - as ‘invented’ (#432: judging blind was dropping ~half the good posts in prod) — parity with discourse/music’s quality gate. The commentate prompt inherits the shared _VOICE_REMINDER block AND the cross-surface _LESSON_* grounding constants (substance / not-curator / one-verdict / plain-not-critic — the single source of truth also composed by _POST_GROUNDING, so a prompt-rule edit propagates to every room surface instead of drifting; tests/test_prompt_lessons.py fails if any surface stops referencing them) so commentary can’t drift from the other room surfaces; _ROOM_DIRECTED/_TOOL_DISCIPLINE are deliberately not applied (commentary calls a game, no debate-starting; the only tool is the narrow slow-trigger lookup_background, no live-data/web tools). One @tasks.loop(60s) tick does one global live_games() fan-out PLUS an upcoming_games(~15min window) fan-out PLUS a recent_finals() fan-out (#512 post-game recaps: a completed game is filtered out of live_games() (g.live only), so the final trigger was structurally unreachable — the #441 bug but for final, and zero finals ever posted in prod; recent_finals surfaces just-finished games (reusing the same day-slate fetches, filtered to completed; SGO has no finished feed so it’s API-Sports-sourced) so the once-per-game final recap fires. Gated in the cog so only a game we were ACTUALLY calling recaps: a completed game with no prior sports_post_state (last_post_ts is None) is skipped, never a cold final for a game we ignored. At final the recap material is already in hand — final score + the full event timeline + final box-score leaders + the story-so-far — so no new persistence is needed, #430 stays deferred) (#441 pre-game detection: the loop used to see only live games, so the pregame rundown trigger was unreachable; now a fixture tipping/kicking off within _PREGAME_WINDOW_SECS is merged in, deduped by game_key, so it gets its rundown before the game — the window reads start_ts added to each snapshot’s meta, padded by a few-minute _PREGAME_GRACE_SECS clock-skew tolerance on both ends so a schedule slip doesn’t drop a game) then loops over guilds, so an idle bot (no live/upcoming games / no configured guild) spends nothing — it returns before the loop when _resolve_targets is empty. (Kickoff at 0-0 doesn’t mis-fire the goal trigger: cadence._score_changed normalizes None→0, so an upcoming game’s null scores becoming 0-0 reads as no change.) The dedup memory (cadence.PostState) is persisted per (guild, game_key) in sports_post_state (restart-proof: last_post_ts is wall-clock epoch, not monotonic, so a redeploy mid-match never double-posts a goal or loses the heartbeat clock). Cross-game spacing (#502): when several concurrent games clear a trigger on the same tick they’d post back-to-back in one channel (a wall); so a guild posts at most _MAX_ROUTINE_POSTS_PER_TICK (1) routine reads per tick and the rest DEFER (their PostState is left untouched, so the cadence re-fires them next tick — no wall, nothing lost). MILESTONE triggers (goal/final, a reaction loses value if held) are never deferred but DO count toward the tick budget, so routine reads space themselves around a goal. Emits commentary_deferred. Per-post game header (#502): a sent message is prefixed with a format_game_header line (matchup + live score + clock, e.g. **Spain 2-1 Korea** · 67') so multi-game commentary is self-labeling, but ONLY when the game or score changed since the guild’s last post (deduped via in-memory _last_header), so a single game reads as a clean stream and the header re-appears on a new game or a goal instead of stacking an identical bold line on every consecutive same-game post. Delivery-only: post_preview stays the take so the eval pass grades her words, not the scoreboard. This (identification) is the complement to staggering (volume). Gating, in order: master kill switch (is_bot_enabled) → mood OFF (it’s a proactive surface, a muted room stays muted) → the per-guild live_scores experiment (production posts to the sports channel, staging auditions the line in #bot-logs with the room kept quiet, off skips the guild — no fetch, no spend). Per-guild watched-sports filter: _resolve_targets resolves each guild’s db.get_watched_sports (the /menu page-3 picker over utils.sportsdata.sports.WATCHABLE_SPORTS, empty/unset = ALL) onto the target tuple, and the game loop skips a game whose meta['sport'] the guild isn’t watching (is_watched, fail-open: an untagged game is always covered). This is the lever that lets a World-Cup-only room mute the MLB/NFL breadth that the all-providers merge surfaces — the fix for a sports channel flooded with games nobody asked for. (Every SGO game now carries a meta['sport'] tag, _SGO_SPORT_LABEL extended to all renderable sports — SGO FOOTBALL→americanfootball, never soccer’s football.) Fail-open throughout (provider miss / compose 429 / send failure never crashes the loop; the state is still recorded so a trigger doesn’t re-fire). Emits commentary_posted (with post_preview on room deliveries, graded by the live-log eval pass) + commentary_scored (the self-gate). The behavioral net is scripts/eval_commentate.py (golden + per-trigger + live-log), parity with the other room surfaces.betting_board.py, the “who’s favored” line posts for the sports a guild takes BETS on (#1229/#1234) — the market-read complement to the two existing sports surfaces: the live commentator CALLS a game in-game, the Bookie TAKES the bets, and this reads the LINE around a game (who’s favored + the implied %) so a bettor sees it and is nudged to /bet. DUAL-PATH (owner steer — the ability to “post today’s bets in the morning” run by the calendar, AND a pre-kickoff nudge): (1) a CALENDAR-scheduled morning “today’s bets” SLATE roundup — a ScheduledPoster subclass (mirroring market_drop) posts a compact board of the day’s bettable games + who’s favored, calendar-managed via /menu (bet_board is now a first-class calendar surface in utils.schedule_calendar.SURFACES + cogs.calendar_view), falling back to a morning-leaning CHILL_TIMES/YAPS_TIMES pool when unconfigured; _todays_slate (today’s UPCOMING games, soonest-kickoff first, capped _SLATE_MAX, then SPREAD across the sports on the day by _spread_by_sport) + _slate_blob feed compose_market_drop; self-gated by drop_score, text-deduped over bet_board_history, slot-paced via bet_board_slots. (2) a per-game PREGAME line, EVENT-DRIVEN off the game’s START time — a @tasks.loop(TICK_MINUTES) (board_tick) posts a game’s line once when it enters the pregame window (_PREGAME_WINDOW_SECS, ~1h, env-overridable), capped at _MAX_POSTS_PER_TICK per guild per tick, per-game deduped via bet_board_matchups (a game’s line posts once, not re-posted while it sits in the window / across a redeploy). The slate board is UPCOMING-ONLY (owner steer 2026-09-13, “games already passed”). _todays_slate judges a game by its KICKOFF TIME, not by the feed’s live flag: a game whose start has passed is dropped even when the feed still calls it upcoming, and a game with no known kickoff is dropped too, because nothing shows it has not started (prefer absent over invented). A slot with no game left today posts nothing — _sched_compose returns None and the board goes quiet. Two cards paid for this rule. (a) The 18:00 ET board on 2026-09-13 led with “Green Bay Packers 86% over Minnesota Vikings” while Green Bay was 19-10 up at half; the card does not mark a row LIVE, so the in-play number read as a price under the headline “Today’s Odds”, and four of its six rows were games already under way. (b) Earlier the same day two MLS games kicked off at 02:30 UTC, ended about two hours later, were still repriced as live 13 hours on (535 reprices each), and the board hero’d “Draw 91%” as the best odds on the slate — the settled outcome of a finished game, offered as a line to bet. That one’s CAUSE was the slate freeze (see the two clocks, below), which is fixed; a 6h ceiling on live rows (_MAX_LIVE_SECS) was the first floor under it, and dropping live rows outright replaces it, removing the class instead of capping how stale a live row may be. The live lane keeps its own surfaces: the commentator calls the game and betting_value/betting_alert read the line moving. The board gives every SPORT on the day a row (_spread_by_sport). The cap used to be filled by soonest kickoff alone, so the sport that starts earliest took every slot. Measured on the live slate of 2026-09-13, an NFL Sunday: 30 priced games were on, 13 of them NFL, and the board showed six — all soccer. The European card kicks off between 8:00am and 12:30pm ET and the NFL card at 1:00pm ET, so the six soonest kickoffs were soccer every week and the whole NFL slate fell off the bottom (owner report: “NFL day, I don’t see odds”). The cap is now filled one sport at a time in rotation, in each sport’s first-appearance order, so the earliest sport still leads the board, a sport that runs out of games simply yields its turn, and a single-sport day is unchanged. It is a SPORT rotation, not a league one: six leagues each holding one row reads as scatter, while three NFL rows beside three soccer rows reads as a board. Decided games are skipped (#betting-boards-game-calls, owner steer: cut the game calls): a game whose favorite is ≥ DECIDED_PCT (97, env BET_DECIDED_PCT) is all but won — a game CALL, not a line read — so is_effectively_decided drops it from the morning slate, the pregame window, AND (since the slate is filtered first) the marquee card, so the roundup never anchors to a ~99% game (the Real Betis screenshot). The betting alert’s live “winner lock” call, which announced this same decided state, was removed in the same change. Deliberately DISTINCT from market_drop: the drop reads cross-market FUTURES and CUTS single games (Bookie/commentator turf); this is the exact opposite — single-GAME lines ONLY, main line only, no props/exact-score/corners (owner steer). The Bookie’s slate cache keeps TWO clocks, and they must stay apart (Bookie._bettable_cache[0] vs _odds_fresh_at). _bettable_cache[0] times the GAME LIST and is what _cached_bettable expires against (_BETTABLE_TTL_SECS, 180s). _odds_fresh_at times the ODDS and is what the /bet board’s “line updated Ns ago” reads. They were one number, and the warm loop’s cheap live-odds re-fold stamped it. The re-fold reprices the games already held and never re-reads which games are on, so the stamp made a frozen list report as freshly fetched — and the loop fed itself: a game only stops being live when a full refresh re-reads it, the stamp stopped the full refresh, so ONE game stuck on live held the list until the next deploy. Measured in production on 2026-09-13: the last full refresh was 10.5 hours old, two MLS games that ended the night before still read live, and the morning board, the pregame line, /bet and BET PLACEMENT (documented to block for a fresh line) were all reading that list. The re-fold now stamps _odds_fresh_at only. bet_slate_refresh.detail.refresh_age_s reports the age of the list each refresh replaces and the ops monitor gates on it (slate_stale) — but only from NOW ON: it could not detect this freeze retroactively, because the stamp it reads is the stamp the freeze corrupted, and a correlation rule built to cover that gap fired on 13 of 22 pre-freeze windows when measured and was cut (Codex review on #3228; a sound detector is #3230). Separating the two clocks is what makes the age honest. Reuse, not duplication (owner steer: reuse the Bookie odds code): both paths ride the Bookie’s own slate (Bookie._bettable_games → _bettable_for_guild, shared via _slate_games) and line math (_sides/_matchup/_game_when, implied % = round(100/decimal), all from cogs.bookie), and compose_market_drop’s voice (angle="leaderboard", a game-line context_blob). Filtered by the guild’s BETTABLE sports and THEN by its COVERAGE sports (bettable_slate → _watched_only, #mlb-menu-visibility); posted to the betting-drops channel (db.resolve_betting_channels — the dedicated betting channel set on /menu’s live-sports page (page 3) if a guild picked one, ELSE the live-commentary sports channel, so commentary and betting drops route independently and an unconfigured guild is unchanged; #betting-links-channel-routing shares this resolver across board + both alerts); gated on its own bet_board experiment (production → the room, staging → a #bot-logs audition, off → skip) + master switch + mood. The bet_board experiment is split out of bookie (which gates bet-taking/settlement/alerts): the two used to be welded together, so silencing the board meant staging all of bookie (which also reroutes bet-taking + the settlement payout pings). Now a guild can take bets WITHOUT the board drops (bet_board off/staging, bookie production), or run the board without opening betting. The morning slate stays calendar-scheduled on top of this gate (the calendar owns cadence, the experiment owns on/off — mirrors market_drop); the pregame line is event-timed so the experiment is its sole on/off. The COVERAGE narrowing (#mlb-menu-visibility, owner ask 2026-09-14). /menu’s live-sports page holds TWO sports pickers and they answer different questions: 🎰 sports you can bet on (bettable_sports) says what the Bookie takes bets on, and 📺 sports she covers (watched_sports) says what Toots covers at all. Betting used to read the betting picker ALONE on every path, so a guild that bet MLB without covering MLB got an all-MLB odds board; the morning board of 2026-09-14 came back five rows MLB out of six. The POST surfaces now require BOTH pickers to agree. /bet is unchanged and still reads the betting picker alone, so a room keeps taking MLB bets while its board stays quiet about MLB. The filter lives in betting_board.bettable_slate — the one entry point the board AND the line-move alert already shared — so both post surfaces take it from a single place, and it reuses sportsdata.sports.is_watched, the one watched-sports matcher, so a game’s LEAGUE refines its key exactly as it does for the live commentator (a room covering the Premier League keeps an EPL line and loses an MLS one; soccer from a source that speaks a different league vocabulary stays ambiguous and is kept). Two fail directions, both deliberate: an UNSET coverage picker narrows nothing (the storage cannot tell “never set” from “emptied” — an empty pick is stored as null — and the betting picker is already the surface’s on/off), and a coverage READ ERROR keeps the full bettable slate, because a DB blip must not silence a surface. The narrowing emits bet_slate_narrowed when it actually cuts something (see docs/OBSERVABILITY.md): without it a board quiet because of a menu tick looks exactly like a dark feed. Cards — two shapes, one per lane (#board-structure, owner steer 2026-08-27 “this should be a board”): the PREGAME post is about ONE game, so it renders a single-game market CARD (utils.market_cards.build_market_card — a clickable Kalshi/Polymarket market link as the title + a %s chart, the take as the description, so the MARKET is the headline and /bet rides below). The SLATE roundup names the WHOLE slate, so it renders a BOARD card instead (_board_rows → utils.market_cards.build_board_card, the shared board portrait the music + sports boards ride, branded “tootsies bets”): one FAVORITE-FIRST row per game — the favored team is the subject, “over {underdog}” is the subline, the implied % is the number — ranked strongest-favorite first, the marquee (_marquee_game) the highlighted top row. The HERO LINE NAMES THE CARD (_SLATE_HERO, “Today’s Odds”), not the leading club (owner steer 2026-09-11, and the same call sports_boards.upcoming_board took on 2026-08-11 with “Tonight’s Favorites”): the card is a whole slate, so a single club in the big slot reads as though the card were about that club – and the caption that used to explain it (“favored at 64% - 6 games on the slate”) is no longer drawn (chart_cards.card_caption). The favorite still leads the take, carries the highlight, and names the caption. This RESOLVES the old KNOWN LIMITATION: the slate used to card only its marquee, so a multi-game take sat beside a one-game picture; the board now shows every game the take names. The cost the owner accepted is the clickable market button — a board carries no market CTA (build_board_card returns url=None), the trade chosen over a one-game link. Favorite-first (not the neutral “away @ home” fixture) matches how the take reads a line (“Barcelona over Athletic Club”) and fits one team name per column, so a long club name never truncates (the reason the sports upcoming board reaches for nicknames it has and the Bookie snapshot does not). Each row also names its own LEAGUE, in a tag pill BESIDE the team (#2982, owner steer 2026-09-05: “this should have the league in pill”, then “cant it fit on one row or be next to the teams”). A slate MIXES competitions — the live slate that day was five Premier League games and one La Liga one — so the card’s one eyebrow can only say “TODAY’S BOARD”, and a reader could not tell which league a row was. utils.sportsdata.sports.league_label reads the game’s own meta in three steps: the provider’s meta['league_name'] (API-Sports + ESPN carry the real competition name, including ones SOCCER_COMPETITIONS does not cover), else competition_for_league(meta['league']) (an SGO code like LA_LIGA or an API-Sports id like 39), else the code itself when it is already the name people use (NBA, NCAAF). It returns “” for anything else — an Odds-API machine key, SGO’s multi-competition INTERNATIONAL_SOCCER bucket — so that row shows NO pill instead of a guessed league (the repo’s prefer-absent-over-invented fail direction). The pill is CardRow.tag, drawn by chart_cards.render_board_portrait on the row’s own line, just after the label. It costs NO height: _tag_width reserves its room out of the row’s span, so _fit_row_size sizes the title around it and the pill can never be drawn over a team name (the cost is that a long club name is cut sooner — “Borussia Mönchengladbach” beside a pill is). A first version gave the pill a second line per row, which made every row 30px taller and cut a board from 10 rows to 7; the owner asked for one row instead. An untagged music or sports board renders byte-identically either way. A ONE-LEAGUE board hoists the league to the EYEBROW instead (_hoist_shared_league, owner steer 2026-09-05 “hoist it”, on an all-MLB board that read “MLB” six times). When every row shares one league the pill repeats and tells the reader nothing after the first row, so the league becomes the card’s eyebrow (“MLB” in place of “TODAY’S BOARD”) and the row pills come off. A MIXED slate keeps its per-row pills, because no single eyebrow can name four competitions. A ONE-ROW board is left alone too: a pill that appears once does not repeat, and that board is the PREGAME fallback, whose eyebrow names the post (“GAME LINE”). The hoist runs in _board_card, the one place both lanes build their card, so neither lane can miss it. The SLATE lane passes “BEST ODDS” as its default label (_SLATE_LABEL), which the hoist REPLACES with the league when every game is in one (owner steer 2026-09-11, on a block that read “6 GAMES”, then “mixed leagues need a block too”): an all-MLB slate wears “MLB”, a mixed slate wears “BEST ODDS” and keeps its per-row pills, and no slate card is left with an empty block. The block itself stays, on this card and on every other board card (owner: “keep the block”). The PREGAME lane keeps its own label, “GAME LINE”. The board’s pure row builder (_board_rows) + the favored-side reader (_favored, label + implied % + underdog off _sides) are unit-tested. CROSS-LANE matchup dedup (#betting-boards-2u0yqe, the duplicate-card fix): the two lanes deduped on different keys — the slate on POST TEXT (bet_board_history), the pregame on the MATCHUP (bet_board_claims) — so the same game carded twice, ~1h apart, one Kalshi card and one Polymarket card (the provider differs because the pregame lane’s coherent_card_snap re-resolves Kalshi-first per post). The slate marquee card CLAIMED its matchup in the shared bet_board_claims table so the PREGAME line lane would not card the same game a second time (_pregame_games skips a claimed matchup, keyed by the shared _norm_matchup, for _RECENT_MATCHUP_HOURS = 12h). The board no longer claims (#2644 Codex review): the old marquee card carried a market LINK, so suppressing the duplicate protected against two LINKED cards of one game; the board card has NO link, so claiming it would suppress the pregame lane — the ONLY remaining path with a clickable Kalshi/Polymarket link — and the marquee game would get no bettable link at all. The board (a morning overview) and the pregame card (a single-game linked nudge) are different posts, not the duplicate the claim guarded, so the board ships without claiming and the pregame lane stays free to post its linked card. The pregame lane still claims its OWN posts (a game’s line posts once in the window), so its dedup is intact; db.refresh_bet_board_claim is now unused. The slate ALWAYS renders the board, even when its headline game was carded recently. A morning roundup names its whole slate, so it re-mentions its headline game every slot and cannot drop one; suppressing only the picture would leave the game NAMED in the take with no card beside it (the 3:00am board that read as broken — Real Madrid carded at 07:00, then the same game named text-only at 10:00). So the picture always follows the headline; the slate no longer reads the avoid-set at compose time. The only cost is the rare reverse order (an early-morning kickoff cards in the pregame lane, then the morning slate boards the same game ~1h later) — two DISTINCT posts (a pre-kickoff nudge vs a day roundup), each of which legitimately wants art, which is better than a headline with none. The PREGAME card fetches the game’s prediction-market snapshot via the shared fetch_prediction_snapshots (Kalshi preferred — #prefer-kalshi, US-web-bettable — Polymarket the fallback, and kept for a soccer game whose Kalshi line lacks the draw, usable_prediction_line guard); a game with NO matched market (common for a preseason / lower-league fixture no exchange lists) first ATTEMPTS a two-way card off the book’s OWN line (_book_line_card → _book_line_snap → build_market_card, attach_market=False): a clean two-sided moneyline (exactly two priced team sides, no draw) becomes the SAME rich cover a matched game gets — both crests + the two big implied %s — so a sportsbook-only game is no longer stuck with the plain single-row board (owner steer: attempt a two-way card, the board remains the absolute fallback). The implied % per side is the RAW 1/decimal, the SAME read the take is composed from (_line_blob) and the board shows (_favored), so the take, the board, and this card all quote ONE number for a game — _CONTEXT tells the take to quote the data’s numbers exactly, so a re-normalized card would contradict it. A prediction-SOURCED line (a Kalshi/Polymarket price folded into game.odds when the game had no book price, meta["odds_source"]) is declined here — the card credits “the sportsbook”, so it must not render folded prediction prices under a false source — and the banner rung of build_market_card is treated as a miss so the board still runs. The single-game BOARD card (_line_board_card → _board_rows/build_board_card, the favored side as its one row, chart-label “GAME LINE”) is the ABSOLUTE fallback below it — reached by a 3-way line, a game with no clean two-sided price, or a card-build error — so the pregame line always ships a picture — numbers + wordmark, no looked-up face — instead of bare text (#art-board, owner steer 2026-08-27 “no art no board”, the Bills/Steelers preseason post that had neither a market card nor a board). The board carries no market link (build_board_card returns url=None) and stamps art_source="sports_api" so the delivery-time media gate keeps its PNG, the same verified marker the slate board uses (a board has no face to mismatch). Only a game with no readable favorite at all falls through to plain text (_line_board_card → None). The single-game fallback board shows as bet_board_posted carded=true with market_source=null (a market-backed pregame card carries the source), so the two are told apart in telemetry. Incident — the caption/chart mismatch (#caption-chart): a season-long OUTRIGHT (“Ligue 1: 2027 Champion”) names every club, so a game’s home + away both appeared in its outcome labels and match_prediction_snapshot’s step-3 raw-text fallback shipped the league FIELD (PSG 86%, Marseille 4%, Lens 3%) as the card chart beside a caption reading the game’s own 1X2 line (Rennes 67% / draw 20% / PSG 17%). Fixed in match_prediction_snapshot with _is_outright_field: a snapshot that prices 3+ outcomes with any runner mapping to neither side nor a draw is a field, never a single game’s line, so it is dropped before matching. bet_board_posted carries carded + market_source. The pure helpers (_line_blob = the who’s-favored blob incl. the soccer draw leg labeled “Draw”; _todays_slate/_slate_blob; _pregame_games = upcoming-in-window, soonest-first, skipping a db.recent_bet_board_matchups fixture; _norm_matchup) are unit-tested; both self-gated by drop_score (0.6 floor, fail-CLOSED). Live-dry-run-verified rendering on real games (Rangers 68%, Angels 38%). X mirror — the two lanes are SEPARATE rows (owner steer 2026-08-28): the per-game PREGAME card crossposts under its OWN X surface, bet_game (X_SURFACE_PREGAME), split out of the slate board’s bet_board row. One toggle used to govern both, so silencing the per-game cards on X also silenced the morning board — the owner could not express “the board yes, a card per game no”. bet_game is opt-in (x_crosspost.OPT_IN_SURFACES), so a guild that never opened the picker tweets the board and NOT a card per game, and a guild that wants both ticks the row. The reason is TIMELINE COST, not what the card claims: a per-game card ships a full portrait image, and the measured week ran 111 per-game lines against 18 slate boards, so a busy night filled the feed with near-identical cards. Discord is unaffected on either setting — the room gets the pregame line and its linked card as before. The pregame lane’s X-only path (x_only_active + X_ONLY_CHANNEL_ID, for a guild with no betting channel) now reads the bet_game row, so a guild that tweets only the board never runs the lane for X. Note what the default costs on X: with bet_game off, the betting posts carry no clickable market link, because a board card has none. Emits bet_board_posted / bet_board_scored / bet_board_dedup. (The calendar grid is 30-min per #1238.)betting_alert.py, the betting LINE-MOVE alert (#1234) — the event-driven sibling of the betting board (betting_board.py posts on a schedule/pregame-offset; this fires when a bettable game’s moneyline MOVES), and it shares the board’s slate entry point (betting_board.bettable_slate), so it also takes the COVERAGE narrowing: an alert names only a sport BOTH /menu sports pickers agree on (#mlb-menu-visibility), the game-line analog of market_alert (the prediction-market movement alert). TWO triggers, both PRE-GAME: a FLIP (the underdog overtakes the favorite — the favored side changed; the rarest, most legible “whoa”, always fires) and a SWING (same favorite, its implied % moved ≥ SWING_THRESHOLD_PP ~15pp from the baseline — pulling away or its edge collapsing toward a flip), both watching UPCOMING (pre-game) games ONLY (#game-lines-live-alerting, owner steer): a LIVE line moves with the play-by-play, so an in-play swing/flip is the game happening, not betting NEWS — it read as noise (a live 27pt Rays swing crossposted to X), so the loop skips game.live (and drops any pending confirmation) and only alerts on the pre-game line, where a move IS news (sharp money / injury / a scratch before first pitch); in-play edges stay owned by the value alert (betting_value.py, book vs live markets) + the commentator. A flip lands only when the new favorite leads by FLIP_MIN_MARGIN_PP (5pp, owner steer) so a pick’em jitter isn’t a “flip”. There is NO live-game trigger (#betting-boards-game-calls, owner steer: cut the game calls). A WINNER LOCK call fired here until then — a side crossing into a near-lock (≥97%) DURING a live game fired ONCE as “team X has it all but sewn up at 99%”. That call only ever said a team had already won, which is a game CALL, not betting news, so it was removed (with _is_foregone_blowout/_maybe_winner_lock/_fire_winner_lock/_lock_blob/_lock_number_figure and the LOCK_* constants). An effectively-decided game is now suppressed at the source for BOTH betting surfaces: betting_board.is_effectively_decided (favorite ≥ DECIDED_PCT, 97 — env BET_DECIDED_PCT) drops such a game from the board’s slate + pregame + marquee, and this alert has no call to make about a live decided game (a pre-game line move in-play is skipped as before). The durable bet_lock_alerts table + its db accessors are left in place (dropping a table needs a migration; the empty table is harmless), and the bet_lock card eyebrow in market_cards is now unreached. _fire shares the _ship compose→gate→card→deliver→record→emit path. Rare + high-bar: a persistence gate — a flip/swing must hold for PERSIST_PERIODS (2, owner steer “2-3 periods”) consecutive polling ticks before it counts (an in-memory _pending debounce, the noise guard bet_alert needs vs market_alert’s much slower cadence; not durable — a redeploy just re-confirms, never false-fires) — plus a per-game cooldown and a per-guild rolling daily cap (BOTH per-guild TUNABLES on the /menu tune page, tunables.bet_alert_daily_cap/bet_alert_cooldown_minutes, grouped as the “betting alerts” card — parity with market_alert_daily_cap + the chime-in cooldown; SEPARATE from market_alert’s cap so a busy market day can’t starve game alerts), and one alert per guild per tick. The baseline is DURABLE per (guild, game_key) (bet_line_alerts, one upsert row carrying ref_favorite/ref_pct + the cooldown/cap clock last_alert_at + the last shipped line — mirrors market_alert_state): the line as of the last alert (or first sighting, seeded WITHOUT alerting), so cumulative small moves accrue into one alert and a redeploy never loses it or double-fires. Reuse, not duplication: rides the SAME betting_board.bettable_slate + line math (_favorite/_line_blob/_matchup), Bookie’s provider-independent match_key for the baseline (utils.sportsdata.models, the SAME key bets/settlement use — NOT the provider-specific commentator game_key, which would fragment one game’s baseline across providers on the merged slate; order-independent + team-folded, so “consistent with bookie”), and compose_market_drop’s voice (angle="race_moved"). Single-GAME main line only; gated on the bookie experiment (production → the betting-drops channel — db.resolve_betting_channels: the dedicated betting channel set on /menu’s live-sports page (page 3) if a guild picked one, else the sports channel (#betting-links-channel-routing) — staging → a #bot-logs audition, off → skip) + master switch + mood. Links the market (owner steer: link the market, drop the misleading play-money /bet nudge): like the board + value alert, a resolved game ships a rich market CARD (build_market_card via the shared betting_board.market_snap_for_game — Kalshi preferred, #prefer-kalshi; Polymarket the fallback) whose clickable title is the Kalshi/Polymarket market the reader can go bet on directly, with the move as the description; a game with no matched market degrades to plain text (fail-open). The compose steer no longer tells her to push /bet — the linked card is the CTA. The card charts the side the take is about (#show-the-fav-line, owner steer “show the Giants line”): a 2-way game’s Polymarket moneyline bundles as ONE leg tracking one designated team, so when the take is about the OTHER side the chart showed the loser collapsing to ~0% while the words narrated the winner at ~99% — so build_market_card(favored_label=...) (threaded from _favorite/the lock side) charts the FAVORED side of that leg (market_cards._favor_outcome inverts a loser-tracking leg + relabels), landing the line + legend on the side the take reads. Pure helpers (_favorite w/ margin, _classify_move flip-beats-swing, _move_blob) are unit-tested; self-gated by drop_score (0.6 floor, fail-CLOSED). Emits bet_alert_posted (now carrying carded + market_source) / bet_alert_scored. (An earlier PR also wires db.prune_bet_board_state into the prune loop — it shipped in #1235 defined-but-uncalled, so the bet-board-family tables were growing unbounded — and extends it to prune bet_line_alerts.)betting_value.py, the betting VALUE alert (#1234 follow-on) — the third betting surface beside the board (who’s favored) and the move alert (the line moved): this fires when the SPORTSBOOK line DISAGREES with the PREDICTION MARKETS on a live bettable game, a profit edge (“Kalshi has Spain at 74%, the book’s still at 60% — value on Spain”). The premise is the #968 odds-health insight, now SURFACED instead of just logged: there’s no oracle for the “true” line, but cross-source AGREEMENT is the signal — when the book and the live prediction markets diverge hard on the same game, the book is the outlier (slow to reprice an in-play swing), and the side the markets rate HIGHER than the book prices is the value side (you’re getting book odds implying less than the market’s probability). Cheap by construction: compares the book line ALREADY in the bettable slate (SGO/enriched, free — no metered fetch) against the two FREE prediction markets (Polymarket + Kalshi via the shared fetch_prediction_snapshots, the SAME entry point the Bookie reprice + commentator color use). LIVE games only (an in-play book lagging the live markets is the sharpest edge; pre-game value is a tracked follow-up). Trustworthy, not trigger-happy: fires only when the game carries its OWN book line (_has_book_line — not one already prediction-folded, else book==market), when the divergence clears VALUE_THRESHOLD_PP (15pp, the odds-health “sources disagree” bar), and — when BOTH markets match — when THEY agree with each other within _MARKET_CONSENSUS_TOL_PP (a lone mispriced market isn’t a consensus to chase); both lines must be _coherent (sum ~100±vig, so a garbled parse can’t manufacture an edge). Rare + high-bar: a per-game cooldown + a per-guild rolling daily cap (REUSES the shared “betting alerts” tunables bet_alert_daily_cap/bet_alert_cooldown_minutes, counted against its OWN bet_value_alerts table so value + move alerts pace independently) + an edge baseline (#2162: the cooldown is pacing, not a content gate — during a long live game it lapsed while the edge stood still, and the surface re-stated the SAME book-vs-market numbers ~50–115 min apart; the edge signature team|book%|market% is stored on each fire (bet_value_alerts.last_edge) and an unchanged signature never re-fires, observable as market_filtered reason=same_answer kind=bet_value) + one alert per guild per tick, self-gated by drop_score. Reuse: the odds_health extractors (game_sides_pct/prediction_sides_pct), match_prediction_snapshot, match_key, compose_market_drop (angle="race_moved"). Pure helpers (_coherent, _has_book_line, _value_edge biggest-gap-first, _value_blob) are unit-tested; live-dry-run-verified that efficient pre-game lines AGREE (book vs Polymarket within ~2pp on real World Cup games → no false edge). Gated on the bookie experiment (production → the betting-drops channel — db.resolve_betting_channels: the dedicated betting channel if set (/menu live-sports page), else the sports channel (#betting-links-channel-routing) — staging → #bot-logs, off → skip) + master switch + mood. Links the SPORTSBOOK where the value bet lives (owner steer: “if the odds are lagging on the odds api, link the sportsbook instead of poly/kalshi; if available use it, else just link the prediction market”): the value is at the LAGGING BOOK (it’s slow to reprice; the prediction market has already caught up to the true number, so clicking through to Poly/Kalshi lands you where there’s NO edge left). So the card’s clickable title links the sportsbook bet-slip for the value side when The Odds API gives a usable one — _sportsbook_link maps the game’s sport → Odds-API key, reads the SAME cached get_odds slate the Bookie uses (no new metered call in the common case), matches the game (match_event_odds) + the value side (sportsbook_link in utils.sportsdata.format, team/draw name-fold), and returns the best-priced book with a CLEAN link (the_odds_api._best_link_per_outcome over the includeLinks=true response — the per-outcome bet-slip link, else the bookmaker event link; _clean_link drops the {state}-templated US-regulated books (BetMGM/BetRivers) we can’t fill). The card links the book (title → the bet-slip, e.g. FanDuel addToBetslip/DraftKings), footer bet it on {book} · odds via {source}, and the CHART still shows the market %s (the evidence for the value). Fallback: no clean book link (or Odds API unprovisioned) → the card links the prediction market as before (build_market_card(link_url=None)). includeLinks is a paid-plan feature that adds NO credit cost (billing is markets×regions), so it rides the slate’s existing call for free. Live-dry-run-verified: MLB + World Cup games resolve real FanDuel/DraftKings/BetOnline/Bovada bet-slip links (a game whose books are all {state}/link-less correctly falls back to the market). Fail-open to plain text if the card can’t build. Emits bet_value_posted / bet_value_scored (posted carries carded + market_source + book = the linked sportsbook, None = fell back to the market link). Kalshi now actually contributes a prediction line (#1234 follow-up): a live-telemetry audit found Kalshi in 0 of 82 production odds_health snapshots (Polymarket 63/82) despite ~64k fetches (99% ok, NOT a cache — 0 cache hits) — the value alert’s cross-market consensus guard was effectively running on Polymarket alone. Root cause was CONSTRUCTION: Kalshi builds one MarketSnapshot per Yes/No contract with an EMPTY outcomes dict (the outcome label lives in meta['yes_label'] = yes_sub_title), so every Kalshi snap failed prediction_line_odds’s empty-outcomes guard, while Polymarket aggregates an event’s legs into a populated outcomes map at construction. Fixed by markets.fold_kalshi_winner_snapshots — applied ONCE at the PUBLIC MarketsManager.kalshi_snapshots entry point, so EVERY surface that reads Kalshi benefits uniformly (the ask kalshi/props tools, the value alert, the Bookie live reprice, and the commentator’s odds_health color) — which groups a Kalshi event’s sibling per-contract snapshots by event_ticker and, for an event with ≥2 DISTINCT yes-labeled legs (a genuine one-winner-per-participant market; a single Yes/No market’s NO side is ‘not-X’, NOT the opponent — Kalshi’s no_sub_title == yes_sub_title — so it’s never folded), emits ONE aggregated snapshot with outcomes = {yes_label: yes_prob} mirroring Polymarket’s shape, PREPENDED to the untouched per-market originals (so ask’s granular per-contract lines + player-prop extraction still work, and its game reads now ALSO get the clean aggregated line). Correctness rides the existing prediction_line_odds guards — a futures/multi-team market (World Cup host, 32-team winner) folds to many outcomes but bails there (>2 valid legs with no draw, or only one side mapping to the game), so it can’t manufacture a bogus game line. Kalshi soccer label normalization (#1234 follow-up): Kalshi’s per-game soccer markets prefix every leg with the period qualifier (Reg Time: France / Reg Time: Spain / Reg Time: Tie), and the raw prefix broke the DRAW detection (the leading token is reg, not tie) — so prediction_line_odds saw 3 legs with no mapped draw and BAILED, rejecting EVERY Kalshi soccer game line (verified on the live World Cup slate: 0/3 games yielded a line before, 3/3 after). _clean_kalshi_label strips the X: qualifier when the fold builds outcomes (Reg Time: Tie→Tie→draw, Reg Time: France→France); a label with no qualifier (MLB Toronto, a futures Greece or Turkey) is unchanged. This is the parsing half of making Kalshi contribute a game line ‘fair and square’ — SEARCHING already works (the live FTS kalshi_event_search index carries 65 game series incl. MLB/NFL/NBA-summer/soccer/UFC), and the source PREFERENCE order is now KALSHI-first (#prefer-kalshi; it was Polymarket-first when this was written); the remaining Kalshi empties are genuine coverage gaps (no NBA regular season, non-WC soccer leagues), not a bug. Cost-neutral (Kalshi was already fetched + discarded; the fix only parses data we already pull). The fix is observable in the SAME odds_health telemetry (watch kalshi appearing in home_pcts/sources); pure fold_kalshi_winner_snapshots is unit-tested.health.py, the self-healing health watch loop (#443): closes the loop from Toots’ OWN telemetry to her self-repair tools. The ops-monitor already flags a silently-degraded integration twice a day for a human; this cog gives her the same eyes in near-real-time and lets her ACT without waiting for a complaint (the day iTunes 403/429’d her ~1,600x and collapsed the /guess clip pools to a few songs, she watched her backbone crater and said nothing). One @tasks.loop(30min) tick reuses the ops-monitor’s own Railway pull (scripts.ops_monitor.fetch_events, run off-thread; gated on RAILWAY_API_TOKEN/RAILWAY_SERVICE_ID, fail-open to a no-op) then runs the pure utils.health.assess over the EVENT window to find a watched backbone integration that has cratered (fail-rate over a floor with enough real attempts, reusing the ops-monitor OkRate counter, hoisted into utils.health so there’s one definition the report + watcher share, the utils/stats.py move). For each crater she (1) autonomously files a fix-order with the real numbers (cogs.order.Order.file_health_order, the bot-attributed sibling of file_autonomous_order: same preflight + kitchen/pipeline/in-flight + AUTO_ORDER_DAILY_CAP gates, attributed to guild.me, filed in the first guild whose gates clear), deduped durably per integration via health_state (24h window, process-wide since a throttle is process-wide, survives the redeploy the filed PR triggers), and (2) alerts the MASTER in #bot-logs with an @master ping (the bot master (utils.permissions.MASTER_USER_IDS)) that a surface has gone dark — #968 owner steer: to #bot-logs ONLY, never a content room, and now for EVERY crater incl. the ops-only backend watches that used to file a fix-order silently (the gap the drained twitterapi.io wallet exposed — detection worked, the fix-order filed, but nothing reached the owner). Deduped per (guild, integration) durably, backstop-gated (a covered outage isn’t dark), and deliberately NOT mood-gated (an ops alert isn’t proactive content, so a muted room can’t hide “your feed is down”; the master kill switch still silences a fully-off bot). Posts with a user-only allowed_mentions so the ping notifies but a stray @everyone/@role can’t. The watched set (WATCHES) is apple_music (keyed on apple_music_lookup’s clean throttle signal, not catalog_lookup’s noisier empties) plus every market_fetch source — api_sports (the commentator’s scores/events/leaders/team-stats/standings backbone), sgo + the_odds_api (the betting lines), and polymarket + kalshi (the prediction markets) — each pinned to its source via the optional Watch.source sub-filter so the sources don’t cross-count, all clean because a successful empty slate is ok=True (only a real fetch failure is ok=False). So a crater of any market/odds/score feed self-files + warns, not just the sports backbone. Broadened to the whole app backbone (#968 “watch everything”): beyond apple_music + the market feeds, the registry now also watches embeddings (OpenAI — semantic recall + Kalshi discovery), elevenlabs_tts + elevenlabs_stt (voice in/out), perplexity (live-web grounding, kinds = all pplx_*), twitterio (#1255 — the content curator’s X source, keyed on curator_fetch; a datacenter-IP crater or a drained credit balance quietly stops the curator finding fresh posts + crawling the follow/repost discovery graph, ops-only notice=""), and odds_divergence (the cross-source line-disagreement signal from #968 — it has no ok field, so its Watch.ok_when predicate treats a ≥15pp odds_health.max_divergence as a “failure”, keeping it inside the same assess machinery). Most of these are ops-only (empty notice → files a fix-order but posts no room warning, since a backend crater isn’t a visible “I’m degraded” moment); only voice (tts) keeps a room notice. Each has a CLEAN ok signal (a real failure, not an empty result). Fail-soft surfaces with noisy signals (gif/image/reference/links/video) are deliberately left to the twice-daily ops-monitor’s integration-health (OBSERVE, no fix-order), not acted on here — the two-tier split: the 30-min loop ACTS on the load-bearing set, the ops-monitor OBSERVES everything. The registry is the extension point for more. Backstop-aware notice suppression (#748): a Watch can name a backstop integration (a bot-client attribute) that carries its user-facing impact when it craters; when that backstop is provisioned + healthy (breaker closed) the in-room notice is held (the fix-order still files for ops) and only fires when the backstop is ALSO down. SGO rides this with backstop="the_odds_api": once the Odds API SGO-down backstop shipped, an SGO crater no longer drops betting lines, so the “lines might be missing” warning would over-promise — she stays quiet unless the_odds_api.degraded too (“everything flaky and down”). The other watches declare no backstop, so they warn on any crater. Fail-open throughout. Emits health (action=filed/dedup/alert/usage_alert, with delivered=bot_logs on a posted owner alert, or delivered=suppressed/reason=backstop_healthy on a held one). The same tick also runs the metered-API usage poll (_emit_usage, #727), and now drives an owner-facing metered-wallet EARLY WARNING off the same reports (_maybe_usage_alert → utils.usage.usage_alerts): a prepaid wallet under its floor (twitterio < 100k / scrapecreators < 500 credits — the credit drain that took the curate feed dark) or a capped source ≥85% of its limit pings the owner in #bot-logs before it drains to a 402/429 wall (deduped per (guild, usage:<source>) on a wider 12h cooldown, since a balance drains slowly): it reads each metered source’s quota via utils.usage.collect_usage (SGO’s quota-exempt /account/usage, API-Sports’ free /status, the Odds API’s free header refresh, Highlightly’s passively-captured headers, ElevenLabs’ free /v1/user/subscription monthly character cap, GitHub’s free /rate_limit REST-core budget, and Giphy’s daily + OpenAI’s per-minute (informational) rate headers captured passively off their search/embed calls, #753; plus twitterapi.io’s prepaid CREDIT BALANCE via the quota-exempt /oapi/my/info, #1255 — a prepaid wallet with no fixed cap, so it flags on a balance FLOOR, not a pct) and emits one quota event per (source, metric), running unconditionally (bot-wide, not per-guild, so it fires even with no configured guild / no Railway creds). This is what turns a quota filling toward its cap into a flagged ops-monitor quota_low finding (high) before the 429 wall — the early-warning that was missing the day SGO’s monthly entity cap (100k, rookie tier) silently exhausted and took /bet dark while API-Sports sat at 90% of its daily cap.voice.py, voice in → voice out: a Discord voice note directed at Toots is transcribed (ElevenLabs Scribe), answered through the exact /ask pipeline (Ask.produce_answer, allow_voice=True so the model can tag a <sing> delivery), and replied to with a native voice note (no file-card attachment). “Directed at her” is a reply to one of her own messages: a Discord voice message has no text body, so it can’t carry an @mention — reply is the only deterministic spoken summon (a voice note that isn’t a reply to her is ambient room audio, heard as transcribed context by the chime-in path, not answered here). Runs whenever the ElevenLabs key is provisioned; whether she answers out loud vs in text is provisioning (the ElevenLabs key / bot.tts): when provisioned she speaks, else she stays in text. A spoken question always gets an answer (text at minimum). Shares the per-user ask rate bucket and the /ask abuse classifier. Failure degrades to a plain text reply (never a file): transcription miss → in-voice deflection, synth/send miss → the composed answer as text. cogs.ask skips voice notes so this cog owns the path. The voice-out delivery decision lives in the public deliver method and is shared with the typed-mention path (cogs.ask routes its mention answers through it with user_sent_voice=False): a voice note forces voice-in→voice-out, a typed mention voices only when the model nominated it, so the delivery format follows the answer, not the input channel.starboard.py, fixes + leaderboards for the server’s MEE6 #wall-of-fame starboard (two independent boards, one keyed on 😭 and one on 😂, each promoting at 10 of that emoji). MEE6’s promotion is rate-limited to one message/minute, so a message that crosses the bar in a busy stretch silently never makes the wall. /star period:[1h|1d|1w] (mod-only, optional threshold defaulting to 10) sweeps the configured discourse channels for messages that cleared a board but aren’t on the wall and lists them as jump links (posted in-channel, NOT ephemeral so anyone can help react; paged; each line tagged with the author mention, pings suppressed) for a HUMAN to react to, because MEE6 ignores reactions added by bots (confirmed in prod #541 via Railway logs: a /star bump added 65 reactions across 64 messages and MEE6 promoted none — starboards gate their reaction handler on if user.bot: return), so only a person’s reaction re-triggers MEE6. (An earlier mode:bump that re-reacted as the bot was removed for exactly this reason — it never promoted anything.) The scan runs in the background (acks ephemerally, posts the list when done) so a big week’s sweep can’t hit the 15-min interaction-token timeout. The report filters out already-promoted messages by harvesting original-message ids from the jump links in the wall’s MEE6 posts (parse_jump_message_ids), resolving the wall channel via the stored channel: pin (set on /star) or a name auto-detect. /star also carries the optional channel: wall-pin (the new home for it — it used to live on /leaderboard bangers, which folded into the /leaderboard stats hub): the pin persists to the per-guild settings KV (wall_channel_id) so /star + the card’s wall-bangers flex resolve the wall by id thereafter, else a name auto-detect (looks_like_wall_channel matches any wall/hall + fame channel ignoring emoji/separators so spelling variants all hit — wall-of-fame, the server’s actual wall-off-fame, etc.; so zero-config works out of the box and a one-time pin makes it rename-proof). Emits starboard_report. /leaderboard stats (everyone) is the single engagement HUB that absorbed the former separate /leaderboard bangers / reactions / card commands (this PR — they were redundant with the in-card board picker, owner steer): one navigable _LeaderboardView driven by FOUR in-card dropdowns so every stat board is one surface: the board toggle (🏆 bangers / 🔥 reactions / 😭 funniest / 🃏 card OVR / 🫶 besties), the when: PERIOD window (all-time / this month / this week, #1017), the show: scope (here-now / everyone), and the how-many: row count (top 10/25/50/full). The command is argument-free — everything is a dropdown (the period + count moved off slash args, owner steer). The hub is never gated so the always-public bangers + reactions + funniest boards stay reachable even with cards off; it opens on the card OVR board when the viewer can see it (the headline ranking), at all-time / top 10, else bangers. The funniest board (#1017) ranks all-time laughs pulled per member (laugh_reactions, 😭/😂) straight from the seed — the comedy-VOLUME twin of bangers (wall-clearing posts) and the leaderboard mirror of the awards’ Funniest (_build_funniest_seed, public like bangers/reactions). WINDOWED stats (#1017): all-time reads the member_engagement seed directly; weekly/monthly subtract a daily SNAPSHOT of the seed from N days ago (db.engagement_snapshots + the pure utils.engagement_window: window_display/window_state diff the cumulative counters + the reply/mention graph) — so the SAME board builders re-rank over the window (_period_display/_period_state swap the data source; the card OVR over a window is a ‘form’ rating via ovr_board(windowed_seed=); besties over a window is the ‘best new duo’). One daily snapshot feeds BOTH weekly + monthly by subtraction — no extra scan jobs, instant reads. Forward-looking (windows fill in as snapshots accrue; a window with no snapshot yet renders EMPTY, never all-time mislabeled). The bangers board ranks all-time BANGERS per member — posts that cleared the wall bar (10+ 😂 OR 😭) — read straight from the member_engagement stats seed, no scan (#957: the bangers board moved off the wall onto the seed; this is the seed’s would-be-wall count, a SUPERSET of MEE6’s actual wall promotions — the card’s separate “wall bangers” flex still reads the wall for that). The reactions board ranks total reactions pulled per member, also straight from the seed (the /stats full-history scan, kept fresh by the daily catch-up writer) — no live scan (#957: the old LIVE windowed 6mo/1w/1d scans were dropped; weekly/monthly are now the period: snapshot-delta windows (#1017), derived by subtraction, not a live scan). Boards show the full board with no row cap (paged past Discord’s 4096-char description limit), delivered as ONE navigable message — the interactive _LeaderboardView (#902): ◀/▶ page flips that are in-memory over the prebuilt pages (instant, no re-fetch), the board picker dropdown (data-driven off self.boards), and a show: scope dropdown (here-now / everyone) that filters whichever board you’re on by guild membership (the universal departed filter, default here-now). Only a board/scope change re-fetches (defer + _build_board dispatcher, which reuses the board builders + resolves the right data source); a public shared board, so anyone in the room can navigate it. The card OVR board (#570) ranks active members by their /card OVR (rows show the OVR number) — built off the PlayerCard cog’s ovr_board (the SAME member_engagement seed + snapshot the card reads, so a member’s board OVR == their card), rendered through the same shared _board_pages as bangers/reactions for a consistent board (_rank_mark + mention_or_name, real-member-only via the PR #985 roster fold). It’s gated on the playercard experiment (PRODUCTION=everyone, STAGING=mods-only, OFF=hidden — exactly as visible as the cards it ranks) and joins the toggle picker only in PRODUCTION (_available_boards), so the toggle never exposes the card/besties surfaces to a non-mod during the staging preview — a mod previewing in staging still gets the card board surfaced by run_stats. (The games + bets boards stay their own /leaderboard subcommands: different data shapes — game points, play-money — not folded into the stats toggle. /leaderboard games is argument-free too (mirroring this hub): its options — when: PERIOD (this season / all-time) + how-many: row count (top 10/25/50/full) — live on in-card Components V2 dropdowns (a _GamesLeaderboardView in cogs/games.py mirroring this hub + /card), so period/count re-slice in place; there’s one game today so it opens straight on Guess the Song (a board picker returns if a second game lands). The board build is the shared Games._build_games_board — paged under the V2 text budget so a “full board” pages rather than truncating.) Boards run anywhere and are all-time and uncapped (accuracy over a ceiling — explicitly NOT capped, per owner steer on #541), tallied by USER ID (fold_entries keys on uid:<id>, the id coming from the embed-author avatar URL for the wall / the message author for reactions) so a person’s posts merge across a display-name change — the prod bug where @zapper showed up as two rows (4 + 2 moments) because he’d renamed. An embed-fixer webhook repost carries the user’s name but the webhook’s id, so it’s keyed by normalized name (clean_author_name strips the (Embed Fixer) marker + #0000 discriminator) and folded onto the real user by resolve, which matches a name-keyed entry against the set of names each id has posted under (so a fixer repost under an old name still lands on the right person). resolve is seeded with the real (non-bot) MEMBER ROSTER (_member_roster, guild members’ display name + username → id) so a webhook/embed-fixer repost folds onto a real member by name EVEN when that member has no id-keyed wall post of their own — and, critically, an entry that matches NO real member is DROPPED (rank_tally now excludes uid=None slots). That’s the fix for the wall board filling with tweet-source reposts (Bleacher Report (@BleacherReport), Fabrizio Romano, …): a fixer/webhook repost of a tweet shows the TWITTER account as the embed author (a Twitter avatar, no Discord id, a name matching no member), so it now stays off the members-only board instead of topping it. The same roster-fold + drop applies to the reactions board (consistency). Every stat board now reads the member_engagement seed — NONE scans the wall. The wall incremental durable cache (db.cache_get/cache_put, namespace walloffame_board_v3, keyed {guild}:{channel}: a cursor + settled tally per channel, re-scanning only new wall posts, immutable → settle on sight) survives for exactly two consumers that genuinely need the ACTUAL MEE6 wall (the seed can’t capture promotions): /star (the un-promoted sweep) and the card’s “wall bangers” flex (lifetime_totals). The bangers + reactions boards read bangers / reactions_received per member from the seed (instant, kept fresh by the daily /stats catch-up writer). The former cold-cache background board-build path (_serve(background=...) + _deferred_board) was removed once every board moved to the seed — boards are always built inline now. The discord-free core (cleared_emojis / message_reaction_total / parse_jump_message_ids / classify_message / parse_avatar_user_id / clean_author_name / fold_entries / resolve / rank_tally / merge_tallies / tally_authors / tally_reaction_totals) lives in utils/starboard.py, unit-tested. The besties board (#570 follow-up) ranks the server’s closest PAIRS — the Ochiai coefficient (provably == cosine similarity; validated against the tie-strength literature, beating PMI/NPMI which inflate rare pairs) of each pair’s two-way reply+mention graph, read from the engagement seed’s state blobs (the full uid-keyed reply_targets/mention_targets Counters — no scan). It’s activity-normalized (the geomean of each member’s share of interaction aimed at the other), so it surfaces genuine duos, not the loudest hubs — “are you each OTHER’s people”, not “are you both loud”; reciprocity is baked in (a one-sided pair’s geomean collapses). The show: filter is UNIVERSAL across the whole /leaderboard family (#570 follow-up — this is also the “departed filter for leaderboards”): a _ScopeSelect dropdown on the _LeaderboardView toggles here-now (members still in the guild) vs everyone (incl. departed — the all-time view) and re-filters WHATEVER board you’re on (bangers / reactions / card / besties), defaulting here-now, persisting as you switch boards. The membership filter lives ONCE in _board_pages (drops uids not in guild.members, applied BEFORE the row cap so ranks re-number contiguously); besties applies the same Scope via closest_pairs. Besties itself is a board IN the shared toggle picker (_BOARD_OPTIONS + _available_boards, joins the cycle in PRODUCTION like card — no separate command, reached by switching boards in the picker), rendered with pair rows (A ↔ B · 20%); playercard-gated exactly like /card (same seed). A fixed interaction floor (40) is the small-sample guard (Bayesian shrinkage is moot at all-time scale — verified — but is the lever a future weekly window would need). The pure closeness core is utils.social_graph.closest_pairs (unit-tested); the cog reads the seed + resolves names. Emits starboard_report + walloffame_leaderboard + reaction_leaderboard + funniest_leaderboard + card_leaderboard + besties_leaderboard./undo command, and its admin.py cog, both removed) now lives in the /order view as a confirm-gated “↩ undo deploy” button, backed by the interaction-free order.py:_undo_deploy core. It’s deploy-scoped (rolls back the live Railway build, not a specific order), so it sits with the kitchen toggle, not the per-order retry/cancel, and only shows when RAILWAY_API_TOKEN + RAILWAY_SERVICE_ID are set. /order’s view is now the single mod-ops console: it absorbed /close, /open (kitchen toggle), and /undo.settings.py, /menu interactive wizard — the single mod-config surface. Config pages (Discord caps a view at 5 action rows / ~5 select menus, so the channel pickers are split across pages to stay ≤4 selects each), paged via nav buttons. The cinema alerts channel picker (_CinemaSelect/db.get_cinema_channels) lives in the channels hub, like every other picker. It used to sit on the betting & markets page (owner steer: the movie/TV NUMBERS are the markets-adjacent factual layer), and it stayed on the market-TOPICS editor page after #2137 moved the pickers into the hub. That left the same select in two places, so the copy on the topics editor is deleted (a #2137 follow-up). The topics editor now renders only its three room → category → tag steps. The channels HUB (_PAGE_CHANNELS, #2137 follow-up — owner ask “move them to one page with buttons”): every posting surface’s channel picker is reachable from ONE directory page — a button per surface (the _CHANNEL_HUB registry: key, label, state key, select class, single?, blurb) plus a “wired now” chips summary and the market-topics button; each button opens the shared one-select edit page (_PAGE_CHANNEL_EDIT, keyed by MenuView.channel_edit_key), so the hub carries 0 selects and a NEW surface’s picker is one registry row, never a new page. It REPLACED the four scattered picker sub-pages (market channels / pop desk / sports desk / cinema desk); reached by the betting & markets page’s 📺 set up channels ▸ CARD button — deliberately NOT an extra nav-row button (owner steer “keep the more ▸ convention”). The market-topics button rides the hub grid too (owner steer “this one too”); the topics editor’s ◂ back returns to whichever page opened it (MenuView.topics_back). The grouped pages below remain a second way into the same settings (same select classes, same storage/autosave). The channel pickers are also grouped onto contiguous pages so the wiring sits together: page 1 = people + vibe (the non-channel selects) = mod roles, mood, girls roles, ignore (silenced members) + the master kill switch on the nav row; page 2 = channels part 1 = discourse channels, chime-in channels (where Toots jumps in), memory channels (what the long-term memory writer reads), music channels; page 3 = channels part 2 + sports coverage = sports channel (the live-commentary channel) with the watched-sports picker beside it (_WatchedSportsSelect, a string multi-select over utils.sportsdata.sports.WATCHABLE_SPORTS — which sports the live commentator calls; empty = all (the default, stored null), so a guild only narrows when it wants to, e.g. mute the MLB/NFL breadth and keep the World Cup, persisted to the watched_sports settings KV and read by cogs.commentator._resolve_targets to filter each tick’s games per guild) plus the betting-sports picker beside it (_BettableSportsSelect over the settleable subset utils.sportsdata.sports.BETTABLE_SPORT_KEYS = soccer/NBA/MLB/NHL/NFL/UFC-MMA — which sports the play-money Bookie takes bets on; UNLIKE watched, unset = the {soccer, NBA} default, NOT all, so the new sports MLB/NHL/NFL/MMA ship OFF until a mod turns them on (#mlb-betting), persisted to the bettable_sports KV, read by cogs.bookie._bettable_for_guild which serves a game only if its sport is in the guild’s bettable_sports set (unset = the default) — betting is INDEPENDENT of watched-sports (owner steer: the two menus are fully separate, so a guild can take MLB bets without commentating MLB and vice versa); the metered Odds-API odds fetch is likewise scoped to the cross-guild BETTABLE union (_bettable_sports_union), not watched. Page 3 also carries the live-commentary PACE knob (_CommentaryPaceButton → _PaceView): the heartbeat/clutch check-in intervals (the two SETTING_COMMENTARY_*_INTERVAL_MIN tunables, commentary_cadence) were MOVED here off the tune page so they sit beside the sports config they scope (the working-hours precedent — a knob lives with its config, hidden=True on tune). It’s a BUTTON opening a dedicated pace SUB-VIEW (_PaceView, swapped in place with a [◂ back] like the models/calendar/tune tail), NOT inline selects, because page 3 already has 3 select menus and Discord caps a message at 5 (the split that made this page keeps each ≤4) — so the two dropdowns get their own view with full headroom rather than pushing page 3 to 5/5. The sub-view renders the two intervals as preset DROPDOWNS (_PaceSelect, reusing the tune editor’s band-filtered preset_values/_option_label ladder, so a pick is always in range), each saved to the settings KV on pick; on [◂ back] the picked values sync into MenuView.selected so the page-3 button label (⏱️ 20m / 10m ▸) updates. The category-routed prediction-market surfaces (market_drop + market_alert) filter their SPORTS content by WATCHED sports ONLY (markets.resolve_market_sports), NOT the bet menu (owner steer — reverts the #1231 watched ∪ bettable union): the BET menu (bettable_sports) is BOOKIE-only and must not gate market drops — a bettable sport still powers /ask scores/odds, the commentator, and the Bookie’s prediction-market pulls (those fetch snapshots per game, never through this filter), but it only appears in a market DROP when its market is actually selected for the room. So the per-room Kalshi category/subcategory picker (_DropSubcategorySelect) is the SOLE control over a drop’s sports when watched is unset (the common case → resolve_market_sports returns None → no sport filter); a set watched list is a coarse per-guild narrowing on top. The subcategory picker now PAGINATES its tags (_subcat_all_pairs + _subcat_page_count, ◀/▶ _SubcatPageButton) since Discord caps a select at 25 options and a room’s covered categories can carry more (Entertainment’s ~24 live Kalshi tags + Sports’ ~20) — so NOTHING is hidden (the bug where a room covering Entertainment + Sports showed only Sports · Soccer and hid Tennis + every other sport). The paged multi-select MERGES on save (_page_values) so a selection made on one page survives editing another; subcat_page resets on room/category change. DEFAULT_BETTABLE_SPORTS (the {soccer, NBA} default) is hoisted to utils.sportsdata.sports as the shared source of truth (bookie + the market filter both read it). The WHOLE lifecycle works because the Bookie sources authoritative API-Sports STATE for the new sports on demand across all phases — hub.live_games/upcoming_games/recent_finals all take an api_sports_extra set (the enabled-new-sports union), so a MLB/NHL/NFL/MMA game shows correct pregame/live/final transitions even while SGO (the ODDS source) is throttled (the ODDS still fold from SGO/The Odds API/Polymarket+Kalshi; only the game STATE + settlement come from API-Sports). The slate scans are anchored on EASTERN TIME and cover TODAY + TOMORROW for all sports (#sgo-espn-backup): the day anchor is _eastern_today (not date.today()/UTC), so a whole US sports day stays on one date and UTC midnight (8pm ET) never splits a night’s card mid-slate. ApiSportsProvider.upcoming_games scans today AND tomorrow for the CORE sports too (soccer/basketball), not just the extra sports. Core USED to stay today-only because SGO’s get_upcoming_scoreboard supplied their multi-day slate; when SGO 429s that slate is empty and a US-evening game (tonight’s WNBA — a 7pm ET tip) never reached /bet (“WNBA isn’t showing”). Once surfaced, an unpriced core game folds ESPN’s FREE pregame moneyline (_supplement_with_espn_odds) or a Kalshi line. The SETTLEMENT + LIVE scans (recent_finals/live_games, API-Sports and the ESPN backstop) union BOTH the Eastern and UTC anchors (_anchor_days): API-Sports files a fixture by its UTC date, so in the ET/UTC offset hours (00:00-04:00 UTC, before Eastern midnight) an Eastern-only scan would omit the current UTC bucket – a late US final would miss settlement until Eastern midnight and leave a stale SGO live row bettable (Codex #2586). Unioning both anchors is a strict superset of the old UTC scan, so settlement never regresses. Settlement is a per-sport REGISTRY, interchangeable the way the odds feeds are (#tennis-registry / #1113 enabler): utils.sportsdata.sources.SourceCapability.settles is a per-SPORT set (not a bare bool) declaring which sports each feed can authoritatively finalize — API-Sports its six hosts (soccer/NBA/MLB/NHL/NFL/UFC), The Odds API /scores the TEAM sports only (verified live: MLB 32/32 completed, but tennis AND mma come back 0-completed, so both are HONESTLY excluded — the matrix’s “encode the real competence boundary” rule; MMA’s real settler is API-Sports /fights). The Bookie’s bettable-capability set (_SETTLEABLE_CAPABLE) now DERIVES from settleable_sports() (the union of every feed’s settles) instead of a hardcode, and the settle loop is a _SettleLeg resolver (Bookie._settlement_legs) that walks the feeds in settlement_sources(sport) PRIORITY order (API-Sports the primary dedicated feed; The Odds API a now ALWAYS-ON /scores backstop, #725/#nfl-preseason — it was gated on api_sports.degraded, but API-Sports’ baseball/NFL hosts are on a free plan that reads only 2022–2024 seasons, so a 2026 MLB/NFL final never arrives there while the breaker stays CLOSED on the healthy soccer/NBA hosts; the leg now runs every tick for any open-bet sport it covers; ESPN is the THIRD layer for those same two sports (#2466) — the Odds-API leg is METERED, so a quota lapse or key failure would leave MLB and NFL with no settler at all, while ESPN is free, keyless, and carries the full final scoreline for both (verified live 2026-08-31: MLB 14/14 and NFL 14/14 completed games returned both scores). Its settles set grew from {tennis} to {tennis, baseball, americanfootball}, and bot.py derives the ESPN provider’s always-on fetch set from that same CAPABILITIES["espn"].settles rather than listing it twice: a sport ESPN is registered to SETTLE must be FETCHED whatever the breaker says, or the leg reads an empty feed and the bet voids at the 96h stale-void. The should_fetch gate could not be left on api_sports.degraded for the same reason the Odds-API leg could not — that flag reads ONE shared breaker which the healthy soccer/basketball hosts hold CLOSED, so it can never open for a plan-limited host. MMA and the remaining team sports (soccer/NBA/WNBA/NHL) KEEP the degraded gate, because API-Sports really does settle those and a free no-SLA host should not be polled for games it is not the backstop for) — each finalizing only the sports it covers through the shared idempotent _settle_game, so an overlap can’t double-pay. The refactor is behavior-preserving (all prior settle/backstop tests unchanged) and makes settlement extensible the way odds already are: adding a new sport is a matrix/registry entry + a canonical_* name fold (a dedicated tennis provider with settles={"tennis"}, or a new API-Sports host like rugby/handball/volleyball — the hosts exist, verified 2026-07-03), NEVER a settle-loop edit; a sport no feed settles (settlement_sources()==()) is derived-out and never offered. utils.api_sports adds v1.baseball/v1.hockey/v1.american-football + the shape-flexible _team_game_to_snapshot, plus v1.mma’s /fights + _fight_to_snapshot (an INDIVIDUAL-event shape: fighters.first/second with an explicit winner boolean and no scores/league, so the winner is encoded as a 1-0 ‘score’ and the shared settlement path resolves it unchanged — a no-winner draw/no-contest reads 0-0 → a 2-way PUSH; season-less, so it plugs into _EXTRA_SPORT_FETCH via _no_season). utils.sportsdata.names.canonical_us_team (co-located with the soccer FIFA-code fold) folds cross-provider team-name spellings so a bet reconciles across the pricing + settling feeds — including a per-sport NICKNAME fold: SGO prices the US team sports with the bare nickname (Cubs, White Sox, 49ers) while API-Sports settles with the full name (Chicago Cubs, …), so canonical_team(name, sport) maps both onto one canonical (the nickname map is built per-sport + drops ambiguous keys so the Cardinals MLB/NFL collision can’t cross-resolve, and the bare Sox is dropped in favor of Red Sox/White Sox); NBA was added to the US roster (it was missing entirely). Without this, SGO-priced MLB/NHL/NFL/NBA bets reconciled 0% against the API-Sports final and all stranded — invisible during #1101 because SGO was throttled, caught live 2026-07-03 the moment it reset (SGO 0/30 → 30/30 on MLB after the fold). Soccer CLUBS get their own fold, canonical_soccer_club (#1469 Stage 2): a club is spelled differently by each feed (API-Sports settles “Newcastle”/”Bayern München”/”Inter”; SGO + The Odds API price “Newcastle United”/”Bayern Munich”/”Inter Milan”), and norm alone can’t bridge a truncation/compound-drop/transliteration/org-affix — so an unbridged club bet stranded + voided at 96h (Gap B on epic #1469, the national+US analog for clubs). Same machinery as the US roster: a curated _SOCCER_CLUBS table of (canonical, aliases) across the six covered leagues (EPL/La Liga/Serie A/Bundesliga/Ligue 1/MLS), built into a _us_key-normalized key→canonical map with the AMBIGUITY GUARD (a key two different clubs would claim is DROPPED → a safe fall-through to norm → a void, never a mis-pay). Canonical = the API-Sports settlement spelling EXCEPT where it nests as a substring of another club (“Inter” → “Inter Milan”, so the hub’s containment matcher can’t merge it with “Inter Miami”). Verified live 2026-07 against the API-Sports + The Odds API rosters (every measured same-club pair converges, every distinct-club pair — Man Utd/Man City, the three Real’s, Inter Milan/Inter Miami, AC Milan/Inter Milan — stays split, zero ambiguous drops); ships dormant-but-ready (off-season until ~Aug/Sep), an incomplete roster is SAFE. MMA fighters get their OWN fold, canonical_fighter — SGO abbreviates the first name (K. Usman) while API-Sports spells it out (Kamaru Usman), so both reduce to initial+surname (k usman), applied by match_key_parts (game dedup + bet↔final keying) AND bookie_bet_outcome(person_names=True) (winner↔side-label matching) when the sport is mma; without it a SGO-priced UFC bet reconciled 0/16 against the API-Sports final and stranded (live-caught 2026-07-03 the moment SGO’s throttle reset — the fold lifted it to 12/12 of the fights on both slates, the rest just not yet in the API-Sports window). A 2-way tie PUSHES rather than loses (bookie_bet_outcome(three_way=...); MLB/NHL never tie, a rare NFL tie refunds, an MMA draw/NC pushes; MMA is NOT in _THREE_WAY_SPORTS). MMA is UFC-scoped by a fold-only guard (_ODDS_API_FOLD_ONLY_SPORTS = {"mma"}): The Odds API’s mma_mixed_martial_arts key prices EVERY MMA promotion (UFC/Bellator/KSW/regional) but API-Sports /fights settles UFC only, so the Odds-API supplement FOLDS its line onto UFC fights already surfaced (by SGO’s UFC-scoped league or API-Sports) but never ADDS a non-UFC fight — which could never pay out a win (it would strand + void at 96h); live-verified 2026-07-03 (36 Odds-API MMA fights, ~12 UFC on the API-Sports slate). Tennis stays unbettable — API-Sports has NO tennis host (v1.tennis doesn’t resolve, verified 2026-07-03), so no finals feed to pay out a win; deferred), then feed channels + logs channel. chime-in + memory are split out of the discourse set so each can watch a different set of rooms, each independent (empty = that surface goes dark; a one-time DB seed copies the discourse set into both on first deploy so nothing regresses, then they’re edited separately); discourse posts/icebreakers + starboard sweeps keep the shared discourse set. The config pages carry a consistent nav row — ◂ back/⏻ power · more ▸ (steps to the next page) · experiments ▸ (the one-tap jump to the experiments hub, on every config page; on the prediction page, whose next page IS experiments, it replaces more ▸). The final hop experiments→models is its own models ▸ on the experiments page (the friendlier pages come first; the raw-numbers tune editor is now LAST), and the models page’s back is ◂ back (it steps back to the experiments page). Arrow-styled to match the nav row rather than standing out as emoji shortcuts. page 4 = the experiment rollout selects (one tri-state select per registered experiment, paginated via ◂/▸ nav buttons sharing the nav row once the registry grows past _EXPERIMENTS_PER_PAGE (4) selects, so a 5th+ experiment is reachable instead of dropped); the experiments page (page 4) leads via its [models ▸] nav button into the chain tail — models → calendar → tune — each a separate view swapped in place with a [◂ back] to its predecessor. There is no standalone /tune command — the tune editor is only reachable through /menu. Every select auto-saves and re-renders the embed. This cog absorbed the former standalone /logs, /girls, /ignore, and /experiments commands (those cogs were deleted). The experiments page leads to the Models page (cogs.models) via a [models ▸] button — one opus/sonnet select per answer/generation surface (ask/recap/discourse/memory) — which in turn reaches the calendar (cogs.calendar_view) via a [calendar ▸] button: a scheduled-post day-planner (chill/yaps pages over a 30-minute working-hours grid — each hour holds a :00 and a :30 slot — that the discourse/music/market-drop/bet-board/curator schedulers read in place of their fixed CHILL_TIMES/YAPS_TIMES slots when a mood is managed — auto-spread the surfaces evenly + tweak per slot, no overlaps, stored in the post_schedule settings KV; falls back to the full hardcoded slot pools when unset, so an unconfigured guild is unchanged — the calendar is now the ONLY per-guild control over scheduled-post frequency (the per-mood posts-per-day caps were removed). The calendar also carries a 🔥 FIREHOSE picker (_FirehoseButton → the _FirehoseView sub-view → THREE guild-wide, DISJOINT settings sets, one per CADENCE: scheduler_firehose_surfaces = every 30 min, scheduler_firehose_hourly_surfaces = every hour, scheduler_firehose_two_hourly_surfaces = every other hour): a PER-SURFACE opt-in where the picked surfaces post at EVERY working-hours slot OF THEIR CADENCE (ignoring their grid assignment), resolved in the single chokepoint every surface reads — schedule_calendar.calendar_hours returns firehose_slot_times(working_hours, cadence) for a surface in a set, else the stored calendar. The sub-view renders one multi-select per cadence and a pick drops the surface from the other two, so a surface has exactly one cadence; the every-other-hour pattern is anchored to the WINDOW START (the first working hour always posts), so moving the window start by one hour shifts the whole pattern by one hour. Per-surface (not all-or-nothing) so a guild firehoses the cheap token-only surfaces (curator/discourse) and leaves the SGO-capped metered ones (market_drop/bet_board) on the normal calendar — the affordable shape per the #firehose cost analysis (whole-bot LLM spend is ~$4/day; the wall is SGO’s monthly entity cap, already exhausted at baseline). The grid stays fully editable (it still drives the non-firehosed surfaces), off by default, never overrides a muted mood, and each firehosed surface is still thinned by its own channel config + dedup + the 0.6 self-gate + rate limits; emits scheduler_firehose). Trial-feature rollout stages (off / staging / production) are backed by the registry in the top-level experiments.py module + the per-guild experiment_states table; each registered experiment gets one tri-state select that auto-saves. GRADUATION (2026-09-01, #2796). A permanent surface leaves the registry: its Experiment is deleted, its key is added to experiments.GRADUATED_MASTER_ONLY or experiments.GRADUATED_ALWAYS_ON, and its gate swaps db.resolve_experiment_state for utils.permissions.graduated_stage (master-only) or a plain ExperimentState.PRODUCTION (always-on). A graduated surface then has NO per-guild stage, NO /menu row, and can never read STAGING – provisioning plus the kill switches are its only gates, and its stored experiment_states rows are ignored. 46 surfaces graduated the same day, in TWO passes, cutting the registry from 50 to 4 and the experiments page from 13 sub-pages to 1. A THIRD pass on 2026-09-07 took 4 more (owner steer “graduate these to on for master guild”): music_chart_tenure, music_board_presence, music_riaa_certs, music_riaa_boards – the #2800 music lanes the master guild had promoted since. Read off the live experiment_states table that day, all four were production in the master guild and off in the other guild, so master-only graduation changed no room’s behaviour; the four cogs/music_desk.py + cogs/music_news.py gates now call graduated_stage and the two RIAA gates collapsed into one read. The FIRST pass (34) took every surface at production in the master guild for three weeks or more. The SECOND (12) took the rest that were at production in the master guild at all, on the owner’s steer “everything in production in master should probably be removed too, and master only for all that posts to x”: market_outcome, market_reconcile, music_catalog_stats, music_lifetime_boards, music_news_artistpull, music_news_firstparty, music_news_newrelease, music_news_nochart, music_radio_boards, music_releases, music_stream_ranking, pollstar_boards. The non-master guild had every MASTER_ONLY key off and every ALWAYS_ON key production, so no room’s behaviour changed – with ONE exception: music_catalog_stats had no stored row there and fell back to its STAGING default, so master-only ends that #bot-logs audition (no room-facing post changes). The X clause was already satisfied structurally: crossposting is locked to _ONLY_GUILD (the master guild), so no other guild tweets whatever its stage says, and none of the four ALWAYS_ON keys makes a crosspost call. (bet_alert/bet_value DO tweet and ride the bookie key, but the lock keeps their X lane master-only regardless, so bookie stays always-on and the non-master guild keeps taking bets.) MASTER_ONLY (live in the master guild, off elsewhere): bet_board, cinema_boards, cinema_desk, cinema_news, market_alert, market_drop, market_outcome, market_reconcile, milestone_qualifiers, music_alert, music_artist_boards, music_board_presence, music_called_shot, music_catalog_stats, music_chart_projections, music_chart_records, music_chart_tenure, music_desk, music_genre_boards, music_lifetime_boards, music_lookup_boards, music_market_board, music_news, music_news_artistpull, music_news_firstparty, music_news_newrelease, music_news_nochart, music_platform_boards, music_radio, music_radio_boards, music_releases, music_riaa_boards, music_riaa_certs, music_standings, music_stream_ranking, music_weekly_charts, pollstar_boards, pop_desk, rt_scores, sports_desk, sports_odds_boards, sports_stats_boards, trending, x_crosspost, x_mentions, x_reply_draft. ALWAYS_ON (live in every provisioned guild): awards, bookie, curator, playercard. WHAT IS LEFT is the real trial set – a surface the master guild has NOT promoted (after the third pass, 7): live_scores, artist_curator, x_game, music_daily_hits, music_artist_markets, ktt2_chatter, versuz. A gate whose key is a runtime VARIABLE dispatches through utils.permissions.resolve_stage; after the second pass only cogs/curator.py still needs it (curator graduated always-on while artist_curator is still a trial), and the two music-desk board composers call graduated_stage directly. The contract is covered by tests/test_graduated_surfaces.py, and tests/conftest.py forces is_master_guild True suite-wide so the rest of the suite exercises the live path. Experiment catalogue (entries for the graduated keys are kept below for history – they describe what the surface does, NOT a stage a mod can still set): live_scores (default staging — gates the live sports commentator loop (cogs.commentator) posting to the dedicated sports channel; production posts to the room, staging auditions each line in #bot-logs, off skips the loop entirely; needs API_SPORTS_KEY/SGO provisioned), market_drop (default staging — the scheduled prediction-market drop), market_alert (default staging — the event-driven movement alerts, split out from market_drop so a guild can run the calm scheduled drops without the breaking-news swing/closing-soon alerts, or vice versa; gates cogs.market_alert), bookie (default staging — the play-money betting game; gates /bet + settlement, distinct from the board), bet_board (default staging — the “who’s favored” line posts (cogs.betting_board): the morning slate + the pregame nudge to the sports channel; production posts to the room, staging auditions each line in #bot-logs, off skips the guild — split out of bookie so a guild can take bets without the board drops, or vice versa), awards (default staging — gates the weekly/monthly engagement-awards loop (cogs.awards, #1017): production posts the weekly Player of the Week + the monthly MVP / Funniest / Besties gallery to the awards channel, staging auditions each post in #bot-logs, off skips the loop), and playercard (default off — gates /card, the engagement rookie card: off = hidden, staging = mods-only preview, production = everyone; ships dark on purpose so a mod runs /stats + validates the seed before the card is exposed; /stats itself is always available, only /card reads the gate, #570), and x_crosspost (default off — gates crossposting Toots’ drops (music / market_drop / discourse / curator) to the @tootsiesbar X timeline (utils.x_crosspost): production actually tweets each drop that ships to a room, staging auditions the tweet TEXT in #bot-logs (no post to X), off no-ops. Ships dark like playercard because it’s the ONE surface that posts OUTSIDE Discord to a public external timeline — so it’s opt-in per guild, which also scopes it to the account’s home server (only a production guild tweets). Production also needs the 4 X_* OAuth env vars (utils.x_poster.XPoster provisioning). The Discord/X destinations are SEPARABLE per surface — the CHANNEL picker is the Discord switch (#2137, owner ask 2026-08-08: “turn off what posted to X from Discord and not have both”, then “no new picker — the existing channel pickers should just stop posting to discord if empty but not stop x”): channels configured → the room posts and X mirrors as picked; channels EMPTY → the room stays silent but the surface KEEPS composing for X, riding the X_ONLY_CHANNEL_ID sentinel channel (0) through the same slots/self-gates/dedup/history (the ScheduledPoster base substitutes the sentinel when _sched_channels is empty; the event-driven alert surfaces let an empty channel list through their gate the same way), with the room send skipped at delivery — the surface’s *_posted event then carries delivered=x_only + channel_count=0. utils.x_crosspost.x_only_active decides ONCE whether the no-channel lane runs: only when the X post would actually ship (a crosspostable surface + home guild + x_crosspost production + provisioned — crossposts_media is the reused gate — + enabled on the X picker), so a guild that never touched X keeps the old behavior exactly (empty picker = fully off, no compose spend), and turning X off in ANY way returns the surface to fully-off rather than posting nowhere. Per-surface notes (EVERY crosspost surface has the lane): the market surfaces’ category config is per-ROOM, so their sentinel lane falls back to the "Global" cross-category pull (market_drop._sched_compose_units / market_alert’s room loop); discourse (its own scheduler, not a ScheduledPoster) runs the lane with a ROOM-LESS compose — _compose/_try_trending accept channel=None, degrade the room inputs (no local-context read, no channel topic, the neutral the-timeline channel name for the search/routing/compose prompts) and lean on the feed channels + Perplexity + Grok + markets, and the ICEBREAKER fallback is skipped on this lane (a room opener has no room to open — prefer absent); the bet surfaces’ channel source is resolve_betting_channels (the dedicated betting channel, else the sports channel), so “empty” means NEITHER is set. (An X-only post can still be lost to the crosspost’s own fail-open skips — cross-guild dedup, over-length — the deliberate prefer-absent direction; the crosspost event records those.)), and x_reply_draft (default staging — gates the X reply-draft queue (cogs.x_reply_draft): off stops drafting entirely and is the surface’s real off switch (it also stops the banger lane’s paid X search), staging queues each draft in #bot-logs, production sends it to the reply-drafts channel picked on the /menu “on X” page (x_drafts_channel_id, falling back to #bot-logs when unset or unreachable). The staging DEFAULT preserves what the surface already did — it ran to #bot-logs for its whole life, at ~120 drafts a day, so an off default would have silently stopped it on deploy. Shipped 2026-08-06 with the header’s master-user @-mention REMOVED, on the owner’s ask: ~120 drafts a day meant ~120 notifications a day for a queue read in batches), all reading their stage via db.resolve_experiment_state. icebreaker, voice, video, gif, and image graduated to permanent surfaces and were removed from the registry (and the menu): each is controlled by provisioning + kill switches alone — ElevenLabs for voice, Giphy for gif, OpenAI for image, the VIDEO_TRANSCRIPTION kill switch (+ yt-dlp/ffmpeg) for video; icebreaker always ships a floor-clearing opener — with no per-guild staging/off. The retired global ICEBREAKER_STAGING/VOICE_ENABLED env flags were the earlier form of the same trial→permanent path. Page 1 also carries the master kill switch (the “complete off button”): a power button on the nav row backed by the per-guild servers.bot_enabled flag (default on, db.is_bot_enabled/set_bot_enabled). Flipping it off takes Toots fully dark in the guild — every entry point checks is_bot_enabled: the mention (cogs.ask) and voice-note (cogs.voice) listeners, all three schedulers (discourse/music/memory) and both chime-in paths gate inline, and the whole slash surface is gated globally by bot._GatedTree.interaction_check (a custom app_commands.CommandTree whose interaction_check declines every command with an ephemeral note when off — the one exception is /menu itself, the way back on; fails open on a DB blip so a hiccup can’t brick every command). This is a strictly bigger hammer than mood=off (which only mutes the proactive posts, leaving mentions/voice/commands live); the kill switch silences those too. Emits bot_power.- tune.py, the per-guild editor view for every user-facing cap/cooldown/cadence knob — the rate caps, the chime-in cadence per mood (daily cap / cooldown — the POST cadence; reactions have no knob, they gate on the model score), the /order cooldown + in-flight cap, the market/betting alert caps, and the image reply knob (per-guild daily cap). (Two TUNABLES groups are hidden=True — still resolvable, but edited ELSEWHERE on /menu beside the config they scope, not on this raw-numbers page: the working-hours window lives on the calendar page, and the live-commentary heartbeat/clutch intervals live on the live-sports page — see _CommentaryPaceButton in cogs.settings.) (Reactions + gif replies are paced by the model’s own confidence, and the scheduled surfaces by the /menu calendar, so none of those carry a tune knob anymore.) Not a cog/command — it’s a view library imported by cogs.settings; reached via the tune ▸ skip from /menu’s calendar page (build_tune_view), as the LAST page of the chain (it’s terminal — no onward button; tune was moved to the end since it’s raw numbers). It’s a paged flat list of rows (tapping a row opens a modal to edit that value, int or float). Related knobs collapse into one row whose modal has a field per knob (a knob’s group/mood/field drive this): the daily caps row edits per-user + per-server (2 fields), the chime-in row edits cap + cooldown per mood (a 4-field modal), the /order row edits cooldown + in-flight cap (2 fields, mixed units), and the working-hours start / end pair is a 2-field modal. A merged row’s button shows its values grouped by unit (5 / 20 posts/day · 40 / 20 minutes, or 15 minutes · 3 orders), and each modal field is labeled by field when the bare mood would be ambiguous (cap (chill) vs cooldown (chill), or per-user vs per-server). Submits are parsed all-or-nothing, so a multi-field row never half-writes. A modal tops out at 5 inputs; the biggest row is 4. These used to be hardcoded module constants; now each is a per-guild override in the settings KV table, read live on every check (no restart), with the code-level value as the fallback default. The single source of truth for the set is utils/tunables.py:TUNABLES; this view renders it via ROWS = _build_rows(TUNABLES) (grouping by group), and page_count/page_rows paginate the rows. Paging (ROWS_PER_PAGE rows + a ◀/▶ nav row) lets the list grow past a modal’s 5-input cap; Discord’s 5-action-row ceiling is per message, so each page gets a fresh budget. The chime-in / reaction score thresholds are deliberately not tunable — they gate on a model-judged 0-1 confidence, so the cutoff is the model’s call (fixed DEFAULT_*_THRESHOLD constants in utils/tunables.py, read directly), kept out of the menu; the reaction threshold (DEFAULT_REACT_THRESHOLD) is now the SOLE reaction gate (no cap, no cooldown). The model knobs moved to their own Models page (cogs.models, reached from the experiments page): the ask-model select (#832) used to ride tune page 0’s top row, but when it generalized into a per-surface registry (ask/recap/discourse/memory, tunables.MODEL_SURFACES) it moved to a dedicated Models view reached via a [models ▸] nav button on the tune editor. Tune’s paging is now uniform (ROWS_PER_PAGE knob rows per page, no reserved row).memory.py, long-term memory: an hourly writer (200 msgs/1h, same fetch as /recap) + a daily rollup that distills a guild’s memory-channel activity into attributed memory notes (the memory channels are set on /menu page 3, db.get_memory_channels, split out of the shared discourse set so memory can watch a different set of rooms than discourse posts/chime-ins; empty = the writer goes dark). Nothing is deleted by decay: the daily rollup synthesizes each day’s hourly notes into one daily note but the hourlies are KEPT (flagged rolled_up, not deleted) for detail drill-down, and daily is the durable searchable grain, retained across the whole history. There is no weekly tier — at this data scale storage isn’t the constraint, retrieval is, so recall searches the day-grained notes (and can drill into the hour-grained ones) instead of compressing the past into a coarse weekly summary. Every note is concept-tagged AND embedded at write time (_tag_and_store runs tag_memory_note — a Haiku pass storing keywords like drama, beef, callout in memory_notes.keywords — AND _embed_note — an OpenAI text-embedding-3-small vector in memory_notes.embedding — concurrently), so recall has two paths: keyword/FTS (the tags carry the concept vocabulary the fenced prose deliberately lacks) and semantic vector (cosine similarity matches the concept natively, so “messiest thing you remember” lands near a drama note with no tag/expand dance needed). Embedding is fail-open: no OPENAI_API_KEY → NULL embedding → that note just falls back to the keyword path. Stored as a JSON float array in a TEXT column with in-process cosine ranking, not pgvector (at this data scale retrieval, not storage, is the constraint, so cosine over the bounded candidate set is fast and adds no Postgres-extension dependency — a CREATE EXTENSION that isn’t installed would break the every-startup schema init). Read back into /ask/mention context as a tier mix (recent daily arc + freshest hourly) so Toots does callbacks and knows her regulars. In the same hourly pass she also keeps a parallel self_* pyramid of her OWN takes (_maybe_write_self_hourly distills just the messages she authored — subject="self", gated on her own lower SELF_ACTIVITY_THRESHOLD, tiled on the self pyramid’s own clock so a dead-but-she’s-talking room can’t double-count — rolled up self_hourly→self_daily by the same _maybe_rollup with family="self"). It rides the same fenced write/rollup (people are still fenced; only the framing changes to “your own opinions/calls/bits”) and is read back via memory_tier_mix labeled [you've said before] so she stays consistent with herself, distinct from the human-attributed memory. Plus /forget (self-service erasure, no parameter, you can only forget yourself; deletes across all tiers incl. self — and the consented user_facts a user taught Toots about themselves, via db.delete_user_facts) and /remember period:[week|month|2months|6months] (mod-only one-time backfill that seeds the room memory from channel history as per-day daily notes across the whole range; idempotent per day; runs as a background task so a long day-grain backfill isn’t bound by Discord’s 15-min interaction window — it acks immediately and drops the result in-channel when done; the same run also reindexes — _reindex_keywords concept-tags any notes that lack keywords AND _reindex_embeddings embeds any that lack a vector, each emitting memory_reindex. The reindex is scoped to the selected period’s window and walks it newest-first one page at a time until the window drains, so the period actually bounds the reindex — a 6months run reindexes 6 months, a week run just the week; rerun the longest period to cover the whole history. Termination is by forward progress (a per-run seen set of attempted note ids stops the walk the moment a page brings nothing new), not a guessed count ceiling. When an idempotent re-run writes no new daily notes but the reindex DID sharpen the index, the reply says so (_reindex_note) instead of reading as a no-op). The write prompt is fenced (observed public behavior only, never inferred private traits, no transcripts) to keep attributed “who did what” memory inside the constitution.polls.py, native Discord polls Toots creates + manages on a regular’s behalf (#888). The on-demand surface is the @Toots mention: “make a poll for who’s recoupling” routes the model to the create_poll tool (and the list_polls/read_poll/update_poll/end_poll siblings), which delegate to this cog. She supplies the question + options — sourcing them first if asked (e.g. look up the Casa Amor cast, use the names) — and the pure core (utils/polls.py) clamps to Discord’s limits (2-10 options ≤55 chars, question ≤300, duration 1-768h default 24) so a bad request degrades to a friendly note, never a 400. Authorization is the ASK-side gate on top of Discord’s own permissions: create/list/read are open to anyone (poll-create rides the per-user ask bucket), end/update are mod-gated (is_mod). Discord polls are immutable once posted, so update_poll CLOSES the target poll and posts a fresh copy with the edits merged over the old values — votes reset, said plainly. Reads are GUILD-WIDE (#888 owner steer): Discord has no “list every poll” API (a poll is a message), so list_polls/read_poll run a bounded multi-channel scan (_scan_guild_polls: every readable text channel, the request’s channel first, recent history each, channel-/concurrency-capped) and label each poll with its #channel; an id from the list resolves anywhere in the guild (mutations too, via _resolve_poll_message’s guild fallback). The model is also steered to read existing polls before creating (the buffer + list_polls) so it doesn’t post a duplicate, mirroring file_fix→check_fixes. Fail-open throughout; emits poll (action create |
list | read | update | end + ok + reason). Pure validate/normalize/format in utils/polls.py (unit-tested); discord I/O here. Reading a poll’s voters is a bounded fan-out (#1950): answer.voters() is a paginated REST call PER OPTION, so a 10-option poll walked serially stacked ten round-trips onto an /ask the user was waiting on; _answer_voters now runs them through fanout.gather_deadline (5s, concurrency 5). An option whose lookup misses renders without names — the vote COUNTS beside it are read off the poll object and are never affected. |
versuz.py, the Versuz host (#2874, owner ask 2026-09-03): Toots hosts a two-player song battle. /versuz typed bare posts a Set up button (owner steer 2026-09-03: “any input buttons, not commands”; the command took NO arguments until 2026-09-11, when the owner asked for a one-line door – /versuz @a @b now skips the form and opens the match in the voice channel the host is sitting in, with no name and no first-to, versuz action=start reason=quick against reason=form for every other path) (the AUTO-START offer posts the same form prefilled: when two different people summon the music bot in a voice chat with no match on it inside 5 minutes (a typed command or a slash /play in that chat, or a command typed in any other channel once the bot’s Started-playing line lands in the chat, because the second player queued from a text channel on 2026-09-03 and no offer came; or a command typed in any other channel by someone SITTING in a voice channel with no match on it, which counts for that voice channel at once (_offer_from_elsewhere; 2026-09-03 23:40 UTC: Jockie stayed bound to the text chat after a night and both players played from it, so no line ever landed in a voice chat; two recent players and nobody in a voice channel is an offer ok=false reason=no_voice miss; a play by a participant of a running match in the guild is never read for the offer, verified night 2026-09-04: a live player’s play logged a no_voice miss), Toots posts “want me to host a versuz?” with a Set up button there (or, when she cannot post in that voice chat, in the last channel a versuz ran in, db.versuz_last_channel; owner ask 2026-09-03), both players and the voice channel filled in and a picker for the match’s text channel prefilled with that last channel; one offer per voice channel per 30 minutes, gated on the same experiment; versuz action=offer) whose form has user pickers for the two players FIRST, then an optional name, a voice-channel picker (blank = the one the host is in) and an optional first-to (owner ask 2026-09-11: “move the name down underneath the players and make it optional”, so the form is two taps); a blank name box titles the match gaza vs cd and every printed title reads MatchState.title, never theme directly – the card’s H1 and hero, the poll title, the archive picker’s row and the X caption (which drops its leading clause instead of opening on a comma). The versuz_matches.theme column stores what the host typed, so a blank box stores an empty string and the readers fall back on the names; it opens one match per text channel (gate: the versuz experiment, default OFF), and the bot needs View Channel + Read Message History on the voice channel. Songs register themselves: the cog listens to that voice channel’s text chat, the players type M!play <link> to the music bot (Jockie) and Jockie answers “Started playing Track by Artist”; the pure core (utils/versuz.py) credits the line to the player whose play command came within versuz_attribute_seconds (a Started-playing line ALSO counts from another channel when it comes from the bot whose lines the voice chat has shown, or from the first bot to post one IN THE MATCH CHANNEL while the voice chat has shown none (a line in some other voice chat is another room’s music bot), and the server has one match (Jockie answers in the channel it was summoned from once it has idled out of the voice channel, 2026-09-03 21:56 UTC: the match sat on “nothing started”); a play command counts from ANY text channel in the server, not only the voice chat: Jockie answers in the voice chat wherever it was summoned from, first-night miss 2026-09-03; outside the voice chat only the STRICT shape counts, a prefix, a slash or a mention, so “play nice” in #general cannot reset the window) and applies the slot rules read off the real 2026-09-02 night (one song per player in either order; the same player again before the poll = a do-over that replaces the slot (within versuz_redo_seconds, or whenever the player SKIPPED their own pick, read off Jockie’s “skipped by <@id>” line, since a self-skip is a withdrawal whatever the clock says; past that window, with the other slot still empty, the same player’s next queued track is an EXTRA: the pick stays, the card says so, a skip or a typed A: song swaps it, since Jockie’s queue ran 12 deep on the first hosted night); once the poll exists the next song opens the next round; anyone else’s song is ignored and noted on the card). When both slots are in and the second song ended (Jockie’s skipped / no-more-tracks, or ANY next track starting, whoever’s it is: with a deep queue A, B, A is the common order and the third track is held for the next round), Toots posts the poll herself (title <the match's printed name> · <scoreA> · <scoreB>, options <track> ft <feature> - <artist> · <player> (owner ask 2026-09-05: the answer names the artist, not only the title; poll_option drops the ARTIST first, then ellipsizes the title around the feature, then drops the feature too, so an option never reads worse than it did before – measured over the real 2026-09-05 night, 15 of 16 options fit with the artist and the 16th falls back to the older shape. The PLAYER TAG is never dropped or trimmed (#2990): it is the only part that tells the two answers apart, and two songs whose titles shared a long prefix used to truncate to the SAME string, which made build_poll_spec refuse a poll with one distinct answer so the round never opened at all (poll_open ok=false reason=invalid); a titleless song bills the artist instead of a bare dash), the 1 h Discord minimum). The poll posts UNDER the card (owner ask 2026-09-09: “poll should post after embed”): _open_poll moves the card down first, sends the poll, then edits the card in place to show it, so the room reads card, then poll, and the poll is the newest message; before this the card moved under the poll. The poll can end by itself: versuz_poll_close_seconds (a /tune knob, default 0 = off, up to 3600; owner ask 2026-09-09: “an auto poll close option”) runs End poll that long after the poll opened (_arm_close / _close_clock, poll_close reason=auto); a deploy re-arms it on resume from the poll’s opened_at, and End poll, a void and the finish cancel it. The poll also opens on a clock, versuz_poll_seconds into the second song (a /menu tunable, default 60 since 2026-09-04 (was 120), 0 = wait for the song to end; owner steer 2026-09-03 after tapping Open poll now 79 s and 48 s in on rounds 4 and 5: “2 minutes into the second song you can auto open the polls”; _arm_clock / _round_clock, poll_open reason=clock): a skip by the OTHER player before that opens it at once, a repick restarts the clock for the new song, a typed slot never starts it, an earlier poll still open defers it and End poll re-arms it (it fires at once when its time has passed), and a deploy re-arms it on resume. A self-skip is the normal end of a turn (live: TSU skipped at 115 s, Papi’s Home at 89 s) and opens the poll like any skip, unless the skip came within WITHDRAW_SECONDS (60) of the start and the player queued another track of their own while the song played: then they have withdrawn it (Song.withdrawn, set by song_ended when the skipper owns the song, no poll is up, and queued holds their later track; a queue-ahead of the next round’s pick followed by a turn-end skip has the same shape late in the song, so the time bound keeps it a turn end, and if the other player plays next the withdrawal is undone and that song opens the next round; live round 8, 2026-09-03: cd played a repeat, queued the right song, skipped the repeat, and the poll opened on the wrong pair and was voided seconds later by the replacement): the round is not ready, the clock stops, the card says “cd pulled their pick · waiting on cd’s next song”, and the queued track replaces the slot and gets its own clock; Open poll now still works as the override. The card says who is up first (owner steer 2026-09-03: “keep track who’s next, versuz does alternating go first by default”): the players alternate, so MatchState.up_first is the other side from whoever started the last round with a song. Before any song lands it is the slot Toots DREW when the match opened (MatchState.opener, a random.choice in start_match, in the payload; owner ask 2026-09-11: “it just randomly spits out who goes first” instead of the two of them picking a number), and the opening lines say it (“i flipped it: cd goes first”) while opener_stands holds – a match seeded from songs already played has a real first player, so she says nothing about a draw. The draw is the opening order only: the first real song to start takes over and the alternating rule carries on from there. The line is shown on the card’s small status line (card_status: “cd is up first”, and “gaza is up first” under a closed round; the “tap Re-vote round to run it back” phrase is gone, owner steer 2026-09-03: the button is on the card); it follows who actually went first, so a player starting out of turn does not break the rotation. The tally is the result, the song, then the score (owner ask 2026-09-13, “reduce the tally too”): # ROUND 18 TO DRAFTDAY2 · 9-7, then ## <the winning song>, then -# <the running score>. What came off is what it said twice – the winner used to be named in the H1 AND again inside the running score, and the votes read “9 to 7” on a second header. The SCORE stays (owner ask 2026-09-13, “don’t cut the score”): one pass dropped it, on the grounds that a close always moves the card down and the card’s H2 carries it, and that was a step too far – the standing is part of the result, and reading it should not need the next message. A tie is # ROUND N IS A TIE · 6-6 over the no-point line and the score. A versuz is a MATCH, not an evening (owner correction 2026-09-13): it runs at any hour, and a tournament runs several back to back – the 2026-09-12 run was nine matches in about an hour. So the finish says # <NAME> TAKES THE VERSUZ (finish_head), the drawn card’s caption line and the X caption say “takes the versuz” (versuz_card, recap_caption and the versuz-recap skill’s shape), the champion line reads “` (Codex review of #3122). The tones run as their own tasks (`_spawn_cue`, held in `_cue_tasks`, cancelled on unload; the warning too, so a slow connect never holds the close clock past its due time): the poll flow saves the state and arms the auto-close first, then starts the tone, so neither the card refresh nor the End poll tally waits on a voice connect. Only the End poll close carries `final_ok`, so it still plays when the match ends before it starts (the End poll that decides a first-to); an open or warning tone still waiting when the match ends stays silent, and both expire (`still`) once their poll closed (the warning also once its due time went by), checked before the tone starts, again after the wait for the guild lock, and once more right after the connect, just before play; ffmpeg is only spawned past that last check, and a `play` the player refuses (the connection dropped between connect and play) cleans its ffmpeg up. The leave at the finish is its own task (`_leave_voice_later`) that keeps the voice channel reserved in `_by_voice` (so another match's cue stays `busy` while the last tone plays) and waits, bounded, for the match's tones in flight and the one still sounding before it disconnects; a tone that arrives while another is sounding (End poll's close, then the queued round's open) waits for it too, and cuts it only past 2 s. Unload drops every voice connection the bot holds, not only the live matches' (a match that just ended has only its leave task, which unload cancels). The warning is also skipped when its lead is already spent at arm time (a resume inside the warning window). The ops monitor reads the ok rate as the `versuz_cue` integration-health line (`no_voice`, `stage` and `busy` excluded: configuration, not degradation). **Anonymous mode** (owner ask 2026-09-12) opens with `/versuz @a @b anonymous:true` and flips any time after that on the control card's **Anonymous** button. It is off unless the host asks. It is not a setup-form field (the modal is at Discord's five-row cap) and not a guild setting, so one night runs blind and the next one open. While it is on, a round IN PLAY never says which player picked which song: the poll answers and the card's slot lines read `pick 1` / `pick 2` (`MatchState.slot_label`) instead of the names, and the tag keeps doing the job the name did -- it is what tells the two answers apart, so two songs sharing a long title prefix cannot truncate to one string and kill the round (#2990). The two run in TITLE order rather than play order (`MatchState.anon_order`): the room watches both songs play, so "whoever went first is on top" would name them, which overrides the 2026-09-03 steer "keep track of who went first per round with the order here" for a blind round only (`play_order` is still the play order, and the host's ephemeral Fix a round page reads it and names both players). An open poll's order stands (`Round.poll_order`), so `pick 1` never moves under a vote in progress. The card holds BOTH songs back until the round is set (`hidden_slots_note`: "one pick is in · both songs show once the round is set"), because one song on a public card belongs to the only player who has played; for the same reason the round line says "waiting on the other pick" and "a pick got pulled" instead of naming anyone. **Who plays next is not printed either** (owner ask 2026-09-12, "i just want to make sure that isn't spoiled"): the drawn opener would otherwise appear in four places -- the opening line, the empty round line, the closed round line, and the two empty slots the closed card prints in UP-FIRST order -- and all four now say nothing. Toots still DRAWS it and sends it to the two players by DM instead (`opener_dm`, `Versuz._dm_opener`), each told only about THEMSELVES ("you're up first" / "cd goes first, you're second") so a forwarded screenshot says no more than that player already knows; the room reads "i flipped the order and sent it to the two of you". The DM is FAIL-OPEN -- a player with DMs closed settles the order with the other player, and the match runs either way. This is defence in depth rather than a fix for a live leak: the title order plus the held songs already stop a card-and-poll voter joining "cd went first" to a pick, but that protection is a CHAIN, and hiding the draw means restoring play order later cannot silently turn the opening line into a spoiler. Every other public mid-round line drops the names too, and this is the part a fix of the poll alone would have missed: the prefill remark ("one pick is in round 1, waiting on the other"), the cleared-slot note ("a pick is off" -- the cleared song was never shown), the extra-track note (the extra track is not a pick so it is named, the KEPT one is not), the repeat note ("a pick already ran in round N", and its public room line is held entirely, since it would print the pick), and the no-track note. The opening announcement says the night is blind. The tally REVEALS the map when the round closes (`reveal_line`: "pick 1 was gaza · pick 2 was cd"), and the scored round, the board, the finish, the drawn card and the X caption name the players exactly as before. The button is disabled while a poll is up: Discord cannot rewrite the answers it already sent, so a flip mid-vote would half-reveal the round. It rides the resume payload AND a `versuz_matches.anonymous` column, so a deploy and Reopen versuz both bring a blind night back blind. Its limit, stated plainly: it hides the pick from whoever votes off the card and the poll, NOT from the people sitting in the voice channel, who see the songs play and the play commands; blinding those needs privately submitted picks, which this does not do. Emits `versuz action=anon` (reason on|off). **Deleting a night** (owner ask 2026-09-12, "so it doesn't count against stats") is the **Delete a night** button on the `/versuz` reply, next to Archive: a picker of the guild's WRAPPED matches (`db.versuz_voidable`, `status = 'ended'` only -- a live one is ended first), then a confirm naming the night, the score and the rounds, then `db.versuz_void` in ONE transaction -- the row to `status = 'hidden'` / `end_reason = 'voided'`, its `game_scores` rows deleted so `/leaderboard versuz` stops counting it, and the crown its finish gave off the winner's streak (`best_streak` stays, the rule `versuz_reopen` and `versuz_merge` already follow). It is **MOD ONLY** and uses its own `_is_mod`, never `_allowed`: `_allowed` also lets the two PLAYERS act on their own match, and a player must not be able to erase a night they lost. The night is UNSCORED and out of sight, not erased -- the `versuz_rounds` rows stay, so a night voided by mistake can be rebuilt from them. `hidden` needed no new plumbing: the schema already documented it as "taken off the archive by hand" and all three doors back into a match (the archive picker, Reopen versuz, Merge earlier match) already filter on `ended`/`live`. No code set it before this; the owner had set it by hand on three matches, all of which carry no `game_scores` rows (read 2026-09-12), so the button is that manual operation. Emits `versuz action=void` (count = standings rows removed). Emits `versuz`.wheel.py + utils/wheel.py + utils/wheel_card.py, the random wheel (#3300, owner ask 2026-09-14): a wheel the room builds, spins, saves and comes back to. Two doors build one and they meet at the same card. /wheel make items: takes a typed list (split on commas, new lines or pipes, so nobody has to learn a format). /wheel theme theme: asks the model for the slices (claude_client.generate_wheel_items, the guild’s wheel Models-page knob, default Sonnet), and the card’s Regenerate button redraws every slice on the same theme with the previous draw passed as exclude — measured 0 overlap across five themes on the live dry run, which is what makes “pull until you like the wheel” work. The posted card carries Spin, Regenerate (themed wheels only) and Save, and anyone in the channel may tap them, like the /guess game controls. Save names the wheel on the GUILD’s shelf (/wheel load, /wheel list, /wheel delete); a wheel is a thing the room spins, so the shelf is shared and created_by is provenance, not ownership (its saver or a mod may clear one).
“Shelf” is the INTERNAL name only. The code calls the guild’s saved-wheel list the shelf (_shelf, saved_wheel_owner, the saved column); no text a user reads ever says it. The UI says saved and the button says unsave, because a control has to state what it does, and a metaphor in a button label does not (owner steer 2026-09-15: “wording is confusing, too clever”). Keep the two apart when editing: rename the internals if you like, but never put the metaphor back in front of a user.
ONE command, and the page is the surface (owner steer 2026-09-14: “/wheel on its own should open up the builder … no need for the shortcut commands, everyone should go through the builder”). /wheel opens the builder page; /wheel name: opens it on a saved wheel. There is no command GROUP – make, theme, load, list and delete are all gone, and the page does all five: add parts, take parts off, hand it to the model, redraw, pick a saved wheel off the shelf, name it, take it off the shelf, post it. One thing to learn instead of six. The count parts: 8 of 24 leads the page, with the ceiling beside it rather than discovered by hitting it. The user-facing word is parts everywhere – the card caption, the page, every reply – rather than the code’s slices/items.
The page holds no state of its own. /wheel writes a DRAFT row – an unsaved wheel, allowed to hold fewer than the two parts a spin needs – and every control carries that row’s id, so the page survives a redeploy mid-build and an abandoned draft is an unsaved wheel prune_wheels clears.
Opening a saved wheel does NOT copy it; the fork is LAZY. The page works on the saved row, so posting it straight back to the room posts the wheel itself: same row, so its spins keep counting and the shelf entry is the thing being spun. That is what “come back to a wheel” means. The copy happens the moment something actually CHANGES, because a saved wheel may already have cards posted in the room and rewriting its parts would leave those cards showing one set and spinning another. The fork takes the name across when the person may take it, so the shelf follows the version they just improved. An earlier revision copied on OPEN instead, which was safe and lost the continuity the surface exists for.
Off the shelf UN-SAVES, it does not delete. The row stays, so the page stays alive and any card already posted for that wheel keeps spinning; it ages out through prune_wheels. Clearing a shelf entry should not kill a card somebody else has open.
A redraw is a NEW wheel, not a rewrite. Every card for a wheel – the one it was posted on AND one per spin since – carries buttons pointing at the same row, so rewriting that row left every earlier card showing one set of options and spinning another (#3301 review). Regenerate writes a new row with its own card; the old cards keep spinning exactly what they show until they age out. The shelf entry follows the redraw when the person may take the name.
One ownership rule, in two places. Taking a shelf name that is in use, and clearing a shelf entry, both need the person who SAVED it or a mod. saved_by and not created_by: anyone may tap Save on a posted card, so the builder and the saver are often different people. Before that column, saving over a name was the way round the delete rule, and /wheel delete’s own words (“whoever saved it, or a mod”) were false in both directions. A takeover UN-SAVES the wheel that held the name rather than deleting it, so its card keeps spinning.
The buttons carry their own kill switch. bot._GatedTree.interaction_check gates the slash surface only, and a component press is not an app command, so a guild that switched Toots off still had every wheel card in its history live and making paid model calls. Each handler checks is_bot_enabled itself.
No session state, on purpose. Every posted wheel is a wheels row from the moment it is posted, and each button’s custom_id carries that row’s id (discord.ui.DynamicItem), so a press is resolved by READING THE ROW. The bot redeploys on every merge to main, so a wheel whose buttons died on a deploy would have been the surface’s most common failure; a card posted last week still spins. One table holds both halves — a posted wheel and a saved wheel are the same object at two ages — with a PARTIAL unique index (WHERE saved) so one name per guild is on the shelf while the unsaved rows, all named '', never collide. Saving over a name in use UN-SAVES the wheel that held it rather than deleting it, because that wheel’s card may still be on screen; the un-saved row ages out through prune_wheels (30 days, saved wheels never).
The draw and the picture are separate. utils.wheel.spin is a uniform random pick with an injectable generator; the card ROTATES the wheel so the drawn wedge sits under the pointer, lights it and dims the rest. The picture never picks anything, and tests/test_wheel samples the pixel under the pointer to prove the two agree. The spin is deliberately NOT a deal without replacement and does not avoid the last winner: a wheel that cannot repeat is not a wheel.
Two fail-open paths, both loud. A render that raises posts the slice list instead of the card (a wheel_card error), so a spin still tells the room what it landed on. A themed draw that comes back with fewer than two usable slices posts NOTHING and says the theme came up short — prefer absent over invented — and shows up as the ops monitor’s wheel_generate health line. The model’s WORKING is filtered out of the draw by utils.wheel.is_narration plus the shared has_self_correction: the live dry run returned Mask Off - No wait, that's Future and Actually let me redo cleanly: as options and the first one won the spin. A song-pool line is verified against iTunes downstream; a wheel slice is verified against nothing, so the parse is the only place to catch it. EVERY marker carries task vocabulary (redo, rewrite, try again, correction), never a bare pronoun or apology – the first version matched sorry, oops, let me and i'll anywhere in a label and deleted Sorry Not Sorry, Oops!... I Did It Again, Let Me Love You and I'll Be Missing You, four real songs on exactly the music themes this surface is for. A filter that silently drops good options is worse than the leak it was written for, because nothing on the card shows that it fired.
Cost. A spin is free (no model call), so only the draws are gated: /wheel theme and Regenerate ride the per-user daily bucket @Toots mentions use, keyed wheel. The slot is charged BEFORE the call, because the call is what costs money – charging only for a draw that produced a wheel let one person spend unbounded model calls on themes that came back unusable. A spin’s only cost is the ~0.5 s card render, which runs on a thread and carries a 1.5 s per-wheel floor so a held-down button is not a load. Emits wheel.message_search.py, Discord message-history search Toots runs on demand. The @Toots mention routes “find where someone posted that link”, “what did discord_info.py, read-only Discord server/member lookups Toots runs on demand — the structure complement to message search (search reads message HISTORY; this reads the server’s STRUCTURE, facts she’d otherwise guess at). The @Toots mention routes “when did X join”, “how many people are here”, “what roles are there / who’s a mod”, “what channels/emojis exist”, “who reacted to that”, “what’s pinned in #rules” to the single discord_lookup tool, which dispatches by aspect: member (account age + JOIN date + roles + booster, target=a person), server (member count people-vs-bots, age, boost tier, channel/role/emoji counts, owner), roles (every role + member count, or target=a role → who has it), channels (the list by category + topics), emojis (custom), reactions (WHO reacted per emoji, target=a message link/id), pins (a channel’s pins). One tool by aspect rather than seven, to keep the wiring proportional. Everything is READ-ONLY (no writes, no moderation) and scoped to what the bot can already see — member roles/join dates are the same facts in Discord’s own UI. Resolves names/handles/#channel/role refs against the cache; member/server/roles/channels/emojis are cache reads (no API), reactions/pins make a bounded fetch. member/server/pins also surface IMAGE URLs (the member’s avatar, the server icon+banner, a pinned message’s image) so she can chain to look_at_image and actually SEE them (“what’s their pfp”, “what’s our icon look like”). Fail-open; emits discord_info (aspect + ok). Wired into client.ask, so it reaches every model uniformly. Pure row shapes + formatters + duration math in utils/discord_info.py (unit-tested); discord I/O here. _reactors is bounded now (#1950): it walked reaction.users() serially per emoji with NO wall-clock bound — the unbounded twin of the feeds.resolve_reactors fan-out — and a message can carry ~20 distinct reactions. It runs through fanout.gather_deadline (5s, concurrency 6); an emoji whose lookup misses still renders its ROW with the aggregate count, just without names.scheduled_events.py, Discord scheduled events Toots creates + manages (#888), the sibling surface to polls. The mention routes “schedule a watch party Friday 8pm” to the create_event tool (+ list_events/read_event/update_event/end_event), delegating here. EXTERNAL events (a free-text location), since a chat request rarely names a voice/stage channel; the model passes an ISO 8601 start_time it computes from the current date (the [ctx] line it’s given is ET, so the pure core reads a naive timestamp as ET, not UTC — what keeps “Friday 8pm” from landing 4-5h off; an explicit offset/Z is respected), and the pure core (utils/scheduled_events.py) validates (name ≤100, future start, end defaulted to +1h, location ≤100 defaulted to TBD) so a bad request is a friendly note. The model is steered to read list_events before creating so it doesn’t duplicate one already on the calendar. Authorization: list/read open to anyone; create/update/end are mod-gated AND require the bot’s Manage Events permission (checked + reported clearly in the cog). Events resolve by id or a case-insensitive name-substring match (_resolve_event, ambiguity lists candidates); end_event is status-aware (a scheduled event is cancelled, an active one ended, an over one reported as such). Unlike a poll, an event edits in place (no reset). Fail-open; emits scheduled_event. Pure core in utils/scheduled_events.py (unit-tested); discord I/O here.playercard.py, the engagement “rookie card” (#570, slash command /card): a public /card [member] builds one member’s Discord-Wrapped-meets-NBA-2K card — a 0-99 OVR, a rarity tier (Bronze/Silver/Gold/Diamond/Legend), three named ratings, a deep “tape” of raw stats, earned badges, and a stat-derived archetype — every rating percentile-ranked against the server’s ACTIVE members (Spotify-Wrapped “you out-rate 80% of the room”), with a “next tier” chase line. Rendered as a PAGINATED Discord Components V2 LayoutView (#570): a hero page (name+OVR emblem + avatar Thumbnail; the LABELED CLOUT/HUMOR/SOCIAL ratings strip; the top headline flexes; then the tier/archetype line BELOW the stats per owner steer; badges; chase) plus ◀ ▶ nav buttons that flip in-memory (mirroring /leaderboard) through FOUR detail pages — 💥 reactions · 🫂 social · 🎧 content · 📊 activity & career — that surface EVERY counted stat, grouped one-per-line so each is self-explaining (the de-jumble fix: 🔥/😭/🤝 are reserved for the ratings and never reused as a detail-line emoji). A 📋 button publishes the explainer — itself paginated (scoring page + one page per stat group) that DEFINES every single stat with a one-line blurb (Discord has no hover tooltips, so the explainer page is the only place an explanation can live; #570 owner steer “fill out the explanation page with every single stat”). The blurbs live in the pure pc.STAT_GLOSSARY (+ a badges page from pc.badge_glossary() explaining how each badge is earned), and tests/test_playercard.py:test_glossary_covers_every_detail_stat asserts the glossary defines EXACTLY the stats the detail pages render (same emoji+label) so a stat can never ship without a definition. Labels are written to be self-clear (e.g. “total emoji used”, “total messages”) so the glossary is a backup, not a requirement. A text fallback covers a V2 send failure. Accuracy + de-fluff pass (owner steer): a banger is now a post that hit the wall bar (10+ 😂 OR 😭 on a single emoji, utils.engagement.BANGER_*), not a combined-laugh heuristic; badges are tightened to a genuine top-10% bar (_top q=0.90) + each carries a how shown in the explainer (no more arbitrary top-quartile tags), and the games/bookie badges (Sniper/High Roller) are dropped; the canned archetype line + the arbitrary chase are cut from page 1 (the memory hype-up replaces that “who is this” role, follow-up); 🎮 /guess wins + the bookie record are dropped from the card in favor of 🏆 server rank (Card.rank_by_reactions, “#N by reactions pulled”) — those game surfaces are too thin right now, lean into the Discord metrics, add them back when real; and a left-the-server member resolves to a real name via db.names_for_user (the leaderboard’s identity map), never a bare “someone”. Server rank rides the HERO (first page, owner steer) — a _render_hero standing line off card.rank_by_reactions, surfaced up front (it also renders on the activity/career detail tab). /guess wins was pulled BACK off the hero (owner steer: too thin for the first page) and now lives on the activity/career detail tab — “where the rank was” — so it’s still on the card, just not the headline (tape["game_wins"] → the career group’s 🎮 /guess wins line, glossary-defined). Nav arrows render as emoji — the ◀️/▶️ page buttons (card + rubric views) carry the FE0F variation selector, since bare ◀/▶ (U+25C0/25B6) render BLANK as Discord button emoji on some clients (the malformed-nav fix). Memory hype-up (#570): the card HEADER (next to the avatar, the “who is this” role the cut archetype used to half-fill) carries a short Toots-voice scouting line generated from her long-term MEMORY of the member. To make it genuinely TRUE it pulls everything she knows about the person across all of memory: cogs.playercard._generate_blurb FTS-searches her DAILY memory of them by every alias they’ve gone by (rename-proof over names_for_user), fetched CHRONOLOGICALLY (db.search_daily_notes, daily-tier only) so it reaches the guild’s FIRST day — then condenses each note to just the sentences that NAME them (the pure, unit-tested pc.about_member_text: word-boundary, NFC, case-insensitive sentence extraction), dedups recurring lines, and SPREADS the kept notes EVENLY across their whole arc (pc.spread_evenly, anchored on the first) to fit the budget. A daily note is about the WHOLE room, so a person is usually a line or two buried in ~13k chars; extracting only the about-them sentences collapses that to pure signal, and the even spread lets a fixed char budget (_BLURB_MAX_CHARS) reflect the member’s ENTIRE history — from their first days to now — instead of only the densest recent stretch (denser for a heavy regular, broader for a light one). Daily-only on purpose (owner steer): the hour-grained tier skews to today’s un-rolled notes, and the daily rollup already synthesizes the hourlies into the daily note. The deduped, evenly-spread block goes to claude.player_blurb, told to build the line from the sharpest, most specific receipts in the notes, to prefer the NAMED + grounded over a vague/loud label (the “Lizz not a streamer” lesson — when the notes are mixed/loaded on a detail, take the grounded version, not the loudest), and to skip a generic role-label opener; fenced exactly like the memory notes it’s built from (observed public behavior only, no private inference/PII, others named are just context); returns “” / EMPTY on too-thin memory. The consented per-user user_facts store is deliberately NOT read here — those are private-to-that-user and must never surface on a public card. A blurb per PAGE (#570 owner steer “one for each page”): beyond the hero memory hype-up, each detail page (reactions/social/content/activity) carries its own short Toots-voice stat ROAST of the actual numbers ON that page (claude.player_stat_roast over pc.page_stat_block(page) — “1312 GIFs and only 22 spotify links, unhinged”; “0.3 hit rate and 7,058 left on read, you’re spraying the whole clip to land 10 bangers”). Each stat carries its glossary DEFINITION inline (pc._STAT_DEFINITION, the same STAT_GLOSSARY the 📋 explainer renders, drift-tested to cover every detail stat) so the model knows EXACTLY what every number means and never misreads one (owner steer); the roast prompt is told the parentheses are context, not fodder. The hero + all page roasts are generated concurrently (_generate_blurbs) as one bundle ({"hero": ..., "<page.key>": ...}), each leg independently fail-open. The card blurbs run on OPUS (_BLURB_MODEL, owner steer: cost no object, a gift to members) — a blinded dry run found Opus pulls noticeably deeper, more specific receipts from the full memory than Sonnet; low-volume + durably cached, so the cost is bounded, and one knob flips it back to Sonnet. Opus EMPTY-parse fix: Opus sometimes THINKS OUT LOUD (“EMPTY\n\nWait, there’s plenty here.\n\nstats.py, the mod-only /stats engagement SEED builder + updater (#570) — the /remember analogue for the player-card stats. A scan over the configured MEMORY channels (the curated “real conversation” set the long-term-memory writer reads, db.get_memory_channels; owner steer) and their threads (active + archived public, rolled up to the parent channel for the per-channel count but scanned as their OWN flow sequence so a thread isn’t interleaved with the main timeline), walked CHRONOLOGICALLY (history(after=, oldest_first=True, limit=None) — no per-channel cap, Discord’s history is already bounded), tallies per-user engagement via the SHARED utils.engagement ScanState and writes the durable member_engagement seed /card reads — so the card’s numbers are ALL-TIME + instant (no per-call scan). Oldest-first is what lets the flow stats (reply latency, closer/reviver/burst) and reply attribution resolve in order (a COMPLETE in-scan msg→author map, no dependence on Discord embedding the replied-to author). TWO MODES so the seed STAYS FRESH without re-walking all of history every time (#570 owner steer — “catch up the latest, don’t rebuild the past; users expect stats to stay updated”): catch-up (the DEFAULT) scans only the messages since a per-guild snowflake WATERMARK (the id of the newest message a prior scan covered, stored in the settings KV) and FOLDS them into the stored totals via the engine’s additive merge (merge_into_state → EngagementAcc.merge), upserting just the active members (db.upsert_engagement) — cheap, the call an hourly/daily writer will make; rebuild (rebuild:True, or implicitly the first run / a legacy seed with no merge-state) re-walks ALL history and rebuilds the seed — the canonical recompute that makes the few boundary-approximate stats exact again + backfills a newly-added stat key across history. The rebuild is RESUMABLE (the shared utils.resumable_scan spine, #570): the owner’s server is ~300-400k messages (one channel, #habibis, is 90k+), so a whole-server scan that holds everything in memory and writes ONCE at the end runs far longer than the gaps between redeploys (the bot auto-deploys on every merge to main) — a run can be killed before it writes a single row, and the prior all-or-nothing rebuild did exactly that (1.5h+ in, seed still empty, then a deploy killed it). So the rebuild folds the memory channels ONE AT A TIME into the seed via the same additive merge (_scan_one → _merge_channel_rows, bounded memory: one channel’s scan in flight, not a whole-server map) and CHECKPOINTS each channel done ATOMICALLY (db.upsert_engagement_and_setting writes the folded rows + the resume checkpoint in ONE transaction, so an interruption between the fold and the checkpoint can’t re-merge a channel and double-count). A fresh rebuild clears the seed first so the per-channel fold rebuilds from empty (guarded: if read access to EVERY memory channel is gone it KEEPS the existing seed rather than wipe it — _any_readable, reason=no_access); a resume keeps the partial seed and skips the done channels. Recovery is automatic: cog_load kicks _resume_on_boot, which picks up any guild whose rebuild checkpoint is unfinished (the deploy that interrupted it is exactly what cancelled it) and continues from the next unfinished channel — so a redeploy/restart/OOM mid-scan never restarts from zero, and the seed accretes channel by channel across however many deploys it takes (a snagged channel is left not-done so a resume retries just it). A catch-up is provably == a rebuild applied incrementally on the additive stats (tests/test_engagement.py); a handful of boundary stats (cross-window reply resolution, conversation flow, reply latency, left-on-read) go slightly approximate between rebuilds, and first_of_day is explicitly guarded against the per-run double-count (the catch-up ScanState(opened_through_ordinal=) suppresses re-crediting the watermark day’s opener — else it inflates every run). Re-running /stats while one is in flight STOPS the prior run and starts over (a per-guild in-flight asyncio.Task map → cancel-restart; never two concurrent scans, which would double the rate-limit pressure). Each member’s row is {"display": blob, "state": merge-state} JSONB (so adding a stat never needs a migration; db.get_engagement unwraps the display, db.get_engagement_states the merge-state; a legacy bare-display row has no state → forces a rebuild), and embed-fixer webhook reposts fold onto the real user BY USERNAME (the repost’s avatar is the webhook’s, not the user’s). Runs in the background (acks immediately, drops a server rundown when done), mirroring /remember’s backfill; mod-gated (a rebuild is heavy). Every author with at least one message is seeded (no min-messages floor — just skip genuine bots). Both writes are guarded so a transient blip (a readable channel erroring mid-walk, an empty scan while a seed exists, or a fresh rebuild having lost read access to every memory channel) leaves the existing stats untouched. Observability (a big rebuild outruns Discord’s ~15min interaction-token window, so the ephemeral followup silently dies — the SCAN must be visible in the LOGS): _build logs the START + the OUTCOME (the rundown text, always — so a long/snagged run is in Railway even when the Discord reply is suppressed), _scan logs per-channel msg + thread counts, _walk logs a heartbeat every 10k msgs (a huge channel shows progress mid-walk) and logs a snag LOUDLY (which source errored, at WARNING — not the old silent DEBUG); _deliver falls back from the (expired) ephemeral followup to a plain channel message so the mod still sees the result; and the snag/empty/no-access NON-write paths now emit stats_seed with ok=False + reason (snag/empty/no_access), so a rebuild that wrote nothing is a structured event, never silent. Emits stats_seed (mode=rebuild |
catchup | backfill, trigger=manual |
scheduled, ok, reason). Scheduled daily catch-up writer (#957, the automation half of the living seed): a @tasks.loop(time=09:00 UTC) (daily_catch_up, started in cog_load) folds the settled new activity into every configured guild’s seed once a day, mirroring cogs/memory.py’s writer — so the leaderboards/cards read current numbers with no on-demand scan. It folds with a 24h SETTLE-LAG (before_cutoff = a now-24h snowflake threaded through _catch_up→_scan→_walk’s history(before=)): reactions on a post keep landing for ~a day and a catch-up freezes a message’s reactions when it scans it, so a message is folded only once it’s >24h old (an hourly fold would freeze ~0 reactions; the watermark advances only to the settled boundary, leaving the fresh tail for the next day). It’s catch-up ONLY (never an unattended rebuild; a guild with no seed waits for a mod’s first /stats), shares the _builds in-flight map (a scheduled run and a manual /stats never scan a guild concurrently; a manual run cancel-restarts a scheduled one), is master-kill-switch gated + fail-open per guild. The MANUAL /stats catch-up keeps no lag (scans to now on demand); a rebuild re-reads everything current. Daily engagement SNAPSHOT (#1017, the windowed-stats layer): right after the scheduled fold (_snapshot_after_fold in _run_scheduled, idempotent per UTC day) it freezes the now-fresh seed into engagement_snapshots via db.snapshot_engagement — a ~1-line server-side INSERT ... SELECT FROM member_engagement copy, NOT a re-scan — so the /leaderboard stats period: weekly/monthly boards compute their window by SUBTRACTION (current seed − snapshot from N days ago, via utils.engagement_window). A cog_load bootstrap (_bootstrap_snapshots) takes the FIRST snapshot for any seeded guild with none yet (so windows aren’t empty until the next daily run); the daily loop also prunes snapshots past _SNAPSHOT_RETENTION_DAYS (40, monthly + buffer). One daily snapshot serves BOTH weekly + monthly — no extra scan jobs. Emits engagement_snapshot. /stats period:[week|month] backfill (#1017, the “seed the weekly NOW” path): the forward-looking daily snapshots mean a window is only accurate once N days of them accrue (weekly in ~7d, monthly in ~30d). The period: arg seeds it immediately: it scans the last 7/30 days, then RECONSTRUCTS the snapshot as of the window’s start as (current seed − the window's scanned activity) and stores it stamped at today−N (db.backfill_engagement_snapshot, a past-dated insert into engagement_snapshots), so the board’s current − snapshot reproduces exactly the scanned window. The reconstruction is the WINDOWING APPLIED IN REVERSE — engagement_window.reconstruct_snapshot is literally window_display/window_state over (current, scanned_delta), so the same diff the board applies forward yields a snapshot whose forward diff is the delta (round-trip proven in tests + validated against the live 180-member seed: 3,735 counter pairs + 1,194 graph edges, 0 mismatches). It writes ONLY snapshots — the all-time member_engagement seed is never touched (owner constraint) — and REFUSES while a scan is in flight (rather than cancel-restart like a rebuild/catch-up) since it reads the current seed to subtract from. Run it per window (period:week seeds today−7, period:month today−30); the rundown reminds you to run the other. Emits stats_seed (mode=backfill) + engagement_snapshot (trigger=backfill). |
awards.py, the weekly + monthly engagement AWARDS (#1017): a Player of the Week (Sundays, a single award but the SAME full fanfare as the monthly — owner steer “Player of the week should get the same fanfare as the others, the only difference is it’s just that single award”) and a FULL monthly gallery led by the {Month} MVP (e.g. “June MVP”, the 1st), posted in Toots’ voice to the dedicated awards channel (a single-channel /menu picker on the “where she posts” page, awards_channel_id in the settings KV). Each award is the TOP of a WINDOWED board — the live seed minus the snapshot from 7/30 days ago (window_display/window_state), ranked by the same windowed counters / closest-pairs the leaderboards use (no live scan, no new table; rides #1021’s windowing + #1033’s backfill). The pure winner-selection (registry + dedup) is utils.awards; this cog is the I/O (schedule, windowed reads, name resolution, the claude.awards_post voice call, the champion-card image edits, the send, durable state). Every award is engagement/activity based (owner steer): weekly = Player of the Week (most engagement = reactions_received+replies_received), a single crowning that gets the SAME celebratory host-intro fanfare as the monthly (awards_post(single=True) — owner steer; the only difference is it’s one award, not a stripped-down “light” post); monthly = a gallery led by the {Month} MVP (the SAME engagement metric, titled "{month} MVP" off the recapped month — “June MVP” — via AwardSpec.title’s {month} template) + Funniest (laughs) + Besties (the duo pair). (Weekly + monthly headline are one award/metric, just titled per cadence.) There is NO OVR / “Player” award (owner steers “no player of the month just week, that’s mvp” → then “use same metric for mvp weekly”): OVR is stable (“ovr doesn’t vary too much”), so it makes a dull award — it stays the /card rating + the card leaderboard, NOT an award. The philosophy: awards reward what you DID this period (engagement / comedy / the closest duo, all of which move week to week + are further varied by the per-category rolling dedup), while the card shows your overall standing (OVR). bangers was REMOVED as an award entirely (owner steer “we aren’t doing banger awards anymore”; it overlapped MVP/Funniest) — the AwardSpec, its bangers metric/_FIELD_LABEL/_detail branch, the _CARD_BODIES card prompt, and its tests are all gone; the separate bangers LEADERBOARD board (a different surface) is unaffected. The player/OVR + breakout AwardSpecs were removed entirely (owner steers “no player of the month, it’s player of the week and month mvp”; breakout was never live), and with them the now-dead window_ovr + alltime_ovr inputs — the awards no longer compute or read an OVR board at all (so the cog no longer depends on the PlayerCard cog). One @tasks.loop(time=noon ET) daily tick (_POST_AT, a DST-aware ZoneInfo("America/New_York") time so it stays 12PM Eastern year-round — owner steer; noon ET is 16-17:00 UTC the same calendar day, so the UTC-based _due_period/_token date logic is unaffected) decides the cadence — the 1st of the month posts the monthly gallery and supersedes that day’s weekly, every other Sunday posts the weekly. The post is a delightful gold-chromed multi-embed GALLERY (owner steer “polish + chrome, delightful, no em dashes” + “draw images for them all individually”): one gold embed per award stacked into a gallery — the FIRST carries the TOOTSIES AWARDS banner + Toots’ host intro (claude.awards_post, Sonnet, a short hosting hook, NOT an award list — the gallery shows the awards) + the headline winner + stat; each subsequent embed is a clean labeled trophy card (emoji title + winner + stat). A CHAMPION CARD per board champion (owner steer “remix an image of their pfp winning, each type in the prompt, a besties image for the pair”): every winner’s Discord avatar is remixed into a trophy card themed to that board via image_gen.edit (_CARD_PROMPTS per key = themed body + the shared _CARD_TEXT_RULE — crown / reactions-raining / comedy / duo). Maximalist style (owner steer “the maximalist emojis and gloss and fireworks is what i want”): the cards are deliberately glossy, badge-heavy FUT/2K-style trophy cards — the rich vibe descriptions in _CARD_BODIES are what the image model renders as the baked title + flair (fireworks, medallions, glaze badges). A “de-corny”/minimal-nameplate/textless detour was tried and reverted — minimal was the wrong direction. {award} is baked as the card title, so the on-card title is always the CURRENT award name. _CARD_TEXT_RULE (owner steer chain: “don’t spell out exactly the metrics” → meme-GLAZE badges (GOAT/ICON/LEGEND) → “remove the meme glaze” → “why does it say june mvp CHAMPION not june mvp”): the ONLY text on the card is the award title itself, spelled EXACTLY, rendered big + glossy as the centerpiece — NO word-badges at all (no metric/stat labels, no glaze/hype words, no slogans, no speech bubbles), and no extra word appended to the title. The “champion” leak was the MVP body wording (a {award} champion card put “champion” right after the title, so the model baked “JUNE MVP CHAMPION”); the bodies now say a {award} card and the rule POSITIVELY pins the exact string (a forbidden-word list is deliberately avoided — naming a word can summon it in an image model). The maximalist hit comes from the visuals (fireworks, floating emoji, crown, trophy, laurels, gold gloss), not text — so the bodies are pure visual direction with no vibe phrases that bake as badges. Besties crowns each person. The MVP card featuring the winner’s SIGNATURE reaction emoji (their most-pulled unicode emoji from the seed’s reactions_by_emoji, threaded in via _card_hints; owner steer “mvp is reactions+replies not most-loved, use their most reacted emoji as input”), and the besties card compositing BOTH pfps side by side (_compose_pair) into one duo portrait; generated concurrently (asyncio.gather), cached per (guild, period-token, award, winners) so a PRODUCTION re-post reuses them (a STAGING audition passes use_cache=False — it neither reads nor writes the card cache, so a preview never warms it for a real post nor is served a staged card; part of staging being fully stateless); fail-open per card (no image client / no avatar / edit miss → that embed falls back to a plain card / the hero a thumbnail). The winners are @-tagged once on the content line (the only place a ping lives, since an embed mention never notifies — allowed-mentions users-only; a crowning, distinct from the no-ping leaderboards). Two dedup layers spread the spotlight (owner concern “will uhlant just win every other week” + “win one per month / one every 4 months”): cross-category (a single person is never crowned twice in ONE post — keys order is priority, so the headline (Player weekly / MVP monthly) takes the top and later awards fall to the next distinct one; the pure taken set in utils.awards) + rolling anti-repeat, PER CATEGORY (the cog feeds recent_winners: the last _WEEK_MEMORY/_MONTH_MEMORY=3 winners OF EACH AWARD are skipped, so a person can’t repeat the SAME award for ~4 periods but CAN win a DIFFERENT one — owner steer “for monthly don’t be so strict on any-award dedup, maybe you can’t win THAT award”, relaxing an earlier global “any monthly award locks you out of all” rule; stored as a trimmed history list in the settings KV). The anti-repeat means the crown is often NOT the top of the board, so the WORDING has to follow the rotation (audited 2026-08-30 over the five weekly posts from Aug 2 to Aug 30: the board leader won 2 of 5, and on Aug 23 the crown went to 4th place at 516 points against the leader’s 1,065). AwardSpec.measure(period, rank) picks the superlative blurb only for rank 1 and the blurb_off_top wording otherwise, so the voice + hype prompts never read “pulled the most engagement of anyone” about a runner-up. The live n=5 dry run measured the claim the model actually wrote on the Aug-23 shape: 4/5 posts asserted a dominance that was false (“Player of the Week, no debate”, “and it wasn’t close”) before, 0/5 after. Idempotent per ISO-week / per-month so a restart never double-posts. Gated by the awards experiment (default staging, mirroring live_scores/market_drop): production posts the gallery to the awards channel, staging auditions the SAME gallery in #bot-logs (room stays quiet, via bot_logs.staging_header + bot_logs.post(embeds=, files=), no pings) so a mod QAs winners + voice + cards on real data, off skips the loop entirely. Plus the master kill switch + mood != off; production also needs the awards channel set on /menu. A mod /awards period:[week|month] posts on demand (preview/manual; respects the stage — staging→#bot-logs, production→awards channel, off→blocked; bypasses the idempotency skip; only a PRODUCTION post records state — a STAGING audition is a pure preview that records NOTHING (no idempotency token, no anti-repeat history), so a mod can re-audition the same period freely without consuming its token (which would block the real scheduled post) or biasing the dedup, owner steer “i can’t audition if it’ll dedup”). Per-award memory HYPE-UPS (owner steer): each award embed carries a one-to-two sentence Toots-voice appreciation of the winner(s) woven from her THIS-PERIOD memory of them + their receipts — claude.awards_hype (purpose awards_hype), generated CONCURRENTLY per award (_generate_hype → _one_hype over asyncio.gather). _one_hype gathers three grounding sources: _winner_memory (the about-them sentences from the period’s DAILY notes — rename-proof over every alias via match_forms + db.search_daily_notes, scoped to span_end >= cutoff, deduped + spread_evenly across the window, reusing the /card hype-up’s about_member_text/spread_evenly core), _winner_receipts (each winner’s biggest messages from this award window — the actual words behind the numbers; up to _HYPE_HIGHLIGHTS=4, most-reacted first, owner steer “it should still cite the week’s highlights from them”). Several, not one, and filtered for WORDS (_window_highlights / _quotable): the reel ranks by reaction count, and a week’s single most-reacted message is routinely contentless — this week’s Player of the Week topped their own reel with a row of crying-laughing emoji, so the hype-up quoted the emoji while their actual lines sat just below it. _quotable drops an entry with no real word left once emoji, URLs and pings come out; the bar is ONE word, not a character count, because the punchy one-liner is this room’s whole register (“6’11 bum” has six word characters). It reads the window by the same subtraction every other award input uses: the seed keeps a 200-deep highlight reel per member (top_reacted_posts), so the reel NOW minus the reel at the window start (_window_highlight) is what they posted this period. It used to hand over top_post_text — the member’s ALL-TIME best message — so the hype-up celebrated an old moment as this week’s; a live n=5 dry run on the real guild quoted that all-time post in 5/5 hype-ups before the fix and 0/5 after, and the windowed receipt also rescued a winner whose hype had been coming back EMPTY for want of grounding. A winner with no new reel entry gets NO receipt line rather than an out-of-window one, and for a DUO _duo_crosstalk (a bounded live scan of the memory channels for messages where the two mention/reply to each OTHER — their back-and-forth, the context memory may not capture). Fenced exactly like the memory notes it’s built from (observed public behavior only, no private inference/PII), fail-open per award (a miss → that embed just shows no hype line). Runs on OPUS by default (the awards are a gift — give them the best voice; a blinded dry run found Opus pulls noticeably sharper, more specific receipts than Sonnet), but the model is a per-guild /menu Models-page knob (awards surface, MODEL_SURFACES, opus/sonnet/gpt/gpt-cheap) via _awards_model so a mod can A/B the GPT-frontier voice. Fail-open throughout (no seed/snapshot → a “run /stats period:” note; a quiet window → no post; a voice/send miss → a soft status). Emits awards_posted (delivered=room |
staged, cards=# themed images rendered, hypes=# hype-ups that generated — a silent-degradation guard: hypes=0 with winners means the memory/model came back empty across the board). Pure core unit-tested in tests/test_awards.py, the schedule/format/embed/compose + hype-receipts helpers in tests/test_awards_cog.py. |
curator.py, the content curator (#curator): Toots keeps a themed room fed with fresh on-theme links, like a tasteful member. A curator channel (its OWN curator_channels set on /menu’s content-curation sub-page — reached by the 🖼️ set up curation button on the behind-the-scenes page, split off so that page stays under the ~5-select cap — separate from feed/discourse) is self-sourcing: she reads the accounts already recurring in that channel’s own history (utils.curator.rank_seed_accounts over the X/TikTok links there, excluding her own posts — the self-feedback guard), pulls their FRESH posts from the source (X via utils.twitterio.XProvider + TikTok + Instagram via utils.scrapecreators.TikTokProvider/InstagramProvider, provider-agnostic behind utils.source_providers.SourceProvider/SourcePost; #1294 the compose loop FANS OUT over every provisioned source — _platform_candidates derives each platform’s own seeds/adjacency, then interleave_candidates merges them into a FAIR judge slate (round-robin per platform, so a big-count TikTok batch can’t crowd X out of the judged top slots); a channel with no seeds for a platform just doesn’t fetch it. NO engagement PRE-FILTER (#curator, owner steer — “give the model more to determine based off everything vs prefilter on engagement, that isn’t working”): an earlier self-calibrating p25 batch floor sorted purely by engagement, so a channel’s single top-pulling account filled the WHOLE vision slate and the judge could only ever re-pick it — which (as a learned seed) re-promoted it into a self-reinforcing pin (prod: 13/14 of a car channel’s posts were one account). Now the only pre-judge gates are STRUCTURAL (dedup + original-with-media + a 24h source-recency gate, utils.curate.is_fresh / MAX_SOURCE_AGE — owner steer “all curation posts must be at most a day old from source”; fetch_recent is newest-first but unbounded in TIME, so without it a slow account’s weeks-old freshest post could still reach the judge; fail-open on an undated stamp — so every mapper MUST stamp extra["created_at"] or the gate no-ops for that platform. The TikTok + IG mappers originally did NOT (only the X mappers did), so the gate read nothing and failed open for every TikTok/IG post — the curator shipped month-old clips (a Post Malone TikTok, #curator). Fixed by stamping tt_video_to_post from create_time + ig_post_to_post from taken_at_timestamp (both epoch seconds → ISO via _epoch_to_iso)), and the per-platform slate is DIVERSIFIED across accounts (utils.curator.diversify_by_account, a round-robin over each author’s engagement-ranked posts, the account-level analog of interleave_candidates’ platform fairness) so the judge weighs VARIED accounts and decides on FIT — a fresh, on-theme, low-engagement post now reaches it instead of being cut for not out-pulling the biggest account (live-verified: a real car-channel pool went from an auto_porn-dominated 4/6 slate to a balanced 2/2/2 across three accounts). TikTok’s/Instagram’s “repost” endorsement analog is the collab (SourcePost.endorsed_handles, the co-creator handle free from collab_info / IG coauthor_producers), folded into the same 3×-weighted adjacency signal as an X repost; IG exposes no follow graph OR repost feed, so IG adjacency leans entirely on the coauthor collab (InstagramProvider.fetch_followings returns []), and its post ids are the shortcode so a channel-URL dedup matches parse_external_id), keeps every original-with-media post (dedup only — no engagement pre-filter, see diversify_by_account below) — INCLUDING a seed’s REPOSTS (#curator, owner steer “reposts are high quality”): is_postable no longer drops reposts, and both X providers (utils.twitterio.tweet_to_post + the ScrapeCreators backup sc_tweet_to_post) now RESOLVE a retweet to the ORIGINAL post it endorses (the original’s media/link/author/id/engagement drive posting + dedup + diversify, is_repost/reposted_handle kept for adjacency; the backup’s nested result carries no url so the permalink is constructed from author+id) — and a QUOTE tweet the same way when the seed added no media of its own (the visual content is the quoted original; a quote WITH the seed’s own media keeps the seed’s post) — an explicit endorsement is high-signal on-theme supply the curator used to throw away, so an aggregator seed that mostly RETWEETS (the #her hot-photo case) now contributes candidates instead of nothing (a media-less text RT is still dropped on an image channel; the fit-judge still gates every pick; curator_posted.via_repost marks a repost-sourced pick) — and hands an account-diversified slate to a fit-judge (a Haiku call comparing each candidate to the channel’s OWN recent posts, so the channel’s precedent sets the bar, no imposed SFW rule — owner steer, #her isn’t SFW), dedups against a durable seen-set (curator_seen, never repost a source id, restart-proof), and posts the winner’s source LINK rewritten to its embed-fixer mirror so Discord unfurls the media (X→fixupx, IG→kkinstagram, TikTok passes through — it unfurls natively; to_fixup_link via the shared _FIXUP_HOSTS; she never uploads raw media — exactly how #her/#art already post). Rides the shared ScheduledPoster spine (master kill switch + mood-OFF + calendar + per-channel slot pacing + text dedup + 429 retry) and the per-guild curator experiment (STAGING auditions each pick in #bot-logs so a mod QAs it’s on-theme; PRODUCTION posts to the room; OFF skips the guild). Calendar-managed via /menu (#curator follow-up): curator is a first-class calendar surface (utils.schedule_calendar.SURFACES + cogs.calendar_view), so its cadence is set on the /menu calendar like discourse/music/market_drop/bet_board; it falls back to its CHILL_TIMES/YAPS_TIMES pool (chill 2pm/8pm, yaps 12/4/7/10pm ET) only when a mood is NOT calendar-managed. (Note: a guild that already had a managed calendar before this must assign curator slots on /menu, else the managed mood posts it zero times — the standard new-surface behavior, same as bet_board.) Provisioning-gated on TWITTERIO_KEY (unset → bot.xprovider is None and the loop no-ops); fully fail-open. Pure seed-ranking + candidate filtering live in utils/curator.py (unit-tested tests/test_curator.py); the twitterapi.io client is guarded like SportsGameOddsClient (rate limiter + circuit breaker + retry). X-source FAILOVER (#curator resilience): when SCRAPECREATORS_API_KEY is ALSO set, the X source is wrapped in a utils.scrapecreators.FailoverProvider — the twitterapi.io XProvider primary + a BackupXProvider over ScrapeCreators (/v1/twitter/user-tweets), routed to the backup only while the primary’s breaker is open (XProvider.degraded), the SGO→Odds-API backstop pattern. So a twitterapi.io crater (datacenter-IP block / credit drain / outage) no longer takes X curation dark. Full SourcePost coverage incl. reposts (retweeted_status_result → reposted_handle), so repost-based adjacency survives an outage; ScrapeCreators has no X follow-graph endpoint so fetch_followings returns [] on the backup (adjacency degrades to the repost path). The façade is a drop-in bot.xprovider (cog unchanged); bot.xprovider_primary keeps the twitterio usage poll pointed at the primary. It forwards by DEFAULT (__getattr__), not by hand-listed method (#facade-forwards-all) — and that distinction was a live bug, twice. The façade originally forwarded an ENUMERATED set, so every twitterio-only capability nobody remembered to add raised AttributeError at the call site. fetch_trends hit exactly that and was fixed as an INSTANCE (its docstring records the silent []), but the CLASS was left open and five more were already broken behind it: fetch_tweet_replies, fetch_user_info, fetch_user_tweets_raw, get_usage, check_health, degraded. The proof surfaced only when the ops monitor learned to read forwarded ERROR logs (#1955): wire_identity.resolve_thread_identity calls bot.xprovider.fetch_tweet_replies(...) and had been raising on every wire moment about an unnamed person — fail-open, so that feature had simply never worked in production. Everyone ELSE had independently worked AROUND the façade by reaching for bot.xprovider_primary (ask, x_reply_draft, x_audience, health): four workarounds for one missing delegation is the tell that the façade, not the callers, was wrong. __getattr__ routes to the ACTIVE leg when it has the attribute, else the PRIMARY — that second half matters, since these are twitterio-only calls the ScrapeCreators backup doesn’t implement, so a degraded primary would otherwise swap in a leg that can’t answer and reintroduce the same error. Explicitly-defined methods still win (normal lookup precedes __getattr__), so fetch_trends’ always-primary rule is untouched. The general lesson: a façade that enumerates what it forwards is a list someone must maintain forever, and its failure mode is silent — a fail-open caller turns the AttributeError into a feature that just never runs. curator_fetch is stamped provider=twitterio|scrapecreators so integration-health attributes each; ScrapeCreators is a prepaid wallet (usage poll off /v1/account/credit-balance → scrapecreators:credits quota + the scrapecreators:budget balance-floor ops finding + a /debug/integrations probe). ScrapeCreators is the multi-platform seam (X/TikTok/YouTube/Instagram + transcripts, one SCRAPECREATORS_API_KEY) the broader curator + /ask cross-platform work builds on. Emits curator_fetch (the twitterapi.io read) + curator_posted. Phase 1.5 — image vs TEXT mode (#curator): the fit-judge is chosen PER CHANNEL from the channel’s own media density (utils.curator.prefers_media, ≥CURATOR_MEDIA_MODE_RATIO=0.6 of recent posts carry an image → IMAGE mode): an image channel (#her/#art) requires media + the vision judge (claude.pick_curator, candidate images vs the channel’s example images — plus each candidate’s CAPTION (#1294 follow-up): the vision judge weighs the image AND its words together, so an on-vibe-looking image whose caption is an ad / off-theme / engagement-bait is rejected on the text a picture-only judge would miss, live-verified that swapping only the caption flips the pick); a link/text channel (#man: cars/menswear/cool finds) drops the media requirement + uses the text judge (claude.pick_curator_text, candidate captions vs the channel’s recent post text + topic, no vision) so bare text/link posts curate too. Self-calibrating like the p50 engagement floor (no per-channel UI; the ratio is env-tunable, empty channel → text mode, the safe superset that still judges fit). The seed-harvest window is 30 days / up to 400 messages (_HISTORY_DAYS/_HISTORY_LIMIT, widened from 21/200): a low-volume curated channel’s 200 messages span well past 3 weeks, so the day window was the binding limit — a wider window keeps a seed account live longer, so a mod posts less often to keep her fresh (owner steer). Phase 2 — adjacent-account discovery (#1232): the taste self-WIDENS beyond the accounts already in the channel. Each slot fetches a 3:1 CURRENT:NEW mix across reserved tiers (utils.curator.pick_fetch_pool): 2 proven in-channel seeds (recency) + 1 learned seed + 1 fresh discovery. Discovery crawls each top in-channel seed for who it FOLLOWS (XProvider.fetch_followings → /twitter/user/followings, (handle, follower_count)) AND who it REPOSTS (the reposted author rides along on the seed’s recent timeline, tweet_to_post → SourcePost.reposted_handle, so no extra endpoint). rank_adjacent_accounts blends both by cross-seed count, weighting a REPOST far above a FOLLOW (#1251, owner steer: score = REPOST_WEIGHT*repost_cross + FOLLOW_WEIGHT*follow_cross, default 3:1 — a repost is explicit content endorsement, a follow is passive/broad), follower count as the final tie-break. A repost also surfaces an account NO seed follows (widening discovery beyond the follow graph). The ranked adjacency is durably cached per channel (DurableCache namespace curator_adjacency, ~3-day TTL) so the metered crawl runs ~once per few days, not per slot. Growth (the “grow the garden” mechanic, owner steer): because seed derivation ignores Toots’ OWN posts (no self-amplification loop), a discovered account can’t become a seed via her re-reading her posts — so an adjacent account that produces a SHIPPED pick is promoted to a durable learned seed (curator_learned_seeds, _maybe_promote), which fills the “learned” tier on future slots, so discovery COMPOUNDS. The vision fit-judge still gates every post, so an off-theme discovery is just skipped. curator_fetch gains phase=followings; curator_posted gains source = in_channel |
learned |
adjacent (watch the adjacent/learned rates to confirm discovery is working + growing). Full observability (#curator ops): the fail-open surface used to return None at 7 points with no signal, so a curator going quietly DARK (fetching fine but posting nothing because the judge rejects everything / the channels stopped being fed) was invisible. curator_evaluated now emits WHY on every no-post slot (no_seeds/no_history/no_posts/no_candidates/none_fit) + the mode; with curator_posted (which carries source + mode) it forms the ship FUNNEL. The ops-monitor renders a Curator health line (post rate + skip-reason breakdown + source/mode split) every run and flags curator_dark when she’s shipping ~nothing over enough attempts — HIGH when none_fit-dominated (judge/seeds off-theme, users see silence), MEDIUM when supply-dominated (no_seeds → post links). The twitterapi.io integration crater stays self-healed by the health cog (#1257); a content-SUPPLY dark is a human nudge (post links), so it’s an ops-monitor finding, not an autonomous fix-order. The text fit-judge is guarded by scripts/eval_curator_pick.py (registered in run_evals; picks the on-theme candidate over off-theme noise, golden = an off-theme post a healthy judge must SKIP; the vision judge shares the pick_gif path eval_gif_pick already covers). The top-artist ROSTER left this cog on 2026-09-07: it was an overlay here (the artist_curator experiment seeded one roster account per slot into every curator channel) and is its own surface now, artist_curator.py below. This cog seeds from channel history alone. The fit-judge must SEE the images it judges (#backpage, 2026-08-30). The vision API rejects an image it cannot use, and that 400 fails the WHOLE call – every other image in the request with it. Two real causes hit production: an X ?name=orig original wider than 8000 pixels, and a vxtwitter.com/rendercombined.jpg composite the API cannot download. Both arrived as EXAMPLE images. recent_image_refs takes the examples from the channel’s own history and does NOT filter them by host, while the CANDIDATE frames ARE host-filtered – an asymmetry nobody had noticed. Examples rank by reaction count, so one top-reacted bad photo stayed the #1 example and failed EVERY slot for days. _call then applied its normal recovery: drop every image and retry on the text alone. That recovery is wrong for this judge. The judge answered about pictures it could not see – 158 of 400 curator picks over two days came back from an image-stripped retry, picking a post on its caption alone – and when it narrated instead of answering, the slot died as pick_error (~2 lost slots per hour). The fix has two parts. (1) _image_block_source runs every vision URL through utils.vision_image.bounded_vision_url, which pins an X image to the large size variant (2048 pixels on the long side, well above the ~1568 the models downscale to), so an oversized original cannot reach the API. That helper is the ONE place every vision block is built, so every surface gets the bound. (2) pick_curator passes images_required=True, which stops _call from answering with the images stripped, and recovers in the CALLER instead: it re-judges the CANDIDATES alone. The examples are optional context and the candidates are the task, so the 1..n candidate numbering survives and the pick still maps to the right post. A rejected CANDIDATE image returns None, because dropping it would renumber the slate and ship the wrong post. Verified live against the real #backpage images (2026-08-30): the pre-fix call replied “I don’t see any images attached”, the post-fix call returned a real pick. The same incident exposed an observability gap and closed it: _sched_compose used to report a judge ERROR and a judge DECLINE with the SAME none_fit reason, so the funnel read “the judge rejects everything” while the judge was in fact failing on an unusable image. A None pick now emits pick_error (what the curate feed already did, and how the outage was found); none_fit means the judge looked and declined. The ops-monitor names pick_error in its curator_dark guidance. The flag rides the GPT path too (Codex review on PR #2731): _localize_openai_vision DOWNLOADS each image to send it inline and DROPS one it cannot fetch, which renumbers a 1..n slate – the model’s “pick 3” would then index the caller’s candidate 4 and the room would get a post the judge never saw. With images_required that raises OpenAIChatUnavailable with a provider category, so the router falls FORWARD to Claude with the images untouched. That direction is deliberate (Codex review): the download that failed was OURS, not OpenAI’s, and Claude fetches an image URL server-side, so the same still-aligned slate can often be judged there; an image Claude cannot use either comes back as its own 400, which pick_curator already recovers from. A request category would have lost the slot to a transient 429 or timeout on our own downloader. Reachable by CURATOR_PICK_MODEL=gpt and by the Claude-to-OpenAI failover, so it is latent rather than theoretical. The rejection is now ISOLATED, not guessed at (#2738, 2026-08-31). Shipping images_required in #2731 fixed the blind judging and immediately broke a second surface: the autonomous curator posted NOTHING for 2.5 hours (baseline 1-4/hour) because a rejected CANDIDATE image had no recovery, and unlike the curate feed this cog has no drain loop to walk with. The measured cause was 24 robots.txt plus 10 undownloadable images, arriving in PAIRS one second apart – the first attempt and the retry-without-examples both failing, which proves the offender was a candidate. The fix ISOLATES the offender two ways, because the API only sometimes says which image it refused – a live dry run caught that, and the unit tests had not: the oversized-original 400 carries messages.0.content.3.image.source.url, but the two that ACTUALLY happen (Unable to download the file and the robots.txt refusal) name no block at all – 72 of 72 over six hours of production named nothing. So _rejected_image_index parses the block WHEN it is there (one extra call, exact), and when it is not, pick_curator ELIMINATES: exclude one candidate per pass until the call goes through, at most one call per candidate and only on a slate that has a bad image. Each pass excludes a DIFFERENT candidate rather than accumulating exclusions, so every healthy candidate is still in the slate at the moment the call succeeds – which is what the drain’s positional peel could not do. _is_image_error keeps a malformed request from spending a call per candidate, and matches per-image failure SHAPES rather than the bare word “image” – a request-level 400 (“this model does not support image input”) says “image” too, and every reduced slate would get the same answer, so the bare word would burn a call per candidate before giving up (Codex review). Known limit: the unnamed path excludes ONE candidate per pass, so a slate with TWO unnamed offenders has a bad image in every trial and still loses the slot – no worse than before this change, and the dominant trigger (Instagram’s robots.txt-blocked CDN) is removed by #2744, which downloads those images instead of asking the API to fetch them. Either way the surviving slate is judged on a clean 1..k numbering and the pick is mapped back through the surviving indexes. Every prompt block – the frame-count map, the captions, the transcripts – is a FUNCTION of the surviving slate, so a dropped candidate never leaves a gap the judge would read as a missing image. It loops, so several bad images isolate one per pass; it terminates because each pass removes one candidate. This lives in the shared judge, so it covers BOTH cogs and retires the drain’s positional peel as anything but a fallback. A second, smaller fix went with it: _call’s robots.txt branch no longer strips TEXT urls and retries for an images_required call carrying images, because the blocked url is an attached IMAGE and stripping text cannot make it fetchable – that retry burned a second failing call every time, 24 of them in 2.5 hours. The CAUSE is now removed, not just isolated (#2744, 2026-08-31). The post-ship verify of the isolation measured it working and delivering nothing: over 70 minutes live, pick_error held at ~11 per 2h against 12 before, and every one of the 6 failing slots burned its full 8-call budget and rescued nothing. Eight calls is one pass with the examples, one without, then one per candidate on a 6-candidate slate, so NO single exclusion produced a clean trial – the two-offender limit above, in production, on every slot. The offenders were Instagram: the roster fetch logged curator_fetch platform=instagram one second before each burst of 400s. Meta’s robots.txt disallows the vision API’s fetcher, and Instagram serves media off TWO host families picked per request (scontent-<pop>.cdninstagram.com and instagram.<pop>.fna.fbcdn.net), so a gate covering one still leaks the other. Both are now in _VISION_UNFETCHABLE_HOSTS, which is the SAME mechanism TikTok already used: download the image and pass base64. Proven against the live roster before shipping – the raw URL 400s from both families, our own egress downloads all of them, and the SAME images sent as base64 are described correctly. This also recovers real posts, not only Instagram reach: before #2731 those slots still posted, judged on stripped images, so the autonomous curator’s drop from ~2.2 to ~0.9 posts/hour is the size of the blind-judging that #2731 stopped. The curate feed needs no change – it is X-only by design, and it logged zero pick_error in the same window. |
artist_curator.py, the artist curator (2026-09-07, owner steer “split them”): the top artists’ OWN fresh Instagram + TikTok posts, in a room of their own. Why it is its own surface and not the overlay it started as. The overlay seeded ONE roster account per slot into EVERY content-curator channel and routed the pick on its own artist_curator stage. Measured 2026-09-07 over 14 days on the main server: 39 roster picks, all delivered staged (the overlay was still STAGING), and the three curator rooms were a car room, an art room and a beauty room – so a Weeknd Instagram was judged against car photos and lost to the room’s own seeds (none_fit in 651 of ~1,400 evaluations that week). Even PRODUCTION would not have put an artist post in a room. So the split: its OWN /menu channel picker (artist_curator_channel_ids, a settings key, on the channels hub), its OWN calendar row (artist_curator in schedule_calendar.SURFACES, default 0 shares like the curator), its OWN slot counters (artist_curator_slots), the artist_curator experiment as its gate (default PRODUCTION now: the handles are the curated OFFICIAL utils/artist_socials map, audited for two weeks in #bot-logs as the overlay, and the surface posts to Discord only – STAGING still auditions, OFF skips the guild with no watchlist read and no fetch). The loop, per slot: watchlist_source.build (the kworb top-100 the music desk ranks) -> artist_socials.roster_handles per platform -> _ACCOUNTS_PER_PLATFORM (3) handles drawn at random per platform (a random draw walks the whole list across slots; catch-up pagination against the SHARED curator_seen set makes a re-drawn account cost one page, and the shared set means a post the content curator already shipped never repeats here or the reverse) -> filter_candidates(require_media=True) + the 24h is_fresh gate + diversify_by_account -> interleave_candidates across the two platforms -> prepare_vision (the module-level function factored out of curator.py, the ONE definition of the TikTok/Meta CDN base64 download) -> claude.pick_curator against the room’s OWN recent images + its topic (a room with no topic gets _DEFAULT_TOPIC, “the top artists’ own instagram + tiktok posts”) -> a CAPTION LINE above the source link on its embed-fixer mirror. The caption (utils/artist_caption.py, 2026-09-15, owner steer “can we add a timestamp and a caption to these? Like, Beyonce with new photo”): one line, Sabrina Carpenter: "One year on pretty girl avenue". The artist NAME comes from artist_socials.name_for_handle (the same curated map the roster seeds from, read backwards, so the line can only name an artist the map already vouches for; an unmapped handle is written @handle, which is the real case for a COLLAB post owned by the other artist). Then the POST’S OWN WORDS, quoted: the second owner steer the same day was “you don’t need to count the number of posts, maybe more relevant is the caption or what it’s about”, so a count (“4 new photos”) gave way to the caption itself. QUOTED, never paraphrased, so nothing on the line reads as Toots describing someone else’s post; FIRST LINE only, cut to 40 characters on a word boundary, with links and hashtags removed and Discord’s markdown characters escaped. Measured on the live roster (62 posts): a caption’s first line carries the thought and everything under it is the promo block, the tour links and the tags; 7 posts carried no caption at all and several more carried only decoration (“🐝”, “♡ ∞”), which is why the MEDIA phrase from SourcePost.extra["media_kind"] remains as the fallback (“a new photo” / “a new video” / “new photos”, never a count; an unstated kind reads “a new post”, never an assumed photo). The post’s AGE goes on the STAGING AUDITION only (caption_for(..., with_time=True), owner steer 2026-09-15 “the timestamp is only for staging, I think”), as Discord’s own relative tag, which a mod sees in their own timezone as “3 hours ago”. It is a DIAGNOSTIC, not decoration: its job is to show whether the 24h freshness gate is holding, which is how the seven-month-old Beyonce post was caught. The room’s line leaves it off, because a post that reaches the room is inside 24h by construction – and if that stops being true, the bug is the gate, not the missing label. Each part is omitted rather than guessed, and an undated post carries no timestamp even on the audition. Both send paths pass discord.AllowedMentions.none() — the body now carries text nobody here wrote, and an @everyone in an artist’s Instagram caption must not reach the room as a real ping. The caption rides Composed.meta, NOT Composed.line. line is what the spine dedups on, and utils/dedup.py strips URLs before it measures text similarity, so a formulaic caption would be almost all the gate could see: measured, “Beyonce with a new photo” against “Drake with a new photo” scores over the 0.6 similarity floor, so every pick after the first would drop as a duplicate of a DIFFERENT artist’s post. _sched_deliver joins caption + link at send time (both the STAGING audition and the room), so the dedup key stays the link exactly as before. X is never roster-seeded (owner steer, the roster targets IG + TikTok) and there is no X crosspost (a bare repost would drown out her own tweets, same as the content curator). Telemetry: the SAME curator_posted / curator_evaluated kinds as the content curator, always source=roster, stamped surface=artist_curator via @scoped_surface on the tick (the content curator carries no surface stamp), so the curator dashboard + ops-monitor funnel read both and Axiom splits them on surface; no_seeds here means the watchlist was down or no artist on it has a mapped handle. The freshness gate was failing OPEN on Instagram until 2026-09-15. The v1 /v1/instagram/user/posts endpoint returned a null taken_at_timestamp AND a null created_at on every post of every handle probed live (beyonce, theweeknd, sza), and utils.curate.is_fresh reads an absent stamp as fresh, so the 24h window stopped filtering Instagram entirely — the surface shipped a SEVEN-MONTH-OLD Beyonce post (DUrhXmwEUF_, taken 2026-02-13) as a fresh pick, which is what the owner’s screenshot caught. The read moved to /v2/instagram/user/posts (same one credit, a populated taken_at, and media_type on top). Expect the room to go quieter: measured after the fix, none of beyonce / sabrinacarpenter / theweeknd had posted inside 24h, so the IG supply the room was drawing on was mostly stale posts the gate should always have dropped. The undated count on every curator_fetch phase=user read plus the ops-monitor source_undated finding are the tripwire for a repeat on either platform. Known thinness (follow-up, not this PR): the live top-100 has ~137 names and the handle map covers 26 on Instagram + 14 on TikTok, so the room draws from a fifth of the roster; a mid-tier act (Tinashe, #552 on kworb) is out of scope until the roster widens. Unit-tested in tests/test_artist_curator.py; the pure map in tests/test_artist_socials.py.curate.py, the curate-feed surface (#curate): the OWNER-DIRECTED, calendar-driven twin of the autonomous curator.py loop. A mod sets one or more X handles on /menu (the content-curation sub-page’s ✎ curate feed button — reached via the 🖼️ set up curation button on the behind-the-scenes page → a modal, stored per-guild in the curate_accounts settings KV; the modal validates on submit by FETCHING each handle — MenuView._validate_curate_handles calls bot.xprovider.fetch_recent, so a typo/empty handle is rejected with a “couldn’t pull @x” note and never saved, and an all-typo edit never wipes the existing list, a deliberately blank field still clears; unprovisioned X → accept as-is); this scheduled surface then rides the curator’s /menu calendar cadence (ScheduledPoster subclass, SURFACE="curate"; _resolve_schedule overrides the calendar-lookup key to curator, so there is NO separate calendar row — a mod configures the calendar ONCE for the curator and both surfaces follow those slots; owner steer). On each due slot, per curator channel, it pulls the configured accounts’ recent posts + reposts (bot.xprovider.fetch_recent, reposts already resolved by utils.twitterio to the endorsed original’s media/link, memoized per tick so the per-channel composes share one pull), filters to the shared 24h recency window (utils.curate.is_fresh / MAX_SOURCE_AGE, env CURATION_MAX_AGE_HOURS, tightened from the old 3-day window — owner steer “at most a day old from source”; the SAME window the curator now uses, via the timeline ACTION time stamped onto SourcePost.extra["created_at"] by the X mappers — a repost’s boost time, not the original’s) + drops replies + drops anything already curated, then BEST-HOME arbitration routes each fresh post to its SINGLE best-fit room across ALL the curator channels FIRST (claude.route_curator, a Haiku best-home classifier computed ONCE per tick + shared, memoized in _route_cache), so a room only ever considers the posts that belong to IT — an art photo #art would claim can’t be grabbed by #her (which, judged in isolation, takes the least-bad option in its slate: the #curate misroute fix). Among the posts routed to a channel it DRAINS every eligible one, best-first (owner steer — “curate all eligible recents vs slow drip”), not just the single best: the due slot is only the TRIGGER; Curate._maybe_post_to_channel overrides the ScheduledPoster one-post-per-slot handler to LOOP the curator’s OWN proven, eval’d fit-judge (claude.pick_curator for an image room, pick_curator_text for a link/text room, image-vs-text mode derived from the channel’s own media density like the curator) + deliver — each compose picks the best UNSEEN eligible post + marks it seen, so the loop slides through the pool best-first (O(N), since each compose judges only the freshest _VISION_CANDIDATES) until the judge declines or nothing eligible remains. Bounded by _MAX_DRAIN_PER_SLOT (20, env CURATE_MAX_DRAIN_PER_SLOT) per slot — set to cover a full fetched page (~20/account) so a single account’s page drains in ONE slot (“at least post the first page”, owner steer; below the page size the tail was left for the next slot, where a fast account had already pushed it off page 1 → missed). The best-home route cap (_ROUTE_MAX, 40, env CURATE_ROUTE_MAX) is likewise >= the page so every fetched post gets a home decision (below it, the oldest fetched posts were silently unroutable → never eligible). Only the fit-judge filters now, not these caps. A multi-account / firehose overflow still BATCHES across slots — the durable curator_seen dedup makes carrying the remainder to the next slot safe (nothing re-posts), and discord.py’s own 429 backoff means a batch never drops a post to rate limits (a small _POST_GAP_SECONDS courtesy gap keeps it gentle). The per-channel history read (_channel_context) is memoized per tick (_ctx_cache) so the drain’s repeated composes share ONE read. A post routed to no room (fits none) is dropped from every room; a slot with nothing eligible/fitting skips with curator_evaluated reason=no_home. It posts each drained post’s embed-fixed link (cogs.curator.to_fixup_link) prefixed with a 🔁 repost marker (an older owner steer dropped the raw via @<account> handle — the CONFIGURED source account still rides SourcePost.extra + the curate_run event for telemetry, just not the post text; pure _attributed_line, unit-tested), so a curate post is visibly distinct from the autonomous curator’s bare links, and marks it seen. A SHORT Toots caption sits on that marker line, above the link, for ARTIST curation ONLY (owner steer: “just needed it for artist curation”; the original steer was “caption them … something simple like ‘karol g posts via tiktok’”): a caption is composed ONLY when the post is a REPOST of a DISTINCT artist (the repost’s reposted_handle, and it differs from the reposting account) — the account’s OWN post, and a self-repost, drop to the bare 🔁 link. When it applies, _compose_caption → claude.caption_curation composes a cheap Haiku line grounded ONLY in that artist + the platform the link RESOLVES to (_platform_label, so “via tiktok” is read from the host, never guessed) + the post’s own text, and it NEVER claims what the unseen media shows. The caption rides a deterministic fail-CLOSED gate in the cog — short (≤ _CAPTION_MAX, env CURATE_CAPTION_MAX, default 90), single line, no link — so any miss (not an artist repost, a compose error, an EMPTY reply, or a caption that fails the gate) drops to the bare 🔁 link exactly as before, and has_caption rides curate_run. The caption stays on the MARKER line (marker still FIRST → is_curate_post seed detection unchanged) and the link drops to line 2 (still unfurls). This is a DISCORD-only change; a captioned crosspost to the @tootsiesbar X timeline is a deliberate follow-up (the curator/curate lanes still do NOT crosspost — see cogs/curator.py’s no-crosspost note — pending an owner decision on that one-way door). Known bound: the fetch is one page (~20/account, no pagination — the twitterapi.io per-page cap), so a firehose account posting 20+ between sparse slots can have middle tweets scroll off page 1 before a drain catches them; closing that needs pagination (metered by tweet volume) or denser slots. Dedup is SHARED with the curator (curator_seen, platform x), so a tweet lands in exactly one room and never double-posts (across both surfaces). Gated by the CURATOR’s experiment (owner steer — one toggle for both: STAGING auditions each pick in #bot-logs so a mod QAs the art/man routing; PRODUCTION posts to the room; OFF skips), its own de-facto on/off being simply whether an account is configured (no accounts → _sched_extra_gate skips it regardless of the curator stage). It keeps its own curate_slots pacing counters but SHARES the curator’s calendar cadence (no separate calendar row) AND the curator’s experiment (no separate experiment). Master-switch + mood gated like every proactive surface. Provisioning-gated on the X source (bot.xprovider) + a configured account. X-only: TikTok/IG reposts + bookmarks (and X bookmarks) are NOT a supported source — ScrapeCreators has no reposts/favorites endpoint and bookmarks are private. Pure timestamp/window/handle helpers (normalize_handle/parse_handles/parse_tweet_time/within_window) live in utils/curate.py (unit-tested tests/test_curate.py); the cog is the scheduler + provider + Haiku I/O. Fail-open throughout. Fully observable (#curate ops): emits curate_run (a post landed) + curator_evaluated source=curate_feed (a no-post slot, with the reason: no_home/none_fit/pick_error/no_candidates/no_posts) + curate_routing (the per-tick best-home DROP funnel: candidates/routed/dropped) + an error on every failure path so nothing fails silently (source=curate_route a route-classifier API failure, curate_drain a compose blip mid-drain, curate_deliver a room send failure, curate_staging a #bot-logs audition failure); the ops-monitor renders a Curate-feed health line and flags curate_dark when it ships ~nothing (its own funnel, kept separate from the curator’s so neither cross-contaminates). pick_error was the #backpage symptom (2026-08). One unfetchable EXAMPLE image failed the whole fit-judge call, so this feed dropped from ~44 posts a day to 1 in that room. The cause and the fix live on pick_curator – see the curator.py entry above. The drain did not drain (2026-08-30). The fit-judge is shown only _VISION_CANDIDATES (6) posts at a time, and the loop STOPPED the moment the judge declined a slate. A decline means “none of THESE six fit”, not “the pool is empty”, so the slot ended with the rest of the eligible pool never judged. Measured against the live account: @mejasonmejason produces ~8 eligible posts an hour, 40 of the 40 freshest were fresh, media-carrying and uncurated, the best-home router kept 39 of them for #backpage, and a full walk of that pool had the REAL fit-judge accept 38 of 39. The room got 1 that day. So the judge was never the gate – the loop was. _compose_once now returns its skip REASON instead of emitting it, and the drain treats none_fit (and a pick_error, so one unusable image cannot block the room) as “walk on”: the declined slate is recorded and EXCLUDED, so the next compose shows the judge the next slate, until the pool is exhausted, the post cap (_MAX_DRAIN_PER_SLOT) is reached, or the walk budget (_MAX_DECLINED_SLATES, env CURATE_MAX_DECLINED_SLATES) runs out. _MAX_DRAIN_PICK_ERRORS ends the slot when the judge is failing systemically rather than on one slate. A post the judge DECLINED, and one whose whole slate carries no fetchable frame (no_candidates, a deterministic property of the post), is remembered per room for _DECLINED_TTL (the source-freshness window) in the in-memory _declined memo, so two slots an hour do not re-judge the same rejects all day. A pick_error is handled differently again, in two ways, both from the Codex review on PR #2731. It is NOT remembered: the judge never RULED on it, and the cause is usually transient, so persisting it would bench a slate of fresh posts for the whole eligibility window on one model blip – it rides a walk-local unjudged set that dies with the slot, and a later healthy slot judges those posts. And it sets aside ONE candidate, not the slate: the error names no candidate, so the drain PEELS – exclude the head, rebuild the slate, judge again. Benching the slate coupled five healthy posts to one persistently unusable image, which left them unjudged on every slot while the bad post stayed fresh. A slate of ONE in image mode that errors is the exception – it IDENTIFIES the offender, since that post was the only image in the call – so it is recorded as a real verdict and the posts peeled on the way are RELEASED for this slot to judge. TEXT mode is excluded: there is no image in that call, so a None from pick_curator_text is a parse or API failure that identifies nothing, and persisting it would bench a healthy post on a blip. That is what keeps a room dark-free when the offender sorts LAST in a small pool: peeling alone ate the healthy post ahead of it and the slot posted nothing. The bet there is that an erroring single image is far more often an unusable image than a parse blip, and being wrong costs one post held for the freshness window against a dark room. _MAX_DRAIN_PICK_ERRORS counts CONSECUTIVE errors and resets on any answer the judge gives, so it covers a full peel and still stops a model that is down; before the reset, two unrelated blips a whole drain apart ended the slot. The memo is deliberately NOT written to curator_seen, which is the shared “already POSTED” set for both curation surfaces and must not gain a second meaning. The slot emits ONE curator_evaluated, not one per slate, so a walk cannot inflate the skip count the curate_dark rate divides by. Its reason is the last SUBSTANTIVE one, not the terminal one: the compose that finds the pool empty reports no_home, so without that the walk’s own exhaustion would overwrite the judge’s verdict and a fully-REJECTED pool would read as a ROUTING problem, pointing curate_dark at the wrong component. detail.dropped carries how many posts the judge passed over on the way. Expect one catch-up burst per room on deploy (up to _MAX_DRAIN_PER_SLOT, paced by _POST_GAP_SECONDS), then a steady ~4 per slot at this account’s rate.clipboard.py, the cross-server link clipboard (mods only): /copy channel: period: name: scoops every URL people posted in a channel over a window into a named, per-user buffer, and /paste buffer: channel: posts those links into a channel in (possibly) a different server — with an out:curate option that instead sorts the buffer’s links across the guild’s curator channels by best fit (/paste run through curation). The point is that Discord’s slash-command channel picker only lists the current guild’s channels, so a single command can’t read a channel in server A and post into server B; the buffer (keyed by user_id only, so it follows the mod across servers) decouples the two halves. Links only for now (Messages/Files/audio notes are a tracked follow-up — Discord attachments, including voice messages + uploaded audio, sit on a CDN with signed ~24h-expiring URLs, so a durable copy means re-downloading + re-uploading the bytes, not storing a link). The pure URL extraction + first-seen-order dedupe lives in utils/clipboard.py — add_urls(seen, out, text) is the incremental accumulator (the building block /copy streams history through so it never holds every message’s text in memory) and collect_urls(texts) wraps it for the batch case (both unit-tested). /copy walks channel.history(oldest_first=True) so the buffer keeps chronological order (it does NOT skip bot/webhook authors — an embed-fixer reposts a link via a webhook after deleting the original, so filtering bots would drop exactly the embed-fixed links), extracting URLs as it streams (only the link SET is held, not all message text) up to a _MAX_SCAN_MESSAGES safety ceiling — if a scan hits it the reply WARNS that older links may be missing rather than dropping them silently (no link loss). Re-copying into an existing buffer MERGES (the accumulator is seeded with the buffer’s existing links): a union, deduped, order-preserving — every prior link is kept (no loss) and a re-copy of the same window adds nothing (idempotent, a true no-op that skips the write). Stores via db.upsert_link_buffer (which replaces the row with the cog-computed merged list). /copy also accepts a THREAD (a thread copies just that thread’s messages, a channel just the channel’s own — Discord keeps their history separate), and the walk is deploy-resilient: it checkpoints to the buffer every _FLUSH_EVERY messages, so a redeploy that SIGTERMs a long scan doesn’t lose progress and the idempotent merge lets a re-run continue (the ceiling warning is oldest-first-accurate — a truncated walk keeps the OLDEST of the window, narrow + re-copy for newer). /paste posts one link per message (so each unfurls), paced (_PASTE_GAP_SECS), as the bot (channel.send, Toots’ own identity — no webhook, so pasted/curated links read like her own posts, matching the curator). /paste out:curate (_start_distribute → _run_distribute) is the curation destination: instead of dumping every link into one channel it enriches each buffer link (utils.link_enrich; social platforms, bare-URL caption fallback via the pure utils.clipboard.route_caption), routes it to its single best-fit curator channel with the curate feed’s OWN best-home judge (claude.route_curator, reused — a per-link Haiku vision call), and posts it there fixup-rewritten (cogs.curator.to_fixup_link) so X/IG unfurl. It AUTO-DRAINS: one invocation keeps going in the background until the buffer is empty, CONSUMING each link as it’s filed (rewriting the buffer to the unprocessed remainder + the un-routable, per batch of _DISTRIBUTE_BATCH) — so a re-run, OR a redeploy that kills the task mid-drain, never re-posts an already-placed link and always advances (the fix for a first-N slice that re-processed the same head; the buffer’s own consume IS the resume). A link that fits no room STAYS in the buffer; route calls run a few at a time (_ROUTE_CONCURRENCY) and a per-invocation safety ceiling (_DISTRIBUTE_BUDGET) stops a giant buffer with a “run it again” note. Targets are the guild’s db.get_curator_channels (each room’s routing vibe = its recent post texts + topic via _room_vibe). Both out paths share the _deliver_links one-per-message delivery (posted as the bot, no webhook). All run as background tasks (_spawn, with the _deliver_result followup→channel fallback) so a long scan / big paste / big sort isn’t bound by Discord’s 15-min interaction window. Mod-gated (_mod_gate). Emits links_copied / links_pasted / links_distributed.x_game.py, the X (Twitter) guess-the-song game (#1497): Toots posts an urban-90s+ song clip to the @tootsiesbar timeline on a scheduled slot, followers reply with a guess, a twitterapi.io filter-rule webhook delivers the replies (no polling — utils/healthcheck.py:/x/webhook → handle_webhook_tweets), the caption is specific — name the song TITLE (the win is the title; the artist isn’t required, just credited in the reveal). The first correct guess ENDS the round with a single board reply (a MUSIC-DROP-toned reveal of the track + a light nod to whoever called it + the running leaderboard — no fanfare); a NO-WINNER round skips the board/leaderboard entirely and just quote-reveals the answer (no “nobody got it” complaint). The winning board is posted UNDER the clip so the whole round is one thread (the round ends at first-correct, a race, not a fixed window), then a music-drop QUOTE of the clip with the answer + a one-line Toots-voice blurb (claude.song_blurb, Haiku) — GROUNDED trivia: the cog gathers real facts (_song_facts: a live Perplexity fact + the Genius reference — producer/writer/samples, citable) and the model writes the line using ONLY those facts (a real detail dropped casually, never invented; falls back to a pure vibe line when no facts are found, which carries no fabrication risk) — + the revealed album art attached when the model nominates it (song_blurb can end its line with an [art] tag, its call like the /ask <image>/<gif> nomination; parsed + stripped by parse_blurb_art so the tag never leaks; a quoted link doesn’t unfurl its player, so the cover is the visual when attached) + its Apple Music link (resolve_apple_music_url) so people can go listen. Two writes a round (board + drop), ~8 rounds/day under X’s ~17/day cap. A standalone @tasks.loop (NOT a ScheduledPoster — it’s a stateful round on an external timeline, not one Discord post per slot) reusing calendar_hours for WHEN to open. Round lifecycle: OPEN (a due x_game calendar slot + no round open → pick+verify a song, render the clip via utils.audio.make_video_clip, post it, register the to:tootsiesbar reply rule via utils.x_filter_rules.create_active_rule, persist) → COLLECT (webhook → judge each reply against the open round via the SHARED /guess matcher (cogs.games.grade_guess on HARD – exact title modulo case/punct/spacing, no fuzzy typo win; #1497 unification, not a local copy), processed OLDEST-first by X snowflake id (reply_order_key) so the genuinely first correct reply wins regardless of batch order, first correct claims the win atomically via db.x_game_claim_winner so a race yields one winner; the twitterapi.io rule polls ~60s, X_FILTER_RULE_INTERVAL_SECONDS — a game runs for hours so a tight poll just burns read credits) → CLOSE (_finish_round: the single board reply + a music-drop quote of the clip with the answer’s Apple Music link, delete the rule, mark closed — fired on the first correct guess, OR on the no-winner TIMEOUT with a “nobody got it” board. The timeout window is DYNAMIC (_round_window + the pure next_slot_gap_seconds): half the time to the guild’s next calendar slot, clamped to [X_GAME_MIN_ROUND_SECONDS 30m, X_GAME_MAX_ROUND_SECONDS 1h], with X_GAME_ROUND_SECONDS (1h) the flat fallback when there’s no calendar — so a dead slot closes with room to spare before the next round opens, capped at 1h, all env-tunable). ACCOUNT-global: one round open at a time across all guilds (the account is one @tootsiesbar); a guild’s experiment + calendar just decide when to open. Song pool is FULLY UNIFIED with the /guess game (#1497), no divergent copies: the candidate set BLENDS the model pool (claude.generate_song_pool(genre=_genre_for_model("urban"), era="1990s+") — the game’s urban hip-hop/rap/R&B bundle + 90s-onward era, proposing ‘Artist - Title’ lines) with REAL grounding off the SAME source /guess grounds off — the SHARED song_pool.current_genre_hits over the urban bundle’s lanes (hip-hop + R&B), Deezer-led per-genre chart + iTunes RSS hedge — so the set isn’t 100% model memory (which skews to the most OBVIOUS hit) and X + /guess ground IDENTICALLY off one function (not the lighter music.hot_chart facade X used before Stage 4). Each candidate is verified against the catalog via music.search_catalog (iTunes-first, Deezer fallback #371) for a real preview + artwork + a guessable, artist-matching track (_is_guessable_title + _artist_matches), gated by a RESOLVED-year era floor (_year_int coercion, >= _MIN_YEAR; a yearless Deezer row passes fail-open), and deduped by the SHARED song_pool.song_key against the SAME cross-round reuse block (game_recent_songs, 7-day window via db.recent_songs_within_days/record_recent_song) — so a song doesn’t repeat across X rounds AND the Discord game, and generate_song_pool gets the block as exclude= too (model-side avoidance). X rejects bare audio, so the clip is a still-cover-art h264/yuv420p/aac mp4 (tweet_video, uploaded via XPoster.upload_video’s chunked flow). Gated: master kill switch + mood != off + the x_game experiment (default OFF — it posts to a public EXTERNAL timeline, like x_crosspost; PRODUCTION runs the game on X, STAGING auditions the clip + board in #bot-logs with NO post to X, OFF skips). Provisioning: the 4 X_* write vars (bot.xposter) + TWITTERIO_KEY (bot.xrules, the reply webhook). Answer JUDGING is the SHARED /guess matcher (cogs.games.grade_guess, HARD), NOT a local copy (#1497 unification — the old utils.x_game.judge_guess was retired). The pure core (utils/x_game.py: reply_targets_round, reply_order_key, format_board_tweet/format_open_tweet/format_answer_drop, parse_blurb_art, apply_win, next_slot_gap_seconds) + the shared utils/song_pool.py (song_key + current_genre_hits) are unit-tested; leaderboard + rounds in x_game_rounds/x_game_scores (account-global). Fail-open throughout. Emits x_game_round + x_game_answer.x_audience.py, the @tootsiesbar audience telemetry (epic #1793 item 13 + #x-churn): two loops on one cog. (1) The nightly sweep (08:00 UTC + an idempotent boot run) emits x_audience – the follower curve plus the replies-vs-originals engagement split the growth work steers by – and joins each owner-posted reply back to its draft as x_reply_edit. (2) The hourly follower-churn sweep (#x-churn) answers the question the nightly one cannot: who left. x_audience records only the follower TOTAL, so it nets follows against unfollows – a day with 5 follows and 3 unfollows reads +2 and the 3 departures are invisible, which is exactly the signal you need when an automated surface starts costing followers. The churn sweep walks /twitter/user/followers (XProvider.fetch_followers, cursor-paginated, 200 rows a page), diffs it against the x_followers snapshot table, and emits one x_follow_change per departure with the account, its tenure, and a confirmation. Three design points carry the surface. Identity is the X user id, never the handle – a follower who renames keeps the id, so a rename cannot read as one departure plus one arrival. A departure is confirmed before it is called an unfollow (one user/info read, bounded per sweep): an account that deactivated or was suspended leaves the list identically, and only a live profile is feedback about what we post. A truncated follower list is never diffed (utils.x_followers.snapshot_trust) – the walk reports whether it finished, and a short list would name every follower past the cut as an unfollow, which is worse than no data; a refused sweep writes nothing and emits ok=False with the reason. HOURLY is a deliberate choice, not a default: the cadence sets the attribution precision, because a departure lands between two sweeps and an hourly window holds the 1-3 posts that could have caused it where a daily window holds ~20. Losses post to the master guild’s #bot-logs; the durable history is the Axiom event stream, which is the same dataset as tweet_posted, so a departure joins to the posts in its window. Pure diff + trust gate + report formatting in utils/x_followers.py (unit-tested); bot-wide, provisioning-gated on the X source, fully fail-open. Correlation, not proof: X publishes no per-viewer data to any API (twitterapi.io has no likers endpoint – verified 404, 2026-09-14), so the window narrows the candidates and the reply/mention/retweet record is the only per-account evidence that someone actually read a given post. Automatically ADJUSTING to the signal is deliberately not built yet (owner steer: “we can figure out the automatically adjust part later”).Utils (in utils/):
rate_limits.py, per-user daily limits (@Toots mentions, /recap) and server-wide daily limits (/discourse, /order) + cooldowns. Defaults are module constants (DEFAULT_PER_USER_DAILY, DEFAULT_PER_SERVER_DAILY, DEFAULT_ORDER_COOLDOWN_MINUTES); per-guild overrides live in the settings KV table and are edited via /menu’s tune page (resolvers _user_cap / _server_cap / order_cooldown_window read them live).tunables.py, the central registry + live resolvers for every mod-tunable behavior knob beyond the rate trio: the working-hours window (start/end ET), the /order in-flight cap, chime-in POST cadence per mood (daily cap / cooldown), and the live sports commentary heartbeat/clutch intervals (commentary_cadence, stored in minutes, resolved to the seconds the cadence engine uses). TUNABLES is the single source of truth (the tune editor renders the non-hidden knobs); each cog reads its value via a resolver (working_hours, chimein_tuning, max_in_flight_orders, commentary_cadence) that falls back to the code-level default. Two groups are hidden=True — kept in the registry (so the resolver + default still work) but edited elsewhere on /menu beside the config they scope, NOT on the tune page: working-hours on the calendar, the commentary heartbeat/clutch intervals on the live-sports page (_CommentaryPaceButton). (The quiet-room icebreaker’s silence window is not a separate knob: it reuses the mood’s chime-in cooldown.) Engineering internals (buffer sizes, tick intervals, retry backoffs, memory gaps) are deliberately excluded — not mod-facing, and so are the chime-in / reaction score thresholds: they gate on a model-judged 0-1 confidence, so the cutoff is the model’s call (fixed DEFAULT_*_THRESHOLD constants, read directly, never from a setting).permissions.py, is_mod() checks against mod_roles table. Also home of the two “master” constants: MASTER_USER_IDS (the master USER — a permanent, role-independent mod-access floor + who ops alerts @-ping) and MASTER_GUILD_ID (the master GUILD — the owner’s home “control room”, env-set via MASTER_GUILD_ID, None = unset). The master guild is (1) the default X-crosspost home (utils.x_crosspost._ONLY_GUILD falls back to it when X_CROSSPOST_GUILD_ID isn’t set to override) and (2) the sole destination for owner-facing ops alerts — the health cog’s crater/usage warnings + the new guild_join/guild_left pages route to ITS #bot-logs alone (cogs.health._alert_targets) instead of fanning out to every server; with no master set, both fall back to the pre-master behaviour (alerts to every enabled guild, no crosspost lock)sportsdata/competitions.py, the soccer-competition registry (#club-soccer) — the single source of truth for which soccer competitions the bot covers for state + settlement (SOCCER_COMPETITIONS: World Cup, UCL/Europa/Conference, EPL/La Liga/Serie A/Bundesliga/Ligue 1, MLS — each with its API-Sports league id + a two_legged flag). API-Sports /fixtures?live=all / ?date= return EVERY league worldwide in ONE call, so ApiSportsProvider now fetches ALL football once and FILTERS to COVERED_SOCCER_LEAGUE_IDS (_covered_football, fail-open on an untagged fixture), instead of the old hardcode that fetched the World Cup league only (which is why non-WC soccer couldn’t get state or finals-settlement from the primary feed). Adding a competition is one SoccerCompetition row — no per-league fan-out, no provider edit. (SGO already auto-discovers its own club-league codes; club-NAME cross-provider folding landed in names.py — canonical_soccer_club (#1469 Stage 2) — so a club bet reconciles across providers.) It also carries each competition’s SGO leagueID beside its API-Sports id (#2268-followup). A competition is a DIFFERENT string per provider – API-Sports says 39, SGO says EPL – and competition_for_league accepts either, because a caller does not know which provider a game came from and matching one vocabulary would silently mis-classify every game from the other. This is what lets the watched-sports filter be PER COMPETITION: soccer used to be two toggles (World Cup vs a single club_soccer), which was cosmetic while the live commentator was the only consumer, and stopped being cosmetic when the sports boards started pacing per sport – six leagues behind one key also shared one daily allowance, so the Premier League competed with MLS for cards while MLB had four of its own. sports.py:WATCH_OPTIONS is now generated from this registry, so a competition added here appears in the /menu picker for free, and normalize_watched expands the legacy stored club_soccer into every club key so no guild’s saved choice changes meaning.x_followers.py, the pure follower-churn math behind the hourly sweep: the /twitter/user/followers page parser, the snapshot_trust gate that refuses to diff a truncated list, the id-keyed gained/lost diff, tenure, the low-signal (follow-back bot) heuristic, and the #bot-logs report format. No I/O.gates.py, require_configured() guard for pre-/menu statefeeds.py, channel history fetching for context. recent_messages(..., stt=) transcribes room voice notes in place (via voice_ingest) so they read as ordinary text everywhere downstream. resolve_reactors (WHO reacted, behind rich_buffer) is a real Discord REST fan-out — one paginated reaction.users() call per emoji on the slow shared reactions rate bucket — and the old sequential walk ran ~16s median on /ask (p90 10s on the memory writer, p99 60s+ on music) as the single slowest step of those pipelines. It now resolves the top-reacted messages CONCURRENTLY under a hard _REACTOR_DEADLINE_S (5s) wall-clock cap: whatever misses the deadline is dropped and format_for_prompt falls back to the aggregate [N reactions] for those messages, so a slow bucket costs reactor names, never the surface (prefer absent over blocked). The fan-out is concurrency-capped at 5 (matching poll voters 5 / message reactors 6) because every reaction.users() call shares ONE reactions rate bucket — an uncapped 8-message burst mostly bought 429s on our own requests. /ask opts out of the lookup entirely (reactors={}, #2175 follow-up, owner call): Axiom showed the 5s deadline binding on most asks, so the p50 answer paid ~5-10s of its pre-model stage for name garnish on a USER-WAITED surface; reactor names matter for the MEMORY writer (attributed notes), not for answering, so ask keeps only the free aggregate [N reactions] weighting. Chime-in’s quiet tick opts out the same way (#1144); the memory writer, recap, and the scheduled compose surfaces keep the full named resolve under the 5s deadline. Chime-in’s quiet-tick opt-out (reactors={}, the #1144 instance fix) predates this and stays — the quiet-room judge never needed names at all. Its cheap neighbours now overlap it too (#1950 Class B): chime-in’s post path and /recap each had plain DB reads (the per-guild model knob, the who’s-who legend_index) sitting SERIALLY around the rich_buffer render; both now gather with it, so the stage costs the render alone. legend_index(guild_id) needs only the guild — only legend_for(blob) needs the rendered text — which is what makes the overlap legal. Deliberately NOT folded in recap: its Perplexity/Grok queries are built from blob[:300], so overlapping the render with THOSE needs the /ask trick (derive the query from raw messages, not the rendered buffer). Feed-channel source gathering (#1950): render_channel_source (ONE channel -> its --- <descriptor> (<label>) --- block + the messages behind it) and gather_feed_sources (every configured feed channel, CONCURRENTLY, in channel_ids order) are the single definition of how a compose surface builds its source blob. discourse and music had byte-identical serial loops — resolve -> history walk -> rich_buffer per channel, up to six deep, each one’s 5s reactor deadline COMPOUNDING instead of overlapping — and now both call these and overlap the local-room pull beside them (ordering is contractual: feed blocks in configured order, local last, because the blocks concatenate into a prompt). _FEED_SOURCES_DEADLINE_S (15s) is a hang BACKSTOP, not a routine bound — a slow healthy channel runs ~8s, and a channel that misses is simply absent from the blob rather than thinning every post with a tight cap.voice_ingest.py, ambient STT: annotate_transcripts(messages, stt) writes a voice note’s transcript onto msg.content so every context reader (format_for_prompt, the memory writer, chime-in scorer, …) treats it exactly like a typed message, the only difference is the words came from audio. Transcribe-once via the shared two-tier DurableCache (namespace voice_stt, keyed by attachment id, failures cached so duds aren’t retried; durable L2 under a 7-day TTL so a recent voice note re-read after a redeploy isn’t re-transcribed, wired at boot via set_durable_db). No-ops without a provisioned ElevenLabs key. Wired into every recent_messages caller (ask/recap/discourse/music/order + the hourly memory writer & /remember backfill) and the chime-in tick buffer. Gating is provisioning only (the key) — voice notes are public channel content she already would have read if typed, so no separate experiment.video_fetch.py + video_ingest.py, video “listening” (she reads what a shared YouTube clip / video IS and what was said in it, not just that one was posted). The sibling of voice_ingest, but ambient AND async: a video is minutes-to-hours, so transcribing on the read path would stall every recap/tick. feeds.annotate_video(messages, stt) (a binder over video_ingest.annotate_video_transcripts, supplying feeds.video_sources) never awaits a fetch — for each fresh video it spawns a background task and returns at once; the resolved VideoResult lands in a process-wide cache and is surfaced inline by extract_media/format_for_prompt (via feeds._video_label: a metadata head — "title" · channel · 12min · 2.1M views — plus the summary or raw transcript, capped 1500 chars) on the next read (so a clip is bare the pass it’s first seen, rich after). video_fetch.fetch_video returns a VideoResult (metadata + transcript + source) by the cheapest route: captions first (pull the caption track via yt-dlp metadata, no ffmpeg/STT spend — flatten_json3/flatten_vtt; any language, falling back past English so a foreign clip is still transcribed), audio + Scribe STT fallback (yt-dlp + ffmpeg download, gated on an STT key and a DEFAULT_MAX_DURATION_SECS 30-min cap so a long stream can’t blow cost or the Scribe 25 MB ceiling). Metadata (meta_from_info: title/uploader/duration/views/upload_date/chapters) comes free from the same pull, so even an un-transcribable or silent clip gets a rich label. Long or non-English transcripts are distilled by video_ingest’s configured summarizer (a cheap Haiku claude_client.summarize_video, wired at boot via video_ingest.set_summarizer, chapters fed in, translated to English) into a compact summary that’s preferred over the raw transcript in context; short English ones go in raw. Transcribe-once: keyed by canonical_key (YouTube URL forms collapse to yt:<id>), word-less results still cache (they carry metadata + mark the key done so duds aren’t retried), bounded in-flight set + concurrency cap. The cache is two-tier (utils.durable_cache.DurableCache): a process-local L1 (read synchronously on the hot prompt-render path) over a durable, TTL’d kv_cache DB row (L2, namespace video, 30-day success / 6-hour failure TTL) so a once-resolved clip survives the every-merge redeploys instead of being re-fetched (re-paying yt-dlp/STT/Haiku); the durable tier is read-through on the async fetch path (_run_fetch restores from L2 before any live fetch) and wired at boot via set_durable_db (absent in tests → L1-only). Wired alongside annotate_transcripts (every recent_messages caller, the chime-in buffer, the memory writer/backfill). Gating: video is a permanent surface (no per-guild experiment), controlled by provisioning (yt-dlp installed + ffmpeg in the image) + the VIDEO_TRANSCRIPTION kill switch (default on, hard-disables everywhere). When transcription is enabled it both transcribes AND injects — feeds.annotate_video reads should_fetch() and extract_media reads should_inject(), both now collapsed to just the kill switch (video_ingest._enabled()). (It went through a staged per-guild rollout while it was a trial — production injected, staging transcribed-but-held, off skipped — then graduated; the gate machinery was removed.) X/Twitter bypasses yt-dlp onto the fxtwitter (FixTweet) API (utils.x_fetch, #491): yt-dlp’s Twitter extractor 100%-fails on a “Bad guest token” upstream bug (#462), so fetch_video routes is_x_url URLs to _fetch_x, which hits the public FixTweet JSON API (the service behind the fxtwitter/vxtwitter/fixupx links people already paste) — no auth, no guest token. Two fail-open layers: the tweet text is the floor (source=x_text, lands even with no video, where X was previously fully dark) and a direct mp4 (when present, within the duration cap) is downloaded via plain aiohttp + Scribe-STT’d (source=audio), no yt-dlp/ffmpeg. canonical_key collapses every X form (host/screen-name) to x:<tweet_id> so one tweet dedups to one fetch (mirrors yt:<id>). The open item is whether the video.twimg.com CDN mp4 is reachable from Railway’s datacenter IP — watch the video_transcribe source=audio vs x_text split; a CDN block degrades to text, not back to dark. Emits x_fetch (the API call). TikTok + Instagram bypass yt-dlp onto ScrapeCreators (#1272): yt-dlp resolves those two hosts poorly from a datacenter IP, so fetch_video routes is_tiktok_url/is_instagram_url URLs to _fetch_scrapecreators FIRST when set_scrapecreators is wired (boot) — TikTok via /v2/tiktok/video?get_transcript=true (a WEBVTT transcript flattened by the reused flatten_vtt + rich aweme_detail metadata → sc_tiktok_meta), Instagram via /v2/instagram/media/transcript (plain AI text). A transcript-less miss returns None and falls through to the existing yt-dlp path (no regression when SC is down/unprovisioned); emits video_transcribe source=scrapecreators only on a hit. Spaces are out of scope (different live-audio endpoint). Pure helpers (youtube_id/canonical_key/flatten_*/pick_caption_track/meta_from_info/x_fetch.x_tweet_id/is_tiktok_url/is_instagram_url/sc_tiktok_meta/sc_flatten_transcript) unit-tested; yt-dlp/network is integration-only. The module has a SECOND job, on the OUTBOUND side: it hands a wire clip’s raw mp4 bytes to the X crosspost (fetch_clip_bytes_any → x_crosspost → XPoster.upload_video, #video-upload), so a desk’s take re-broadcasts the clip natively instead of linking the wire account — X via fxtwitter, TikTok + Instagram via ScrapeCreators, every other host returning None so the caller links it. An over-long clip is CUT, not dropped (#video-trim): the @tootsiesbar tier refuses a native video past two minutes (exceeds_x_video_limit / X_MAX_VIDEO_SECONDS), and the /video/1 deep-link fallback embeds only sometimes, so utils.video_trim.trim_clip stream-copies the first x_trim_target_secs() seconds (the ceiling less a frame-boundary margin) into an X-ready mp4. The cut END snaps to a shot boundary (#2215): a fixed cut ends mid-shot, so a second short ffmpeg pass (select='gt(scene,N)' over the X_TRIM_SNAP_WINDOW_SECONDS before the cut) reports the shot changes and the latest one at or before the ceiling wins — measured on the reported clip, 118s → 115.5s for a ~0.4s pass. This is deliberately not a vision pass; stills carry neither motion nor audio, the two things that mark where a moment ends. The cut is fail-open — a miss returns None and the caller takes the same link fallback it always did — and the SNAP is fail-open inside that, keeping the fixed cut when detection finds nothing or breaks. It emits clip_trim, whose ok rate is the only sign the cut has stopped working and whose method shows where the cut landed.lru.py + durable_cache.py, the shared caching primitives. LRUCache is a tiny bounded most-recently-used cache (hit/miss-aware get, used by voice_ingest + as the L1 of DurableCache). DurableCache is a two-tier cache: a process-local LRUCache (L1, sync, zero-RTT) over a durable, per-row-TTL’d kv_cache Postgres table (L2, async), partitioned by namespace, generic over the cached type via encode/decode (typed L1, string L2 — JSON for structured values). Read-through (get: L1→L2, populating L1 on an L2 hit) + write-through (put: both, with the L2 write best-effort so a DB blip never breaks the feature); peek/set_l1 are the sync L1-only ops for hot read paths. db=None degrades to L1-only (tests / unprovisioned). Expiry is lazy on read (db.cache_get filters expires_at > NOW()) plus the daily db.prune_kv_cache sweep on the _pruner loop. This is the durable backing the video transcript + voice STT + link-enrichment caches use. In markets the split is by data KIND: live-odds/price caches stay in-memory (freshness-critical, durability would serve stale data; utils/async_cache.py remains for those), and the SGO sports/score fetch (SportsGameOddsClient.get_event_odds/get_player_props) is fresh-first with a bounded stale-on-error fallback: every call always attempts a live fetch (a successful read is never stale), and only when the fetch FAILS (a 429 — the amateur tier’s per-minute + monthly quotas are tight) does it serve the last good result, and only if younger than _SGO_STALE_FALLBACK_SECS (180s — extended from 60s in #725 so a stale-served slate/score survives a throttle that outlasts one per-minute window AND covers the SGO circuit breaker’s open→probe cooldown; failure-path only, so best-case freshness is untouched); a genuine empty result (no live games) is never masked by stale, only an outright failure is. The in-season league set for the live-scoreboard fan-out is also durably cached (namespace active_leagues, ~12h TTL, warm-started — bumped from 6h in #831 since the in-season set only changes on a weekly scale, halving the 14-entity probe with no freshness impact): SGO has no “season active” flag (its /leagues//sports are static catalogs), so MarketsManager.resolve_active_leagues derives it — a cheap next_event_start probe per candidate (limit=1 + a startsAfter/startsBefore window = ONE entity each, and the window excludes a future-season schedule like NFL games months out), refreshed lazily under a lock so concurrent scoreboard calls don’t all probe. The probe returns the SOONEST game’s START epoch (not just a bool — has_upcoming_events is now a thin is not None wrapper), captured per league into _league_next_start for the negative cache below. get_live_scoreboard fans out only over the active subset of the candidate set instead of all of it; a cold cache / failed probe / empty result falls back to the full candidate set, so the filter only ever trims, never blanks. Live-scoreboard NEGATIVE CACHE (#sgo-burn / #2398): the 3-day active window kept a league “active” all day, so the fan-out re-polled a league’s live board every tick even when its next game was hours off — that empty polling was the bulk of the commentator’s SGO burn (measured: ~2,400 SGO calls/24h, all leagues returning 0 live games in-window). _fetch_live_scoreboard now SKIPS a league AFTER it comes back empty, until ~_LIVE_POLL_LEAD_SECS (15min) before its next scheduled game (from _league_next_start), capped at _EMPTY_SKIP_MAX_SECS (20min); an unknown/already-past start uses a _EMPTY_SKIP_DEFAULT_SECS (5min) skip. SAFETY: the skip is set only after the LIVE scoreboard itself returned empty, so a league with a live game (non-empty) is never skipped; an IMMINENT game (start within the lead) is never skipped (polled every tick to catch kickoff); and the 20min cap bounds any mispredicted/newly-scheduled game to ≤20min late. Freshness of a live league is untouched (it re-polls every tick). Fail-open (empty maps → poll everything, as before). SGO 403s from a datacenter IP, so this shipped without a live dry-run — verify on deploy via the market_fetch source=sgo query=scoreboard call-rate drop (#2398). The fan-out is also narrowed to WATCHED sports (#831, the dominant SGO entity saving): get_live_scoreboard(sports=) / live_games(sports=) take an optional set of internal sport labels, and the SGO provider trims the in-season leagues to only those sports, so the commentator (which passes its watched-sports union across guilds — None if any guild watches all) never spends an entity on a league no configured guild displays. Before this it fetched every in-season league every 60s, then discarded the unwatched ones per-guild AFTER paying for them. Threaded through hub → providers (only SGO honors it; API-Sports/Odds aren’t entity-capped); the bookie + ask call unfiltered (sports=None, all in-season) so their coverage + odds-freshness are untouched. Fail-open throughout (unmappable sport / catalog miss / empty intersection keeps the full set) and freshness-neutral (it drops whole unwatched leagues, never serves a stale score/odd). Shipped without a live dry-run (SGO entity cap was exhausted); verify on reset via the per-league market_fetch source=sgo events (#833). The per-tick narrowing itself reads a durably-cached leagueID→sportID map (MarketsManager._resolve_league_sports over namespace sgo_league_sports, ~24h, warm-started — the SAME /v2/leagues catalog the candidate set uses, SportsGameOddsClient.list_league_sports, so list_team_leagues is now its keys), rather than a LIVE /leagues read every tick: that per-tick catalog fetch (~1,440 SGO reads/day, mislabeled “free” in _scoreboard_leagues) was the single biggest STEADY entity drain against the monthly cap (#SGO-eff, the 63k-of-100k-in-5-days incident), running 24/7 regardless of whether anything was live. Fail-open — an unavailable map declines to narrow (returns the full in-season set) rather than blank the slate. The candidate set itself is resolved dynamically from SGO’s /v2/leagues catalog (resolve_candidate_leagues → all enabled leagues whose sport is in _PARSER_SPORTS, the sports _sgo_event_to_snapshot can render as a two-sided matchup — the team sports PLUS head-to-head individual sports (MMA/UFC, tennis, which come back in the same teams.home/teams.away + moneyline shape, just no point score); only FIELD/outright sports (golf, horse racing) are excluded as unrenderable; durably cached namespace sgo_candidate_leagues, ~24h, warm-started), so a league SGO adds is picked up with no code change. The hardcoded COVERED_LEAGUES is the offline fallback (catalog fetch failed / cold / no key / tests) and the base for the SGO_COVERED_LEAGUES env override (which pins the set and skips dynamic discovery). Concurrent scoreboard calls are also single-flighted (an in-flight asyncio.Task + lock): a burst of mentions shares ONE fan-out instead of each launching its own (the #289 stampede — a result cache can’t help since all N start before the first returns), and once done the next caller refetches fresh. Each scoreboard fetch uses SGO’s server-side live=true filter (get_event_odds(live_only=True)) so it bills only the in-progress games per league (often 0-3) instead of fetching up to limit upcoming events and discarding the non-live ones; the named-league odds fetch (_sports_snapshots) stays unfiltered (it wants upcoming odds too) but trimmed to limit=5. The live scores themselves stay fresh/uncached-on-success. But the Kalshi series index — the discovery catalog (which markets exist), not prices — IS durably warm-started (namespace kalshi_series, ~6h TTL): it’s expensive to rebuild (no Kalshi search API) and was rebuilt from scratch on every boot, leaving discovery dead until the first post-deploy refresh; now a fresh process warms instantly from L2 while the hourly live refresh catches up. Its DB ops flow through db._run, so the per-query p99 instrumentation (db_query events) times them like any other query. Note: this deliberately persists derived public content (transcripts/summaries) to the DB, a conscious reversal of the prior “nothing durable” stance, justified by it being public video content under a bounded TTL.billboard.py, the first-party Billboard chart read (epic #1981) — the Hot 100 / Billboard 200 / Global 200 / Artist 100 / Streaming / Radio / Album Sales / Digital / Country Airplay / Rap family, one registry row per chart and one parser for all of them (they share the row markup). This is the chart the bot never had: kworb.py is Spotify + Apple proxies (kworb publishes no Billboard mirror at all — kworb.net/charts/billboard/ 404s), luminate.py is the pre-filter consumption input Billboard’s published number is filtered DOWN from, and chart_data.py/wiki_hits.py are historical. So the newsroom’s chart-claim gate was checking @billboardcharts claims against Spotify rows — 25% of those lookups came back unresolved. Sources, cadence and the two governance calls: docs/BILLBOARD_SOURCES.md. artist_entries(artist, chart_key) reads the per-artist CHART-HISTORY page (/artist/<slug>/chart-history/<code>/, billboard200→tlp / hot100→hsi, #2154 follow-up) — the CAREER-ENTRY count a weekly chart page cannot carry, server-rendered like the weekly charts, so counting the rows is the entry ordinal (EXACT, no judge). It fails-open and QUIET (its own _history_cache, ~6h; a guessed-slug 404 is an ordinary miss, never a block_state() touch), because a miss only suppresses one optional “their Nth entry” clause. The newsroom’s compose (cogs/music_news.py) turns the count into a grounded fact (music_news.entry_ordinal_fact) so a debut take states “JUNGLE’s fourth Billboard 200 entry” instead of the guessed “first” it shipped 2026-08-25, and the fact grounds the career_ordinal gate so a correct ordinal ships instead of dropping. The wrong-artist guard (the reported title MUST be among the entries) is what makes a guessed slug safe — a wrong page yields no ordinal, never a wrong one. #2587 adds the PEAK column to each row ((title, debut_date, peak)) and a second, STRONGER fact, music_news.peak_history_fact: “their first #1” (current position 1 and no prior entry peaked #1) or “their highest-charting entry yet” (current position beats every prior entry’s peak). It is keyed on the CURRENT week’s position — so the claim is fresh news, never an all-time peak reached months ago — and can fire on a CLIMB, not only a debut; the compose picks the peak fact over the entry ordinal (one career clause ships, strongest wins). Same guards: wrong-artist, a missing peak or a same-titled re-release suppresses the high-stakes claim, fail-open. The music_news_entry_ordinal event’s detail.claim is peak, weeks or entry. Dry run (real page + real compose, both shipped models, n=5 each): 20/20 stated the fact cleanly, no fabrication. The peak fact has a FRESHNESS guard and a second rung (owner steer 2026-09-08). Keyed on the current position alone, “her first #1” fired every week a #1 HELD: “Choosin’ Texas” shipped as “her first #1” off a Hot 100 row reading last week #1, peak #1, 45 weeks on, and the owner pulled the post. So the weekly row’s own last_week and weeks_on now ride the settled reading (ChartRow -> resolve_chart_position -> ChartPosition.last_week / .weeks_on; None on a platform chart) and peak_history_fact fires only the week the position is REACHED (last_week above the position, or a debut); a hold, a fall, or a re-entry (no last week, 2+ weeks on) returns None. The held case gets music_news.chart_run_fact instead — “Choosin’ Texas is in its 45th week on the Billboard Hot 100”, straight off the row’s weeks column, written as a DIGIT ordinal so the digit gate grounds the take’s “45th week” / “45 weeks”. The daily-chart twin is the days-at-#1 STREAK (owner example, Pop Crave 2026-09-08: “holds at #1 on Global Spotify with 6.024 million streams. It has now spent 12 days at #1.”): kworb’s daily pages carry a (x?) column, days at the peak position, which kworb.parse_daily_chart now reads into ChartRow.peak_days; with pos == peak == 1 it is the streak, and music_news.chart_streak_fact states it as a TOTAL (“BbY WOW has 11 days at #1 on the global Spotify daily chart in total”) because kworb counts days at the peak, not a run — worded “has now spent 11 days at #1” the compose wrote “11th straight day” 2/4 (dry run 2026-09-08), and “in total” took that to 0/10. The day’s plays ride the SETTLED READING, not the clause: settled_claim for a daily row reads “#1 on the global Spotify daily chart with 5.4M streams on the day” (numfmt.abbrev, so the digit gate grounds the take’s “5.4M” / “5.4 million”), because a trailing clause is the part the compose drops — told to state the position and stop, it kept the streak and dropped the streams 5/5. The chart lane’s hint in compose_music_record was the other half: “NEVER add a stream count, sales figure, or units number” (#1705, against a FABRICATED magnitude) was obeyed against a real one too, so it now reads “a stream count … belongs in the post ONLY when THE NUMBER/RECORD line itself states one, quoted exactly” — streams present 5/5 after, 0/5 before, and the weekly no-magnitude case (Choosin’ Texas on the Hot 100) still states no figure under the reword. The digit gate stays the deterministic backstop for an invented one. The scar check on the full wired path (owner ask 2026-09-08, “dry run and check for scars”) found the bigger cause one layer up: the Perplexity BACKGROUND block. Its header (format_perplexity_for_prompt, written for the search surface) says its figures are the ground truth and “win”, so on a chart story it competes with the LIVE CHART block that settled the claim, and it carries the WIRE’s own figures. With the REAL research for Choosin’ Texas (“21 weeks at No. 1 … within one week of the all-time record”) 5/5 takes led with that over the settled “#1 … 45th week” and the self-gate dropped all 5; the pre-change path (no run clause) did the same, 5/5 dropped, so this was a standing cost, and the measured chart-lane drops carry the shape (“post states #9, ground truth is #12”). It also re-opened the stale “first #1” through a second door: the research’s “is Langley’s first Hot 100 No. 1” grounded “her first career chart-topper” and it shipped at 0.92. The fix is the #2774 rule applied to the research: once a live chart reading settles the claim, the BACKGROUND block leaves the compose context AND the gate source (bg_research in _compose_one); the verify judge still reads it. Ablation (real model, real rows, n=5 per arm): Hot 100 held, research in: 0/5 ship; research out: 5/5 (all “45th week”). Spotify held, real research in (“5.4M is not supported … 3.3-3.4M”): 4/5; out: 5/5. A conditional preamble that kept the research but told the compose to take no clause from it was tried first and made it WORSE (5/5 EMPTY): the model would not reconcile “22nd week at No. 1” with “45th week on the chart”, so the input had to go, not be argued with. The chart lane hint’s clause rule was the other contradiction: it listed “its run” among the things to leave out while the CHART HISTORY block handed it the run; it now names “THE NUMBER/RECORD line or a CHART HISTORY line” as the two sources of the one trailing clause (5/5 on both cases after). The held daily #1 was also never COMPOSED: the chart-repost gate (should_repost_chart_position, the high-water store music_news_chart_hw) suppresses a held position by design (“hate that i made you love me” at #1 eleven times). The one bounded exception now: a held #1 re-reports when its streak has CROSSED a milestone bucket of MUSIC_NEWS_STREAK_MILESTONE_DAYS since the last report (env, default 7 = a weekly “14 days at #1” beat; 1 = Pop Crave’s daily cadence; 0 = off) – buckets, not an exact multiple, so a boundary crossed on a day no wire was processed still reports once (Codex review). The mark stores the streak it shipped with (format_chart_mark, “1|14”) so a second wire the same day does not re-ship it, and it stores the streak ONLY when the delivered line stated it: a streak-triggered repost whose take omitted the clause is dropped (music_news_filtered reason=streak_unstated, marked seen) rather than shipped bare, so it neither repeats the held #1 nor consumes the milestone. The selected chart-history fact also rides the self-score’s source beside the record line, so the judge does not hard-fail a correct “45th week” as a figure absent from its ground truth. Priority in the cog: fresh peak > streak (daily) > run (weekly) > entry ordinal (a debut’s run is one week, below the run floor; a daily row has no weeks column and a weekly row no streak column, so none of these collide). Without row stats the position-only rule stands, as before.
One fetch is the whole alert lane. Every row carries rank/last_week/peak/weeks_on, so debuts, re-entries, jumps, falls and new peaks are pure DERIVATIONS (movements()), no stored history; past weeks are addressable by date (/charts/hot-100/2026-07-25/), so a week-over-week diff (diff_weeks) needs no state either. Movement.notability collapses rank × kind × move-size into ONE number so an alert lane thresholds on a single value — the live dry run showed the raw thresholds yield ~45 moves on a normal week (a weekly report), thinning to ~6 at a floor of 5.
The parse is LABEL-driven, never positional — it finds the LW/PEAK/WEEKS captions and reads what follows, so a reordered column can’t silently shift peak into weeks. Badges are only considered on rows whose LW is -, since keying on the badge alone made a song titled “New” parse as a debut and eat its own title.
Loud about being blocked (owner steer). billboard.com’s robots.txt names anthropic-ai, so being cut off is an EXPECTED end-state, and both ways it happens are silent by default: a 403/451, and a 200 whose markup stopped parsing. A THIRD silent shape is a page read MID-PUBLISH (partial_credits, 2026-09-09): Billboard flips a page in two steps — the rows land, and the artist links on the NEW entries are attached minutes later — and the one-minute flip poll is built to read inside that gap. The first read of the Sep 12 Hot 100 carried 15 of 16 debuts with an empty artist, was cached for 12h, and the standings lane posted “Take Care leads 16 new entries … from 2 different acts” (no act on the lead row; the empty string counted as an act). chart() now refuses a song/album page with ANY credit-less row exactly as it refuses a shape change (nothing served, nothing cached, the next poll re-reads, the block clears itself on the full page), and a CACHED week with a credit-less row is treated as a miss and re-read too – the cache is durable across redeploys, so the bad row would otherwise have been served for the rest of its 12h TTL after the fix deployed (Codex review, #3116); artist-unit charts are exempt because their artist cell is empty by design (measured: 5/100 on the Artist 100, 41/50 on Emerging Artists, 0 across ~1,500 song/album rows). All three emit billboard_fetch with an explicit reason plus an emit_error(recoverable=False) (first of a streak, then every 10th) plus a sticky block_state() — and the streak is counted PER CHART (BlockState.streaks, #2824): one shared counter reset on any chart’s success, so on the 2026-09-01 refresh global200’s 40-minute run of empty pages read as six one-off blips while hot100 kept working, and the error’s consecutive= lied. The ops monitor splits health BY SOURCE (a healthy mirror must never mask a dead primary) and raises its own source_blocked finding naming the required action — high for a refusal or a chart dark for ≥3 consecutive fetches / still dark at the window’s end, medium for an empty page that recovered sooner, which is Billboard republishing (the parser reads today’s page 200/200), not a redesign. It gets its own event kind rather than kworb’s chart_fetch so a Billboard block can’t corrupt kworb’s signal (the curator_frame_fetch precedent). No stand-in source (owner call 2026-08-04): the GitHub-mirror fallback was removed — itself a once-daily crawler of the same site, it added a hop and flip-day staleness, never data; a blocked or unparseable read yields nothing, loudly (source_blocked), and the caveat machinery (stale) stays for any future degraded source. Rides the MUSIC DESK as its FIRST-PRIORITY lane (#1981 owner steer: “this should ride music desk, and have its dedicated prioritized lane” – the same call as the streaming-milestone lane #1495, so it is a lane in cogs/music_desk.py, not a cog). Priority is a DECLARED rank, not append order: _LANE_PRIORITY (chart > called_shot > reveal > milestone > tracking > projection) sorts the assembled units before the base delivers them, so a future lane (radio) cannot outrank the chart by being typed higher in _sched_compose_units; an unregistered story tag sorts last rather than raising. A real chart move therefore leads the slot while a quiet week returns [] and the desk is unchanged. The per-slot cap counts units that SHIP, not attempts (bounded by _MAX_CHART_ATTEMPTS_PER_SLOT), so a move stuck under the self-gate can’t starve the moves ranked behind it – and an unshipped move is never dedup-stamped (_record_ship runs only on a ship), so it carries to the next slot automatically. No new scheduler/kill-switch/dedup table – it reuses music_desk_events under a bb:<chart>:<week>:<title> key (and every lane’s avoid-set now comes through _recent_desk_keys, which folds in the COMPOSE-FAILURE COOLDOWN (2026-09-06): a pick whose compose failed a guard or the self-gate is stamped fail:<dedup key> by _note_compose_failure – in _compose_board_unit, _milestone_candidate and _compose_record_unit – and skipped for MUSIC_DESK_FAIL_COOLDOWN_HOURS (12). Measured over 7 days before it: one radio board composed and dropped 165 times, one record card 61, one chart-peak card 73 – an identical failure every slot, paid every slot, and burning the attempt budget ahead of the picks behind it. A transient API error is never stamped; a shipped unit is never stamped; the stamp is a prefix in the same table, so no real key can collide with it), and runs the SAME compose_market_drop + music_desk_score 0.6 floor as every other desk story (priority, not a lower bar; live dry run scored a #1 debut 0.95 and a new peak 0.92). Ships the unified big-number card with the RANK as the figure. chart_context() also feeds the subject’s live standing into the desk’s MARKET compose – the join the prediction reads never had, since a chart market settles on the position while the context only ever carried the Luminate units underneath it. Being first is a CACHE property, not a scheduler one: expected_chart_date knows which Saturday-dated chart should be current (verified against the observed flip), and superseded is the ONE predicate both cache paths share – the READ path serves a cached chart only while it IS the current week (once a flip is due it goes and looks), and the WRITE path holds the current week for hours but re-checks a behind-the-due-week chart every ~20min. The first slot after a flip already has the new week, with no tighter posting cadence and no extra reads the rest of the week. The read-time check is load-bearing, not belt-and-braces: DurableCache’s L1 tier is a plain LRU with NO expiry (utils/lru.py keeps no timestamps; get() returns an L1 hit without checking a deadline), so a TTL only shortens the DURABLE row while the in-process copy is served until the bot restarts. As first shipped, the two-speed TTL was therefore decorative – the first read of the day pinned last week’s chart in memory and the flip would never have been seen (caught + fixed the morning after, regression: test_a_superseded_chart_is_refetched_not_served_from_cache). Keying on the chart DATE also asks the stronger question: is what we hold the current week, rather than how long ago we asked. The same L1 hole affects EVERY other DurableCache consumer (songstats’ 12h stream counts, markets’ league lists, reference’s 24h) – tracked separately, because fixing the shared cache changes upstream call volume on metered APIs and is the owner’s call. The model-facing framing is PURE and shared (compose_inputs / card_fields / describe_move), so the prompt text is reviewable in one place per docs/PROMPT_OPTIMIZATION.md and the dry run exercises the exact strings the surface ships; its fence is dry-run-earned – given a #1 debut the model wrote “Gracie Abrams’ first chart-topper”, plausible, ungrounded, and NOT a number, so the number-only rule missed it. A chart row carries position/movement/peak/weeks and nothing else, so any career or record claim is invented by construction; the framing now forbids them (0/6 samples after, vs a hit on the first sample before). Plan + lane taxonomy: docs/BILLBOARD_ALERTS.md. Politeness: honest UA, tiny rate limit, two-speed durable cache on a weekly resource. Dry run: python -m scripts.dryrun_billboard (--compose runs the real lane compose + gate). VERIFIED RANKING QUALIFIER — the same claim class, GROUNDED instead of forbidden (owner ask 2026-08-24, utils/milestone_qualifier.py + _qualifier_block/_drop_for_ungrounded_ranking): the framing above FORBIDS a ranking claim (“first chart-topper”, “highest debut”) because a chart row cannot ground it. But the RANK a milestone gives an artist is the engagement driver — “the first female rapper to hit the mark this year”, “their tenth release past 100M”, “the longest run on the chart”. So this lane LOOKS one up and PROVES it, then hands the caption a grounded fact instead of a ban. Two qualifier sources, tried in order. (1) The DETERMINISTIC catalog count, preferred for a per-track streaming milestone: nth_release_qualifier COUNTS the artist’s tracked songs at or above the rung off the kworb per-artist songs page (kworb.songs_for_artist → resolve_artist_id off the all-artists leaderboard, then artist_songs), so “Chanel is Frank Ocean’s 9th song to pass 1 billion Spotify streams” is exact and free — no web read, no judge (owner example 2026-08-24: “this could’ve said his 9th song to do so”). The milestone song is guaranteed counted even if kworb lags a hair under the rung. (2) The web DOUBLE check, the fallback for the cross-artist superlatives the count cannot give (“first female rapper”, “highest debut”): a Perplexity lookup of the web AND a Grok LOOKUP of X (grok_search.qualifier_lookup_pulse, owner ask 2026-09-10 – chart accounts post a ranking within hours and the web carries it a day or more later; measured two hours after Pop Crave’s Doja Cat post, the web read returned an unrelated “#41 among artists” and the X read returned the exact claim; it replaced the Grok claim_verify_pulse corroboration read, which only re-asked X about the web’s angle), a second Perplexity read only when the web alone found a claim, then the claude.qualifier_verify judge, which is biased toward ADDING a real reported rank — one credible source is enough — and drops only a CONTRADICTED or fabricated claim (owner steer 2026-08-25: the second read SEARCHES for a qualifier to add, it is not a two-source drop-gate; a milestone the reads cannot confirm ships bare). The verified text also GROUNDS the output guard output_checks.ungrounded_ranking_claims, the NON-possessive sibling of ungrounded_career_ordinals (which reads only “her fourth #1”): it catches “first … to reach” and “highest debut” / “longest on the chart”, and drops a caption stating a ranking claim the source does not carry. The NEGATIVE case (nothing verified) leaves the compose UNCHANGED — no “make no ranking claim” prompt (owner steer “be careful with negative case”, 2026-08-25): a dry run showed the base compose fabricates 0/5, and the note both PRIMED the ranking words and suppressed the artist’s OWN grounded standing rank; the guard is the deterministic backstop. The guard also does NOT police that standing rank (“the world’s 71st most-streamed artist”), whose number the number backstop grounds — “most-streamed” is deliberately not a ranking context (only a scope-qualified “most-streamed of 2026 / ever / all-time” is). Wired into BOTH the streaming-milestone lane (_milestone_candidate) and the reveal/annual/sales lane (_compose_line, only on a MEASURED count or a SETTLED reveal — a real achieved number, never a projection, so an achieved qualifier is never pinned to an “on pace for ~X” forecast), so streams AND sales captions — from kworb or the markets — carry the same checked rank. The output guard grounds a ranking claim on the VERIFIED QUALIFIER TEXT ONLY (not the number blob or the unverified research), so a caption may state the double-verified rank and no other first / highest / record claim. Gated on the milestone_qualifiers experiment (default PRODUCTION, live — it shipped dark, was validated on real data, then flipped): OFF changes nothing, STAGING looks up + emits the qualifier event WITHOUT attaching or enforcing (a real audition, no public claim, no dropped post), PRODUCTION attaches + enforces. Every outcome emits qualifier so a silent “nothing ever verifies” is visible.chart_standings.py, the ranked BOARDS off the chart pages the desk already holds (#2031), and chart_cards.py, the renderer that draws them (render_board_portrait → PNG bytes). The card, after the 2026-08-09 pass (owner, on a shipped projections board: “make this bigger and less boring”): a portrait panel whose height follows the row count (_P_HEADER_H + rows + _P_FOOTER_H, floor _P_MIN_H 700, cap 1200), the branded floor, the red-to-green hairline, the tootsies music wordmark, the _equal_tag chart tag, the hero line, the rows, and the _draw_number_stamp source pill. Four things changed in that pass and each answers one half of the steer. Bigger: the hero, the wordmark and the row type all went up, and the row type now AUTO-FITS the board (_fit_row_size picks the largest size from _P_TITLE_SIZES that cuts no more text than the smallest size would) — so a ranked top-ten board lands at 38px where a market board with a units column settles lower, from ONE renderer. The TITLE also claims the row before the credit does (_label_room / _P_CREDIT_MAX), which is what stopped a long artist name from cutting the song short. Less boring: CardRow.highlight is DRAWN at last (a tinted lane plus a solid accent edge on the one row the board is about — every builder already marked it, and nothing rendered it between 2026-08-07 and 2026-08-09), and _art_wash gives each card the COLOUR of its own subject — the cover scaled to 12px, blurred and darkened under the floor, so a Hot 100 board and a Latin albums board are no longer the same green picture with different words. The corner-art bleed came down to _CORNER_ART_MAX_ALPHA 0.22 in the same pass, because the wash now does the job of showing the subject and two treatments of one picture fought each other under the rows. Contrast was re-measured, not assumed: with the wash the worst case is a pure WHITE cover, and the accent value column holds 5.13:1 there (WCAG AA floor 4.5:1) — the numbers live in the ACCENTS comment. HOW A CARD PICKS ITS COLOURS is written out in full in that same ACCENTS comment (owner, 2026-08-09: “i dont understand how we choose colors now”) — four rules, and every colour on a card comes from exactly one: FIXED brand colours (the wordmark red, the lane grey, the title white, the hairline), the PER-CHART accent from the ACCENTS table (tag + rank column + value column + highlight lane, picked by hand per chart), the PER-CARD computed backdrop (_art_wash off the subject’s cover, or BOARD_BLOOM green with no cover), and the PER-OUTCOME ranking on the market cards only (market_chart._outcome_colors: favourite green, longshot red, cycled middles). The gap that makes it look arbitrary is the table’s FALLBACK (#2189): a chart key with no entry takes _DEFAULT_ACCENT, which IS the Hot 100’s green, so global200, sp_us, sp_global, ap_songs and ap_albums — five live board keys — all wear it. The wordmark itself was LIFTED in the same pass (owner: “can we make the tootsies brighter on all charts”): brand red 4.76:1 → 6.28:1 and the lane grey 5.60:1 → 8.8:1 against the dark field, which lifts every card, not only the boards. On a LIGHT card the lift REVERSES — the brand red only reaches 2.4:1 on the light panel, under the 3:1 floor large text gets — so the light card carries its own darker _LIGHT_WORDMARK at 4.95:1, guarded by a test. Owner steer 2026-08-05: “our individual cards and reports are good but people love ranks, and overall facts and insights not just chart movements” — the reference being a chart account’s “Drake has NINE albums out-charting ‘GNX’ on this week’s Billboard 200”, a ranked list plus one derived fact. Every lane before this one tells ONE entry’s story; none can read the chart as a whole. chart_standings is the PURE derivation half (chart weeks in, Boards out), posted by the music desk’s standings lane. Three boards after the owner’s review of the first live output: artist_entries (one act’s entries LISTED with their positions — not “Drake: 10” but the ten albums and where each sits, which is what the reference post actually shows; the card draws at most MAX_ROWS rows, so when the act holds MORE the CAPTION says “top N of M shown” as the headline does — without it the card read “11 albums” over 10 drawn rows and a reader named the missing one, Nothing Was The Same at #169, off Drake’s 11-deep Billboard 200 week 2026-08-25. That cut-off note is the ONLY caption artist_entries and album_drop carry (owner steer 2026-09-10, “only keep the caveat when there is a cutoff”): the old “11 albums on the Billboard 200 this week” repeated the tag block, the row count and the date line, so a board whose rows fit passes an empty caption and the renderer drops straight from the hero to the rows (_P_HEADER_H_BARE); the count stays in the headline and the take. MAX_ROWS is 11 since 2026-09-10, owner steer off that same shape a fortnight on: the reader wants the eleventh row, not the qualifier, so chart_cards._P_ROW_CAP went to 11 with it and the card’s height clamp _P_MAX_H is now DERIVED from the cap — the old fixed 1200 fit ten rows under a one-line caption and nothing more, so an eleventh row, or ten under a two-line caption, ran under the source pill. No row carries a credit on artist_entries or album_drop (same steer): the act is the headline, and the joint credit a collaboration used to print beside its title (“$ome $exy $ongs 4 U PARTYNEXTDOOR & Drake”) crowded the title; debut_class keeps its per-row credits because that board mixes acts). The caption rule then moved into the RENDERER and applies to EVERY board (owner, same day: “do the follow up for everywhere, we wanna apply the rule consistently”): chart_cards.card_caption keeps only a caption’s cut-off note (“top 11 of 13 shown”, normalized from whatever phrasing the builder used – “(best 10 of 23 shown)”, “top 10 of the 16 shown”, “top 10 of 59”) and render_board_portrait draws that or nothing, so no board card anywhere carries a descriptive line; a board’s caption field is unchanged and stays the DESCRIPTIVE line the compose framings read (THE BOARD (hero -- caption) in chart_boards, chart_records, riaa_boards, cinema_boards, chart_ages, chart_credits, sports_boards), which is why the builders were not blanked – the watch board’s “plays on the right” line came back for the prompt’s sake. What a caption used to tell the READER (the value column’s meaning, an RIAA board’s as-of date) now has to reach them through the take. The same three rules run on the act boards of the OTHER chart types (owner steer 2026-09-10, “make sure this works for other chart types too”): chart_boards.watch_artist_board (the Apple / Spotify / YouTube / Billboard watch board, watch_artist + watch_standing) and radio.artist_board draw no row credit and no caption unless the list is cut; chart_boards.platform_presence drops the row credit and its HERO is now the act (it was the count, “23 on the chart”, and with the caption off the card nothing would have named whose 23 they were); the count is the figure, the tag says “Most on the chart”. All three cap at ACT_ROWS / _MAX_BOARD_ROWS = chart_standings.MAX_ROWS (11); the radio rows keep their <format> radio credit, which is the format the move is on, not the act, debut_class (everything that entered this week — every row carries its credit, and on a SONG chart each row’s LIFETIME Spotify total off the act’s kworb songs page, the individual-artist board’s own column, resolved after dedup by _with_debut_streams (owner ask 2026-09-09 on the shipped card: “need the artist, and streams if possible”); an act the leaderboard does not know stays blank, the compose framing swaps its no-streams sentence for the lifetime fence when the column is drawn (value_kind), acts are counted on the LEAD credit so a feature clause is not a new act, the acts holding 2+ of the class are STATED in the fact (“Rod Wave has 14 of the 16” – live dry run on the 2026-09-12 class, n=8: every take counted the drawn rows and got 8 or 9 for a 14, and the gate dropped 7 of 8; with the parenthetical “(Rod Wave 14 of them)” the take wrote 14 and the self-gate still failed 5 of 6 reading it against the 16; the sentence form shipped 5 of 6), and a class bigger than MAX_ROWS says “(top 10 of the 16 shown)”), and genre_entries (the same derivation run on a genre page, so an act that never leads the Hot 100 can still own its genre — and since 2026-09-12 it draws the SAME lifetime-Spotify-streams column the individual-artist board and the debut class draw, resolved after dedup by _with_genre_streams off the act’s kworb songs page, one read for the whole board because every row is one act’s. The owner reported the gap on the shipped Karol G Hot Latin Songs card: “where did our streams go” — the board is artist_entries on a genre page, but it ships from the STANDINGS lane, which never built a value column, so its rows carried positions alone while the Hot 100 board beside it carried streams. A title the songs page does not carry stays blank, and value_kind="lifetime_streams" makes the compose framing name the figure as a lifetime total, never as this week’s plays. Live dry run 2026-09-12 on that exact card: 11 of 11 rows filled, 3 of 3 takes shipped at 0.92). Cut in review, all owner calls: a cross-chart “on the most charts” board (people follow the Hot 100 and the Billboard 200, not a chart census), a head-to-head “above the rival’s best” board (too niche), and a top-10-only variant of artist_entries (the same board wearing a cap). A long-runners board was cut too, and the reason generalises: measured on the real Billboard 200, its top ten was 10/10 identical, in the same order, from one week to the next — only the numbers ticked up by one. A board whose rows never change is a fact about the chart’s history, not news. The owner asked for that story per RELEASE, which is a MILESTONE rather than a board, so it became the desk’s chart_run lane: milestones.LADDERS["chart_weeks"] (round hundreds plus 52/104, because the first and second year read as milestones where 100 weeks does not) over the EXISTING generic songstats_milestone_state store under a bbrun: entity prefix — no new table, no new fetch (weeks-on is already on every row), seeded on first sight so a release met at 700 weeks never fires a stale “hit 500”. Two ladders, after the owner’s review (“50 is kinda high, e.g. 2-3 weeks should be celebrated for a single, or debut album top 10”): a top-10 debut is not a long RUN and no weeks ladder can express it, so chart_peak is the run ladder’s twin on the release’s peak column — tiers at top 40 / 20 / 10 / 5 / 3 / No. 1, with no weeks floor, since a release that just cracked the top 10 usually has very few weeks on the chart. Its scale DESCENDS (#1 is the best number), and rather than teach the shared high-water detector a second direction, milestones.peak_score inverts it once into an ascending tier score so seed_rung/newly_crossed work unchanged. The card heroes the EXACT rank, not the tier (owner steer 2026-08-27 “number the 18”): the tier crossing is only the TRIGGER, so a song that just cracked the top 20 at #18 shows “#18” under a “NEW PEAK” eyebrow, not “TOP 20”. The card fires only in the week the peak PRINTS (row.rank == row.peak, 2026-09-06): the peak column is a high-water mark, and a row sitting below it set that peak earlier – “reaching #18. It sits at #29 this week” composed for two weeks (Steve Lacy). A below-peak row is SETTLED silently (_settle_milestone: seeded on first sight, else the crossed rung recorded as passed) so it can never fire late and a later real peak measures from the right rung. The web qualifier is scoped to the milestone’s own chart (qualifier_scope=week.label, milestone_qualifier.on_chart): that card was handed “his second top 20 Hot 100 hit, joining Bad Habit” – a verified fact about ANOTHER chart – and the take fused the two 73 times in two days. A qualifier that does not name the chart is dropped before the compose, with a qualifier ok=false reason=off_chart row — the same rank-as-figure the movement lane’s cards already use. _chart_peak_candidate passes the real #{row.peak} through the shared _milestone_candidate card_figure override (empty for every other metric, so the streaming rungs still hero their abbreviated count, “1B”); the override is also grounded into the number backstop so a take restating the rank is not dropped. The SPOKEN take is unchanged — its value_line already gave the model both the tier (“broken into the top 20”) and the exact rank (“reaching #18”). The card art follows the chart’s UNIT, not a constant (owner “make sure art works”): a chart milestone (_chart_peak_candidate / _chart_run_candidate) passes art_kind=bb.chart_unit(week.chart) through _milestone_candidate — “song” for a Hot 100 / Global 200 row, “album” for a Billboard 200 row. Before this the lane hardcoded “album” for every non-streams metric, so a Hot 100 single (“Loser”, “Redbone”, “Passionfruit”) searched the ALBUM catalog, found no album of that name, and fell through to the artist PORTRAIT instead of the single cover (the #2546 class the streams lane already fixed). Verified live: resolve_art_url(..., subject_kind="song") returns the single cover where "album" returns the portrait. The weeks ladder also came down from 50 to open at 26, and a young run now has to be PLACED to count (_CHART_RUN_MAX_RANK, waived past _CHART_RUN_VETERAN_WEEKS) — every song that survives passes 26 and 52 weeks, so a bare rung fired ~10x a week, mostly records simply still being there near the bottom. Measured after both changes: 6 crossings/week across the two charts (2 peak + 4 run), capped at 1/slot. That extension added four optional kwargs to the shared _milestone_candidate (source / card source / eyebrow / value line), each defaulting to the Spotify behaviour so the streaming milestones are byte-identical. There is deliberately no movers board either — the movement lane already posts those one row at a time. Which charts: the main three run on the Hot 100 + Billboard 200 only; genre_entries runs on billboard.GENRE_CHARTS — three charts (rap, R&B/hip-hop, latin), not the five that parse: country and rock were cut on the owner’s call (2026-08-06), and their registry entries stay only because they cost nothing unused. A genre board additionally requires its subject to be on the desk’s kworb top-artist list (“i dont want too many unknowns”): a genre chart is small, so leading one is arithmetically easy and says nothing about how big the act is, where a main chart’s leader is big by construction. That check FAILS OPEN when the list is unavailable (owner call: “is kworb not available no filter just let them go”) — deliberately against the repo’s usual instinct, because the failure mode here is not a FABRICATION: the act and the count are real either way, so the worst case is a board about someone less famous, and losing the genre lane every time kworb blinks costs more than that. The counting rule’s fence is the fabrication guard. A per-act count must read “Drake & PARTYNEXTDOOR” as both acts, so joint credits split — and splitting on punctuation alone invents acts (“Earth, Wind & Fire” becomes three artists). artist_watch.credit_parties owns the rule: it credits every part that charts on its own somewhere in the weeks we hold and DROPS (never guesses) a part that does not – part by part, because the corroboration set is a WINDOW, not a census of every act that exists. So a four-way credit whose only currently-solo member is Morgan Wallen still credits him (“HARDY, Eric Church, Morgan Wallen & Tim McGraw” -> Morgan Wallen); the old ALL-or-nothing rule dropped the whole split and lumped the four into one phantom act, so his census read 2 for a 3-entry week. “Earth, Wind & Fire” still stays whole (no part charts alone, so nothing is credited and it counts as itself). Two attribution fixes came out of the #2710 sweep over 1500 live chart rows (362 distinct credits; only these two changed, so the shipped boards move nowhere else). (1) The LEAD of a feature credit is credited without corroboration. lead_artist cuts only at the four unambiguous feature markers – no act is called “… featuring …” – so the text before one is a real act by construction. Without that, a corroborated GUEST was credited while the uncorroborated LEAD was dropped, which attributes the song to the wrong artist: live, “Skilla Baby Featuring Chris Brown & Bryson Tiller” counted for Chris Brown and Bryson Tiller and NOT for Skilla Baby. A COMPOSITE lead is judged on the evidence: a band whose OWN name carries connectors is still one act when known names the WHOLE and not its parts (“Aly & AJ”, “Earth, Wind & Fire”, “AC/DC”, all on the kworb list), and it is then credited whole with its fragments suppressed so the song cannot count twice; but TWO corroborated fragments mean the lead joins separate acts (“Macklemore & Ryan Lewis”, both charting alone) and it is credited to the individuals, the same two-corroboration test lead_party uses and the owner’s “count features to both” rule. Codex #2719 caught the first half missing: a corroborated “Aly & AJ” was dropped because the fragment lookup asks for “Aly” and “AJ” while the list holds the whole name. (2) A split part is stripped of credit punctuation before it is matched or drawn (_PART_EDGE_CHARS): “HUNTR/X: EJAE, Audrey Nuna & REI AMI” splits at the “/” and again at the stray “X”, which left “: EJAE” – a name no artist has, on a card. Live check on the 2026-08-29 Hot 100: the under-count was pervasive – Morgan Wallen 2->3, Drake 5->6, Karol G 3->4, and the LEADER itself was wrong (Olivia Rodrigo 7, not Ella Langley 6). (3) A PLACEHOLDER credit is never an act (#2739): “Soundtrack”, “Various Artists” and “Original Broadway Cast” are the catalog’s stand-ins for a record with no crediting artist, and the Billboard 200 prints them on real rows. _entries_by_act drops them (artist_watch.is_placeholder_credit), so every reader of that map is covered at once – chart_census, the per-act catalog_picks / leader_entries boards, and the record watch’s deepest-act cards through entry_counts. Reproduced on the real 2026-08-01 Billboard 200: “Soundtrack” held THREE albums, cleared the two-entry floor and drew as the ninth ranked act; with the filter that row is gone, Kendrick Lamar takes the tenth slot, and the headline field size reads 31 acts rather than 32. The year races took the same filter first (#2735). Notability + a floor (MIN_NOTABILITY 12, calibrated on real weeks) keeps a dull week silent; longevity alone is CAPPED, because its rows barely change and an uncapped weeks-on number would let a static fact outrank the week’s news forever. Dedup keys the FACT, not the week (bbst:<chart>:<kind>:<subject>:<figure>) — a movement is new every week by definition, a standing mostly stands still. The CARD is its own renderer, not render_market_card (owner: “the bar isn’t meant for this”, with billboard.com’s graphics as the reference). The market renderer draws a legend above a SEPARATE band of bars, which works when a colour ties the two together and fails on a ranked board — the reader has to match six colours across a gap. chart_cards puts the label INSIDE its own bar (bars, for counts) and draws a position board as a ruled table with no bars at all (list) — because a bar says “more is bigger” and #1 is the smallest number on a chart. One accent per chart, a white row for the subject, and the LAYOUT is borrowed while the identity is not: our wordmark, Billboard credited in words, no reproduction of their logo. Three dry-run-earned fences, each a deterministic backstop rather than louder wording (the has_career_claim precedent): output_checks.has_row_arithmetic fails a take that SUMS the rows (the model wrote “Olivia Rodrigo owns 11 spots, more than Drake and Ella Langley combined” off rows reading 7 and 5, and the 0.6 judge scored it 0.92 while printing “11 vs 7 + 5 = 12” in its own reason); the lane takes only the ALL-TIME half of the career check (has_alltime_claim), since a board genuinely carries the act’s other entries; and two misread FACTS are labelled in the data block rather than counter-ruled — Billboard’s weeks-on is a lifetime total across separate runs (so never “continuous”), and a debut class states how many distinct acts it covers (a seven-act week composed as “six more Wallen tracks”). Dry run: python -m scripts.dryrun_standings (--compose the real compose + gate + shape guards, --card writes the PNGs, --streams resolves the genre boards’ streams column off live kworb through the cog’s own method). Follow-up, not built: a season-to-date board needs stored chart history; every board here comes off the current week. 2026-08-10 additions (owner brainstorm ask): chart_census (every act’s entry count on one chart, ranked — the WITHIN-one-chart census; the cut #2031 board was the CROSS-chart one) It rides the standings lane unchanged (same experiment, dedup-on-the-fact, notability floor); entry_counts is the shared public derivation chart_records reuses. A feature_credits board shipped the same day and was CUT on measurement (owner call): collaborations spread THIN rather than concentrate, so over five real Hot 100 weeks the best any single act reached was TWO shared credits, once, against a board needing a leader on three plus a field of acts on two — it could not fire on real data, and retuning the floor would only have made it fire on a technicality. Worth knowing that its silence before #2271 was a BUG (the parser was destroying joint credits) while its silence after is the honest distribution; the interesting fact here is chart-level (how many of the week’s entries are collaborations at all), which is deliberately not built. 2026-08-11 additions (owner steer “cut mid chart moves”): two FOLD boards that REPLACE cards the movement lanes would otherwise ship one at a time. album_drop_board (+ debut_clusters) turns one act’s whole week of new Hot 100/200 entries into ONE board — measured over eight real weeks, three of them were album bombs (Ariana Grande 11 debut cards off petal, Future 16, Olivia Rodrigo 10 — 37 near-identical cards from 3 releases). The act’s best-placed debut still cards (owner: the headline entry is the story, the board is the depth); the rest fold out of music_desk._pick_chart_moves via _folded_debuts. The board names the act’s own album debut as a SECOND stated fact off the same-week album chart, and names no album when none debuted (owner: “only if theres not album ofc”). It rides the MOVEMENT lane’s stage and chart set, not the standings lane’s, because it must switch together with the cards it replaces (story album_drop, bbad: keys). The mid-chart cut itself is _within_rank_cap: a MOVE cards only in the top 20 (MUSIC_DESK_CHART_MOVE_MAX_RANK), an ENTRY to top 40, a WATCHED artist’s entry uncapped (“we can let it happen if its a tier artist”). The sibling board for the platform sweep is chart_boards.watch_artist_board (story watch_board) — one watched act’s whole presence on ONE kworb chart, folding the per-move cards the watch sweep used to ship in a burst (Pooh Shiesty #8 and #9 in one minute). That board has TWO ways in, because the moves-only floor buried a lead (#2662). The original way is several NOTABLE MOVES by one act on one chart in one sweep. The second is the act’s STANDING: it holds MIN_TOP10_STANDING or more of the chart’s TOP 10, and then the standing alone makes the board with no second mover. The miss it fixes, live on 2026-08-28: Rod Wave held 7 of the Apple Music US songs top 10 and 24 titles on the page, but only ONE of them moved per sweep, so no group ever reached two moves and the desk posted single-song climb cards eight hours apart (“Piece Of Your Love” 13:06, “Hustle” 21:06, the second one crossposted to X). Each card was true and each was the small story; the owner read the X post and said we were burying the lead. The desk’s own judge had said the same thing that morning on a Dolly Parton board (“could’ve noted the full takeover (13 songs on chart)”). The general shape is that an act’s STANDING does not move in a sweep, but the chart page states it on every read — so the board now reads the PAGE for the takeover and the SWEEP for the moves. The floor is TWO, and it came down from three on 2026-09-05 (owner steer). Three was set on the argument that two of a top 10 is an ordinary strong week (Dolly Parton held Jolene and 9 to 5 in that same top 10 the same day). The owner’s call, off a chart account’s post that Lil Baby held 3 of the Apple Music top 7 with songs from three different years, is that two titles in one top ten is the post those accounts make and the board is worth having at that count. The same steer forbids the WORD: the board must not call the standing a takeover, so the kind, the headline and the compose steer say only the count and the chart. _WATCH_STEER['watch_standing'] and the mentions-lane fences name the banned words back (takeover, owns, runs, lock, sweep, chokehold), which is what docs/PROMPT_OPTIMIZATION.md says works where a general rule does not. A standing takes its own board KIND (watch_standing), which buys three things: a compose steer that leads with the top-10 count instead of the flat “how many titles they hold” (_PLATFORM_STEERS), telemetry that tells the two boards apart (board.kind rides music_desk_scored.matchup), and a dedup key on the COUNT rather than the movers (_watch_board_dedup_key(..., top_ten=N)) — keying it on the movers would repost the same sentence every time a different title of the act climbed, which is the exact burst the board exists to replace. The counting FENCE is per-kind for the same reason (chart_boards._count_fence): every other board forbids the model counting rows at all, because no total is handed over; a standing board hands one over in THE FACT and the whole post leads with it, so its fence forbids the model’s own tally and names the number to use — keeping both rules would have told the model to lead with a count and never state a count. That distinction is load-bearing, since a standing board draws rows BELOW the top 10 (Rod Wave’s card runs to #16), so a tally of the drawn rows returns 10 where the honest answer is 7. Three guards had to learn the same distinction, and the review found each one (#2671): (a) the compose fence above; (b) the DETERMINISTIC ship guard has_row_tally, which rejected the phrase outright – measured, 4 of 4 real composes would have been dropped before the judge, so the board would have shipped NOTHING; it now GROUNDS instead (output_checks.ungrounded_row_tally, passed the board’s own block through _drop_before_gate), and a caller that passes no source keeps the flat ban; (c) the X-MENTIONS lane, which never ran that guard at all and became count-led when this shipped, so _ship_unit runs it now too. The top-10 COUNT also keys on the normalized TITLE rather than song_key, because song_key folds the row’s credit in and one release under two credits (“Rod Wave” / “Rod Wave Featuring Luv Von”) would otherwise manufacture a standing out of a single song – which matters more at a floor of two than it did at three; the board and the desk read ONE helper (_top_ten_releases) so the threshold and the dedup key cannot disagree. The watch steers’ right-hand-COLUMN sentence now follows Board.value_kind (_WATCH_COLUMN_FENCE) for the plain board as well: it always said “MOVE”, which is false on a Spotify board whose column is the period play count and on a Billboard board drawing lifetime totals – contradictory ground truth that invited a play count to be written up as a climb. Measured live on the same chart page: the board formed off one mover and three composes all led with the count at 0.95. The STANDING PASS closes the last hole in that trade (owner steer 2026-09-05). Reading the page for the standing still needed ONE mover, because the board GROUPS were built only from the sweep’s stories – so an act whose whole standing sat still, with no title moving and none flagged new, produced no group and no board however much of the top ten it held. music_desk._watch_boards now seeds from two places: the sweep’s stories as before, and the CHART PAGE, where chart_boards.presence_leaders groups the top ten by act and every act on MIN_TOP10_STANDING is seeded directly. It is built like the Billboard artist-catalog lane (_artist_catalog_units): read the page, pick the acts, build the board, dedup on the standing itself. It adds NO fetch (the rows are already in rows_by_chart), presence_leaders is pure, and at most five acts can hold two of any one top ten. WHICH ACTS follows the card path’s own rule (_TOP_ALL_CHARTS): any act on the five major Spotify + Apple charts, watchlist-only on iTunes and radio. watch_artist_board stays the AUTHORITY – presence_leaders groups by lead_party where the board counts by credit_matches, so a seed can over-select and simply build no board, which is the safe direction. Mover seeds sort AHEAD of standing seeds, because a mover board REPLACES the cards it folds while a standing board folds nothing and costs nothing to defer. One fold rule changed with it, and it was a real bug. A SEEN board used to fold its cards straight away. That is right for a MOVER-keyed board (its key IS the set of movers, so those cards really did post) and wrong for a STANDING-keyed one: the count key holds still while WHICH titles move underneath it changes, so yesterday’s board suppressed today’s real moves – hardest on exactly the acts the standing board exists for. A seen standing board now leaves its cards alone. Every top-ten CHANGE also cards now (owner steer 2026-09-05: “post individual cards for top 10 on every change”). Every kind in artist_watch.chart_stories before this needs an EVENT – an arrival, a #1, a rung crossed, or a leap of jump_min spots – so a #3 -> #2 climb crossed no line, moved 2 spots, and told nothing, and a FALL inside the ten told nothing ever. TOP_MOVE_KINDS (top_climb / top_fall) is checked LAST, so it only ever catches a move no kind above it claimed, and its band is rungs[-1] so the move band and the rung band are one definition. The DIRECTION is the kind, because _STORY_WHAT and _STORY_EYEBROW are keyed by kind and a fall written up as a climb is the plainest wrong post; chart_story_card_fields learned the same lesson and now reads “down 2 from #4” where it used to print “up -2 from #4”. A title that fell OUT of the ten is deliberately NOT a story – it is no longer in the top ten – and the title that replaced it takes its own rung card. A FALL IS POSITION-ONLY, AND SO IS BILLBOARD’S (owner steer 2026-09-05: “never call it a slip just say where it is”). The first top_fall wording labelled the decline on every layer – eyebrow “Top 10 Slip”, blob “slipped DOWN inside the chart’s top 10, to”, sub “down 2 from #4”. The owner’s words are the SAME ones that set the radio_entry rule in 2026-08-17, so the fall takes the same shape: it joins artist_watch._POSITION_ONLY_EYEBROW, so the card wears the bare chart label and heroes the position; the blob reads “is on this chart at”; and the sub reads “was #4”, with no size and no direction word. The figure is where it sits now and the sub is where it sat, so the reader sees the decline in two numbers without the card judging it. The class sweep found ONE sibling and it was live: the Billboard move lane’s “Big Fall” (billboard._KIND_EYEBROW), whose card also read “down 11 from #9” and whose describe_move led with the verb (“falls 7 spots to #5”). It now takes the identical treatment – billboard._POSITION_ONLY_EYEBROW, a “was #9” sub, and “is at #5 this week (was #12)”. An EXIT is the one decline that CANNOT take it and is excluded by name on both lanes: the release is off the chart, so there is no current position to state and its card heroes last week’s rank instead – “say where it is” has no answer when it is nowhere. Removing the label moved a burden onto the FRAMING, and both lanes carry it: with nothing naming the direction, only a fence stops a take reading the two positions the wrong way round and writing a decline up as a climb. Each fall compose now states which position is higher, forbids the climb reading, and names the banned words back (slip, slide, fall, drop, tumble, losing ground) – the form docs/PROMPT_OPTIMIZATION.md says works. A CLIMB is unchanged on both lanes: it names its size and its kind, as every upward kind here does. THE CLASS SWEEP ABOVE MISSED A THIRD SIBLING, AND IT LEAKED THE NEXT DAY (owner steer 2026-09-06: “never say sliding down, big fall, dont narrate the downwards movement just say where it is”). The newsroom’s chart lane (music_news._compose_one) also states one title’s current chart position, off a live read that REPLACES the wire’s claim (settled_claim), and it carried no fence: its LIVE CHART block ended “Anything below reporting a different position is older than this reading”, which hands the model two positions in time order and nothing to do with the older one but relate them. It shipped “No Me Arrepiento De Sentir Tanto sits at #16 on the Billboard 200 right now, sliding down from its #8 debut” to X at 23:21Z, and the self-gate scored it high for its “grounded context (debut peak included)” – the judge grades whether a claim is TRUE, and that one is. Two changes. First the sentence now says what to do with the older position: leave it out. This was ABLATED, not assumed (owner: “fix any scar”). Real inputs, real model, n=6 per arm: the old sentence leaked 5/6; the reword alone 0/6; the reword plus a list of the banned words (slide, slip, fall, drop, dip, tumble, ‘down from’) also 0/6 – the list bought nothing and was cut as scar tissue, so the newsroom’s sentence is a cause reword and nothing more (the desk’s two lanes keep their words-back form because THEIR framing must guard the direction with no eyebrow label). The same ablation showed the RESIDUE the cut left: with the decline gone the take filled its trailing clause with the title’s run (“still hanging around months after that August debut”, 4/6 and 2/6 across the two arms) – the newsroom twin of the desk’s age-clause cut (#3038). Its cause was compose_music_record’s chart lane_hint, which invited “a trailing clause ONLY for a REAL second fact the block gives” with two examples the career fences BAN (“a career-first, a biggest-ever debut”); it now names the two facts the record line can actually carry (weeks held at #1, the record it set), says the context under it is not a second clause, and keeps the title’s age, run, debut and former position out. Measured after both rewords, n=8: 0/8 decline, 0/8 run clause, every take the bare position sentence. Second, output_checks.has_decline_narration is the DETERMINISTIC backstop behind the framing on all three position lanes – the career_claim precedent, wired through music_desk._unshippable for chart / watch_chart (_POSITION_LANES) and ship_guard.drop_decline_narration for the newsroom, all emitting phase=shape_reject reason=decline_narration. The checker matches the DOWNWARD direction only (a climb names its size by design) and every arm needs a chart-movement object beside the verb, so “the album drop”, “drops Friday”, “fall tour”, “Fall Out Boy”, “fell short of #1” and “slides into the top 10” pass; it also reads “went from #8 to #16” as a decline by comparing the two ranks. The chart EXIT lanes are excluded by name for the reason above: the departure IS their story. The newsroom marks a dropped story SEEN like its two grounding gates, because a re-roll is a phrasing lottery that costs a verify + compose + score cycle. The eyebrows are SHORT on purpose (Top 10 Climb / Top 10 Slip): the card renders <chart label> - <eyebrow> in one tag pill, and a long one used to run off the card edge and be clipped with no error. _equal_tag bounds and WRAPS the block itself since #3001, so the clip is gone – but a short eyebrow is still the better card, because a wrapped tag spends two lines on what one should say. Caught on a rendered card – the first wording, “Down in the Top 10”, needed 1711px against the ~1576px of room on the 1800px card and lost its last characters. test_every_story_eyebrow_fits_the_card_s_tag_pill measures every eyebrow this module can produce so the next one cannot clip silently. #3001 fixed the guard, and the investigation corrected the claim that filed it. The issue said reentry’s “Catalog Entry” clips today. Rendered, it does not: it draws 1590px wide and the card has room to about 1690px, so it fits with ~100px to spare. The 1576px figure it was filed on came from reimplementing the pill’s arithmetic against a symmetric margin the card does not use. The BUG was real all the same, and the diagnosis got sharper: two of the four callers did guard, with _ellipsize against the PLAIN text, while the tag draws each glyph letter-spaced inside a padded block – measured, a 336px undercount on the clipping case – and the board and Versuz cards had no guard at all. The bound now lives in _equal_tag, the only place that knows the drawn width (_tag_width and the drawing share _tag_gap, so they cannot disagree again), and every caller passes its available width. It WRAPS rather than truncating (owner call): an eyebrow reads <label> - <kind>, so cutting the end throws away the kind – the half that carries the news, where the label is already implied by the source pill. The break prefers the “ - “ seam; an ellipsis is the last resort for a single unbreakable run. The eyebrow-width test now RENDERS a card and reads the drawn block out of the pixels, rather than reimplementing the renderer – which is what got the original claim wrong. top_for_all widens these kinds like the crown and the rungs, or half of one top ten would be told and half not. The VOLUME is held by the dedup key, which carries the POSITION (kc:<chart>:<song>:pos<N>, where every other kind takes the bare per-song key), so a title wiggling between #4 and #5 posts twice inside the window and then goes quiet. _MAX_TOP10_MOVES_PER_SLOT (1) is the kind’s OWN per-slot budget, SEPARATE from the event cards’ cap – and it was a SHARE of that cap first, which shipped nothing at all. The reasoning for a share was that the kind fires on a far bigger population than the events, so it should not grow the lane. Measured end to end through the real lane against the live charts on 2026-09-05: 21 moves derived, 16 past the cross-chart dedup, and ZERO shipped – 15 event stories survived the same sweep against a 2-card cap, so the events filled it every time and the events-first ordering meant no move was ever reached. The share was not a bound on the new kind, it was an off switch, and the sub-cap never even bound. Two budgets is what the steer asks for; the honest cost is one more watch card on a busy slot, which is the feature rather than a side effect. MUSIC_DESK_MAX_TOP10_MOVES=0 turns the kind off without touching the event lane. The same kind now runs on the BILLBOARD charts and on the US YOUTUBE chart (owner steer 2026-09-06: individual cards for every top-10 change, on the chart, sales and video charts too). billboard.TOP_MOVE_KINDS is the Billboard twin, sharing the platform lane’s kind names so ONE wording rule covers both – including the position-only fall, which _FALL_KINDS now carries for fall and top_fall together. It is a new LAST branch in bb.movements bounded by top_move_max (10) rather than min_jump, because the fact is WHERE the title sits, not how far it went; notability needs no special case, since 100/rank dominates the score and a one-spot move at #2 scores ~51 against the lane’s floor of 8. The CHART SET widened from hot100,billboard200 to the four majors plus Top Album Sales and Digital Song Sales. Measured live on 2026-09-06: those six carry ~36 top-10 moves a week, against ~104 for all twenty charts in the registry; the genre and airplay charts are deliberately left out for now and widening is a MUSIC_DESK_CHART_LANE config change, not a rebuild. The new kinds arrive in their OWN derivation pass and the desk’s three existing passes pass top_move_max=0, so the change is purely additive: without that, a watched act’s 7-spot climb into the ten changed kind silently, because pass 1 would claim it as top_climb before pass 2 could call it a jump. The US YouTube chart (yt_us) is free: USVideoRow already carries pos_change, so it duck-types straight into chart_stories and joins _WATCH_CHARTS and _TOP_ALL_CHARTS with no new fetch and no new card shape; its stream cell is a WEEKLY view count, so its period is “week”. The other two YouTube charts CANNOT do this – kworb.VideoRow carries no previous position, so a change is underivable without storing a daily snapshot and diffing it, which is filed separately. THE SLOT BUDGET BIT TWICE IN TWO DAYS AND THE SECOND BITE IS THE ONE TO REMEMBER. The Billboard lane took two budgets from the start, having learned from the platform lane – and still shipped ZERO moves, because its attempts counter read the INDEX in picks rather than counting real composes. That was identical only while every pick was composed; two budgets mean a pick can be SKIPPED, and an index-based counter charges those skips against the compose budget. Measured live: 33 event picks sorted ahead of 25 top-ten moves, so the index passed the attempt cap of 4 long before the first move. The budget checks now come BEFORE the attempt check and attempts counts composes. The general lesson is that a cap test must OVER-SUPPLY every budget it claims to exercise: the fixture needs more events than the event cap AND more picks than the attempt cap, or it goes green on a lane that ships nothing. The lesson for the test, and it is the general one: the first regression test put ONE event against a cap of two, so the cap never filled and the test went green on a lane that could not post a move card at all. A cap test whose fixture cannot fill the cap tests nothing; test_a_top_ten_move_ships_even_when_the_events_fill_their_cap now over-supplies the events on purpose. all_stories also sorts EVENT kinds ahead of moves before the cross-chart song dedup runs – sorting by position alone put a one-spot shuffle at #3 ahead of a debut at #40 and, worse, let the shuffle suppress that song’s debut on another platform. Both rank-capped/board changes measured live 2026-08-11 (real charts + compose + the 0.6 gate): album drop 3/3 at 0.92, watch boards 2/2 at 0.92–0.95. Full plan + measurements: docs/BILLBOARD_ALERTS.md (Lane C, the top-artist watch). Non-Latin titles no longer render as tofu boxes (#2344): the bundled LiberationSans has Latin glyphs only, so a Korean or Devanagari row on the YouTube most-viewed board drew a line of □□□□. PIL does not fall back to another font on its own, so chart_cards now splits a string into runs by Unicode block (_bucket / _runs) and draws each run with a Noto font that has its glyphs (_text / _textlen are the fallback-aware draw.text / draw.textlength, used for every TITLE, CREDIT, HERO and CAPTION — the ranks and values stay ASCII). The Noto files are NOT in git; they ship in the Docker image via fonts-noto-core + fonts-noto-cjk and are read by path. A missing file degrades the run to the base font — the same tofu as before, never an error — so a Latin-only card renders the same in local dev, and the fix is live wherever the fonts are installed (production). The CJK collection carries one face per region; the KR face is used, so a Chinese or Japanese title wears the Korean regional form of a shared Han ideograph, which stays legible. A BOLD request degrades to the REGULAR-weight file when the bold file is absent (#2483): Debian’s fonts-noto-cjk ships only NotoSansCJK-Regular.ttc (the bold weights live in the separate fonts-noto-cjk-extra package the Dockerfile does not install), so a bold Korean title looked for NotoSansCJK-Bold.ttc, found nothing, and fell back to the Latin base font — tofu on the bold hero and bold row titles, the one line most likely to be non-Latin (the regular caption rendered fine). _fb_path now tries the bold file first and the regular file second, for CJK and every per-script family, so a missing bold weight renders regular-weight glyphs, not boxes. Right-to-left scripts (Arabic, Hebrew) are deliberately left on the base font (#2348): _runs draws its runs left to right in logical order, and Pillow cannot run the bidirectional algorithm across separately drawn runs, so a multiword RTL title would come out in the wrong visual order — a garbled title is worse than the box it replaces, so RTL stays tofu until it is done properly. The sibling market_chart renderer has the same gap and is NOT fixed here (#2345): its own _font / _ellipsize / _text_vcenter still draw with LiberationSans, so a market card whose label is non-Latin (a Kalshi YouTube-video leg) tofus the same way — a shared fallback util is the real fix.stream_totals.py, the lifetime Spotify total for a charted title — the ONE home for the number a Billboard board draws in its right-hand column. A printed Billboard row carries a position, a peak and a week count, and no streams, so the figure is built from kworb: a SONG matches its title on the act’s songs page (chart_boards.song_total_streams), an ALBUM matches its title on the act’s albums page (kworb.artist_albums → chart_boards.album_page_total), and an album the albums page does not list falls back to the older tracklist sum (iTunes tracklist × the songs page + a Haiku recovery for a track the two sources spell differently). The albums page leads for two measured reasons (2026-08-28): it is Spotify’s own PUBLISHED per-release figure where the sum only approximates it — a track’s own total carries the plays it took as a single, so summing over-counts a release that has one — and iTunes does not return every act’s albums at all, so the sum could never resolve them (an iTunes album search for Bad Bunny returns his singles and features and none of his albums, which is why his Billboard 200 board drew move markers until the page was added). The StreamTotals resolver caches each page per artist id, so an act’s ten rows cost one fetch per page; board_stream_totals is the board-shaped entry point, resolving exactly the rows chart_boards.watch_artist_rows says the board will draw. Fail-open per row: an unresolved title returns None and the caller leaves that cell blank (prefer absent over invented). The artist id itself has two sources, and the desk lane needs both (2026-09-02): a kworb weekly chart row carries the id for free, but only for an act with a song on the Spotify Global Weekly top 200; a catalog act charting albums on the Billboard 200 with no current global single (Future, three albums on the 2026-09-05 chart) has no weekly row, and the desk lane used to stop there and ship the board with an empty streams column — silently, since that branch emitted no event. MusicDesk._catalog_artist_id now falls back to resolve_artist_id (the leaderboard by exact name, the mentions lane’s route), and a miss on both emits no_artist_id per row (StreamTotals.emit_miss) so the blank column shows on the ok=False rate. The module was extracted from cogs/music_desk.py when the X-mentions boards lane needed the same number for the same Billboard rows; the desk’s individual-artist board, the mentions lane and the manual artist_sweep all call it, and each stamps its own name on the song_total / album_total events so the lanes stay separable on the dashboard.chart_boards.py, the PUBLICATION chart cards (owner steer 2026-08-06: “for all the main charts publish the new top 10 every week too”, “a chart for their new this week sales”, “our own daily chart projections in 4 variations … best in class view of weekly projections better than talkofthecharts and hitsdd”). Where chart_standings builds INSIGHT boards that post only when notable, this module builds the recurring chart graphics a chart account runs on a calendar — the product reference is @talkofthecharts’ weekly cycle (midweek predictions graphic → final predictions graphic → per-chart top-10 graphics, their best-performing content). Eight board kinds, all drawn by the same chart_cards.render_board_portrait card: weekly top_ten_sales — the PRINTED Billboard 200 top 10 with each album’s UNITS (owner ask 2026-08-08, “we need 200 ranks but sales for them”): rows stay in CHART ORDER (the rank is the fact, the figure explains it — that is what separates it from proj_top_sales, which re-sorts by units and covers a chart that has not printed), figures marked ~ because they are HITS estimates beside printed ranks, fenced on the SAME week alignment as debut_sales (HITS’ building document rolls to the next tracking week within days of the print, so outside that window it yields rather than pairing a printed rank with another week’s number) and yielding whole below _MIN_SALES_ROWS matched rows because a top 10 with holes is not a chart. It carries a _PRINTED_STEERS entry for the reason the projection variants do (#2098): the ranks card and the sales card describe ONE week, so this one leads with the FIGURES and the gap and is fenced off positions and moves. The gaps are STATED in the headline, in code (_sales_gap_clause, 2026-09-06): the steer asked for “how far clear the #1 album is” and the block never stated it, so the model subtracted and the number backstop dropped 53 takes in one week (2.8K, 3K, 7K, 10K – every one a computed difference). The headline now carries the #1’s margin over the board’s next-biggest FIGURE (named with its rank, because HITS’ estimates can sort differently from the printed order – Dolly at #5 carried more units than Dandelion at #2) and the board’s full spread; the steer says use them exactly and never call a bigger lower figure the leader. When a LOWER row out-units the printed #1 the board YIELDS: the data contradicts itself, and the dry run (2026-09-06, n=3 each way) showed no wording survives it – the stated inversion read to the judge as a contradiction of the printed order, and leaving it out had the model invent a “within ~4K” spread; 0/6 shipped. The aligned shape (#1 the biggest figure, the live Sep 5 rows) passed 3/3 writing “~18.8K clear of the field”. The ranks card and the debut-sales card still cover such a week. The printed #1 MUST match (pairs[0][0].rank == 1, else None): the 2026-09-04 board lost #1 and its headline said the #2 album “leads at #2”, which the take repeated and the self-gate failed as a fabricated position. The #1 was lost because Billboard prints “(EP)” on the row and HITS does not (“THE SIN : BLISS (EP)” / “WILD (EP)”), so _norm now drops a trailing EP marker on both sides. Dry run 2026-08-08 (real HITS figures, printed ranks reconstructed for the aligned window): composed 0.94 — “‘Petal’ opens the Aug 15 Billboard 200 with ~291.9K units this week, per HITS, and the gap to #2 Morgan Wallen at ~75.2K is not close.” top_ten per main chart (printed top 10 + week-over-week move markers, NEW/RE/+n/-n/= — ASCII because the bundled font has no arrow glyphs; on ship the top_ten / top_ten_sales board stamps the #1 row’s own bb: move key, plus its cross-surface debut key when the #1 is a debut (_top_ten_lead_keys, 2026-09-11) – the board’s headline leads on the #1 (“debuts at #1”), and without the stamp the movement lane carded the same debut on its own 39 hours later: “Don’t Look Down debuts at No. 1” on 2026-09-09 18:14 as the board’s take and again on 2026-09-11 08:11 as a move card, with “WILDCHILD” on Top Album Sales the same; a #1 that HELD stamps nothing, there being no move to card) and debut_sales (the week’s new Billboard 200 entries with their first-week units, per HITS, joined by debut_sales_pairs under a hard ALIGNMENT FENCE: projection.chart_date == week.week_of, because the building chart rolls to the next tracking week within days of the print); daily proj_top_rank / proj_top_sales / proj_pure_sales / proj_debut_rank / proj_debut_sales off the freshest HITS document, with Kalshi’s live market-implied units (market_context) as compose-blob context — two sources, each named, never averaged. The two DEBUT boards confirm every row’s RELEASE DATE, and are absent without it (#3170). They read proj.new_entries(), which is the document’s no-last-week column, and that column reads exactly the same for a catalog RE-ENTRY — hits.ProjectionRow has said so since it was written, and music_desk._pick_called_shots already confirmed dates for that reason, but these boards drew the column raw. So the card for the chart dated 2026-09-19 shipped nine rows to X under the hero “Projected Sales: New This Week” and eight were catalog: Kanye West’s GRADUATION (2007) at #32, Drake’s VIEWS (2016) at #50, Beyoncé’s B’DAY (2006) at #8, Dolly Parton’s greatest-hits comp at #21. ADELA’s PRIMA, 8 days old, was the only real release. The owner caught it. music_desk._fresh_projection_entries now dates every no-last-week row through the shared utils.release_age resolver on the called-shot lane’s own 0-28 day window and its own cache, and hands the confirmed rows to projection_boards; both boards take them as a required argument and return None when it is None, so a caller that forgets gets silence rather than the old card. The floor is 2 confirmed rows (_MIN_PROJ_DEBUT_ROWS) — one confirmed release already posts as its own called-shot card, so a board of one repeats it. The three RANKING boards do not read the gate: they rank the whole chart and claim nothing about what is new, which is why a light release week silences the debut pair without taking phase=chart_proj dark. Measured in production over 21 days, the called-shot gate confirmed 1-3 releases a day, so roughly half the days now yield no debut board. The age is measured at the document’s TRACKING WEEK, not at wall-clock now — a projection is a claim about one week, so “was this new that week” is the question, and release_age_days takes an asof for it. On the document dated 2026-08-27, twenty one pilots’ MORE THAN WE EVER IMAGINED (released 08-07) is 20 days old inside that week and 36 days old read on 09-12; one release date, opposite sides of the 28-day window. Live the drift is a few days at most, but the week is the correct anchor and it makes the gate reproducible. Two resolver fixes landed with it, both measured on that document. album_first now reaches the iTunes FALLBACK (resolve_music_row(album_first=...)), which was song-first whatever the caller asked: STELLA LEFTY’s LONG WAY HOME resolved to track 12’s own single (2026-06-23) instead of the album (2026-08-21) and a real debut fell outside the window. With both fixes that document confirms 3 of 8 rows rather than 2. What it still gets wrong is WEEZER (GOLD ALBUM): apple_music._normalize_title drops the parenthetical, so every Weezer colour album normalises to weezer and the title guard cannot separate them (#3180) — the drop is a wrong one and the shared cover-art matcher is where it lives, not here. ED SHEERAN’s tour collection is datable by neither catalog and drops as no_date, which is the fail-closed contract working. The gate emits one proj_debut_gate event per evaluation with a reason tally; read a no_date majority as the catalogs failing, not a quiet release week (docs/OBSERVABILITY.md). proj_pure_sales is a SEPARATE board, not a re-sort of the units one (owner, 2026-08-07: “change the copies bought back to sales, you can add a new chart for that”). Units is the quantity a Billboard 200 position is computed from, so a units ranking restates the rank board; copies actually bought reorders it — measured on the Aug 15 week only 2 of 10 rows held position, Shaboozey went chart #9 → sales #2, and Morgan Wallen held chart #2 and fell out of the top 10 on ~2K bought against ~76K units. Its lead column keeps the projected CHART position so the divergence is the graphic, and it is ABSENT rather than falling back to units when the document ships no pure-sales column — a card calling streaming activity a sale is the one way it could mislead. A projected board is honestly labelled end to end: Board.projected flips the card’s date line to “PROJECTED FOR …” (never “CHART DATED”), Board.source puts HITS on the source pill, and the compose framing carries the called-shot lane’s forecast fence + attribution.credit_rule. The desk lanes (cogs/music_desk.py _weekly_board_units / _sales_card_units / _projection_board_units, stories chart_board / debut_sales / chart_proj) own pacing + dedup: weekly cards key on the chart WEEK (bbw: prefix, one card per chart per week); a HITS projection variant keys on the SOURCE DOCUMENT VERSION (bbp:<kind>:<version>, version = the projection’s tracking week + the source’s own updated_at, MusicDesk._proj_board_version) — so a fresh HITS document is postable on the NEXT 30-min slot after it lands, not the next UTC day, and the same numbers never repost. This is the owner’s “be first” steer (2026-08-19: “we need to beat everyone else, be first to publish all boards as soon as they update”), and it REPLACES the earlier bbp:<kind>:<date> daily key. That date key had two costs the owner hit on 2026-08-18: it front-loaded the whole day’s boards at 00:00 UTC (the date rolled, every variant re-opened, the day’s budget filled in the first slots) and it BLOCKED a document HITS published later the same UTC day (the date key was already stamped) — so on the Tuesday HITS drops the midweek preview (~20:36 UTC) our boards still showed last week’s building chart and trailed @chartdata by ~a day (KATSEYE #1 in the preview, our boards still leading “Stray Kids”). Version keying makes “first” a cache property the scheduler no longer fights (the two-speed HITS cache already re-reads every ~20min while a publish is due), and it removes the “same numbers every morning” repetition the date key caused. The MARKET board (market_units) keeps a per-UTC-day key (bbp:market_units:<date>) — its data is the Kalshi slate, which moves daily. There is NO per-day ceiling anymore: the old one existed to stop the date key’s daily re-flood (six Ariana boards before 09:02 on 2026-08-07, then 21h silent), and version keying removes that pressure at the source (a variant re-opens only when the document actually changes, ~twice a week), so the per-slot cap alone paces the burst. Variants rotate least-recently-posted first (_rotate_by_last_posted, read off the version keys already in the window) so the whole set of a fresh document clears across the next few slots, highest-notability first, and the default cap is 3/slot (MUSIC_DESK_MAX_PROJ_BOARDS, owner steer 2026-08-19 — the five HITS variants clear in two slots after the drop, lead board first, rather than trickling over three). Checked against the 9-day window (_WEEKLY_BOARD_DEDUP_HOURS), not a ~1-day one, so the key outlasts the ~weeklong document it names. The variants are SEVERAL READS OF ONE CHART WEEK, and the dedup stack had to be taught that (#2098). Measured on the live Aug 15 week: all of them composed and cleared the 0.6 gate, and only ONE reached the room — the other three died on text_similarity, every take having opened “Ariana Grande’s Petal is tracking…”. Three layers were fixed, each measured: (1) each board now leads with a fact only IT holds and is fenced OFF the others’ figures (_PROJ_STEERS — positions on the rank board, the leader’s units + the gap to #2 on the units board (the steer once said “the SPREAD rather than who is #1” and the 2026-09-08 take named a ~4K gap with nobody in it while Ella Langley’s “Dandelion” sat unnamed at #1 – every steer names the act on top, the ANGLE is what differs; platform_streams carried the same clause and got the same reword), the entry class on the debut board, copies bought on the pure-sales board, the pure-sales split on the debut-sales board; the owner’s rule is that the same album is fine but the intro must name the chart and which read this is). That alone dropped mechanical overlap under threshold but left the topic JUDGE catching them, in its words “just breaks down the sales mix… No new ranking movement”. (2) So the chart-card lanes are deduped on WORDING AND LINKS ALONE (_sched_dedup_wording_only, one hook on ScheduledPoster, default False) — they are named for the effect, not for a category of post. Such a unit keeps same_link + text_similarity and drops the rest: it drops the two FORMAT signals (dedup.FORMAT_SIGNALS) because a card must name its chart, week and source every time and “the Aug 15 Billboard 200, with HITS Daily Double” is a 40-char shared_run before the post says anything of its own, and it drops the judge’s CATCH direction because “is this a new development?” is the right question for a news unit and the wrong one for a recurring graphic. The judge’s OVERTURN direction stays. same_link and text_similarity are NOT switchable and still run on every board, so an actual repost is still caught on its wording. _sched_dedup_history(composed, recent) is the SECOND spine hook and the orthogonal axis: _sched_dedup_wording_only changes WHICH SIGNALS run, this changes WHAT the unit is compared against. Default returns the whole list; music_desk is the only override (#2170). One album carries a Pure Sales ladder AND an Album-Equivalent Units ladder at once (live 2026-08-07: petal at ~314.2K equivalent units and ~177.3K pure sales, ten minutes apart), and #2163 put music_alert into WIRE_DEDUP_SURFACES so those two ladders met in this gate for the first time. The topic judge suppresses that pair 5/5 while naming the metric difference in its own reason (“different metric (pure sales vs total units) but no material development”) — its DUPLICATE-vs-DEVELOPMENT axis has no slot for two PARALLEL facts about one subject, so a second true measurement reads as a restatement. It is fixed at the READ and not in the judge’s prompt because the prompt is measurably not the lever: five arms (add to the allow list, cut the market-restatement clause, reword it to name the quantity, extend the subject-collapsing line, cut-plus-extend) each scored 0/5 on the pair while every cross-domain control held 25/25 (scripts/ablate_topic_dedup.py reproduces it), and that judge serves the other four wire desks, so loosening it for one music case is the wrong tool even if an arm had worked. Dropping the line is safe rather than convenient: two posts reporting DIFFERENT quantities cannot be duplicates of each other, so nothing catchable is lost, and output_checks.album_metric_named fails closed into KEEP (a line naming neither metric, or naming both, stays in the list) so the narrowing only ever applies to the pair it was measured on. Owner steer 2026-08-08: “Separate metrics are okay to post.” Like _sched_dedup_wording_only it emits no event of its own — the behaviour is deterministic and pinned by unit tests rather than watched on a dashboard. After all three: every variant reaches the room (measured 4/4 at the time, 5/5 once proj_pure_sales was added). The debuts on the sales board also ship individually as big-number cards (sales_card_fields, figure = units, HITS-credited, capped MUSIC_DESK_MAX_SALES_CARDS/slot). Gated by music_weekly_charts + music_chart_projections (both PRODUCTION on the owner’s direct ask). The KALSHI MARKET board (market_units, hero “Market Projections”, story market_board, experiment music_market_board — PRODUCTION, #2098). It is the ONLY market board: the coalesced market_rank and market_debut cards were built, measured and then removed on the owner’s call (2026-08-07, “remove market adjusted for now” and “market priced debuts is now redundant”) — the projected debut coverage they duplicated already lives on debut_sales (printed) and proj_debut_rank / proj_debut_sales (projected). The whole coalescing layer went with them. The surviving board is the forward RELEASE CALENDAR: Kalshi’s priced first-week markets for the UPCOMING chart weeks, in DATE order (owner calls 2026-08-07: “okay lets do the upcoming weeks”, then “sort by date” — a calendar reads forward, so the chart that prints next is the top row and the reader scans down to see what is further out; units break a tie inside one week, and the top-10 cut therefore keeps the NEAREST weeks. The headline still names the biggest CALL, and that row carries the highlight rather than row 0). Multiple weeks on purpose — Kalshi prices only 2-3 releases per Billboard week, so a one-week cut is two rows and usually zero: measured on the live slate that day the five covered weeks held 2, 2, 2, 1 and 1 markets, and a 3-row floor left the board dark in every one. Spanning the weeks is what makes it a board, and the whole forward slate at once is the thing no other chart account offers. The lead column is the chart week — on a calendar “~296K” means nothing until you know which chart it lands on, and the week cannot ride in the credit column beside the artist, where the card truncated it mid-word (“Rod Wave · Sep 12 ch”). HITS’ projected RANK cannot lead here: HITS publishes one document covering one week, so a rank column would be blank for most rows; HITS’ rank and figure ride the compose block instead (market_board_lines, which takes an optional week so a single-week consumer is never handed a release its card omits). The card draws no date line — no single week is true of it, and a calendar wearing one chart’s date would be false for most of its own rows. Kalshi’s numbers only on the card; a market whose period carries no full end date yields no week and is dropped rather than guessed. HITS’ figure and the GAP between the two sources live in the compose blob (market_board_lines), not on the card: the disagreement is a sentence, and it works on any slate. Dry run: python -m scripts.dryrun_chart_boards (--cards writes every PNG, --compose the real compose + gate). The PLATFORM DAILY BOARDS (owner steer 2026-08-07: “lets do charts for top ten apple music and spotify top ten too. And also new charts for those too, also streams equivalents”). Three board kinds over the kworb platform rows the watch sweep already caches (no new fetches), on the four charts the owner named (_PLATFORM_BOARD_KEYS = Spotify US + Global daily, Apple Music songs + albums): platform_top (the top 10 with day-over-day move markers), platform_streams (the same ten with the platform’s own play counts as the value column — Spotify only, since Apple/iTunes rows are rank-only and a count we don’t have is ABSENT, never estimated; the #1-vs-#2 gap is computed in code so the compose does no row arithmetic), and platform_new (the titles new to the chart, listed at their CURRENT rank: a title is new for the chart_presence 24h first-sighting window — owner steer #2139, so a release-day entry that climbs overnight is still new the next morning at the rank it climbed to — run through the SAME memory the watch lanes use so a tracked-range bounce is not re-announced; when the memory is unavailable the board falls back to kworb’s own NEW flags alone, never “the whole chart is new”; worded “new to the chart” — never “debut”, which claims a release date no chart row carries, the #2045 class). The board also DATES its shown entries (the fourth appearance of the #2045 class): kworb’s NEW flag says only “new to this chart today”, so a catalog SURGE — Dolly Parton’s whole catalog re-entering US iTunes the day she died — filled the board with 1970s songs and the compose wrote “‘I Will Always Love You’ debuting straight to #1”. The cog dates each shown row through the shared _release_age_days (fail-open per row: an undatable title stays None, so one bad title never blanks the top-10/streams boards in the same try). When the highest new entry is _NEW_RELEASE_MAX_AGE_DAYS (45) or older, platform_new_entries reframes the whole board as a Catalog Surge — “N older titles back on the chart, not new releases” — and the platform compose fence forbids a debut claim; a fresh or undatable leader keeps the neutral “new to the chart” wording. Every header, fence and source pill names the PLATFORM (a bare top-10 claim reads as Billboard); per-kind steers mirror _PROJ_STEERS’ measured discipline (#2098) so three boards about one chart on one day lead with different facts, and the lane rides the wording-only dedup (_CHART_CARD_STORIES). Cog lane _platform_board_units: story platform_board, experiment music_platform_boards (PRODUCTION on the direct ask, the weekly-charts precedent), one post per board per day (pfb:<chart>:<kind>:<date> keys), least-recently-posted rotation, caps MUSIC_DESK_MAX_PLATFORM_BOARDS (1/slot) + MUSIC_DESK_MAX_PLATFORM_BOARDS_DAY (4/day) pacing up to 10 possible boards a day. Dry-run 2026-08-07 on live kworb rows + the real compose/gate: three kinds composed 0.92–0.95, each naming its platform. The GENRE streams + sales boards (owner steer 2026-08-08, “lets have streams and sales by genres”; the reference is Kurrco’s HITS-sourced “best-selling albums by rappers” card). Two board kinds per genre GROUP — the owner’s grouping (“combine rap and hiphop”, then “Separate rnb”): hip-hop (the rap charts), R&B (Billboard’s R&B sub-charts rnb/rnb_albums, added + live-verified for this split — the combined R&B/Hip-Hop family mixes both so it scopes neither), — both kinds cut from the ONE HITS Top 50 document (LATIN is OFF as a group, owner call 2026-08-08 “replace latin with pop … or just turn off latin”: it yielded under the row floor most weeks, and POP — the wanted replacement — has no Billboard genre album chart; Apple’s marketing feed carries per-album genre tags and is the probed path, tracked as its own follow-up) (hits.CHART_BUILDING, whose rows carry the full split): genre_sales ranks the genre’s albums by PURE SALES (album_sales — copies bought; ABSENT when the document ships no pure column, never units relabeled as sales) and genre_streams by STREAMING units (the sea column). Genre membership is Billboard CHART membership — the ALBUM on the genre’s Billboard ALBUM chart this week (chart_boards.GENRE_MEMBERSHIP maps the genre to that chart key; _genre_rows joins on the normalized album|artist key, the same key debut_sales_pairs uses, and there is deliberately NO artist_watch.Watchlist in this path — membership is the ALBUM’s own presence, never an artist-level inference, so Drake charting one R&B song cannot drag his rap album onto the R&B board) — never a guessed tag; an album on no kept genre chart is simply absent, and a genre with under 3 matched-and-valued rows yields no board (measured live: Latin yields, rap and R&B/hip-hop fill). The DEBUT fallback (owner question 2026-09-05, “no way these are the only rap or rnb albums here”): the genre chart the join reads is the PRINTED week, and the HITS document projects the week AFTER it, so an album in its first tracking week is on no printed chart and missed both boards on its biggest week — measured live, Rod Wave’s Don’t Look Down projected #1 on ~100K units and sat on neither card while the hip-hop card showed five catalog-week titles. So genre_split_boards takes a third argument, debut_groups: the cog (_genre_debut_groups) fetches the printed billboard200 beside the genre charts, takes genre_debut_candidates (HITS rows on NO row of the printed 200 — with no printed 200 nothing is a candidate, prefer absent over guessed), and reads each one’s genre from Apple’s own per-album primaryGenreName through one apple_music.search_catalog(entity="album") call, accepting the result only when it matches the row on the same album|artist key (genre_row_key); APPLE_GENRE_GROUPS maps the tag to the kept group at both of Apple’s levels (“Hip-Hop/Rap” AND plain “Rap”, since primaryGenreName is sometimes the sub-genre — SZA’s SOS reads “R&B/Soul”, Rod Wave’s album reads “Rap”), and a tag outside the kept groups leaves the album absent. Still a published fact about the ALBUM, not an artist inference (the Iceman leads R&B streaming rule holds: Drake’s albums chart last week, so they never enter this path). TWO SOURCES MUST AGREE (confirmed_genre_group, 2026-09-11, owner report “bday isn’t a hiphop album” then “the release isn’t the problem, it’s the genre”): Apple’s tag is simply wrong on some albums, and nothing about the album says so. Beyonce’s B’Day reads Hip-Hop/Rap on the 2006 record and Pop on that same album’s other editions, so Apple does not even agree with itself; it led the hip-hop SALES card at ~15.9K for the week of 2026-09-19. So the tag is now CONFIRMED against Deezer’s own album genre (deezer.album_genres, guarded by the same album_title_matches + _artist_overlaps wrong-match rule the other Deezer readers use), and the album joins a board only when both sources name the same kept group. Deezer writes its own vocabulary (Rap/Hip Hop where Apple writes Hip-Hop/Rap), so DEEZER_GENRE_GROUPS maps it separately; one kept group among an album’s several genres confirms it, two different ones do not. Deezer is read ONLY when Apple already names a kept group, so a pop or country debut costs no call. The two ways it can fail are separated in telemetry: genre_disagree (Deezer names another group, or none — the B’Day case) and genre_unconfirmed (Deezer holds no genre for the album yet, the release-day shape, which is NOT cached so a later slot confirms it). Live tags measured 2026-09-11: B’Day Apple Hip-Hop/Rap / Deezer Pop; Iceman, Views, Graduation, Take Care, The Diamond Collection Apple Hip-Hop/Rap / Deezer Rap/Hip Hop; Don’t Look Down, Octane, The Real Me Apple Rap / Deezer Rap/Hip Hop; SOS Apple R&B/Soul / Deezer R&B. The RELEASE DATE is deliberately NOT the guard. The first fix fenced the tag on the matched Apple row’s release date, which caught B’Day only because the wrong edition was also the old one — it gave no protection at all on the single-edition debut the fallback exists for, and it wrongly dropped a catalog rap album that re-enters off the Billboard rap chart. The owner’s steer killed it. Capped at MUSIC_DESK_GENRE_DEBUT_TAG_CAP (8) lookups per read, cached per album for the process, one genre_debut_tag event per lookup (ops-monitor misc health itunes_genre_tag). Dry run 2026-09-05 on the live document: the hip-hop card went from 5 rows to 6 with Don’t Look Down leading streams at ~90.5K, and the R&B card was unchanged. NOTE the two reads deliberately diverge: Kurrco’s “best-selling” figures are UNITS, so catalog with real physical sales (Take Care, Thriller) tops OUR sales board while the streams board matches Kurrco’s ordering — the honest split, stated per board by the _PROJ_STEERS entries (each names its measure and keeps the claim on the rows the board read). The CLAIM IS BOUNDED TO THE TOP 50, because the rows are (#2252). _genre_rows intersects the HITS Top 50 with the genre’s Billboard chart, so a genre album that misses the overall 50 is not on the board at all — measured live 2026-08-10, the rap chart’s 25 rows yielded 5 matches and R&B’s 25 yielded 5. The first version said “leads the week’s chart_records.py, the chart RECORD watch (owner ask 2026-08-10, “records that they’re breaking”): the durable crown ledger — one kv_cache row per (chart, year), namespace chart_records, each printed week compressed to WeekSummary (the #1, the top 10, the highest debut, the biggest climb, the deepest act; the top 10 is filtered on the printed RANK, never the first ten parsed rows – a parse that drops one row is still accepted at half the chart, and rows[:10] would then record rank 11 as a top-10 song, #2710) — plus the pure derivations: year_best_cards (biggest climb / deepest week of the year — there is deliberately NO highest-debut card: excluding #1 debuts makes the superlative false, including them duplicates the crown lane, so it was cut on the shipping dry run; WeekSummary.high_debut is still recorded because re-deriving it later would need a full dated-page backfill), alltime_cards (checks against the small verified RECORDS table — Hot 100 only: 42 simultaneous entries/Drake, the full-top-10 sweep/Taylor Swift, 19 straight weeks at #1/Old Town Road, the 97→1 jump/Kelly Clarkson; every value carries its provenance in a comment and an unverified record has NO row), run_rung_card (the Nth-straight-week-at-#1 beat at rungs 5/10/15/20; its blob states the year’s PRINTED WEEK COUNT and whether the run is the year’s LONGEST (year_longest_run; the 2026-09-06 dry run shipped “the longest run of 2026 so far” at 0.92 with nothing in the blob to say so, so the fact is now stated either way) too, because the take reaches for “20 of 36 printed weeks” and 36 was in nothing it was handed – 61 ungrounded_number drops in one day, 2026-09-03; the other flagged figure, the run’s “10” against the blob’s “10th”, is grounded by output_checks now reading a digit ordinal’s digits on the SOURCE side), and year_race_board (weeks at #1 per act, YTD), year_n1_songs_board (distinct #1 SONGS per act, YTD – the “most #1s in {year}” board, owner steer 2026-08-20; story year_n1_songs, fires once per printed week, needs a real race so it is silent below a 2-song leader / 3 acts), and year_top10_songs_board (the same tally one rung wider – distinct TOP 10 songs per act, YTD, the “most top 10s in {year}” board, owner ask 2026-08-30; story year_top10_songs, hero “Most Top 10s This Year”). The record watch reads THREE charts (_RECORD_CHARTS): the Hot 100, the Billboard 200, and – from 2026-08-31, owner ask “what about other charts” (#2729) – the Global 200. That is what gives the two song races a SECOND chart to run on, the other two record charts being the Hot 100 and the album chart. The Global 200 is the right one to add: 200 rows of real competition, where a 25-row genre chart makes a “top 10” 40% of the page and would need the genre boards’ own known-artist guard first. Adding a chart costs one backfill of its year (~35 dated pages at BACKFILL_PER_SLOT=2 a slot, about a day of slots) before year_complete lets it say anything; verified before shipping that the dated Global 200 page fetches and parses to a full 200 rows, since a chart whose dated pages 404 would leave the lane silent forever. The LOOKUP boards keep their own _LOOKUP_CHARTS pair so a new record chart does not make that lane fetch a page it never reads. The corroboration these boards split credits against is supplied by the DESK, not derived from the ledger alone (#2710 follow-up): the ledger holds one top 10 a week, so its own solo credits are ~29 acts for a whole year, and a featured act without a top-10 hit of its own was dropped from the tally while a two-act credit whose parts both missed stayed WHOLE and counted as one act under a name no artist has. _record_units now passes the same chart_standings.known_acts set the standings boards use (the full 100-row weeks plus the ~1000-name kworb list) into alltime_cards and both song boards, and into the dedup signatures so a key can never read rows the card does not draw. Measured on the live 2026 Hot 100: Future, JENNIE, Zara Larsson, PinkPantheress, EJAE, Audrey Nuna and REI AMI became credited acts, and the two phantom composite “artists” disappeared, with the drawn rows of both cards unchanged. Fail-open: a kworb outage passes an empty set and the boards fall back to ledger-only corroboration. Both song races run off ONE pure core (_distinct_song_rows / _song_race_signature / _song_race_board), so the joint-credit rule, the co-leader headline and the top-10-row dedup signature are defined once; they differ only in which entries they read (the week’s #1 vs its whole top 10), their hero/metric wording and their leader floor. The TOP 10 floor is THREE songs, not the #1 board’s two: a year prints ten top-10 slots a week, so a two-song lead there is a tie, not a race (measured on the live 2026 Hot 100 ledger, Aug 29: Drake 9, Bad Bunny 4, Olivia Rodrigo 4, then a wide band of 2s). Both boards now draw a sub naming the act’s BEST song – the highest position the act printed at in this year’s weeks, and the title that printed there (owner ask 2026-08-30, both cards shipped with bare counts). The WORDING differs per board, and _song_race_row_sub owns both: the TOP 10 board draws best #<rank> "<title>", the shape the census and credits boards use, because its rows reach different heights; the No. 1 board draws the TITLE alone, because every row there is a #1 and “best #1” would print the same two words ten times (owner call on the rendered card). Ties on these small counts now break on the better best-rank before the act name, so a four-song row with a #1 never sorts under a four-song row whose best is #7; each dedup signature hashes its own board’s rendered sub, so a named song or a moved best mints a new key at unchanged counts (the new card would otherwise never post) and nothing else does. The BEST song of an act is the one that printed at its best rank; two songs tied there resolve to the one that printed in more of the year’s weeks, then the alphabetically first title. The compose blocks are UNCHANGED: the framing already tells the take not to recite rows, and a “best #N” line inside the top-10 block would invite the year-scoped achievement claim that block’s fence forbids. Both run on SONG and ALBUM charts (owner ask 2026-08-30, “do 200s too”): _race_unit reads billboard.chart_unit, so a Billboard 200 card says “top 10 albums” in its headline, its caption and its compose block, and the earlier song-chart gate in _record_units is gone – it existed only because the wording was hard-coded. A PLACEHOLDER credit never becomes a row: the 200 prints “Soundtrack” and “Various Artists” on real top-10 rows, and is_placeholder_credit drops them, because ranking one puts a name no artist has on a board about acts (the same fault as the phantom composite credits #2710 removed). The census board took the same filter in #2739. Two fences came out of the #2710 review: a tie as WIDE as the drawn rows makes the board silent (with no row below the lead, the compose topic counts ten highlighted rows while the headline states the true larger number), and the top-10 block says a song APPEARED in the top 10, never that it “reached” it this year (a song that entered the ten in December is still in January’s weeks, so “reached” would claim a year-scoped achievement the ledger cannot see). The honesty fences are structural, not prompt rules: a year claim needs year_complete (the cog backfills dated pages BACKFILL_PER_SLOT=2 per slot until the ledger holds every week of the year), a consecutive-run count needs run_is_bounded (the ledger must have SEEN the run start), and Mariah’s 22 CUMULATIVE weeks are deliberately not in the table because a year ledger cannot measure a cross-year total. The desk lane (_record_units, experiment music_chart_records, stories record_card/year_race/year_n1_songs/year_top10_songs, dedup rcw: – the two song races key on their ranked-tally signature, not the printed week, so an unchanged board never reposts) composes the cards through crx.compose_inputs — whose fence INVERTS the standings “no record claims” rule into “ONLY the record stated in the block, at its exact scope” — and records year-best high-waters on ship (_record_ship → record_state, kv key <chart>:<year>:state). Ships STAGING.lifetime_boards.py, the LIFETIME / career record boards (owner ask 2026-08-19, off the @billboardcharts “most No. 1s on Rhythmic Airplay” graphic → “can we do lifetime boards milestones for everything for known artists”): the evergreen career-tally leaderboards — “the artists with the most No. 1s on a chart across its whole history” — drawn as the same ranked Board card the standings lane uses (leader row highlighted, the count in the value column). WHY CURATED, not derived: chart_records states plainly that cumulative-history metrics are out of scope — no week read produces a career count, and no free source publishes the per-format all-time #1 tallies (kworb’s radio page defers format charts to All Access, Pollstar’s free tier is this week’s spins). So this is a fixed, cited registry, exactly like the verified RECORDS table: every LifetimeBoard carries its source, an as_of date, and a provenance citation, and the validate guard is the fabrication fence — it drops a board that is a leaderboard of one, is not strictly ranked largest-first, repeats an artist, has a non-positive tally, or is missing a source/as_of/provenance (a golden bad board in the tests must stay rejected). build re-sorts defensively before drawing so the drawn #1 can never disagree with the largest tally; the take is BRAND-FREE (compose_source="", the pill is the credit) and its framing is the standings fence with ONE inversion — the all-time claim is ALLOWED (that is the surface), but only the record the block states, never widened past the one board. The registry GROWS one cited board at a time. It carries the Rhythmic Airplay #1s board (@billboardcharts) plus the Billboard Hot 100 career records (epic #2436, Tiers 2/3): most No. 1 singles (Beatles 20), most top-10 hits (Drake 90), most cumulative weeks at No. 1 (Mariah 101), most Hot 100 entries (Drake 403) — each read table-exact from Wikipedia’s “List of Billboard Hot 100 chart achievements and milestones”, pill-credited to Billboard with the Wikipedia section in provenance. Genre-chart records ride the same discipline (epic #2436, Tier 2 subsets): most No. 1s on Hot R&B/Hip-Hop Songs (Drake 32), Dance Club Songs (Madonna 50), and Hot Latin Songs (Enrique Iglesias 27), each Wikipedia-sourced and cited (Pop Airplay deferred — its Wikipedia list is an unranked alphabetical page that did not parse cleanly, so it is not shipped rather than shipped from a noisy parse). The Billboard 200 album records ride the same discipline, parsed BY ACT from Wikipedia’s ‘Billboard 200’ article (the honest cut — the Beatles hold the album-#1 record as an act at 19, where “Paul McCartney 27” counts Beatles+Wings+solo as one individual): most No. 1 albums (Beatles 19), most weeks at No. 1 (Beatles 132), most top-10 albums (Rolling Stones 39). metric carries the chart SCOPE (“No. 1s on Rhythmic Airplay”, “top-10 hits on the Hot 100”) so the headline can never read as an unscoped “the most ever, any chart”; column_header keeps the card column short. credit_basis carries the FEATURES-vs-LEAD nuance: Billboard’s artist chart records count every song an act is CREDITED on — lead OR featured — so Drake’s “90 top-10 hits” includes songs where he is a guest (which is why a fan graphic that counts lead-only lands on a different number). Every SONG record (the No.1s / top-10s / entries / weeks boards) sets credit_basis="lead and featured credits", which adds a compose fence: the take must say “hits” / “songs he’s on”, never “his own songs” or “he made/wrote them all” — a featured credit counts for the record but is not a solo hit. The album boards (bb200) leave it empty; the guest-credit case does not arise the same way there. The desk lane (_lifetime_boards_units, experiment music_lifetime_boards, story lifetime_board) FIRES ON CHANGE, not on a schedule (owner steer): lifetime_boards.signature hashes a board’s ranked rows, the cog stores it durably in kv_cache (namespace lifetime_board_state, long TTL), and a board posts on FIRST sight (once — it is new to the room) and then ONLY when its signature CHANGES; an unchanged record never re-posts. The signature is keyed per (guild, stage, board) and written only on a CONFIRMED delivery or an intentional dedup, never at compose time (Codex #2437): a STAGING audition in #bot-logs stamps its own key, so the board still posts ONCE to the room after the experiment is promoted to PRODUCTION. It is per-GUILD, not per-channel, on purpose — like every desk lane, the scheduler’s guild-wide history dedup already posts a story to one room per slot, so the first room delivers and stamps while the next room skips at compose. (Per-channel keying was tried and reverted: it does not actually deliver to a second room either — that copy is history-deduped and closed out — it only spends a wasted compose; genuine per-room delivery would need destination-scoped history dedup on the shared ScheduledPoster spine, out of scope here.) The state rides Composed.meta (extra_meta={"lifetime_state": (key, sig)}) and is persisted from _record_ship on the delivery success path — and also from _sched_on_dedup, so a board the scheduler rejects as a cross-desk duplicate is closed out instead of recomposing every slot. NO WALL on a first deploy or a big batch: at most ONE board posts per slot (_MAX_LIFETIME_BOARDS_PER_SLOT), at most a few real composes are attempted per slot (_MAX_LIFETIME_BOARD_ATTEMPTS_PER_SLOT, so a gate-held board can’t walk the whole registry in one slot), and the lane sits low in priority, so new boards trickle out one at a time. (An earlier “seed silently unless updated within N days” window was cut — the per-slot cap already prevents a flood, and the date window only added a late-merge trap; LifetimeBoard.updated stays as audit metadata, not read by the lane.) It reuses the generic _compose_board_unit/_sched_deliver path. Dry run: python scripts/dryrun_lifetime_boards.py --cards DIR. Ships STAGING. Tier 1 (live, self-updating) is deliberately NOT here — a stream-total leaderboard goes stale in days, so it can’t be a fixed curated list; those live in the artist-boards lane (music_artist_boards), which posts all-time artists / videos / monthly listeners and now the all-time most-streamed SONGS board too (chart_boards.song_streams_board off kworb.spotify_songs, spotify/songs.html, functional-audio filtered). That songs board is an all-time record, so it takes its OWN story song_streams and its OWN compose inputs (chart_boards.song_streams_compose_inputs) — not the shared platform framing, whose base fence forbids “the most ever” and opens with a Billboard-printed header, both wrong for a Spotify all-time ranking (Codex #2437 P2). Its story joins _BOARD_LANES (row-arithmetic / row-tally guards) and _ALLTIME_EXEMPT_LANES (the all-time claim is allowed, exactly like lifetime_board), while the daily platform boards on the same lane still cannot make an all-time claim. Its song_streams kind is also in _CHANGE_GATED_BOARD_KINDS, so this evergreen list reposts only when its lineup MOVES (a 9-day lineup key), not once a day like the editorial platform boards (Codex #2437).riaa_boards.py, the RIAA record boards (#2839, line B of #2800), the GENRE boards (#2860, owner ask 2026-09-02: the highest-certified singles and albums of one genre off the site’s own genre-filtered award search, RiaaClient.awards_by_genre, Diamond level and up, all acts, all time; Pop, R&B/hip-hop, Country and Rock, GENRE_BOARDS; the Latin genre is left out because it sits on the Latin program’s 600,000-unit ladder; each board on its own state key riaa_genre:<slug>:<format>, the take fenced to the one genre “as RIAA files it”), the FASTEST TO DIAMOND board (owner pick 2026-09-02: the roster’s Diamond singles ranked by the days from the first RIAA certification to Diamond off each title’s timeline, riaa.diamond_singles + riaa.diamond_path + riaa_boards.fastest_board, chart riaa_fastest_diamond; the timelines fill on the same per-slot budget as the roster walk, AFTER the eight genre searches (the genre boards land in three slots; the ~60 timelines take a day), and the board waits for every one, music_ranking reason=riaa_timelines_partial meanwhile; a title certified straight to Diamond has no road and is not ranked; the take fenced to “certified”, never “sold”, and to today’s top acts), plus the per-act NEW PLAQUES board (owner ask 2026-09-02, “an artist board for their new certifications in period”; the fold #2854 asked for): an act with MIN_PLAQUES (2) or more awards inside the 3-day window the newsroom’s cert lane reads gets one board, its titles at their new levels highest first (plaques_board, chart riaa_plaques:<act>, its own fire-on-change state per act, the SAME cached feed read), ahead of the evergreen boards in the slot; the newsroom cards only the act’s top cert batch cards awards (the tune knob, default 5) and stamps the rest seen (music_news_filtered reason=cert_batch_deferred), so a batch is at most five cards in the room and one board, never 59 cards (Rod Wave, 2026-08-31). The plaques board is MAIN program only (plaque_rows, #3308), the same scope diamond_board, genre_board, batch_cluster, artist_board and the newsroom’s cert lane (#2902) keep: the Latin program runs its own ladder (Oro 30,000, Platino 60,000, Diamante 600,000) on the same rows while Certification.award_name reads every row on the main one. It was the one sibling the earlier sweeps missed, and on 2026-09-14 it shipped a Tito Double P board to X whose eight rows were all program="LA" – a Latin 38x Platino (2.28 million units) drawn as “38x Platinum (Diamond)”, the main ladder’s words for 38 million. The count, the titles and the badge levels were all right; only the ladder was wrong. A Latin act now draws no plaques board at all. A sweep a WIRE reports late takes a second route into the same board (#3211, owner report 2026-09-12, “wheres the board”): the lane above reads awards RIAA certified in the last 3 days, which is right for a sweep the desk sees first and cannot reach one the wires notice months later. Measured live 2026-09-12: RIAA dated 50 Future awards 2026-07-16 and 4 more 07-15, the X accounts reported them 58 days later as “53 certifications”, and the recent feed held ZERO Future awards over 14 days – so the newsroom carded the wire’s COUNT as a bare number card with no titles under it, and the board lane never saw the sweep at all. cogs/music_news.py:_cert_batch_card closes that: a cert story whose FIGURE is a count rather than a level (music_news.cert_batch_count, deliberately narrow – “15x Platinum” and every other hero read as None) is drawn as the plaques board instead, off the act’s OWN award search (riaa_boards.batch_cluster takes the awards within BATCH_SPAN_DAYS of the act’s BUSIEST certification date inside BATCH_MAX_AGE_DAYS (120) – busiest, not newest, because Future’s newest award that day was one feature credit dated 08-27 while his sweep was the 50 titles on 07-16). It reads the search CACHED ONLY (fetch=False): one search is up to 16 paced pages, ~100s measured, which a post cannot wait for, and the roster walk already holds RIAA’s top acts warm for a week – which is also why a climbing cert_batch reason=cold_cache rate points at _riaa_candidates, not at RIAA. The call is gated on the CERT kind, not on the figure alone: another lane can state a count of certifications and mean something else, and a record story about a career total would otherwise lose its record card to whichever sweep is cached. The figure is read BEFORE _build_card’s phrase guard, which clears anything over 24 characters – “53 new RIAA certifications” is 26. PREFER ABSENT four times over: the counts must agree within a factor of two BOTH ways (count_mismatch – too few and a three-row board carries a take saying 53, too many and the busiest-date rule found an OLDER sweep and drew it under a take about a new one); the story’s authority must be RIAA or unstated (other_authority, and cert_batch_count matches no BPI figure), because the board reads bot.riaa and wears its pill; the sweep is MAIN program only, since the Latin ladder is different while award_name reads every row on the main one; and every skip ships the number card the story would have had, so no card is lost. The last four all come from the Codex review. The plaques board rides the CERTIFICATION SOURCE’s experiment (music_riaa_certs, story riaa_plaques), not the record boards’ knob (owner call 2026-09-02, “the artist board should fire regardless”): measured that day, the certs lane was PRODUCTION and posted 14 Rod Wave cards to the room while the board, on the STAGING music_riaa_boards knob, reached only #bot-logs at 22:59Z. _riaa_board_units resolves both stages; the plaques boards are built when the certs knob is not OFF and carry that stage (their fire-on-change state is keyed on it), the record boards when their own knob is not OFF; _riaa_board_compose ships the first changed board of the slot, plaques first. riaa_plaques takes the wording-only dedup gate (it follows the act’s own cert cards into the room by design, so the full topic gate would read it as their restatement), ranks with the dated board lanes in _LANE_PRIORITY above the evergreen boards, and takes the row-arithmetic guards but is NOT all-time exempt (its claim is one window). Both readers credit an act through the one artist_watch.credited_names resolver. One recording is counted ONCE, on riaa.title_identity (#2884): RIAA lists the same recording more than once when the feature clause is punctuated differently or when one row carries it and another does not – measured live 2026-09-03, “‘Till I Collapse” three times at 8x, “Stan”/”Stan (feat. Dido)” both 4x, “What Do You Mean?”/”What Do You Mean” both 8x – and every count keyed on the raw title, so Eminem’s certified units read 29 million too high and he sat a place above Michael Jackson he had not earned. The identity strips ONLY a real feature clause (a bracket that merely CONTAINS a marker names a different recording: “1989 (Taylor’s Version)”, “All Of The Lights (Interlude)”, “Love The Way You Lie (Part II ft. Eminem)”), and the tallies de-duplicate HIGHEST LEVEL first so a folded group keeps its current certification. strip_title_feature is the display side of the same rule. The PER-ARTIST CATALOG boards (artist_board, charts riaa_artist:<act>:single and :album) are the career sibling of the windowed plaques board – one act’s catalog of ONE format, highest level first, that format’s certified units as the figure, MIN_ARTIST_TITLES (3) or no board. Singles and albums are separate charts and the rows carry no format sub (owner calls 2026-09-03: “remove the singles sub its pretty redundant”, “we shouldn’t mix album and singles on the same chart”, “ship 2 instead” – the same call made earlier on the Diamond board). The figure totals that format alone (units_tally(fmt=...)), so the two boards sum to the career total (Miley: 87.5M over 45 singles + 16.5M over 9 albums = 104.0M) and neither claims to be everything; the take is fenced to say the format word and never one that covers both, and the TOTAL rides the CAPTION because a list card draws no figure of its own. The windowed plaques board is split by format too now (owner calls 2026-09-04: “no single or album ever … that should either [be] in the board or implied”, “no plain ‘single’ string”): plaques_board(fmt=...) draws one act’s new SINGLES on riaa_plaques:<act>:single and its new ALBUMS on riaa_plaques:<act>:album, each with its own MIN_PLAQUES floor, fire-on-change state and plaque_facts read (that key rename re-fired every open batch once, 2026-09-05: the state is keyed on the chart, so riaa_plaques:nickelback -> riaa_plaques:nickelback:single read as never posted, and Nickelback’s board went to the room a second time at 02:08Z, 11 hours after the first, with the SAME signature under both keys in kv_cache; Disney’s twin was stopped by the text dedup. The lesson: a fire-on-change board’s chart key is its memory, so a rename either migrates the stored rows or is announced as a one-time re-fire per open batch. A collaboration batch draws ONE board, not one per credited act: awards_by_act hands every act on a shared credit the same rows, so “Intro” 7x + “Ganga” 4x boarded as Peso Pluma at 23:04Z and again as Tito Double P at 00:02Z the same night, one signature under two act keys, and only the text dedup stopped the second. So _riaa_board_compose keeps a second state keyed on the signature alone (<guild>:<stage>:riaa_plaques:sig:<sig>, value = the chart that shipped it, lifetime_twin in the unit’s meta, written by _record_lifetime_meta on the same confirmed delivery or dedup close-out as the act’s own key); a plaques board whose signature already shipped under another act is skipped before compose, music_desk_scored phase=riaa_plaques shipped=false reason=plaques_twin, detail.twin the chart that owns it); the caption (“singles certified Sep 4, 2026”) and the headline (“… certifications on singles …”) carry the format, the take is fenced to say that word and never one that covers both, and NO ROW ever names it. The cost the open question predicted is accepted: a batch of one single and one album is two newsroom cards and no board. Its row sub is the title’s RELEASE DATE and DAILY STREAMS (owner ask 2026-09-04 on the Disney card, “replace ‘single’ with the actual release date maybe streams per day”): plaque_sub draws “Jun 14, 2015 · 1.2M/day”, either half alone when the other is missing, and NOTHING when neither resolved – an empty sub, never the format word and never a guessed date. The cog resolves a PlaqueFacts per drawn row (_plaque_facts, keyed by plaque_key = title_identity + format, so a single and an album of one name each keep their own date) BEFORE building the board, and only for the rows plaque_rows will draw, so an undrawn award costs no lookup. The date is MusicBrainz’s first-release-date (chart_ages.first_release_date, the release-GROUP’s earliest release, the field the oldest-songs board already reads, at the precision MusicBrainz holds – “Aug 21, 2001”, “Aug 2001” or “2001”, release_label), memoized per process (_first_release_cache, one paced ~1.1s read per title, a miss memoized too), looked up under the row’s own credit first and the act second (RIAA files a soundtrack cut under a cast cell no catalog credits). NOT the Deezer/iTunes release_age resolver the debut gates share (#2074), and this was measured, not assumed: the first dry run wired it, and on the live 2026-09-04 feed Deezer dated Nickelback’s “How You Remind Me” (2001) to a 2013 best-of, “Far Away” (2006) to a 2024 live re-release, Josh Turner’s “Would You Go with Me” (2006) to a 2011 “Best Of”, and six of Nickelback’s ten rows to one compilation date – because Deezer’s catalog for those acts holds the reissues and NOT the original albums, so no “earliest of N hits” rule could recover the date either. Right question for a debut gate (“is this out yet”), wrong one for a certification card, where every title is years old by construction. MusicBrainz dated 12 of 26 rows on the same batches, every one correctly (How You Remind Me 2001-11-12, Your Man 2005-07-26, It’s Been Awhile 2001-07-03); a title it cannot date – the Disney / High School Musical soundtrack cuts, the kids’ YouTube channels – draws an empty sub (prefer absent over invented), never Deezer’s reissue date and never the format word. The daily rides StreamTotals.song_daily / album_daily (chart_boards.page_daily_streams, the same title fold as the lifetime totals, on the SAME cached kworb page), read only for an act the kworb leaderboard knows by exact name – Disney is not on it, Nickelback is (4 of 10 rows carried a daily on the dry run) – so a catalog act draws dates alone. The plaques board’s signature still reads label + value only, so a daily figure moving never re-posts a board. One plaque_facts event per board (ok = at least one row dated) is the fail-open signal, on the ops monitor as riaa_plaque_dates; the MusicBrainz reads ride reference_fetch source=musicbrainz. The compose block names the bracket as the release date and the daily so the take cannot read a daily as certified units, and the SCOPE line says a release date is not the certification date. It is MANUAL only: scripts/artist_sweep.py renders it beside the chart cards (--no-riaa skips the read), for a question about one artist (owner ask 2026-09-03, after a reply on X asked for Miley’s totals; her chart sweep held one Billboard row while RIAA had 87 million units over 36 titles). Its take is fenced to the act’s OWN career: never a comparison, never a record. An act’s records are read across every NAME they are filed under (RiaaClient.awards_for_act over artist_watch.performing_names): RIAA files a PERSONA’s catalog under the persona, so one search misses it – 16 awards sit under “Hannah Montana” plus two under “Miley Cyrus as Hannah Montana”, 17 of her 104 million certified units, invisible from her own name (owner call 2026-09-03, “yes include hannah montanna”, “use all our folding logic across these charts”). The alias table in artist_watch carries the pair beside “Ye -> Kanye West” and credit_matches now reads it too, so the single-name form and the watchlist form agree on the same credit, one-directionally (a credit under the persona is hers; a credit under her name is never the persona’s). RIAA’s own “X AS Y” artist cell credits BOTH halves (riaa._PERSONA_AS_RE); “as” is not a credit connector anywhere else, because making it one globally would re-fold every stored identity containing the word. A merged read returns None when ANY of its searches comes back short, so a partial catalog never serves as the whole one. Verified 2026-09-03: her 104.0M reconciles exactly against RIAA’s own tally rows (103.0M across seven artist-cell groupings) plus her 1.0M feature on Mark Ronson’s single; the “VARIOUS |
Hannah Montana” soundtrack is correctly not hers. The two evergreen leaderboards read LIVE off RIAA’s own tally pages through utils/riaa.py – the highest-certified ALBUMS of all time (the site’s top-tallies list, certified units in millions, “40x” = 40 million) and which of the desk’s top acts hold the most DIAMOND SINGLES, and the most DIAMOND ALBUMS as a separate board (owner call 2026-09-02), plus the MOST PLATINUM SINGLES (level_tally at floor 1, chart riaa_platinum_singles: the Diamond board one rung down, a Diamond single counted as a Platinum one) and the MOST CERTIFIED UNITS (units_tally, chart riaa_units: every credited title once at its current level, Gold half a million, Nx Platinum N million, singles and albums together, main program only, the way RIAA’s own per-artist tally adds them, the figure in millions to the half million; owner ask 2026-09-03, both LAST in the slot’s order and off the same cached search, so they cost no read) and the MOST PLATINUM CERTIFICATIONS THIS YEAR (year_tally, chart riaa_year_awards:<year>: the titles each act was certified Platinum or higher on this calendar year so far, any format, a first Platinum and a 16x alike, a Gold not counted, owner call “should be ranked by platinum”; the key carries the year so the board restarts each Jan 1 on a fresh state; the take fenced to ‘this year … so far’, never a career total and never releases; owner call 2026-09-03, “that should be different from career total”, ahead of the two lifetime boards in the slot; the lifetime captions read ‘all time, as of |
chart_credits.py, the producer / songwriter boards (owner ask 2026-08-10, “how about by producers”): joins a Hot 100 week to Genius’ structured producer_artists/writer_artists per row. Per-song credits live in a durable cache (kv_cache: chart_credits, 45d; verified misses 7d) filled by a paced, budgeted live resolve (20 lookups/build, 0.3s pace) — steady state is the week’s debuts. The join’s fabrication fence: a Genius hit is used only when its primary artist fold-matches the chart credit (a cover’s credits on the wrong row is the one way this board lies); a confident mismatch caches as EMPTY credits. The performing artist is NOT counted on their own row (owner ask 2026-08-19, “exclude the artist”): most artists write (and many self-produce), so a self-writing star would top the board off songs the chart already prints their name on — but this board exists to surface the people it does NOT print. The tally drops the row’s own act (credit_matches against row.artist, so a featured act on its own row drops too); the SAME person still counts on other acts’ rows, so it is per song, not a global block. Measured on the real Hot 100 of 2026-08-22: both boards flipped from a performing artist (Ariana Grande, 11) to a real producer/writer (ILYA, 11) as leader, and stayed full 10-row boards well above the floor. Boards build only past COVERAGE_FLOOR 0.8 of the chart (below it: silence + cache-filling, never a partial-chart claim); leader needs 3+ credited songs, the field 3+ people. Emits chart_lookup (source=genius) per build; per-call telemetry rides reference_fetch. Desk lane _lookup_board_units, experiment music_lookup_boards, story lookup_board. Dedup keys on the RANKING, not the week (owner ask 2026-08-19, “only ship on changes to rankings, like youtube views”): the lkb: key ends in ranking_signature(board) (a 12-char digest of the ordered person=count rows), so a new chart week whose top credits are unchanged does not repost — the board ships only on a ranking change, the way the watch board keys on its moving songs. The 9-day _WEEKLY_BOARD_DEDUP_HOURS window still bounds it: a ranking that stays identical for longer reposts once past the window. The sibling oldest-songs board keeps its per-week lkb: key. Ships STAGING (external-join accuracy is the audition question).chart_ages.py, the oldest-songs board (owner ask 2026-08-10): MusicBrainz release-group first-release-date per chart row (Hot 100 + Billboard 200), the catalog/seasonal read weeks_on cannot make. Same shape as chart_credits: durable per-song cache (chart_ages, 120d / misses 14d), paced 1.1s under MB’s ~1 req/s etiquette, 15 lookups/build, COVERAGE_FLOOR 0.85, and a title+artist double fold-match before a year is accepted (a same-titled cover’s 1962 on a 2026 single is the board’s one fabrication mode; _matching_year takes the earliest matching group so a reissue’s 2004 never shadows the 1985 original). The board also needs a real story: oldest row ≥ MIN_AGE_YEARS 20 back. Emits chart_lookup (source=musicbrainz). Same lane/experiment as the credit boards.sports_boards.py, the sports stats BOARDS (#2268, the sports slice of brainstorm #2262 – the music slice #2264 is the build pattern). Pure builders: rows in, a SportsBoard out, no network and no Discord. Seven board kinds over one league registry (LEAGUES: NBA, WNBA, NFL, MLB, NHL + six soccer leagues, each carrying its ESPN path, its /menu watched-sports key, its accent and its curated stat categories) – league_table_board (the top of a table), streak_board (the longest active runs in ONE direction, called once for win streaks and once for loss streaks so they are two separate cards; off the SAME standings read so it costs no extra fetch), leaders_board (a season stat category), title_odds_board (championship futures by implied probability), upcoming_board (TONIGHT’s slate with each game’s posted line), player_board (tonight’s player prop lines OR the season’s names to watch) and game_totals_board (tonight’s slate ranked by the book’s over/under). The GAME + PROPS board (game_props_board, #market-siblings) is the sports family on the sibling spine (utils/market_siblings.py): it pairs ONE game with its player props on a single card – the matchup as the hero, the favourite’s moneyline as the figure, and one prop per stat (the biggest line) as the rows. A game and its props are siblings of the same subject (the game), so _game_props_boards fetches BOTH halves from SGO (get_event_odds + get_player_props), keeps only props for a game upcoming tonight, and hands them to sibling_groups(..., GAME_PROPS_FAMILY, ...), which joins them on the shared SGO event id (variant “game” vs “prop:cogs/sports_boards.py, the sports stats board LANE: a ScheduledPoster subclass that fetches one league per slot, builds every board that league supports, composes and posts the best one. ONE LEAGUE PER SLOT is the pacing decision – the registry covers eleven leagues and fetching all of them every tick would be ~90 requests for at most one card, so a slot takes the league that has gone longest without posting (read off the sports_boards_keys dedup history) and one fetch set feeds everything: the standings read serves the table AND the streaks, the day scoreboards serve the offseason gate. Five module-level DurableCaches carry it, each TTL’d by how fast the thing behind it actually changes – standings 3h, season leaders 6h, a PAST day’s scoreboard 7d (immutable once its games are final, and the one that turns a 77-request liveness window into a handful), TODAY’s slate 15 min (it carries live book lines), the futures field 12h. It shares the sports CHANNELS with the news desk but keeps its OWN slot counter (sports_boards_slots), so a busy news day cannot silently spend the boards’ daily slots. Pacing is PER SPORT (owner steer): each league gets _MAX_BOARDS_PER_LEAGUE_PER_DAY (4) cards a day of its own, so an in-season league works through its whole set in about two days rather than waiting a week for its turn in a shared queue – and the /menu watched-sports filter therefore CONCENTRATES cadence rather than only removing leagues, since each surviving league keeps its own four. A global daily ceiling (8) sits above it as a flood guard for a guild watching everything, and the slot pool usually binds first. Within a league the order is LEAST-RECENTLY-POSTED FIRST, notability only as the tie-break – ordering by notability alone was the first version and it starved most of the lane, because notability is dominated by the board KIND rather than by how newsworthy that board is this week: measured live 2026-08-10, MLB scored results 90, title odds 64, leaders 60 and its table 45, so MLB would have posted its top-scoring board every time its turn came and its table would never have posted at all (the results board that topped that measurement is since removed, #3237). A board that has never posted has no date stamp, and “” sorts ahead of every date, so new boards lead the queue (the same rotation music_desk’s platform + artist boards use). Two experiments: sports_stats_boards (the free ESPN boards) and sports_odds_boards (the futures, slate and game+props boards, which make a price claim and spend an Odds API credit), both STAGING by default so a mod auditions them in #bot-logs first – and split so a guild can run the free lane without ever touching the metered source. Dry run: python -m scripts.dryrun_sports_boards (renders every board’s real PNG; COMPOSE=1 runs the real compose + self-gate too). X mirror (#twitter-post-failure): on a PRODUCTION room post the board card is also mirrored to @tootsiesbar through the shared crosspost path (utils.x_crosspost.crosspost_with_quote, surface="sports_boards"), so the card PNG + take ship to X with no market link. The X-only sentinel lane runs too — an empty channel picker stops the room copy but still tweets. It is OPT-IN on the /menu X picker (OPT_IN_SURFACES): a price-claim external surface stays dark until a mod ticks it on, and while the x_crosspost experiment is STAGING it auditions the tweet text in #bot-logs first. A STAGING sports board (auditioning in #bot-logs, not a real room post) never crossposts. No new event — it rides the existing crosspost event with surface=sports_boards.hits.py, the HITS Daily Double forecast client (#1981) — the industry FIRST-WEEK projections + Mediabase radio feeds, read off HITS’ public Sanity GROQ API (project 8aky18h3, no key, ~35ms JSON; the access reasoning + attribution obligation live in docs/BILLBOARD_SOURCES.md §5b — every HITS number is credited to them by name, baked into format_projection_block). The BE-FIRST half of the chart frontier: midweek-20 + hits-top-50 land ~5 days before billboard.com flips, and projected_chart_date (tracking-week Thursday + 9 days, verified against the Gracie Abrams #1 call) maps a projection to the exact Billboard week it is a call ON — so scoring a called shot is billboard.chart(week=proj.chart_date), a lookup. A projected DEBUT is confirmed by RELEASE DATE, never by the document’s own column (#2038): HITS carries no last-week position for a catalog RE-ENTRY either, so new_entries() mixes real debuts with albums that fell off the chart and came back — live on the building chart dated 2026-07-30, 4 of its 7 no-last-week rows were re-entries (Drake’s VIEWS 2016 at #44, Taylor Swift’s FOLKLORE 2020 at #49, Pitbull’s GREATEST HITS at #50, Megan Moroney’s CLOUD 9 at #47). The plain rank band (40) hid all four, but the WATCH band (#2007) reaches rank 100 for exactly those artists, so the lane could have called a ten-year-old album as a projected debut. music_desk._pick_called_shots dates each candidate against Deezer and fails CLOSED on a miss — the same fix and the same fail direction as the Apple debut lanes (#1887), whose class sweep never reached HITS. Emits called_shot with the disposition (fresh |
stale | no_date). format_projection_block, the background block other lanes read, states what the column actually supports rather than calling those rows debuts, because its rows are NOT confirmed — the earlier wording put the false claim straight into a prompt. Radio rows carry their FORMAT (by_genre is the only correct way to read a rank — the docs concatenate ten format charts each ranked 1-50; flattening made ten songs all read “#1”, caught live, pinned by test). Columns read by LABEL never position; missing numbers stay None never 0. Guards mirror billboard/kworb (limiter + breaker integration=hits + retry + durable cache at each source’s real cadence); hits_fetch health in the ops monitor; a /debug/integrations probe catches the dataset going private. The called-shot grounding handoff keeps its selected fact (#2253): compose_inputs keeps the general chart context at ten rows, but appends the exact selected row when it ranks lower, so both the composer and self-gate see its rank, units, artist, and album through the lane’s rank-40/50 reach. The CALLED SHOT lane is built on it (Lane B, the desk’s SECOND-priority lane): a projected DEBUT inside the top 10 becomes one card per release per projected chart week, ranked behind the printed chart (a harder fact) but ahead of everything else because it is the most PERISHABLE thing the desk carries – its whole value is the lead over Billboard’s print, and it is worth nothing once the chart lands. The dedup key carries the projected CHART DATE rather than the doc, so the Tuesday midweek wins the post (being first, mechanically) and the Thursday building chart can only ADD releases the midweek’s top 20 didn’t reach. It carries its OWN rollout stage (music_called_shot, default STAGING) because it is the one desk lane making a falsifiable PUBLIC claim – per-unit staging already works since the base poster routes on each unit’s meta["stage"], so a mod auditions calls in #bot-logs without muting the desk; OFF skips the lane with no HITS read and no compose. The Kalshi projection lane YIELDS to it (_hits_covered_albums): both estimate one quantity – the release’s first-week number – so posting both reads as the desk repeating itself; HITS is the industry’s own tracking estimate where Kalshi’s is a market’s guess at what that estimate will say, and the market number isn’t lost (it still drives market_drop/market_alert). Fails OPEN – an unreadable HITS leaves the older lane exactly as it was. Sales projections – both metrics, one lane, same window (#2515/#2519, owner steer 2026-08-23 “always do both”, 2026-08-24 “separate cards, same window, not different days”): a release carries two SALES ladders – Pure Album Sales and Album-Equivalent Units (“overall”) – and they must post as two labeled cards TOGETHER, on one cadence. A single lane drives both: _compose_sales_projections -> _pick_sales_projection -> music_markets.sales_projection_slate, which GROUPS both metrics of a release and returns the top-N releases by activity. The grouping is now the GENERAL sibling spine (utils/market_siblings.py, the “search for siblings and post the pair” ask): sales_projection_slate is sibling_groups(readings, MUSIC_SALES_FAMILY, top_n=…) – the group-by-shared-key / one-per-variant / rank / cap loop lives once on the spine, and the MUSIC family supplies four pure functions (join key = release_pair_key, variant = metric_slug, keep = _sales_projection_keep, activity = ladder rungs). Music is the first family on it; a SPORTS game↔props family plugs in the same way with an event-id join, no copy of the machinery. The picker emits one release’s cards per slot (both metrics), each on its own daily key proj:<release>:<metric>:<day>, so the pair refreshes together each day and the slate’s ranking cycles the rest across the day’s slots. Live SALES markets are EXCLUDED from the news lane (_pick_readings skips them) so they only post here – that is what stops the earlier split where album-equivalent refreshed daily on the projection lane while pure sales sat 10 days on the news lane. Robustness: (a) the two markets GROUP on release_pair_key – the shared event-ticker suffix (KXPUREALBUMS-ANG26SEP24 and KXALBUMEQUIV-ANG26SEP24 both give ANG26SEP24) – NOT the subject text, which Kalshi names inconsistently across the two series (artist-only “Angel” vs “Angel – Lizzy McAlpine”, “WILDCHILD” vs “WILD CHILD”; a subject match missed 3 of 9 live pairs, measured 2026-08-23); (b) the ALBUM-EQUIVALENT metric must clear is_confident_projection (a wide-band “overall” number the market doesn’t stand behind is dropped), while PURE SALES keeps its looser handling (no confidence gate – a thin pure-sales number still posts). There is DELIBERATELY no tracking-window gate: the desk posts pre-release sales projections (the Carly Rae case is a pre-window Sept debut the owner posts), and gating the metrics differently would re-split the pair. Only DEBUT readings qualify – an annual (year-to-date) KXALBUMEQUIVY market is a reveal-lane fact, not a debut projection; (c) the two cards clear the topic gate because _sched_dedup_history (#2170) drops the other-metric post before the compare, reading each unit’s metric from the reading METADATA (canonicalized through the Luminate registry, so a scope variant compares equal), not the composed prose. The lane also honors the legacy dedup keys of the code it replaced for one window after deploy, so a market carded just before the change is not immediately re-posted, and it orders the slate least-recently-posted-first so a guild with few calendar slots still rotates through the pool across days instead of re-posting only the top-ranked releases. A release Kalshi opened only ONE ladder for (the Carly Rae Jepsen “Day and Night” case – pure sales only) yields a single card: the desk posts what exists and never invents the missing metric. Being first here is the same CACHE property as Billboard’s, ported deliberately: expected_tracking_end knows which tracking week a fresh doc should carry INSIDE a publish window (midweek Tue/Wed, building Thu/Fri/Sat – each widened a day because the container clock is UTC while HITS publishes on ET), superseded is the shared read/write predicate, and outside a window it returns None so a skipped publish costs one slow cycle instead of a runaway poll. Framing fences are dry-run-earned twice over: the first version produced wire copy with a trailing “per HITS Daily Double” tag – the same clunky-literal form _CONTEXT already bans for Kalshi – because the BLOCK said “credit them by name” while the framing banned the tag, and the two fought; aligning both on “credit HITS inside the sentence, in your own words” took the banned tag from 1/5 to 0/6 samples. The 0.6 self-gate could not have caught it: the copy was accurate the whole time, and the gate grades grounding, not voice. Live dry run 6/6 ship at 0.92. Remaining lanes: reconciliation + radio (docs/BILLBOARD_ALERTS.md D/E). |
stats.py, the shared percentile(values, p) primitive (nearest-rank: integer-clean, tail-biased on small windows). Hoisted out of scripts/ops_monitor.py (which now imports it from here) so there’s one definition of “p99”.numfmt.py, the one compact-number formatter — abbrev(n, *, prefix, suffix, decimals, k_decimals, keep_trailing_zero) renders a count as “140.5K” / “1.2M” / “11.5B” / “$940K”. The “1.2M”-style formatter was hand-copied into ~8 utils (luminate/box_office/netflix/feeds/genius/link_enrich/social_profile/social_search), each slightly different and most carrying the same rounding bug where 999,999,999 rendered the malformed “1000M” instead of “1B”. This is the single source of truth (rounding-correct thresholds that promote a value rounding to 1000 into the next unit; each caller’s exact output preserved via the knobs). Use it — don’t re-roll a K/M/B formatter. Also home to display_pct(p), the one probability→display-percent converter: a 0-1 probability as a 0-100 int, clamped so a LIVE price never rounds into a certainty it hasn’t earned — anything strictly inside (0, 1) lands in 1..99 (0.996 → 99, not the settled-looking 100; 0.004 → 1, not the dead 0), while exactly 0/1 (an actually settled book) still render 0/100. Born from the winner-lock card that heroed a giant “100%” over a match still being played (the take beside it said 99%) — the same round(p*100) shape lived in ~15 display sites (the single_leg_figure hero, the chart legends, market_alert/market_drop figures + blobs, betting_alert._favorite’s implied %, the markets.py/sportsdata prompt blobs, alert_triggers.top_standing), all now wired to this one converter. Use it for any percent a reader or the compose model sees; trigger/settlement MATH keeps its own precision (e.g. market_settled compares raw 0-1 prices, unaffected). Pure, unit-tested.song_pool.py, the shared guess-the-song core (#1497 unification) — the surface-agnostic pieces BOTH the Discord /guess music game (cogs/games.py) and the X/Twitter game (cogs/x_game.py) reuse so the two can’t drift: song_key(title, artist) (the canonical _norm_artist - _normalize_title dedup/reuse identity, so a song keys IDENTICALLY into the durable game_recent_songs block on either surface) and current_genre_hits(genre_key, *, cap, cache=None, seen=None, skip=None) (the current-hits GROUNDING sourcing extracted from the /guess game’s _current_hits_into/_charted_into: the Deezer per-genre chart leading ~2/3 + the iTunes RSS hedge, each guessable + preview-backed + not-seen + not-skipped, window-shuffled + capped, returning raw catalog rows). The genre→chart-id maps (DEEZER_GENRE_CHART/RSS_GENRE_IDS) + the bundle map (GENRE_BUNDLES) live here too, re-exported into cogs/games.py under their historical private names. Games._current_hits_into is a thin wrapper that threads the /guess-specific bits (the durable source cache, the served-set dedup, the reuse block) in and converts the rows to _Clip; XGame._pick_song calls current_genre_hits over the urban bundle’s lanes for its grounded blend — so both games ground IDENTICALLY off one function (not the lighter music.hot_chart facade X used before). Answer JUDGING is deliberately NOT here — it’s the /guess matcher (cogs.games.grade_guess, HARD) shared directly. A JOINT primary credit counts for EITHER act (cogs.games._credit_acts, owner report 2026-08-31). _artist_named reads a credit as ONE name – the whole token run, or the single longest token in it – so “Pop It Off” by “Tyga & Lil Wayne” scored NOTHING for a guess of “Tyga”: the room named the artist and the game said miss. (“Lil Wayne” banked a point only by accident, because “wayne” happens to be the credit’s longest token.) The grader now also matches each ACT of the credit, split by artist_watch.credit_parties – the ONE home for that split, so a stylized single name that merely contains a connector (“Earth, Wind & Fire”) is never torn into acts that do not exist: a part counts only where the top-artist list has seen it stand alone. The whole credit is always kept too, so the change can only ADD ways to name the artist, and an empty list (a kworb outage, or the X game, which judges titles only) grades exactly as before. Dependency-light + pure (reaches DOWN into utils only, no cog import), unit-tested in tests/test_song_pool.py. The ERA year gate on the grounded bench (#2724). The /guess era toggle only filtered the MODEL half of the pool (_resolve_into checks the year iTunes returns for each proposal). The GROUNDED half — about 90% of the bench — appended chart and playlist rows straight to the queue with no year check at all, and a hot-now genre chart is not all recent music: the live Deezer R&B chart runs from Otis Redding (1968) to this week. So a genre:urban era:2000s+ game served Bill Withers (1977), Prince (1986), Digital Underground (1990) and En Vogue (1996) — confirmed in prod, game 1788118436883, with era_drops=0 because nothing was checked. Every grounded source now runs through ONE gate, Games._era_into: a row that CARRIES a year (an iTunes catalog row, or an iTunes RSS chart row via the new im:releaseDate read) is filtered in place with no extra call; a YEARLESS row (every Deezer chart / playlist row) is resolved through _resolve_into, which looks it up on iTunes and applies the same gate. Deezer’s own release_date is NOT usable here — it reports the reissue date, so it dates “Kiss” to 2007 (see docs/INTEGRATIONS.md). A track whose year cannot be established is DROPPED, and every drop is counted into the song_pool event’s era_drops. Note what era_drops does NOT say: zero drops is a healthy current-decade round, not a skipped gate. A per-refill “was anything verified” counter was tried and REMOVED (#2724 review) — it needed the same per-call-site discipline as the gate itself, so it failed in exactly the cases the gate fails, and it drew six review findings of its own. The regression guard is the per-path unit tests; the dashboard reads era_drops next to itunes (did the grounded majority hold) instead. Two consequences to know: an era game reads _ERA_WINDOW_MULT (6x) its cap from each source, because the gate rejects most of what it reads; and the last-resort pick_clip fallback is SKIPPED for an era game, since that path returns a yearless clip it cannot verify — a starved era refill ends the game through the empty-refill safety instead of serving an unverified song. The RECOGNIZABILITY gate on the grounded bench (owner steer 2026-08-31, “limit artists to our watch tier ~1500”, then “we just dont want truly unkown songs appearing, but dont wan to exclude older classics”). A guess-the-song round only works when the answer is knowable, and a Deezer per-genre “chart” is a chart by name only – it runs deep into the catalog, so the live hip-hop lane served “Do Better” by Jazzbois, Goya Gumbani & Franky Bones (1,821 Deezer followers) into a live round. song_pool.recognizable(artist, fans, rank, watched) is the ONE gate both games run every grounded row through, and any ONE of three signals keeps a row: the CREDIT names a top artist (artist_watch.Watchlist.contains over the kworb list, KNOWN_ARTISTS names); the ACT has a real audience (Deezer nb_fan at or above ARTIST_FAN_FLOOR, 10k); or the SONG is played a lot right now (track rank at or above TRACK_RANK_FLOOR, 600k). THE ACT ARM IS THE MAIN GATE, AND THAT IS WHAT MAKES IT ERA-NEUTRAL. The first cut gated on the track’s rank alone at 300k, which measures how much a record is played THIS WEEK, so it punished old records for being old: measured live, it dropped “Summertime” by DJ Jazzy Jeff & The Fresh Prince, The Staple Singers, Maze, Incognito and “Mr Saxobeat”. A follower count accumulates over a career, so a 1971 act with an audience passes on the same terms as a current one, and the kworb list – ranked by CURRENT streaming, holding no Ginuwine, Bell Biv DeVoe or Boyz II Men – stops being the only rescue. The rank arm survives at DOUBLE its old floor for exactly one class the act arm gets wrong: the ONE-HIT WONDER, a famous song by an act nobody follows – measured on the live decade playlists, the act arm alone dropped “Who Let The Dogs Out” (4.9k followers), “Samba De Janeiro” (7.5k) and “The Way It Is” (6.0k), and 600k takes those three back while admitting nothing from the deep-catalog tail (Jazzbois ranks 199k, Swamp Dogg 37k). Measured end state over the live genre charts + every decade playlist: every decade playlist keeps 100% of its rows (43/43, 51/51, 52/52, 56/56, 57/57 – an era round loses nothing), and the genre charts drop only the unknowable tail (Andrea Lisa 156 followers, Stevie Woods 34, Byrne & Barnes 116). The known casualty is a one-hit wonder whose track ALSO ranks below 600k – “Good Vibrations” by Marky Mark And The Funky Bunch, whose Deezer act entry carries 1,975 followers. A follower read takes the artist ID off the row, never a name (deezer.artist_fans; _normalize now carries artist_id), because /search/artist ranks tiny homonyms and duplicate entries first – a name search returns 5 followers for “Vanilla Ice” and 7 for “Naughty By Nature”. Lookups are concurrent (6) and cached 30 days in song_pool.artist_fan_cache, so a cold pass over 439 rows costs 5.3s and a warm one 0.7s with no requests; a row with NO id or a FAILED lookup is KEPT (an iTunes RSS row is a hit by construction, and a Deezer outage must never empty the bench). An ARTIST round is exempt (the room named the act, so its deep cuts are the theme), exactly as the cross-game reuse block already exempts it; an empty kworb list leaves the other two arms working. Wired through the async current_genre_hits(filter_rows=) hook – run BEFORE the window and the cap, so a drop costs the bench nothing – shared by /guess (Games._known_rows, which also covers the decade-playlist path in _charted_into) and the X game; every rejection is counted into the song_pool event’s unknown_drops (known_artists reports the list size, 0 = kworb failed). A JOINT primary credit counts for EITHER act (cogs.games._credit_acts, owner report 2026-08-31). _artist_named reads a credit as ONE name – the whole token run, or the single longest token in it – so “Pop It Off” by “Tyga & Lil Wayne” scored NOTHING for a guess of “Tyga”: the room named the artist and the game said miss. (“Lil Wayne” banked a point only by accident, because “wayne” happens to be the credit’s longest token.) The grader now also matches each ACT of the credit, split by artist_watch.credit_parties – the ONE home for that split, so a stylized single name that merely contains a connector (“Earth, Wind & Fire”) is never torn into acts that do not exist: a part counts only where the top-artist list has seen it stand alone. The whole credit is always kept too, so the change can only ADD ways to name the artist, and an empty list (a kworb outage, or the X game, which judges titles only) grades exactly as before. Dependency-light + pure (reaches DOWN into utils only, no cog import), unit-tested in tests/test_song_pool.py. The ERA year gate on the grounded bench (#2724). The /guess era toggle only filtered the MODEL half of the pool (_resolve_into checks the year iTunes returns for each proposal). The GROUNDED half — about 90% of the bench — appended chart and playlist rows straight to the queue with no year check at all, and a hot-now genre chart is not all recent music: the live Deezer R&B chart runs from Otis Redding (1968) to this week. So a genre:urban era:2000s+ game served Bill Withers (1977), Prince (1986), Digital Underground (1990) and En Vogue (1996) — confirmed in prod, game 1788118436883, with era_drops=0 because nothing was checked. Every grounded source now runs through ONE gate, Games._era_into: a row that CARRIES a year (an iTunes catalog row, or an iTunes RSS chart row via the new im:releaseDate read) is filtered in place with no extra call; a YEARLESS row (every Deezer chart / playlist row) is resolved through _resolve_into, which looks it up on iTunes and applies the same gate. Deezer’s own release_date is NOT usable here — it reports the reissue date, so it dates “Kiss” to 2007 (see docs/INTEGRATIONS.md). A track whose year cannot be established is DROPPED, and every drop is counted into the song_pool event’s era_drops. Note what era_drops does NOT say: zero drops is a healthy current-decade round, not a skipped gate. A per-refill “was anything verified” counter was tried and REMOVED (#2724 review) — it needed the same per-call-site discipline as the gate itself, so it failed in exactly the cases the gate fails, and it drew six review findings of its own. The regression guard is the per-path unit tests; the dashboard reads era_drops next to itunes (did the grounded majority hold) instead. Two consequences to know: an era game reads _ERA_WINDOW_MULT (6x) its cap from each source, because the gate rejects most of what it reads; and the last-resort pick_clip fallback is SKIPPED for an era game, since that path returns a yearless clip it cannot verify — a starved era refill ends the game through the empty-refill safety instead of serving an unverified song. The RECOGNIZABILITY gate on the grounded bench (owner steer 2026-08-31, “limit artists to our watch tier ~1500”). A guess-the-song round only works when the answer is knowable, and a Deezer per-genre “chart” is a chart by name only – it runs deep into the catalog, so the live hip-hop lane served “Do Better” by Jazzbois, Goya Gumbani & Franky Bones (Deezer rank 199,228) into a live round. recognizable(artist, rank, watched) is the ONE gate both games now run every grounded row through, and it takes a row on EITHER of two independent signals: the CREDIT names a top artist (artist_watch.Watchlist.contains over the kworb list, KNOWN_ARTISTS names), or the TRACK itself is played (Deezer’s own rank at or above RANK_FLOOR, 300k). Both arms are load-bearing, and the second one is why this is not the watch list alone – kworb ranks by CURRENT streaming, so the list holds no Ginuwine, Bell Biv DeVoe, Toni Braxton, En Vogue or Boyz II Men, and a watch-only gate cuts “Pony”, “Poison” and “Un-Break My Heart” out of a 90s R&B game (measured live: a watch-only gate keeps 25% of the 1995 Billboard Year-End chart, and 69% of the grounded pool overall, against 92% for the two arms together). A row with NO rank – an iTunes RSS chart row, a Billboard Year-End pair – is KEPT: it is on a chart of hits by construction, and gating those on the watch list alone drops “Jump Around”, “Ice Ice Baby”, “My Girl” and “Sweet Caroline” (measured). The RESIDUAL that leaves is a novelty/AI-slop buy on the iTunes RSS chart, which carries no rank and so passes – the one unguessable class still open. An ARTIST round is exempt (the room named the act, so its deep cuts are the theme), exactly as the cross-game reuse block already exempts it; an empty list (a kworb outage) leaves the rank arm gating alone rather than emptying the bench. Wired through current_genre_hits(keep_row=) so /guess (Games._known_row) and the X game share one predicate; /guess also runs it in _eligible_rows for the decade-playlist path and counts every rejection into the song_pool event’s unknown_drops (known_artists reports the list size, 0 = kworb failed). Live dry run: every genre lane still fills its cap 18/18 – the gate reaches deeper into the chart, it does not starve the bench.fanout.py, the one bounded async fan-out — gather_deadline(coros, deadline=, limit=, settle=): run N independent reads concurrently, results IN INPUT ORDER, None for anything that missed the deadline or raised, stragglers cancelled (never left paginating). The shape is “several paginated Discord REST reads whose results are nice-to-have context, not the answer” — the reactor fan-out (#1947), poll voters, message reactors, feed channels (#1950). Returns T | None rather than taking a default on purpose: a shared default would alias one mutable object across every missed slot. The cancellation settle is BOUNDED too (settle, default 1s): a cancelled discord.py request can await during its own unwind — with the rate bucket exhausted (a 429 burst), Ratelimit.__aexit__ runs await _refresh(), an asyncio.sleep(reset_after) — and the old unbounded settle (await asyncio.gather(*pending)) waited that sleep out, so the 5s reactor deadline measured ~10s on /ask (Axiom feeds.resolve_reactors spans clustered at exactly 5.0s deadline-hit AND ~9.7-10s deadline+reset). Total wall clock is now at most deadline + settle; a member still unwinding past the settle window is abandoned (it stays cancelled and dies on its own, with a done-callback logging any late non-cancel exception). Don’t hand-roll another asyncio.wait loop — extend this.release_age.py, the ONE definition of how old a release is (#2074) — the shared resolver behind every debut gate. A chart’s own “new” marker never means “new release”: kworb flags a catalog re-entry NEW on the rank-only charts exactly like a fresh drop, its days-on-chart column counts days on THAT chart page (so a title crossing onto a chart it had never reached starts at 1 however old it is, #2062), and a HITS projection carries no last-week position for a re-entry either. The release DATE is what separates the two, so release_age_days(title, artist, album_first=…, cache=…, source=…) is what four lanes now call: the Apple debut lanes (#1887), the Spotify releases lane (#2062), the HITS called shot (#2038) and the watch lane (#2045/#2054). Fail-CLOSED — None means we could not date it and the caller must refuse the debut claim rather than make one. TWO CATALOGS, Deezer then iTunes: Deezer is first because it is the proven path and answers most rows, so no answer it already gets right can change; the iTunes read goes through the artist-verified catalog_art.resolve_music_row, because a bare search falls back to an album match and returns the wrong record’s date. Resolver ORDER is the caller’s (album_first): a SONG resolves track-first so an album CUT dates, an ALBUM resolves album-first so a re-released title track cannot mis-date it; each falls back to the other. The per-process cache memoizes the MISS too, so a dead title is not re-probed every slot — but a miss now EXPIRES after ~2h (RELEASE_AGE_MISS_TTL_SECONDS, #2125): a drop-day release is probed minutes after it charts, before the catalogs index it, and a permanent miss-memo froze the gate at no_date until the next deploy (measured: 81 rejections across one album’s whole NEW day). A resolved date still never expires, and the key carries the order so an album and its title track cannot collide. source labels the emit_error so triage keeps per-lane attribution. This existed as two drifting copies before #2074 — #2054 gave the desk’s helper the iTunes fallback and the alert’s never got it, so two lanes reading the same charts dated the same row differently. The debut WINDOWS stay per-lane (0–14d the Apple debut lanes — floor 0 because they are rank-only and kworb’s one-day NEW flag made a day-0 release unpostable under a floor of 1; 1–14d the Songstats-reading release lanes, whose day-0 skip is real; 0–28d the called shot, 0–45d the watch lane) and the module docstring is where their relationship is written down. The release date is HALF the gate: it separates a fresh drop from CATALOG, but it cannot separate a fresh drop from a RECENT release re-entering the tracked range (its age passes the window every time it bounces back) — that half is chart_presence.py, below.chart_presence.py, the first-sighting chart memory behind the kworb debut gates — the second half of the debut question release_age.py cannot answer. kworb’s rank-only charts (Apple songs + albums, US iTunes) mark a row NEW whenever the PREVIOUS SNAPSHOT’s tracked range did not carry it — a tracked-range bounce is re-marked NEW days after its real entry (measured 2026-08-07: “petal” by Ariana Grande, charting since day one, shipped as “new on the Apple Music US songs chart, entering at #6”), while a release-day title that entered early LOSES its flag the next snapshot when it is still genuinely new (measured 2026-08-08: Pooh Shiesty’s “All Eyes on Shiest” entered at #35, stood at #2 the next morning flagless, and missed the new-entries card, #2139). THE RULE (owner steer, #2139): a title is NEW to a chart for NEW_WINDOW_HOURS (24) after its FIRST sighting there, reported at its CURRENT rank; past the window it is ESTABLISHED and a NEW flag on it is a bounce or a re-entry, never a debut. kworb’s flag is trusted exactly once, at first sighting (flagged → the window opens; unflagged → established immediately, first sighting unknown — which also makes warm-up safe). One kv_cache row per chart (namespace chart_presence, keys shared between consumers via _WATCH_CHARTS’ short keys: ap_songs, ap_albums, …) maps song_pool.song_key → "first|last|gap|entry": first is IMMUTABLE while the title stays inside retention — the #2139 fix; the prior #2119 version stored one overwritten last-seen date, so the first consumer after UTC midnight (the desk sweep at 00:00:24) erased the prior-day evidence and every later consumer that day (the 10-minute alert polls, the platform cards) re-claimed week-old titles as debuts — last drives 60-day retention pruning, gap records the days absent before the latest return, and entry keeps the RANK of the first sighting (0 = unknown, immutable like first) — the second owner steer (PR #2140): the CURRENT rank is never the DEBUT rank once the title moved, so a #35→#1 climber “hits #1”, it does not “debut at No. 1” (the live crown post that raised this). presence(db, chart, entries) takes (song key, kworb-new flag, rank) triples, refreshes, and answers from the REFRESHED state — key → TitleInfo(new, gap_days, entry_pos) — so every consumer and sweep of the day reads the same verdict. Consumers: music_alert._releases_signal reads it for the ENTRY RANK only (#2224) under the shared sp_global key — chart_debuts accepts a row up to 2 days old carrying its CURRENT rank, so “entered the chart at #N” was stating a day-2 rank as an entry rank (measured: Stray Kids’ “This & That” entered at #31 and still counted as a debut at #82 the next day). The post leads with the LATEST rank and names the entry as context (owner steer 2026-08-09, matching this module’s own rule that a consumer inside the window reports the CURRENT rank); info.new gates only the “inside a day” phrasing. It deliberately does NOT suppress on info.new there: the Spotify days-on-chart count is cumulative so a bounce never reads as day 1, and the 24h window would drop every legitimate day-2 debut this lane is built to allow. music_alert._apple_debut_core skips an established NEW row outright (reason=charted on apple_debut), cuts a real-but-deep debut on the tiered depth floor (reason=deep, #2207 — see the artist_watch.py bullet), and phrases an in-window mover off its entry rank (“entered at #35 … now stands at #2 — say it hits/climbs to #2, never debuts at #2”). BOTH directions lead with the CURRENT rank and carry the entry as context: the FALL case used to lead with the entry instead (“the debut rank is #35 — never call #40 the debut”), which reported a rank the chart no longer showed, and the owner aligned it 2026-08-09. The two directions are now ONE sentence differing only by the verb (hits / climbs vs slips), so they cannot drift apart again. The one exception is a RELEASE-DAY RAMP (owner steer, 2026-08-09), which takes the DEBUT wording instead: kworb’s Apple pages are near-realtime snapshots, not weekly chart dates, so a title released today starts at zero plays and rises as they accumulate — its first-sighting rank records WHEN THE BOT FIRST POLLED, not where it entered (measured the same day on the live albums chart: one album read #98 then #97 on consecutive 10-minute polls). The gate is narrow: the release must be _DEBUT_RAMP_MAX_AGE_DAYS (1, MUSIC_DEBUT_RAMP_MAX_AGE_DAYS) old or less, and the move must be UPWARD — the artifact argument says a first sighting UNDERSTATES a title whose plays were still accumulating, while a fall is a real decline off an observed rank, so a fall keeps the entry rank. On a ramp the entry rank is not named at all, because naming the polling artifact is what made the wording misleading. The CROWN lane is deliberately NOT changed: it is where the #2140 post came from, and its pure pos_change gate has no release-date read to tell a ramp from a climb; music_alert._chart_crown needs no presence memory — its pos_change > 0 gate already proves the title held a real prior rank, so its blob states “climbs to #1, up from #N” and forbids “debut”; the one exception is a RELEASE-DAY RAMP (#2235, the crown half of the debut-lane rule above), where a title released within _DEBUT_RAMP_MAX_AGE_DAYS rose through its release day as plays piled up, so the #1 it reaches really is a #1 debut. The lane runs _debut_release_age AFTER the pos_change gate, so it costs a lookup only when a crown actually fires and the resolver is cached; it fails to CLIMB on an undatable row, because pos_change already proves a climb while “debut” is the claim needing evidence. The ramp blob never names the prior rank — that number rides the chart_crown event for diagnosis and is kept out of the compose’s context, so the model cannot cite it. The RADIO crown has its own inline implementation, does not use this helper, and needs no ramp branch: radio adds take weeks, so a same-day release cannot top airplay; music_desk._resolve_new_entry refuses the debut claim — an absence ≤ _WATCH_BOUNCE_MAX_ABSENCE_DAYS (7) is a range bounce (no story), a longer absence on a DATED CATALOG title stays a genuine re-entry story only when it lands inside _WATCH_REENTRY_MAX_POS (10, MUSIC_DESK_WATCH_REENTRY_MAX_POS) — deeper than that it drops with reason deep, an established FRESH title is dropped. And a new_entry on the US radio-airplay chart is never a debut — it is stated as a POSITION, gated on depth (reason radio_entry, _LEADING_CHARTS, 2026-08-17): radio is a LEADING chart — stations build a song’s spins over WEEKS after release — so a first appearance on the airplay chart is decoupled from the release date, and the 45-day age gate cannot tell a genuine debut from a song radio picked up late. A 2-week-old song entering radio at #72 passed the window and shipped as “‘petal’ debuts on the US radio-airplay chart at #72” (owner call). So the lane makes NO release claim for radio: it reads no release date, and states only WHERE the song sits (“now on the US radio-airplay chart at #N”, kind radio_entry with a bare chart-label eyebrow and a leading-indicator compose fence) when the entry lands inside _WATCH_RADIO_ENTRY_MAX_POS (20, MUSIC_DESK_WATCH_RADIO_ENTRY_MAX_POS, anchored to the per-format lane’s top-15 breakout cap _RADIO_MAX_RANK). A deeper entry is deep-tail airplay churn and drops as deep (petal #72); a tracked-range bounce drops as charted. The crown / top-N (top 10, top 5, top 3) / jump kinds carry no release claim and are untouched, and utils/radio.py still tells the per-format “climbing on radio” story off the spins. chart_boards.platform_new_entries lists every ranked row NOT established at its current position. Fail direction: None = memory unavailable (no db / error, emitted to triage) and each caller falls back to its pre-memory behavior (kworb’s raw flags for the board, no suppression for the gates) — never “nothing established”, which under first-sighting semantics would read the whole chart as new. Legacy #2119 single-date values migrate as established. The Spotify charts self-heal without it (kworb’s days-on-chart count is cumulative per chart page, so a bounce keeps its count) but feed the memory anyway via the watch sweep.artist_watch.py, the artist RECOGNITION tiers + credit matching (owner steer 2026-08-04). kworb’s two free Spotify ranking pages (all-artists by streams, monthly listeners) define three tiers: WATCHED is the top ~100 (MUSIC_DESK_WATCH_ARTISTS), KNOWN the top ~1000 (MUSIC_DESK_KNOWN_ARTISTS, extended with the owner-kept rap / R&B-hip-hop / Latin Billboard charts so a genre name past kworb’s global cut still counts), UNKNOWN everyone else. recognition_tier(credit, watch, known) is the ONE definition; Watchlist.contains does the credit matching, which is stricter than kworb._fold containment — “Drake” must match “Drake Featuring Yebba” and never “Drake Milligan”, so both sides normalize to tokens with every collaboration connector folded to “and” and a name matches only on a segment boundary. The tier picks a FLOOR, never a hard block. A watched artist clears LOWER floors on the desk’s chart and radio lanes (notability 2 vs 8, 20 vs 40) because a superstar’s real move was being lost to size-blind rules; an unknown artist must clear HIGHER ones (chart notability 16, radio 80, #2097). Fail direction: an EMPTY known list means the kworb pages failed, and every credit then reads KNOWN — each gate returns to its pre-tier behavior rather than silencing an entire tier, and a suspiciously SMALL build (under half the requested size) is forced empty and sent to error triage rather than passed off as a real list. Consumers: music_desk’s chart-move, radio and exits lanes (_is_recognized), the called-shot band, and — via the cross-cog seam MusicDesk.recognition_lists() — music_alert’s Apple DEBUT depth floor (#2207). That seam exists so the two cogs share ONE build and ONE ~1h memo: a second copy of the kworb fetch in music_alert is exactly the drift _debut_release_age already paid for once. The debut floor is MUSIC_DEBUT_KNOWN_MAX_POS (50) / MUSIC_DEBUT_UNKNOWN_MAX_POS (25), with a watched artist exempt. It was added after the albums lane posted, and crossposted to X, “Lil Tony Official’s ‘ELIJAH’ debuts at #98 on the Apple Music US albums chart” — a top-100 chart’s third-to-last rung, by an artist on neither ranking page. A FLAT floor was rejected on the measured data: over the 30 days to 2026-08-09 the lanes shipped 11 posts, and a flat top-25 cut would also have dropped Pooh Shiesty’s #9 debut. Depth and recognition only answer the question together — how deep a debut may land depends on who made it. Note this REVERSES the debut exemption watch_new_entry’s re-entry floor still carries: a new title is news wherever it lands holds for a name the room knows, not at #98 for a name it does not. The table moved to utils/artist_watch.py (DEBUT_KNOWN_MAX_POS / DEBUT_UNKNOWN_MAX_POS / debut_depth_floor / debut_clears_depth) on 2026-09-08, when music_news.deep_platform_cut – the other sibling, which had exempted a debut outright – shipped a #199 Spotify “debut” and was wired onto the same floor. lead_artist(credit) is the ONE-NAME form the ART lookups take (#2274) — a credit with its trailing feature clause removed (“Steve Lacy Featuring SZA” -> “Steve Lacy”). Deezer’s artist search and iTunes’ discography read each search for ONE act, so handing them the raw Billboard row searches for an artist who does not exist: the “Is It Cool?” Hot 100 exit card resolved no cover (the track is not in the catalog), then missed BOTH artist-photo rungs on the joint credit, fell to the open-web search, and shipped with the no-art bloom floor after the vision gate declined all 5 candidates. It cuts ONLY the four unambiguous feature markers (feat / featuring / ft / f/), never “&”, “,”, “+”, “x”, “vs” or “with” — those sit inside real act names (“Earth, Wind & Fire”, “Tyler, The Creator”, “Sleeping With Sirens”), and cutting one invents an act. That is the split credit_parties may make and this may not: credit_parties CORROBORATES every part against acts that chart alone, lead_artist corroborates nothing, so it only cuts where no real name can be cut. The bare word “and” is CONDITIONAL (#2899, _split_re). fold_tokens maps “and” to a connector and _ordered_runs splits segments on it, so credit_matches read “FUTURE AND HALSEY” as two acts while the party splitter did not cut there at all and credited_names returned NOBODY – the two halves of the one credit rule disagreed, which cost Future a title on the RIAA year board. Cutting at EVERY “and” fixes that and breaks worse things: measured against the live lists 2026-09-03, “Sam and Dave” credits “Dave” (a different charting act, so a 1960s duo’s plaques land on a contemporary rapper), “Ike and Tina Turner” credits “Tina Turner”, “Tom Petty and the Heartbreakers” credits “Tom Petty” – and corroboration cannot catch any of them, because the fragment IS a real solo act. The signal that separates the shapes is a LIST SEPARATOR: an enumeration carries a comma or “&” (“BEELE, Ovy On The Drums, and W Sound”), a duo’s own name carries neither. So “and” cuts only where a comma or “&” already proves the credit is a list. Measured over the 716 distinct artist cells of the 2026 RIAA feed: three cells gain a real act (including the live HUNTR/X credit, whose two acts after the bare “and” were being dropped), and none of nine real “X and Y” band names fragments. ACCEPTED MISS: a genuine two-act credit with no other separator (“ROA AND HADES66”, live in the same feed) still reads as one act, because nothing distinguishes it from “Sam and Dave” – dropping one act is a miss, crediting a duo’s catalogue to an unrelated artist is a fabrication. The RIAA reader keeps its OWN rule for a FEATURE clause (riaa._feature_guests), which this cannot serve because a clause is usually “X and Y” with no other separator: it resolves the clause whole first, then splits only when EVERY piece corroborates – one piece resolving and the other not is exactly the “Sam and Dave” shape. is_placeholder_credit(credit) gates every PORTRAIT rung (#2274). A catalog stand-in credit – “Soundtrack”, “Various Artists”, “Original Broadway Cast” – names no act, and it is lookup POISON precisely because it is a real word: an artist search happily returns a match for it. Deezer has an act literally named “Soundtrack” whose picture is a “Music From Lord Of The Rings” cover, so the Billboard 200 row KPop Demon Hunters / Soundtrack resolved a LOTR sleeve onto a card about a different record. A live A/B sweep over real chart rows caught it; the unit tests did not, which is the argument for sweeping real data before shipping an art change. COVER rungs need no such gate – they verify the matched row against the credit, so a placeholder simply fails to match. Bare “Cast” (a real band), bare “Various” and “Traditional” are deliberately OFF the list: Billboard writes the placeholder forms in full, so the compounds cover real usage without costing a real act its portrait. The 2026-08-31 reversal: the blob was investigated and deliberately LEFT ALONE. A watch-chart take posted to X reading “the cure rockets from #30 to #52 – wait, reversed: it climbs from #52 to #30”. chart_story_inputs reads “It just made a big climb, now at #30. It was #52 last period.”, which names the new rank first and the old one second, so the obvious theory was that the take fills “from X to Y” in block order. That theory is unproven and the wording was not changed for it. The reversal did not reproduce in 74 live composes on that exact blob (real _CONTEXT, the real wire history, claude-sonnet-4-6, every number pair the four failures used), against a production rate of four in six that afternoon. The gap is still unexplained. An ablation at n=12 (cut the prior rank / reword it as a directional pair / leave it) then showed the unchanged blob writing the pair CORRECTLY 11 times in 12, so the “reversible form” is not itself failing; cutting the prior rank lost it from the prose and pulled the trailing clause toward an ungrounded past (5/12), and the reword bought nothing measurable. Changing model-facing text against an unreproduced cause is the scar tissue docs/PROMPT_OPTIMIZATION.md warns about, so the fix is the deterministic guard instead: output_checks.has_self_correction now catches an INLINE correction, and a drop emits music_desk_scored phase=shape_reject. That converts an invisible hallucination into a measured drop rate – query that rate before touching this blob. The CARD never had the bug (chart_story_card_fields renders “up 22 from #52”), and utils/billboard.py:_move_phrase has always written “climbs {delta} spots to #{rank} (was #{last_week})”, direction first: copy that shape in a NEW move blob, but do not rewrite this one without evidence. row_credit / row_is_theirs are the one definition of what credit a CHART ROW carries (owner rule 2026-09-13, “features and collabs always count”): the credit line plus any act named only in the title’s feature clause, because the streaming charts file “So Good (feat. Kendrick Lamar)” under “Jhene Aiko” alone. Every counting/listing lane reads it (see line 50); attribution does NOT – who the record BELONGS to stays lead_artist.retry.py, the one async retry primitive — retry_async(fn, ...), the single home for every transient-failure retry (no cog/util hand-rolls a for attempt in range(...) loop). It takes a zero-arg coroutine factory and retries on a caller-supplied exception predicate (each site keeps its own notion of “transient”: an aiohttp 403/429 for iTunes, an anthropic 529/timeout for the API, a bare connection drop — a non-retryable error still propagates at once) AND, optionally, a result predicate (for fetchers like markets._fetch_with_retry whose inner call swallows the error and returns a None sentinel instead of raising). Exponential backoff (base_delay·factor**k, capped at max_delay) + optional jitter, with leading_jitter adding a pre-first-attempt jitter to de-sync a concurrent burst (the apple_music pool-prefetch fan-out, #371). An optional retry_after extractor (order #2350) lets a caller whose retried result carries the server’s own stated wait (a 429’s Retry-After header) FLOOR the next backoff delay with that value instead of the blind exponential guess — still capped at max_delay, and only ever consulted on the result-retry path, never the exception path. utils.markets.KalshiClient._guarded_fetch is the first user: Kalshi’s default retry predicate (unlike SGO’s, which never retries a 429 in-call at all) DOES retry a 429 within the same call, so a stated wait is worth honoring mid-retry, not only when widening the breaker’s cooldown for the NEXT call (_note_retry_after, order #1339 follow-up). Only catches Exception, so CancelledError/KeyboardInterrupt propagate untouched. After the attempts are spent it re-raises the last exception (exception path) or hands back the last sentinel (result path) — the caller’s fail-open handling stays in charge. Pure control flow, emits nothing: it nests INSIDE an @instrument/timed_event so one domain event fires per logical call, not one per attempt. Current callers: claude_client._create_with_retry (anthropic 529/5xx/timeout, exp backoff), utils.apple_music._request_json (iTunes 403/429/5xx, jitter + leading-jitter burst de-sync), utils.markets._fetch_with_retry (the Kalshi paginated open-events walk — a (data, status) result predicate that retries 429/5xx/network but gives up at once on a permanent 4xx), utils.stt.transcribe (ElevenLabs Scribe transient timeout/connection, one retry), every bare-read outbound client via utils.http_retry (below), and the scripts/eval_* harness _ask helpers (a result predicate that retries past the empty-answer fallback).http_retry.py, the aiohttp-aware layer over retry.py — the read clients all share ONE notion of a transient HTTP failure (a 429 or 5xx status, or a raw connection/timeout drop), so the predicate + retryable-status set live here once instead of being copy-pasted into each. is_transient(exc) is the retry_on predicate (a permanent 4xx propagates at once); retry_http(fn) is retry_async pre-wired with it + a short exponential backoff (DEFAULT_ATTEMPTS=3, DEFAULT_BASE_DELAY=0.5s) — the single entry point the outbound read clients use. The convention for a client: the inner round-trip raises on a retryable status (resp.raise_for_status() guarded by status in RETRYABLE_STATUS) so retry_http rides it out, and returns a sentinel ((None, status)) on a permanent failure so it falls straight through to the existing fail-open handling — no behavior change beyond riding out a transient blip. Keeps the generic retry.py primitive aiohttp-free. Wired into the idempotent reads only — perplexity.search, embeddings.embed, gifs.search, link_enrich._fetch_json, github.get_issue, railway._gql(retry=True) (its list_deployments read; the redeploy write deliberately does NOT retry, since a re-fired mutation could double-trigger a deploy). External writes (create_issue/comment/close_issue/redeploy) and paid-asset POSTs (tts/image_gen) are left single-attempt on purpose.circuit_breaker.py, a minimal failure-rate circuit breaker (#615) for an outbound integration that craters from the bot’s datacenter IP. Three states — CLOSED (healthy, calls flow, outcomes tracked in a rolling window) → trips OPEN once the failure rate over the window crosses a floor (default 50%) with enough samples (so a couple of unlucky early failures can’t open it) → short-circuits calls for a cooldown → HALF_OPEN lets ONE probe through after the cooldown → CLOSED on recovery / re-OPEN on failure. Pure + clock-injectable (every method is synchronous so it’s atomic within the asyncio loop; tests drive the cooldown via clock=), fail-open by construction (it never raises — wiring it wrong degrades to “no breaker”, never a crash), and telemetry-agnostic (the caller passes an on_transition hook to wire state changes to its own event stream, so the module stays dependency-free + unit-testable in isolation). The reusable orchestration lives in the util, not the call site: breaker.call(fn, success=..., short_circuit=...) is the one-stop entry point — it owns the allow→run→record→short-circuit dance (short-circuits to the caller’s sentinel when OPEN with no call to fn, records an fn exception as a failure and re-raises, classifies a returned value via the success predicate so a permanent 4xx reads as a healthy “no”), so the next integration that craters wires a predicate instead of re-implementing the loop (the low-level allow()/record() stay public for advanced use). Guards four outbound clients, each behind its own breaker instance (a crater of one never stalls the others, nor Polymarket which has none): every KalshiClient read (#615) + every SportsGameOddsClient read (#725) in utils/markets.py, every ApiSportsClient read (#725, the live-scores + settlement backbone), and every TheOddsApiClient read (#725, now load-bearing as the /bet SGO-down slate source + settlement backstop). A sustained crater trips that source’s breaker and stops the hammering — no network call — for a cooldown, while reads degrade gracefully: SGO/Odds-API to a stale-on-error cache, API-Sports’ degraded routing bookie settlement to the Odds API. The SGO breaker is the answer to the amateur-tier 429 storm (window 30, min-samples 10); API-Sports matches it; the Odds API is slightly lower-volume-tuned (window 20, min-samples 8). The success predicate is per-client, not uniform: Kalshi’s and the Odds API’s treat a permanent 4xx (e.g. an unknown-ticker 404) as a SUCCESS (“a legit not-found”), since those per-ticker/per-event GETs can genuinely answer “no such thing” — only a 429 / 5xx / network failure counts against them. API-Sports and SGO (order #45) instead only count actual data coming back as success: every SGO endpoint (/events, /leagues, /account/usage) is a filtered LIST read that answers a bad/unknown filter with 200 + an empty array, never a legitimate 404, so reusing Kalshi’s “permanent 4xx = healthy” logic there meant a sustained 403 (a datacenter-IP block or a bad/revoked key) recorded as a SUCCESS and never tripped the breaker — silently disabling the Odds-API live-scores backstop and the SGO-down market-edge fallback that gate on sgo.degraded. So for SGO/API-Sports a 429 / 403 / 404 / 5xx / network failure ALL count against the breaker. Emits circuit_breaker (with integration) on each transition.rate_limiter.py, the async token-bucket rate limiter (AsyncRateLimiter, #843 / order #34) — the proactive companion to circuit_breaker.py: the breaker REACTS to a crater after the 429s land, this PREVENTS them by pacing reads so they never blow the upstream’s per-minute cap in the first place. A token bucket (up to burst tokens, refilled at rate_per_min/60 per second); await acquire() consumes one, sleeping until one is free — a burst up to capacity passes instantly, beyond it calls drip at the steady rate. Same conventions as its sibling primitives (retry.py/circuit_breaker.py): clock + sleep injectable (deterministic tests), fail-open by construction (rate_per_min <= 0 → acquire is a no-op, so a misconfig degrades to “no limiter”, never a hang), loop-FREE (sizes ONE sleep then grants, so it can’t spin even if a caller mocks sleep instant), serialized accounting (a lock makes refill-check-consume atomic and spaces concurrent waiters FIFO into the even drip), and telemetry-agnostic (emits nothing — its effect shows as the existing market_fetch rate_limited rate dropping). Wired into every SGO read via SportsGameOddsClient._guarded_json’s _do, inside the breaker gate, so a breaker short-circuit (SGO down/degraded) and the quota-exempt get_usage read spend no token — only an actual breaker-allowed network attempt paces, so the limiter is inert while SGO craters and adds latency only under a genuine live burst. Env-tunable SGO_RATE_LIMIT_PER_MIN (40) / SGO_RATE_LIMIT_BURST (10), <=0 disables; these defaults are calibrated to SGO’s documented rookie-tier cap (50 requests/min; 100k objects/month) — a steady 40/min + 10-burst keeps the worst-case rolling minute (burst + rate) at ~50, i.e. right at the ceiling with steady-state headroom, while the burst still passes a normal single-flighted fan-out unthrottled; confirm/fine-tune against real load via /debug/usage once SGO is live (a Pro-tier upgrade lifts the ceiling to 300/min). Kalshi got its own instance too (order #1057): wired the same way, inside KalshiClient._guarded_fetch’s _do, ahead of every retry/breaker-allowed attempt (a paginated open-events page and a live single-shot read share the one limiter, so the page walk’s own explicit 250ms inter-page sleep and the token bucket both bound the same traffic). KALSHI_RATE_LIMIT_PER_MIN (240) / KALSHI_RATE_LIMIT_BURST (8) default to the SAME 4 req/sec steady rate the open-events walk had already proven safe against the ~6 req/sec public-read limit Kalshi enforces (unlike SGO’s cap, this is empirically observed via live testing, not a number published in Kalshi’s own API docs — Kalshi does not document a numeric public-read rate limit; see the walk’s own pacing comment in utils/markets.py), with an 8-call burst (matching the Kalshi breaker’s min_samples) so a normal bounded fan-out (the category/topic pools’ Semaphore(3-4)) still passes instantly — the proactive complement to the existing Kalshi circuit breaker (#615) and jittered retry (#905), so a concurrent fan-out across surfaces (live commentary + /ask + market-drop firing the same tick) can’t self-inflict a 429 before the breaker ever reacts. Generic + reusable: the next metered integration instantiates its own.kalshi_ladder.py, the ONE definition of what a Kalshi scalar LADDER is (#ladder-forecast) — i.e. “is this leg one RUNG of a ladder, or a standalone proposition?”. A scalar market isn’t one contract, it’s a ladder of “Above N” rungs over a single underlying number (one film’s Tomatometer, one album’s first-week units), and the rungs are a survival curve, not independent propositions. One subject per ladder (the KXMLABELSHARE guard, 2026-08): Kalshi also ships threshold-SHAPED events whose legs are thresholds over DIFFERENT subjects (“UMG above 56%” / “SME above 28%” / “WMG above 16%” — three labels’ market shares in one event); shape-wise they pass is_threshold_ladder, and the monotone clamp flattened the cross-subject prices into a plausible curve, so ladder_forecast shipped a fabricated “forecast ~19.8%” for “the leading label” while UMG was the actual leader. Two guards now refuse the read (absent over invented): rungs_share_subject (the rung labels’ non-numeric residues must agree — the semantic tell) and a clamp tolerance in implied_estimate (LADDER_CLAMP_TOLERANCE 0.15 — a rung priced 15c+ above a lower strike’s is a contradiction no one distribution produces, not thin-book noise; refuse, never repair). A crossing read across a HOLE in the market is not a forecast (2026-09-07, the Bass Persuades pure-sales card). The price-trust rule drops a rung with no trustworthy price and the curve interpolates across the gap, which is right for one or two thin rungs (the petal case) and wrong for a board nobody has traded: the Bass Persuades (Miley Cyrus) first-week PURE SALES ladder, 20 rungs from 20K to 400K, carried exactly two prices (20K at a lone 0.75 print, 400K at a 0/3c book) and eighteen 0c/97c never-traded rungs between them, so the 0.50 crossing drew a straight line 0.75 -> 0.015 across the whole 380K span and read ~149.3K. It shipped to X as “Market’s calling for ~149.3K pure sales” one hour before the SAME album’s fully-priced album-equivalent-units ladder (23 of 23 rungs priced, thousands of contracts) posted ~52.1K – and pure sales are a component of album-equivalent units, so the pair was impossible on its face. implied_estimate(brackets, strikes=...) now takes the ladder’s FULL strike list and refuses the read when more than MAX_UNPRICED_GAP (LADDER_MAX_UNPRICED_GAP, 2) unpriced raw strikes sit inside the interval the crossing was interpolated between (unpriced_in_crossing_gap); ladder_forecast and luminate.parse_event_reading pass it, the candle-trajectory readers (which only see rungs that traded) do not. It is a GAP rule, not a coverage floor, on purpose: a board with three priced rungs adjacent around its crossing has formed exactly where it matters and still reads, while a 60%-priced board whose crossing falls in its unpriced block does not. Measured on the live population the day it shipped: 63 of 3,394 readable ladders refused (1.9%), every one a handful of prices with a run of empty rungs between them (index price-at-time boards, quarter totals, state gas prices); of 84 music ladders, 2 – both Bass Persuades (the pure-sales board and its 6-of-10-priced album-duration board). The music sales pair also carries the sibling tell the incident exposed, now a backstop of its own (#3069): a pure-sales forecast above its own album-equivalent-units forecast is impossible (pure sales are one component of units), so music_markets.contradicted_pure_sales names the pure reading of any such pair (joined by release_pair_key, never subject text) and fetch_desk_readings drops it at the ONE sweep every consumer shares (the desk slate, the newsroom’s reading_for_subject, the chart boards) with a market_filtered reason=pure_over_units event; sales_projection_slate repeats the drop on the pure side for a caller that hands it readings from elsewhere. Only the pure side is ever dropped: on every live pair measured the units ladder is the deep, traded one. The cap is the units band’s UPPER crossing (estimate.high), not its point forecast, and an open-ended units band caps nothing (Codex on #3073: a thin units ladder’s point figure is too noisy to be a hard ceiling; a pure figure above the units’ upper quartile is a contradiction the units market itself stands behind – Bass still trips it, ~149.3K against a units high of ~81.8K). The band crossings get the same gap test as the headline (Codex on #3074): implied_estimate reads a 0.75 / 0.25 crossing interpolated across more than MAX_UNPRICED_GAP unpriced rungs as None, so a hole under the upper quartile yields an unknown bound (no cap, and is_confident_projection False) rather than a fabricated one. The same-series shape (both metrics as two events under ONE series) is NOT joined, by measurement: on 2026-09-07’s 11,306 open events KXPUREALBUMS and KXALBUMEQUIV each held 29 events and no series carried both, and sales_projection_slate has always joined on the suffix for the same reason. The alert cog does NOT seed a baseline off a live reading with no estimate (music_markets.has_baseline, Codex on #3073): baseline_for would store 0 and classify_music_signal’s ref_median > 0 gate never overwrites it, so the event would lose its move lane for good; the first readable tick seeds instead. A release with only a pure ladder has nothing to contradict it, so the owner steer that a thin pure-sales number still posts stands. And a partial-resolve event whose active ladder the gap guard refuses keeps its MEASURED count (#3070, Codex on #3065): parse_event_reading returns the reading with estimate=None and value_so_far / crossed_strike set, format_reading reports the measured count without a projection, and every projection consumer already treats a None estimate as nothing to project (the 11 reading.estimate.<field> reads in utils/ + cogs/ were audited: all guard None). The pure-sales lane is deliberately NOT confidence-gated (_sales_projection_keep, owner steer: a thin pure-sales number still posts) – the gap guard is a different question (“is there a curve here at all”), so the steer stands. Such an event gets NO ladder forecast and does NOT confirm as a ladder (is_threshold_ladder = shape AND one subject), so market_alert’s not-a-ladder path reports each leg as its own named binary with the event-leader standing clause (“UMG leads”) — honest coverage instead of silence. The market_drop lone-leg binary guard no longer asks about SHAPE at all: it counts the event’s LEGS (len(siblings) > 1 -> refuse), because a binary is an event with ONE market and the picker’s own filter counts PRICED ones. The shape-only is_threshold_family test it replaced saw a threshold family but not a family whose legs are NAMES – the same bug with no numbers in it, and the one that shipped the WARDOGS card (#2663) (see market_drop._kalshi_binary and the entity_image place/thing rung below). A lone rung with NO siblings in its own event is untouched, so the companion-ladder path (#2490) still owns it. Reading one rung as a standalone market is the trap this module closes: an at-the-money rung is a knife-edge that amplifies a tiny move in the underlying into a huge move in its own digital odds. Live-measured on KXRT-SPI: the figure Kalshi publishes held flat at 90 while the “Above 90” rung swung 66%→48%, because the distribution merely tightened AROUND 90 (rung 89 85→73, rung 90 68→54, rung 91 39→15) — and cogs/market_alert read that rung and posted “a favorite falling to a coin flip” against a market whose published number hadn’t moved in two weeks, three times in three days off the same film. is_ladder_rung(meta) is the tell, hoisted from cogs/market_drop._is_threshold_leg (#1011, which already refuses to narrate this shape) so the alert surface applies the SAME test instead of growing a second copy — two tells, since Kalshi marks ladders inconsistently: a NUMERIC strike (numeric strike_type / floor_strike / cap_strike) OR a threshold-PHRASE label (“Above/Before/More than X” — the only signal on a DATE ladder, which carries no strike metadata). An ENTITY leg (a plain candidate name) is neither, and stays headline-able. What a ladder DOES forecast — the survival curve’s 0.50 crossing (ImpliedEstimate.forecast), the figure Kalshi prints — now lives HERE too (ladder_forecast / implied_estimate / is_material_forecast_move), hoisted out of utils/luminate.py where it was proven on the music ladders and re-exported there for its existing callers, since the math is generic over any (strike, price) ladder. That’s what lets cogs/market_alert alert on the thing that actually moved instead of just suppressing rungs. The headline is the CROSSING, not E[X] (#2817, 2026-09-01). The headline was the mean E[X] from #1863 to #2817, chosen on the Tyla A*POP fixture (median ~4.8K vs a published ~9,028) and calibrated on boards that were TIGHT (Spider-Man RT, the Bieber views ladder), where the mean and the crossing agree — so the choice was never tested. The Ellie Goulding album-units board tested it: a thin ladder (~$4.5K volume) whose 20K/25K/30K rungs all sat at 0.25 read E[X] 19.4K, shipped to X as “~19K” under a ✓ Kalshi badge, while Kalshi’s page printed 11K. Rebuilt from ten days of hourly candles, the crossing tracked Kalshi’s line (10.5K–11.4K) within ~3% every day; E[X] doubled 13.6K→26.8K the afternoon someone bought the tail rungs from 3c to 25c, and Kalshi’s line did not move. The alert that shipped was itself a mean artifact: ~$300 of trades took the 50K rung 0.11→0.05, the mean fell 19% (over the 15% bar), Kalshi’s figure moved ~200 units. Across the 60 open album ladders the mean runs 1.2x–3.1x the crossing on most boards; only the two $100K+ boards agree. So every headline consumer (market_drop companion projection, market_alert forecast_value / forecast_move / outcome grading, the music desk’s projection figure + ledger, music_news, chart_boards, luminate.forecast_series) reads est.forecast — the un-rounded float crossing (a CPI ladder’s 3.438 keeps its resolution, #1864) — and est.mean stays on the dataclass as a DIAGNOSTIC only (dry-run scripts print both). The cost the mean was chosen to avoid (the crossing hops a strike bucket when a thin low rung matures) is absorbed where it already was: the alert’s two-tick persistence + 24h pacing, the music lane’s PERSIST_PERIODS + is_confident_projection gate. Stored baselines name their statistic: market_forecast_alerts.ref_stat / music_alert_state.ref_stat (default 'mean' for rows that pre-date the switch); the readers treat a non-'forecast' row as unseen and the seeds upsert the value while keeping the row’s clocks, so the switch re-baselines every open ladder silently instead of firing a 30–50% “move” on every one the first tick after deploy. Kalshi exposes no API field for the printed forecast (v1/events/{ticker}/forecast_history exists but rejects every parameter shape tried), so the candle rebuild is the calibration method; tests/test_luminate.py carries the Ellie ladder as the golden. The crossing is not a perfect reproduction of Kalshi’s formula either — on the Spider-Man RT book it reads 90.6 against a 90.1 print (E[X] read 90.06), a 0.6% gap inside one rung’s interval — but its error is bounded by one strike gap, where the mean’s is bounded only by tail-rung liquidity (the 2x miss). Kalshi’s exact method is unknown; if a board ever shows the crossing off by more than a strike gap, re-derive from candles before changing the statistic again. The E[X] right tail is scaled by the ladder’s own SPAN (top − bottom strike), not the top strike (#forecast-card-copy): the old E[X|X>T]=2T tail is only sane on a from-zero ladder (span≈T — the Tyla 1K..10K calibration held under either model), and on a NARROW-BAND board it fabricated — the live Bieber weekly-views ladder (15.25M..17M, top rung ~0.31 survival) read 21.9M against the ~17M Kalshi’s own card printed; span-scaled it reads 17.1M (+0.7%), Tyla 8.46K (−6% of the published 9,028), and a thin-top ladder (Spider-Man, S(top)≈0) is untouched since the term vanishes. Strikes are FLOATS end to end (same PR): an int(s) key collapsed every fractional-strike ladder’s neighbouring rungs onto one integer (live: natgas $2.705/$2.75 → “2”, the board reading “forecast ~1” against its own 83%-to-clear-$2.705 rung) — invisible while the only readers were the integer-strike music boards, caught by the awakened alert path’s population sweep; and the alert’s rendered figure follows the BOARD’S OWN units read off the rung label (market_alert._forecast_figure: a unit-scaled Netflix “At least 18 million” board renders “18.9M” not a bare “19”, a $-commodity board keeps its $). A FAT-TOP ladder posts the OPEN form, never its point E[X]: when the survival curve never crosses 0.5 inside the board (open_high — the market’s own median lies beyond the top strike, e.g. the NCAA season-wins boards topping at 18 with that rung at ~0.98 for a ~25-30-win answer), the board only honestly says “above market_outcome.py, the decided-ladder OUTCOME lane’s pure core (#2655): when ladder_is_decided fires (the event happened, Kalshi unsettled — the GTA VI runtime settle-lag window), don’t just suppress the stale forecast (#2654), HUNT the real result and post it against the market’s forecast, hours before Kalshi settles. The trust model (owner steer 2026-08-28 “kill grok if you have to”): the WEB leg (Perplexity) must fully land — a non-X publisher citation + a number extracted by its own Haiku call (claude.outcome_extract) — and the confirmed value must match the COLLAPSED BOARD (market_consistent): the press and the market’s own traders are the two independent authorities. GROK x_search is the BONUS corroborator, not a gate — the first dry run showed X doesn’t carry niche numbers (3 of 5 real settled outcomes declined on the X leg while the web read matched settlement exactly every time) — so a silent X leg proceeds (recorded as x_miss/corroborated for telemetry) while an X read that SPEAKS a different number still vetoes (disagree, never post through an active conflict). Each read gets its OWN extraction call so a transcription slip can’t manufacture agreement; the confirmed value is the WEB figure (never an average nobody reported). market_consistent is the wrong-subject guard, with a corroboration-GRADED tolerance: a decided board is near-certain about its value, so a confirmed number far from the collapsed E[X] means the search answered a DIFFERENT question (a sequel’s runtime, the company comp instead of the brand comp) — decline, never “the market was wrong”. X corroborated → ~1.5 rungs (the board is a third voice); X silent → the strict half-rung bar sources must meet, because the board IS the second source (the live URBN catch: an uncorroborated 8.4 company-comp read against the 6.2 brand board sat inside 1.5 rungs and would have posted wrong). The card grades the PRE-collapse forecast (market_forecast_refs, the same baseline the forecast-move lane alerts off): the collapsed board’s E[X] copied the answer, so quoting it as the forecast would be fake precision — it serves only the consistency gate. compose_inputs/format_block follow rt_reconcile’s doctrine verbatim (say the number plainly in every branch, two publishers never blurred). card_fields matches it again on WHICH figure heroes. rt_reconcile was inverted on owner steer 2026-08-31 to hero the FORECAST, and the owner reversed that on 2026-09-14 off two live posts – The Uprising heroed its ~59.8 forecast over the 52% that printed, Runner its ~53.3 over 71% (“these numbers are wrong… it should obviously be the actual final value, not the forecast”). Every settlement card now heroes the number that PRINTED and demotes the forecast to the standing strip: called_shot (the chart position), market_outcome (the reported result), and rt_reconcile (the settled Tomatometer). RT’s tag took the called_shot shape, “Rotten Tomatoes - Final”, and its chip credits Rotten Tomatoes, who published the hero – which stamp_is_redundant then suppresses, since the tag already carries the name. The forecast keeps KALSHI’s name on the strip, so the card still never credits one publisher with the other’s number. The rule to apply to a NEW settlement lane: the fact is the hero, the claim it checks is the context. The shared rule is the one underneath: the eyebrow and the source chip name whoever published the BIG number — so the RT card’s labels followed its hero BACK to Rotten Tomatoes on 2026-09-14, and this lane’s hero stays the result and keeps crediting the result’s publisher, and the card’s source chip credits the RESULT’s real publisher — the web read’s first cited domain (cited_host) — never Kalshi, which is unsettled and supplies only the forecast. Every decline is a named market_outcome event and falls back to #2654’s suppression (absent over invented). Cost bounds: the market_outcome experiment (default STAGING), a durable per-(guild,event) attempt state (market_outcome_attempt kv namespace via DurableCache — posted/rejected terminal under a 30-day TERMINAL_TTL; declines carry a 6h retry deadline ENCODED in the value — encode_retry/attempt_blocks, restart-proof against the L2→L1 default-TTL re-pin), 1 paid hunt per guild tick (one budget across every room + pool). Round-2 review hardening: the web leg must cite a NON-X publisher (cited_host skips _X_HOSTS — an X-only web read can be the very post Grok reads, one rumor counted twice); a stored PRE-collapse market_forecast_refs baseline is REQUIRED to post (and the move lane never seeds a decided ladder’s collapsed E[X] as a baseline — a false forecast history would grade itself exact); format_value carries the unit on every rendered figure (‘26 minutes’, ‘$56.25’, ‘3.5%’), with ladder_unit accepting only a residue that TRAILS the number (a leading residue is the SUBJECT — ‘petal’ — not a unit) and recovering %/$ symbols. The cog half lives in cogs/market_alert.py (_maybe_post_outcomes → _resolve_and_post_outcome → _post_outcome, the _post_reconciliation spine on a search-confirmed result); it rides the market_alert tick — the tick’s pools ARE the collapse detector, so market_alert OFF / a spent alert cap idles the hunts, and the trending pool’s contested-band narrowing is bypassed for discovery by the snapshot-price pre-tell (_collapsed_candidate_events) so a slow collapse only visible in trending is still found. The ENTITY twin (the Emmy incident, 2026-09-06; trial key market_outcome_entity, default STAGING): ladder_is_decided needs numeric strikes, so a WHO-WINS field (an award, a race – every leg a NAME) could never enter the lane. Live, the Guest Actress in a Drama Series market went 55 -> 98 during the Creative Arts ceremony: the swing lane posted “tracking toward” at 88 mid-collapse, every later tick dropped the 98 leg as soft_price (a decided book is one-sided by construction – everyone holds YES), and nobody posted that she won; the Guest Actor sibling (Ernest Harden Jr., +82) never posted at all. The owner’s reference shape is a wire desk’s one-liner: “Ernest Harden Jr. is a first-time Emmy winner.” Three parts, one class: (1) the tell, entity_is_decided, read off the SAME event fetch the race path already makes (_RaceInfo.decided): exactly one leg a lock (>= DECIDED_LOCK_PRICE, pinned equal to SETTLED_LEADER_PCT) whose price the book stands behind – lock_supported: either the live conviction test (bid_supported, a bid within 10c) or the DECIDED-BOOK signature (ask >= 0.98 and bid >= 0.70: nobody sells a known YES under the line while bids relax overnight; live at 03:50Z three of the night’s four decided Emmy fields sat at bid 0.76-0.88 under a 1.00 ask and the tight-bid test alone refused them; the phantom-96% shape, ask 0.96 over bid 0.20, passes neither) – every sibling dead (<= 5%), and the lock NEW – its previous_price under the lock line, so a category that has read 97% for a month is not a fresh result and the tell switches itself off a day after any real one (a leg with no prior is refused: unknown is not new). (2) the hunt, resolve_winner: the same two-source shape extracting a NAME from the market’s CLOSED slate – claude.winner_extract can only return an index into the candidates or null, so a search read can never introduce a name the market did not price; the press pick must match the lock leg (names_match, the wrong-subject guard, or market_contradicted), X corroborates or vetoes (disagree). The lane runs only on an event Kalshi marks mutually_exclusive (the flag rides every pool leg from the events payload; absent = refuse): an inclusion board (“Artist(s) with a #1 song”) can settle several legs YES, so a lone lock there is one artist’s fact, not the event’s result. A decided field seen only in the TRENDING pool is discovered by _collapsed_entity_events, the entity twin of the ladder pre-tell, off the raw pool before the contested-band narrowing. The cog CLAIMS a decided event whatever the hunt returns: its legs leave the swing pool (market_filtered reason=decided_entity) and its fold leaves the lead-change pass, so a lock is never narrated as a forecast and never posted twice; OFF leaves the pool exactly as before. (3) the final notice: the moment the tell fires the cog writes a market_outcome_calls ledger row (free, idempotent), and the settle sweep on reconcile_tick reads each pending row against Kalshi’s raw markets (settled_winner: a leg result=yes under determined/settled/finalized) and posts the OFFICIAL result – source chip Kalshi, official=True – only when the press hunt never shipped (a row whose attempt state is posted closes silently; the two delivery paths run on different ticks, so each stores a 15-minute HOLD state – retry:hunting: / retry:settling: – before it composes and re-reads the state right before it ships (held_elsewhere / _taken_elsewhere), the other side standing down on a live hold; the sweep reads the whole field in one 1000-row page and leaves a page-filling field unjudged rather than read void off a cut page; the queue orders by checked_at so a field that never settles cannot starve a newer one; on a flip – Kalshi settling on a leg other than the lock – the settled leg is the official winner and posts, with detail.flipped=true for the panel; a field settled with no YES closes void). The card heroes the winner’s NAME over the winner’s photo (the art snapshot names the leg via yes_label, so the resolver photographs the person, not the category), eyebrow the CHART the market’s words name (market_chart_tag: “YouTube Global music videos”, “Billboard Hot 100”) else the outcome word “Winner” — it read a generic Final until 2026-09-09 (owner, on the Dai Dai card: “replace ‘final’ with the actual outcome or chart”; the market number card had moved its tag onto the chart the day before and the FINAL card had not; the numeric card_fields card wears its metric or “Result” the same way), NO sub-line; the art snapshot also carries the winning leg’s raw Kalshi subtitle (market_subtitle, 2026-09-07, the Choosin’ Texas card): on a Billboard chart market that subtitle names the crediting artist (“:: Ella Langley”), and market_subject_image.subtitle_catalog_hint reads it as the deterministic cover rung. Before this the lane’s snapshot named the leg and nothing else, so the KXTOPSONG-26SEP19 winner card resolved through the classifier (no artist named, so no subject), the photo search switched off, and the platform-logo rung shipped the Billboard WORDMARK to X as the art (market_subject source=wiki_logo, the X media gate passed it as coherent because the card’s TEXT matched the take). The subtitle rides EntityLeg.subtitle -> DecidedEntity.subtitle -> WinnerCall.subtitle on the press-hunt path and SettledField.subtitle -> WinnerCall.subtitle on the settle sweep, so both winner cards get the cover; an awards field has no subtitle and the ladder runs as before. The drop and alert lanes had stamped the same hint off their leading leg since #thin-ahead-art; this lane was the sibling that dropped it; the compose framing asks for one plain sentence – the winner, what was won, as a done thing – with no market and no number anywhere, and forbids the career tally the block cannot ground (“4x winner”). No market, no percentage, on the line or the card (owner call 2026-09-06: “remove the market sell on a percentage, and the percentage”). Mechanized twice: the composer’s block carries no number and no source (“(per Kalshi’s settlement)” came straight back as “the market … just settled on him” on the first live sweep), and has_market_talk drops a line that quotes a %, names Kalshi or a market, or uses the odds/forecast words – after ONE re-ask that names the offending word back to the composer (market_talk_phrase; the measured pattern: naming the phrase removes it, “vary it” primes it). The self-gate is the lane’s OWN judge, claude.winner_line_score: a persona-less Haiku faithfulness check (“does this sentence state that WINNER is the result of QUESTION, and nothing else?”, told to treat the fact as true and never use its own knowledge of who won). The path to it is the record: the bare line scored 0.00 under the scorecard rubric (it requires a forecast figure) and 0.20 under the report rubric; the report judge, which runs with the persona attached, then failed three TRUE winners in three live ticks on the Creative Arts Emmys – two as “not her lane” (the constitution’s STAY IN YOUR LANE line read as a topic gate) and one by asserting a different winner from its own memory (“the actual winner was Jon Hamm”) – the judge-error class docs/PROMPT_OPTIMIZATION.md names, and one no rubric wording moved (two rewords measured). The faithfulness judge behaved on 9/9 cases (five true winners 0.95 stable across n=3, a different name 0.0, a career count 0.35, a hedge 0.15, a trailing question 0.2), and the live re-run then shipped four of tonight’s winners at 0.95 and the G7 official final. The report rubric keeps the two clarifications made along the way (a fact the ground truth states is grounded even when it is news to the judge; the subject is never a reason to score low), measured at its settled-out baseline (15/20). The prior (WinnerCall.prior, the ledger’s prior) is telemetry only. Both lanes share _ship_outcome_card (compose -> shape guard -> number backstop -> the lane’s pre-gate + scorer -> art -> _ship_card) and the market_outcome_attempt state (an event is one shape or the other, never both); the entity lane has its OWN per-tick hunt budget (_MAX_ENTITY_HUNTS_PER_TICK, 3 – results cluster on an awards night, six fields in one hour, and one per tick posted the last winner two hours late). Telemetry rides market_outcome with kind=entity; the panel splits on it.kalshi_price.py, the ONE definition of “is this Kalshi yes price trustworthy”, shared by every reader of a Kalshi book — the market snapshot (markets._kalshi_market_to_snapshot), the candle trajectory (markets._candle_mid), and the survival ladder (luminate._survival_price). Kalshi hands three price signals per market (the two-sided yes_bid/yes_ask book + the last TRADE) and neither is trustworthy alone, so the rule lives here once: a TIGHT two-sided book (spread ≤ WIDE_SPREAD 0.10) → the MID; a WIDE/one-sided book → the last trade only when it’s a real in-book print (within LAST_TRADE_TOL 0.05 of the book, since on a wide book the mid is a price nobody would trade at — bid 0c / ask 97c mids to 48% on a rung really worth ~95%); otherwise NO price, and the caller drops that sample (a dropped rung/candle beats a fabricated one — the ladder interpolates across the gap, the chart omits the point). traded_price is the companion guard: Kalshi reports last_price_dollars of exactly “0.0000” on a market that has NEVER traded — a no-trades SENTINEL, not P=0 (the 1c tick is the floor, so a live market can’t sit at a true 0). Reading that 0 as a probability is what cratered the petal (Ariana Grande) album-units forecast from ~371K to ~208.8K: four never-traded mid-ladder rungs briefly lost their bid, _survival_price fell through to the 0, and implied_estimate’s monotonic clamp propagated it up the WHOLE ladder — truncating the survival curve just above 200K, which shipped a false “steep cut” line-move alert and a forecast chart that plunged at its anchored right edge. Dry-run-verified against the live ladder: the same bid-pulled state now reads 358K vs the healthy 356.6K (a ~0.4% wobble instead of a 44% crater), and the healthy figure moved 371.4K → 356.6K, i.e. CLOSER to Kalshi’s own published 351K, because the far tail now reads its real ~0.005 book instead of stale 0.02–0.05 last trades. Swept across EVERY live Luminate music market (34 readings, 47 series): 6 MORE were collapsed by the same bug at that moment (KXARTISTSTREAMSY yearly-streams events, each recovering 55–93% — Bad Bunny 32.2B→50B, Bieber 18.6B→31.9B, Billie 11.4B→20.1B), 15 were unchanged, the rest moved <10% (the far-tail cleanup). The second failure shape the sweep exposed is an EMPTY market: a zero-volume event quoted 1c/97c on every rung (PRIMA by ADÉLA) has no market price at all, yet the old read shipped a FABRICATED figure either way — the 1c/97c mid read as 49% on every rung (~10K), and with the bid at 0 the never-traded 0 read as P=0, whose trapezoid first segment is exactly the “~500 pure sales” the desk posted. Now every rung is unusable → NO reading → the desk posts nothing, the fail-safe direction (a dropped rung beats a fabricated one, and a whole dropped market beats an invented number). Pure, unit-tested. Two more readers wired in (2026-08-10, the Kendrick 4% print): the rule is about BOOKS and PRINTS, not about one exchange, so it now also backs (a) the music desk’s FIELD markets (music_markets.field_outcome_pct, which quoted the raw last_price_dollars) and (b) Polymarket’s two-sided case (markets._extract_market_yes_price, which took the mid with no spread check). The incident: one 266-contract order printed 4c on KXALBUMRELEASEDATE-NEWKEN-27JAN01 at 17:26 UTC while the book never left 59/62; the next trade cleared back at 59c and the market still reads 59c. @Kalshi_Music tweeted the 4c snapshot, the newsroom composed “before 2027 at just 4%”, and only an unrelated gate stopped it — nothing on that path questioned the price, because the raw last trade WAS the price. The shared rule reads that same market at ~60%. Measured before wiring, on Polymarket: 85.5% tight (unchanged) / 1.5% wide-with-an-in-book-last (now the trade, was the mid) / 0% dropped, over 400 live open markets. Polymarket’s ONE-SIDED fallbacks are deliberately left alone: book_yes_price reads a half-book as no book and would change 13% of live Polymarket markets, which is a separate call with its own blast radius. The market_drop wired in (2026-08-11, #2299): the drop’s Kalshi paths read the raw meta["last_price"] at six sites, and those sites DISPLAY the number – a leaderboard board builds outcomes and chart_specs from it, and the binary path renders it as the question’s percentage. #2287 called them ranking sites and left them; they rank AND render. market_drop._leg_pct is now the one place the cog asks for a leg’s percentage, it returns snap.probability, and every caller DROPS a leg the rule refuses (a board with one fewer bar beats a board with one invented bar). Measured over the 7380 legs of the 1008 live leaderboard events the drop picks from: 2561 legs (34.7%) rendered a percentage more than a point off their own book, 171 (2.3%) more than ten points off, 69 showed a price the rule refuses outright, and 48 boards (4.8%) led with the wrong leg. The clearest live case is the Peyton Watson next-team board: it led with “Milwaukee 51%” off a 51c print while Milwaukee’s book said 20%, so the board named the wrong team and overstated it by 31 points (it now leads Cleveland 29%, Milwaukee fourth at 20%). Coverage is close to flat – live, leaderboard events go 1007 -> 940 and binaries 890 -> 900, because the trusted rule also ADMITS legs that never traded but carry a firm book. Still on the raw print, deliberately: market_alert’s last/previous_price/price_change trio and utils.market_triggers move detection. A trusted DELTA needs a trusted PRIOR price, and Kalshi’s previous_price is a bare trade print with no book beside it, so there is nothing to validate it against – that is its own piece of work, not a rename (#2299 step 3). The change LINE too (2026-08-11, #2308): the snapshot’s price_change was last_price - previous_price, so after the drop started rendering probability a board could state a percentage and a move measured to two different numbers – and the change line reaches a post through _format_change (“up 7 pts 24h”). It is now probability - previous_price. Live, of the 674 boards rendering a change line, 322 (47.8%) were off by more than a point, 48 (7.1%) by more than five, 17 (2.5%) by more than ten – and the worst were self-contradictory, not merely imprecise: KXNFLSEASONRECYDS-27C1000 showed “11%, up 62 pts” (up 62 from a 18 prior is 80, not 11; the real move is 7), and KXIPODISCORD-27JUN01 showed “69%, down 35 pts” when the market had RISEN 10 points, so the arrow pointed the wrong way. The PRIOR stays raw on purpose: Kalshi sends previous_price with no book beside it, so the snapshot builder has nothing to validate it against. The alert path solves that properly and already did – _verify_move re-reads the prior off the candle series, takes the current price from probability, writes all three fields back, re-classifies, and FAILS CLOSED with no history (#1829/#2288), which is why every market_alert site reads corrected numbers and needed no change here. This is the cheap screening delta, so it fixes the half it can see. Screening effect: swing-bar candidates 381 -> 357 (43 dropped, 19 newly surfaced, 338 kept), and since the alert re-verifies every candidate against candles this moves mover COVERAGE, never what a verified alert asserts. trusted_price on the meta, and the class sweep that came with it (#2308, caught by the PR reviewer): price_change measures to the trusted price, but market_triggers.classify_market_event takes a META DICT and read now off the raw last_price – and EVERY directional branch in it is a LANDING test on now. The mix made the landing judge a number the delta was never measured against: a 0.76/0.80 book whose last trade and prior are both a stale 0.94 gives a -0.16 delta, but now=0.94 fails the now <= 0.80 test, so the 94%->78% near-lock crack never fires – and at 16 points it sits under the 20-point swing bar, so nothing else picks it up and it is never candle-verified either. The snapshot now carries trusted_price beside the raw print; the classifier prefers it, falls back to last_price only when the KEY IS ABSENT (a Polymarket or hand-built meta, where the raw field is the only price), and treats a present-but-None as no event rather than reaching past it. _verify_move sets it alongside its other corrections. last_price stays the raw trade deliberately – #2301 made “the refused print is still on the meta” a guarantee. Live screen effect: 75 legs newly caught, 61 dropped, all still candle-verified downstream. The sweep also found betting_board’s Kalshi card, whose own docstring says it mirrors the market-drop construction “from last_price” – #2306 fixed the original and left the mirror, so it built outcomes + chart_specs from the raw print. It reads probability now, and a refused leg drops off the card. bid_supported, the CONVICTION test, and the market_drop tiers that needed it (2026-08-29, #ella-dandelion). book_yes_price is generous on purpose – on a WIDE book it accepts the last TRADE, because for a chart line or a ladder rung a lone real print beats both a mid nobody would trade at and a dropped sample. A surface that ASSERTS what the market BELIEVES needs more than that. market_alert has sat such a leg out since the phantom-96% fix, using firm_book; the DROP’s three conviction tiers (_frontrunner, _is_locked_leader, _clear_winner_lock) never got any guard, and they make the same kind of claim. The shipped case: the “#2 on the Billboard 200, Week of Sep 5” board. DANDELION quoted bid 6c / ask 58c and last traded 40c EIGHTEEN HOURS earlier, so the 52c-wide book bracketed that stale print and book_yes_price returned 0.40. In the same session Ultimate Dolly Parton took 958 contracts at 90c, and its own last price read 14c only because a 5-contract NO print cleared 150 seconds before the drop. So _frontrunner crowned DANDELION, clearing its 0.25 gap bar by a single cent, and Toots posted “DANDELION at 40% is the market’s top pick for #2” – two minutes before the music desk posted the HITS projection with Dandelion at #3, behind Dolly at #2. The test is the BID, not the spread – and the reason is PRECISION, not safety. firm_book (the predicate market_alert uses) asks whether the two QUOTES are close, which is a proxy for the same thing and a SAFE one: it is strictly STRICTER than bid_supported, never looser. That is structural, not luck – on a tight book book_yes_price returns the MID, which sits at most half a spread over the bid, so firm_book implies bid_supported by construction. Measured across the 71,314 priced legs of the 12,000 live open Kalshi events (2026-08-29): ZERO legs where firm_book passes one bid_supported refuses, and on the >= 95% lock tier the two cut the IDENTICAL 119 boards. What the bid test buys is the absence of an over-cut: firm_book refuses a leg whose bid plainly backs its price but whose ask is lazy – 3.6% of priced legs, and 27 of the drop’s frontrunner boards, e.g. Choosin' Texas for the Sep 12 Hot 100 at bid 80c / ask 93c, whose 81% the book obviously supports. Both predicates refuse every case that actually matters (the phantom-96% leg’s 96c ask over a 20c bid; the live locks quoting “99%” over a ZERO bid; DANDELION’s stale 40c over a 6c bid). So bid_supported is the one to reach for where a wrongly-silenced surface has a real cost, and firm_book reads as the conservative sibling, not a broken one. The first draft of this fix used firm_book and the prose justifying it claimed the predicate was unsafe; the 71,314-leg sweep is what showed the real difference is an over-cut – worth remembering as a case where the measurement corrected the rationale after the code was already right. The FIX is a SKIP, and gating only the tiers was not enough – that is the lesson here. The first attempt muted the three conviction tiers and let the board drop with its leaderboard read. Running the real card builder showed why that fails: with number_figure left unset, market_cards.leader_figure re-heroes the identical leg one layer down (#2 / DANDELION / "40% · ..." against the shipped #2 / DANDELION / "... · frontrunner at 40%") – the same card minus two words. A board’s LEADER is what the board claims: the model blob leads with it, the card heroes it, and the tiers report it, so gating any single layer just moves the claim down one. So _kalshi_leaderboard now SKIPS a board whose top-priced leg is not bid_supported (_soft_priced_leader) and the picker takes the next candidate – live, 8.9% of leaderboard-eligible boards, and the slot still posts because the picker walks candidates hottest-first. _kalshi_binary carries the same guard on its lone leg, which renders its price AS the answer (“Will X happen? 13%”). A soft TRAILING leg never costs a board its slot – only the leader speaks. The regroup still stamps meta["soft_priced"] (_soft_priced_labels, judged only on legs carrying the raw book fields, so a non-Kalshi source passes untouched) and the tiers still refuse a soft leader, kept as defence in depth because _netflix_gateable reaches those pure predicates directly and the stamp is what the skip computes from anyway. Both skips emit market_filtered reason=soft_price_tier, so a board stepping aside is never silent. The alert’s RACE fold was the third reader that never asked (the Emmy “Tie” incident, 2026-09-07). _soft_priced sits a soft leg out of the MOVERS pool, but the lead-change lane and the leader rule read the race rebuilt from the event’s own /markets fetch (_race_snapshot / _event_leader), and that fold took each leg’s probability as given. Every Emmy category board carries a “Tie” leg that prices ~1%; the Supporting Actress in a Comedy board’s Tie leg held one 95c print against a ZERO bid and a 95c ask, book_yes_price accepted it (an in-book last trade on a wide book), and the fold crowned “Tie” at 95% over Kate O’Flynn’s 80% on a real 76/84 book. It shipped as “Tie has overtaken Kate O’Flynn as the forecast Emmy winner” to the room and to X, with a Kate O’Flynn headshot under a “Tie” hero, and the leader rule then suppressed the board’s real legs as trailing behind it. Live sweep the same hour: 28 Emmy boards, this was the only one with a soft top leg. Two changes. (1) _race_price is the number the RACE reads a leg at: the trusted price when bid_supported backs it, else the BID itself, and None when nobody bids – so the Tie leg leaves the race and Kate leads at 80. The bid rather than a drop, because a real leader with a lazy ask (bid 80c / ask 99c / last 95c) fails bid_supported too, and dropping it crowns the runner-up: a fabricated overtake, and a second one when the book firms back up. The fold stamps the priced legs it left OUT (no bid at all) as meta["soft_priced"] (the drop’s key, _soft_race_labels) – not the legs read at their bid, which are still in the race at a real number, so a firm leg overtaking one is a real flip the lane reports (Codex review). (2) The fired alert had RESET the durable baseline (market_leader_alerts) to “Tie” at 95, so the corrected fold alone would fire “Kate O’Flynn overtook Tie” next – _pick_lead_change now RESEEDS a baseline that names a soft-priced leg to the current leader (db.reseed_market_leader_ref, leader + pct only, the alert clock untouched) and emits market_filtered reason=soft_baseline instead of measuring a flip from it. The other Kalshi folds were swept: markets.py’s player-prop fold (/ask data, no conviction claim) and the sports aggregate fold (its snapshot goes through _soft_priced per leg) were left as they are.luminate.py’s ALBUM_METRICS registry, the ONE source of truth for WHAT a Kalshi music market counts — metric_from_text matches a title/scope against the rows in REGISTRY ORDER and the first hit wins, so the order IS the precedence rule. A cue that names a METRIC outranks one that names only a TIMEFRAME (#3008). first_week_units’ cue is "first week", which says WHEN Luminate counts and not WHAT, so every First Week <metric> title carries it; ranked third it beat streams and swallowed Kalshi’s whole KXALBUMSTREAMS series — all 29 live events read "I Know Too Much: First Week Streams" -> "First-Week Units". The label is not cosmetic: release_stage calls a first-week UNITS market a debut by construction and guards that branch on "stream" not in metric, so the wrong label also walked the streams ladder into the units projection slate (music_markets._sales_projection_keep, which reads the stage with age_days=None). Live on 2026-09-06 that put Ellie Goulding’s 1.5M first-week STREAMS forecast on an X card reading “~1.5M first-week units”, for an album whose units ladder prices P(above 10K) at 8.5% — a 150x overstatement, and the media-coherence judge passed it because the art and the take agreed with each other. The diagnostic tell is that one album posts BOTH labels in one day: the series’ own metric was already right (series_metric reads KXALBUMSTREAMS’ title “how many streams will album have” -> "Streams"), and event_metric OVERRODE it with the title read, so a surface reading the SERIES metric posted a correct “2.3M first-week streams” at 10:09 while the units lane posted “~1.5M units” for the same ladder at 13:06. first_week_units is now LAST: the fallback for a title naming a first week and no metric at all. Adding a metric row means placing it ABOVE the timeframe fallback.retrospective.py, the ONE definition of “is this wire post a throwback, and how do we FRAME it” — retrospective_frame(text) / is_retrospective(text) (detection) + retrospective_note(text) (the compose instruction), shared by all four wire desks (music_news, cinema_news, pop_desk, sports_desk). The desks gate a candidate on the TWEET’s age, not the age of the EVENT it describes. @chartdata’s “1 year ago today, HUNTR/X’s ‘Golden’ reached #1 on the Hot 100” is a fresh tweet about a year-old milestone: it passed the recency gate, the fact is TRUE so the verify step confirmed it, and it shipped as breaking news (“‘Golden’ by HUNTR/X hit No. 1 … ever”). No downstream gate can catch this class — verify checks whether a claim is TRUE, not whether it is CURRENT, so a real anniversary is confirmed every time. The fix REFRAMES, it does not drop (owner steer: “I didn’t need you to cut them, just not report them as current”). The desk detects the throwback and hands the compose retrospective_note — a past-tense look-back instruction that quotes the source’s own time frame — so Toots posts “a year ago today, HUNTR/X hit No. 1 …” instead of “hits No. 1”. music_news applies the note in _compose_one AND skips the LIVE-CHART settle for a throwback (settling reads the CURRENT chart, which would state a year-old event’s position as it stands now, or drop it as “moved”); the classifier extracts the throwback normally (it is a real chart story, the desk frames the time). cinema_news/pop_desk/sports_desk prepend the note to their compose blob in _compose_one. All FOUR desks ALSO drop a fresh-tweet-old-news RECAP via the Grok is_stale_recap gate (a separate mechanism, the utils/news_age.py gate) run as the first gate in _compose_one. music_news was the one desk this gate had NEVER been wired into (#2505) — the class-sweep miss that let an old box-office figure (the Eras Tour concert film’s $93M opening, October 2023) re-posted by a trusted touring account ship as current news. It now runs the same gate, scoped by stale_gate_applies to the NEWS kinds only (the settled/live lanes — chart/milestone/cert/first_week — read CURRENT data, so a re-report of old news cannot enter through them and a Grok read there is wasted spend) and skipped for an explicit throwback (which the reframe above ships AS past), so the reframe and the drop never fight over the same post. ONE residual hole in the “chart reads CURRENT data” reasoning was found later (#golden-hour-part5): the chart lane reads current data only when the live chart actually settles the claim, and a WEEKLY chart (Billboard 200, Hot 100) read as ABSENT does NOT settle it — the album fell off the current chart, or the wire’s title/artist did not match a row. _chart_claim_too_stale treats a weekly absence as a match miss and ships the claim, which is right for THIS week’s chart and wrong for an old one. So ATEEZ’s “GOLDEN HOUR : Part.5” — No. 1 on the Billboard 200 dated 2026-07-11, re-posted by a fresh tweet on 2026-08-25 after it had dropped off the chart — read absent and shipped as “lands at No. 1”, current news. The tweet was fresh, so tweet-age could not catch it, and the verify step confirmed the number because the number is TRUE. _compose_one now hands exactly this residual case (music_news.absent_on_weekly_chart: absent + every chart named is weekly, AND nothing settled it — live_proj is None, so a first-week projection settled off the HITS document is never dropped as stale) to is_stale_recap with event_age=True. The event-age prompt (grok_search._EVENT_AGE_PROMPT) dates the CHART WEEK, not the chatter, and calls STALE only past ~10 days — so a current week’s chart (including a this-week debut whose row we merely mismatched, reported a few days late) reads FRESH, while a 45-day-old #1 reads STALE. It is fail-open, so it only DROPS a story that ships today, never invents a new one. Each reframe emits wire_retrospective (surface + frame + subject; no post text, data minimization). The match is narrow: every date frame is anchored on BACKWARD evidence and must LEAD the post — an “ago” (“N years/weeks/days ago today”, any count incl. “twenty one”), a STRICTLY PAST year (“this day/date in <year>”; a future or current-year date does NOT fire), a “today marks … since / anniversary” lead, or a throwback tag (incl. the compact “#ThrowbackThursday”, matched anywhere). Punctuation is never proof, and an anniversary embedded MID-SENTENCE in fresh news (“To celebrate the album released ten years ago today, X drops a deluxe”) does not fire — the frame must lead, so real announcements still ship as current. wire_sources.rank_wire_posts (the shared ranker behind wire_material, read by discourse + the music desk) does NOT filter retrospectives: it is context material for a composed take, not a single post restated as breaking, and its own throwback handling isn’t needed there. THE NOTE IS AN INSTRUCTION, NOT A GUARANTEE, so the composed take is checked back against it (#3270). @Genius posted “eight years ago today, 6LACK dropped ‘east atlanta love letter’”; the desk detected the throwback, told the compose to write a look-back, and the take that shipped to X on 2026-09-14 read “East Atlanta Love Letter debuted at No. 3 on the Billboard 200 with 77,000 equivalent album units in its first week”. The model obeyed half the note: past tense, no time frame. Past tense is not the frame — a wire desk reports every story in the past tense, so a reader sees this week’s debut for a 2018 album. No gate below could catch it either: the self-gate grades whether the claims are GROUNDED and every word of that line is true, so it scored high and shipped. The fix has two halves, and both were measured on the PRODUCTION compose model with the real inputs (the real claim block, the real Perplexity BACKGROUND, the real grok X CROWD READ), n=12 per arm. (a) The note was reworded, not added to. The old note asked for two things in one clause (“past tense, and make the time frame clear”) and closed with two “never” rules; it framed 4/12, and three of the failures reproduced the shipped line verbatim. The new note names the STOP CONDITION (“you are done when a reader who sees only your line knows this happened THEN, not today”), hands the source’s own phrase back as the SHAPE to write, and cuts both “never” clauses — 12/12 framed on the production model, 4/4 on Sonnet (Sonnet framed 4/4 under the old note too, so this is a per-model failure the note now covers for both). The cause of the old note’s failure is worth keeping: it only broke once the X CROWD READ block was in the context, which tells the model what people are saying “RIGHT NOW” — the frame lost to a later, present-tense block, exactly the layer-fight docs/PROMPT_OPTIMIZATION.md warns about. (b) A deterministic backstop — frames_as_past(line) asks whether the take states ANY look-back marker (a backward interval, an anniversary, a “N years since”, a throwback TAG, or a year with a date around it), and all four wire desks drop a throwback take that states none, as <surface>_scored reason=retro_unframed. The checker is deliberately GENEROUS where the input-side match is narrow: it reads Toots’ own line and the consumer DROPS on a miss, so a frame worded a way we did not list would throw the post away — and the owner’s steer on this class is still “I didn’t need you to cut them, just not report them as current”. It accepts the throwback TAGS off the same _TAG_RE the input frames use, because the note hands the matched phrase back as the shape to write: a tag the note asks for has to be a tag the check takes. The one place it is NOT generous is a bare YEAR, and that is correctness, not taste (Codex review): a four-digit token on this desk is as likely to be a TITLE as a date — Prince’s “1999”, Taylor Swift’s “1989” — so a bare year would have passed “Taylor Swift’s 1989 debuted at No. 1” as its own frame and shipped the exact take the guard exists to stop. A year counts with a date preposition (“back in 2018”), a possessive (“2018’s”), or a release noun after it (“its 2018 debut”); and EVERY year match is read, not the first, so a line naming this year before a past one still frames. One interaction had to be fixed with it: the note tells the compose to write “8 years ago today”, which states a NUMBER, and BOTH of music_news’s grounded gates — the digit gate and the self-gate — hard-fail a figure their ground truth does not carry, so the desk would have dropped the take it just asked for. The frame is therefore checked IN ITS OWN LANE and then removed from the line the value-set gates read (retrospective_values + look_back_values + strip_look_back). The first attempt put the interval in the general grounding block instead, and that was wrong in the other direction: ungrounded_numbers compares UNTYPED value sets, so an “eight years ago” source authorized the value 8 in every role and a take reading “debuted at No. 8” off a No. 3 claim passed the fabrication gate. A number the frame supplies is evidence for the frame and for nothing else, and a take that invents its era is dropped as retro_interval. The MODEL scorer still reads the phrase as prose (retrospective_grounding), which carries the wire’s own words plus the numerals the frame fixes: the grounding side expands a single spelled cardinal, so “eight” grounds “8” on its own, but it cannot compose “twenty one” into 21 or read “a year” as 1. It carries BOTH READINGS of the anniversary, because the note offers the model both — an “eight years ago today” wire grounds the year 2018, and an “on this day in 2016” wire grounds the interval 10, so the take that writes “back in 2018” is not dropped as inventing a year (the shape Sonnet actually wrote in the dry run). The VALUE SET carried only one of those two readings, and that cost a real post (#3271 follow-up). _frame_numbers DERIVES numbers. An “eight years ago today” wire yields the interval 8 and the year 2018. A “today in 2007” wire yields only the interval 19, because the year is already inside the phrase the grounding prose quotes. The deterministic gate reads the VALUE SET, not the prose, so 2007 was not in the set. On 2026-09-15 @Genius posted “today in 2007, soulja boy’s ‘crank that (soulja boy)’ reached no. 1 on the billboard hot 100”. The note hands that phrase back in quotes as the shape to write, the take wrote it, and the desk dropped the take as retro_interval with detail.numbers=2007 — for stating the year its own source printed. The date was right and the anniversary was lost, because music_news marks the drop SEEN. A YEAR frame carries 22% of this desk’s throwbacks (13 of 58 in the 30 days to 2026-09-15), so this was not a corner case. retrospective_values now adds the year the source PRINTED to the numbers it derives, so both readings are allowed in both directions; the gate still drops an invented era (“back in 1999” off a 2007 wire). The dry-run harness had three cases and every one stated an INTERVAL, which is why the year direction shipped unmeasured — the Crank That wire is now its fourth case. A count it cannot read in full states no numeral at all: “half a year ago today” and “a few years ago today” both parsed to 1, and that numeral would have grounded a take restating a vague interval as an exact figure. FIVE markers were cut or narrowed for one reason, and it is the lesson of the whole review: a marker a TITLE or a QUANTITY can satisfy is not a frame. “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 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 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”.
markets.py:qualify_platform_metric, the ONE place a Kalshi title is told what its metric is measured ON. The exchange writes titles that name a metric and drop the platform — “Morgan Wallen: Highest daily view count (July 27 - August 2, 2026)” — while the rules that SETTLE that same market spell it out (“above 5.75M Global daily views on YouTube”). Every market surface renders the exchange title verbatim, so the one blank reached the alert’s embed heading, the number card’s caption AND the grounded blob the compose writes from at once: a live alert shipped a 6.6M hero with no unit anywhere on it and the room asked “views on what” (owner report). So the weave happens at the ONE snapshot boundary (_kalshi_market_to_snapshot, on both the leg title and the event_title) rather than per surface — every consumer of meta['event_title'] (~30 reads in market_alert alone, plus market_drop / /ask) inherits it, including the ladder path that re-stamps the title off _LadderInfo (it reads the already-qualified value off the snapshot). Grounded, never guessed — the platform must be named in that market’s own rules_primary, and EXACTLY one must be, so a rules text weighing Spotify against YouTube is left alone rather than labelled with whichever matched first. The series TICKER is deliberately not consulted: prefix-matching KXYT* looks obvious and is wrong (KXYTHUG is Young Thug, not YouTube), which is the invented-over-absent direction. A title that already names its platform (Netflix’s “How many views will the #1 Movie on Netflix have”), carries no ambiguous metric noun, or arrives with no rules passes through untouched, so it is inert on every family but the handful that need it; the ambiguous nouns are views / streams / subscribers only (NOT the singular “view”, a verb, nor “video”/”song”, which would take the platform in the wrong clause of “Daily Top Music Videos USA”). rules_primary is read in passing and still never stamped onto the meta (detail-only, per market_detail), so this adds no event field and no fetch. Pure, unit-tested; live-verified across the KXYTVIEWSW / KXYTVIEWSHIGH / KXYTVIEWS families (qualified), Netflix + Oscars (untouched).alert_triggers.py, the shared alert TRIGGER primitives (#generalize-alerts) — the pure movement classification every alert surface reuses, so FLIP / SWING / lead-change is defined ONCE instead of re-implemented per surface. Each alert cog keeps its OWN fetch, durable baseline, compose voice, card, and gating; this owns only the pure decision “did the leader/number move enough to be news, and how?”. Standing (who leads a 2+-way market: leader + integer % + margin over the next), top_standing(outcomes) (the leader of a multi-outcome {entity: prob} map — the leaderboard analog of betting_alert’s _favorite), and classify_leader_move(ref, cur, *, swing_pp, flip_margin) → "flip" (the LEADER changed AND the new leader clears flip_margin over the runner-up, so a near-tie jitter isn’t a flip) |
"swing" (same leader, its % moved ≥ swing_pp) |
None. Consumers: cogs.betting_alert (a bettable GAME’s moneyline flip/swing — the FIRST implementation, now a thin adapter over classify_leader_move) and cogs.market_alert (a prediction-market LEADERBOARD lead-change — a new artist/name overtaking the prior #1 on a Kalshi ranklist; the SECOND consumer that earned the extraction, rule-of-two). Percentages are integer POINTS so both surfaces compare like with like. Pure, unit-tested. |
tmdb.py, the guarded TMDB client + pure parsers for the cinema desk’s RELEASE calendar + title metadata (#cinema-desk): now_playing (theatrical, popularity-ordered — the clean release signal), discover_streaming (new-on-a-platform via watch-provider), new_tv (discover/tv by first_air_date), releases_between (the coming-soon calendar – it DROPS any row TMDB dates outside the asked-for window, because the region+type filter matches a re-release on its US re-release date while the response still carries the film’s PRIMARY date; that is how Avengers: Endgame dated 2019-04-26 drew on a live “Opening Next” card, #3285; emits tmdb_calendar_filter when it drops), movie_detail (budget / lifetime WORLDWIDE revenue / runtime / genres — the enrichment, NOT the daily beat), search_movie, and trailer_url (the OFFICIAL YouTube trailer for a title off /{movie,tv}/{id}/videos — the video twin of poster_url, an AUTHORITATIVE lookup rather than a search, which is what lets utils.watch_link route a film/TV post deterministically; prefers an official Trailer, then any Trailer, then an official Teaser, YouTube-only so the link unfurls into a player. It is community-maintained, so it LAGS a trailer that just dropped – #trailer-lane measured the reported “Awarapan 2” post: at post time that title’s /videos held ONE clip, a six-week-old Teaser flagged official:false, which this correctly refuses, while the real trailer was on the studio’s own YouTube channel. That lag is why watch_link puts a search rung UNDER this lookup rather than treating an empty /videos as “no trailer exists”). Auth = the v4 bearer TMDB_ACCESS_TOKEN (authorizes v3 too; TMDB_API_KEY the fallback). TMDB is free + unmetered (no quota header, verified), so NO usage poll — just the standard guards (AsyncRateLimiter + breaker integration="tmdb" + retry). Emits tmdb_fetch; fail-open (a miss → None/[]).box_office.py, the guarded Box Office Mojo scrape client + the pure parse_chart HTML parser for the cinema desk’s box-office grosses (#cinema-desk): weekend_chart(year, week) / daily_chart(day) → ranked BoxOfficeRows (period gross + running total). No API/quota; the guards are a GENTLE rate limiter + breaker (integration="box_office", trips on an IP-block/outage — the thing that 403s us on The Numbers) + retry + a short in-process TTL cache. Emits box_office_fetch; fail-open ([]) after HTTP failures and exhausted network retries, so a dead BOM read omits its story without aborting the later TMDB, Netflix, OMDb, and news lanes. Parses cleanly from our host (verified).netflix_top10.py, the guarded Netflix Top 10 TSV client + pure parser for the cinema desk’s streaming numbers (#cinema-desk): latest() → this week’s Top-10 rows (weekly hours viewed + views, across Films/TV × English/Non-English). Reads the whole all-weeks TSV (oldest-first, so the latest week is at the END — must read fully) + caches weekly; free, no key, guarded (breaker integration="netflix_top10" + retry). Emits netflix_top10_fetch; a TSV shape change shows as a fail rate. Fail-open ([]).omdb.py, the guarded OMDb client + pure parser for the cinema desk’s critical-scores lane (#cinema-desk): by_title(title, year) → an OMDBRatings (RT % + Metacritic + IMDb, parsed out of OMDb’s Ratings array; a brand-new film may carry Metacritic/IMDb before RT populates). Free tier (OMDB_API_KEY); guarded (rate limiter + breaker integration="omdb" + retry + TTL cache). Emits omdb_fetch; fail-open (None). The one differentiated cinema FACT Kalshi’s thin score markets can’t cleanly give.cinema_numbers.py, the cinema desk’s pure data layer (the movie/TV analog of utils.music_markets): the story BUILDERS (box_office_story — release-centered, leads with the biggest new opener; streaming_story — the #1 film/show by hours; scores_story — a film’s RT/Metacritic critical scores off OMDb, “reviews are in: 94% on RT”; release_story — the notable new theatrical/streaming/TV drop), each turning already-fetched rows into a CinemaStory (headline + a grounding blob the compose model quotes + a dedup_key + a story-priority score), plus rank_stories (order + dedup) and the async fetch_cinema_stories orchestrator (the guarded client reads → the builders; the scores lane looks up the freshest notable theatrical release’s scores). Builders/ranker are pure + unit-tested.cinema_boards.py, the cinema desk’s ranked BOARD builders (#2266): pure derivations over BoxOfficeRows into a CinemaBoard (the fact, the rows that prove it, a notability score, and the card’s head lines), plus the model-facing compose_inputs. It carries its own CinemaBoard record rather than reusing chart_standings.Board, whose source DEFAULTS to Billboard – a cinema board that forgot to override that would credit Billboard for a Box Office Mojo figure, the one mistake a source pill must never make. Three fences come straight from the source pages (docs/INTEGRATIONS.md): the year board prints only the Gross column BOM ranks the page by, because the page’s second gross column disagrees with it for a carry-over title in a direction that cannot be established from outside; the per-screen board reads the WEEKEND page only, because the year page’s theater count is the MAXIMUM the film ever played; and a row is dropped below three screens, where a ‘per-screen average’ is one venue’s take wearing the word average. The board is RANKED by per-screen average, but the row’s HERO number is the film’s WEEKEND GROSS (humanize_gross, compact $61K/$39M); the screen count and the per-screen average ride in the sub-line (N screens · $X/screen). So a three-screen platform release still leads the board on its per-screen figure while the small gross at the top of the card shows what it actually made – the contrast the board exists to show. Keeping the per-screen RANKING (not flipping to gross) is deliberate: it is what keeps this board’s take distinct from the weekend_top_ten gross board and the box-office number lane, so it is not the board that keeps getting deduped (owner steer 2026-08-24). The weekend_top_ten board leads with the weekend’s DEBUT (owner steer 2026-08-25): its story is who OPENED. _debut_lead picks the biggest new opener (a row _move marks ‘new’, i.e. no week-over-week change in its first week) above a _DEBUT_LEAD_MIN_GROSS $3M floor – the same wide-opener floor box_office_story uses – and the board’s subject FOLLOWS that lead, not always the number-one film. Two reasons the subject matters: the card’s corner poster is resolved from subject, so a take about a #2 debut beside the #1 holdover’s poster would be a wrong-art mismatch (Codex review, #2569); and the compose block NAMES the lead on a LEAD WITH: line so the take, the poster and the desk’s pick cannot disagree (the #2082 supply-the-field pattern). The fence tells the model to follow that line and not pass a debut over for a percentage swing on an older film – with NO ‘wide’ claim (the block carries no screen count) and NO ‘adding screens’ cause (no theater-change), both of which the block cannot substantiate. A live take led “Tony is the story” off a +576% swing on a $5M week-3 film while two real debuts sat above it. The swing is a FALLBACK, not banned (owner refine 2026-08-25, “the swing is okay if no debut”): on a debut-less weekend _debut_lead returns None and the subject follows cinema_numbers._notable_holdover – the sharpest hold or cliff, the same pick box_office_story makes – so the swing lead is chosen DETERMINISTICALLY and bound as the subject, and the LEAD WITH line names it. Only when there is also no standout hold or cliff does the subject fall back to the number-one film. That binding is the second Codex fix on #2569: without it the fence could send the take to a lower row while the poster stayed on the #1 film. Pure + unit-tested (tests/test_cinema_boards.py). A TMDB CALENDAR board rides alongside the box-office four (#2266): coming_soon, the US theatrical releases for the next fortnight, laid out in THREE columns – the release DAY as the lead (a #1 would imply a ranking of quality), the title with its DIRECTOR under it, and the STUDIO in the right-hand column (owner ask 2026-08-10). The two were packed into one credit line first and ran to fifty characters, squeezing the title beside them; the row already had a right-hand column and nothing else on this board wanted it. ONE director, ABBREVIATED to an initial plus the surname (short_director: “David Robert Mitchell” reads “D. Mitchell”) – TMDB credits a duo as “Jon Lucas, Scott Moore” and a solo director in full, and both cost the TITLE its width in the row they share. The abbreviator keeps a surname PARTICLE (“Lars von Trier” stays “L. von Trier”, not “L. Trier”, which is a different person) and a generational suffix, and leaves a one-word name alone since an initial identifies nobody. The studio is trimmed of the corporate suffix nobody says (short_studio: “Warner Bros. Pictures” reads “Warner Bros.”). The early-score column is GONE – the studio owns that space, and the score was measured unpublishable anyway. Neither is a DISTRIBUTOR: TMDB carries none, and an unreleased film has no Box Office Mojo row to read one off. A list endpoint carries no credits either, so the desk spends one movie_detail call per drawn row, fail-open per title. A TRENDING board was built here and CUT on the owner’s read (“kill most looked up that’s kinda lame”): the honest version could carry no numbers at all, since TMDB publishes no figure that IS that ranking, so it was a numberless list of films the room had already seen on the box-office boards. tmdb.trending_movies stays in the client with nothing reading it. The coming-soon board renders its early-score column only when _MIN_SCORED_ROWS rows carry one: measured on a real window ONE row of ten had a score, and the model then wrote about which rows did and did not have one, twice getting it wrong and shipping past the self-gate. A lone value in a column of blanks is not a data point, it is an invitation to count the blanks. Its per-day counts ARE handed to the model in the blob, because a calendar invites ‘four open on the 14th’ and supplying the count is the fix where fencing it off is not (the #2082 lesson). The CRITICS SCOREBOARD is the lookup-gated board (the chart_credits pattern: a per-build lookup cap, a coverage floor, one chart_lookup event). Each row shows BOTH scores as metacritic / rotten-tomatoes (owner steer 2026-08-10, “consider a _ / _ format”), with a dash where OMDb has no Rotten Tomatoes score – a column of blanks reads as a rendering fault, a dash says there isn’t one, and the caption names the order so no row carries a label. It ranks on METACRITIC, not Rotten Tomatoes, and that reverses the epic’s own wording on measurement: across a real weekend top 12, OMDb returned Metacritic for 10 titles and Rotten Tomatoes for 4, so an RT-ranked board resolves at a third of the field and would have been silent nearly every week. RT still rides on the row when OMDb has it, and a row without one SAYS so rather than showing a zero. The two scales are never merged: Metacritic is a weighted average of critic scores, Rotten Tomatoes the share of critics who were positive.instrument.py, the one consolidated timing tool. Instrumenting anything is a single line and the util owns the clock/ok/duration/emit (no call site touches a stopwatch). Four primitives: @instrument("kind") (decorate an outbound-client fn → ONE domain event on exit with ok+duration_ms; set fields inside via the ambient event().set(...)/event().fail(...), the ambient handle resolved through a contextvar so a helper can reach it; used by github/railway/tts/stt/image_gen/gifs/apple_music/video_fetch/link_check/…), timed_event("kind", ...) (the block-scoped form for a dynamic kind or a partial-function span: db _run, pplx_<purpose>, market_fetch), @timed("name")/timed_span("name") (a heavy fn / flow step → span event), and Stopwatch (the escape hatch for the two calls that must read elapsed time at several points in one operation: the claude_api retry loop and @track_command). event() is always safe (a detached no-op outside any scope); everything is best-effort (a metrics failure never breaks the wrapped call) and the call’s own exception propagates (auto-tagged ok=False+error). Replaced the hand-rolled start = time.monotonic() / int((...) * 1000) pattern every client had copy-pasted.context_beat.py, the shared LIVE CONTEXT BEAT (#cinema-facts) — the reusable primitive that pulls the current buzz / reception / discourse around a post’s subject from Grok (the X crowd) + Perplexity (web / critics / Reddit / TikTok / YouTube, swept in ONE Perplexity call so “other socials” ride along cheaply — owner steer) and merges them into ONE block a compose surface weaves into its take as SENTIMENT + the sharp ANGLE the bare numbers can’t carry (a fandom civil war, a second-season-slump framing, a critical split). The generic sibling of grok_search.market_topic_pulse (which is market-framed): a neutral “what’s the buzz” read that fits ANY subject. CONTEXT, NOT GROUNDING — the consuming surface still quotes every HARD NUMBER from its OWN grounding blob, never from the beat (the compose_market_drop x_context slot already enforces “the ONLY numbers you quote come from the grounding block”, so a surface just passes the beat there). gather_context_beat(bot, subject, *, purpose, extra, framing) runs both sources CONCURRENTLY, provisioning-gated per source, fail-open (a missing/erroring source contributes nothing; both missing → None). Emits nothing itself — the underlying pplx_<purpose> + Grok search events carry the telemetry, so purpose attributes cost to the calling surface. framing picks the Grok leg: "general" (default) a neutral “what’s the buzz” read (cinema/film/show/song); "market" reuses the proven market-tuned grok_search.market_topic_pulse (crowd-vs-reality) so a MONEY surface keeps its EXACT existing X read and only GAINS the Perplexity layer; "breaking" (the wire desks’ breaking lane, #sports-desk) leans HARD on Grok’s real-time X for the freshest just-broke chatter (_GROK_BREAKING) and pulls the Perplexity leg at recency="day" for max freshness — Grok beats the lagging web index on a just-broke story. The Perplexity PROMPT is the same across framings; only its recency tightens for breaking. Adopters: the cinema desk (framing="general") and market_drop (framing="market" — it already had the Grok pulse, so the beat’s only net-new call there is Perplexity; no Grok is doubled). Music + discourse deliberately do NOT use the shared primitive — they already pull their OWN Grok + Perplexity (interwoven with surface-specific enrichment), so routing them through the beat would DUPLICATE those calls; they’re left as-is. The live sports commentator is deliberately excluded (owner steer) — it’s latency-critical + already live-fed, so a buzz round-trip per goal/interval isn’t worth it. Pure gather/merge logic unit-tested in tests/test_context_beat.py; the Grok/Perplexity I/O is the clients’.audio_tags.py, the v3 performance-tag sanitizer (voice “directing”): a voiced line can carry inline cues that ElevenLabs v3 reads as delivery direction. ALLOWED_TAGS is the audition-confirmed set, [laughs]/[sighs]/[sarcastic]/[whispers]/[excited]/[curious] (reactions/tone) + [sings]/[singing]/[singsong]/[humming]/[hums] (melodic/wordless). Two invariants keep it safe: filter_to_allowlist(text) drops any non-allowlisted [tag] before synth (v3 reads an unknown tag literally, so a hallucinated [angrily] must never reach the API — applied inside tts.synthesize, filter_tags=False bypasses it for the audition); strip_all_tags(text) removes every [tag] before any text path (the text fallback, dedup, the stored memory/discourse rows, the self-gate scorer) so tags are audio-only and never render as text. The regex only matches lowercase-bracketed tokens, so [2024]/[NBA] pass through. ALLOWED_TAGS is the curated set; a new tag earns a spot only after scripts/audition_voice.py (key-gated, never in CI) confirms it renders on the real voice vs gets spoken literally. Distinct from the <voice>/<sing> control signal in voice_signal.py (those choose the delivery channel; these are performance within the line). The voiced prompts (ask SPOKEN DELIVERY + chime-in SPOKEN OPTION) teach the allowlist as “seasoning, most lines have none.”voice.py, canned quip pools (rate limit, permission denied, pipeline red, etc.) with pick() for random selectionbot_logs.py, structured logging to the guild’s #bot-logs channelimage_gen.py, ImageClient for OpenAI GPT Image (the picture-equivalent of tts.py): an async aiohttp wrapper gated on OPENAI_API_KEY, fail-open (None on any miss → text fallback), model/quality env-overridable (OPENAI_IMAGE_MODEL/OPENAI_IMAGE_QUALITY, defaulting to gpt-image-2/medium). Two calls sharing one POST/decode/emit core (_execute): generate (text→image, JSON) and edit (remix an existing image, multipart upload to images/edits). Emits image_generated with mode=generate|edit. Relies on OpenAI’s own moderation; the prompt is composed under the constitution.openai_chat.py, OpenAIChatClient for OpenAI Chat Completions — the OpenAI TEXT backend (#openapi-parity), the generation sibling of image_gen.py/embeddings.py: an async aiohttp wrapper gated on the same OPENAI_API_KEY, fail-open (a CLASSIFIED OpenAIFailure on any miss, #973 — category routes the failover: provider/quota fail OVER to Claude, auth/request/malformed surface; a 429/insufficient_quota out-of-credits is classified quota and is NOT retried, the OpenAI analog of Anthropic credit exhaustion), model env-overridable (OPENAI_CHAT_MODEL/OPENAI_CHAT_CHEAP_MODEL, the best/cheap pair gpt-5.6-sol/gpt-5.6-terra — the opus/sonnet analogs; the Haiku-tier gpt-5.6-luna is an env-override only). Per-model request rules on the OpenAI side (#cheap-model-pricing, the twin of claude_client’s capability sets): is_reasoning_model keys on the gpt-5 / gpt-6 prefixes (_REASONING_MODEL_PREFIXES — a new generation lands there first, or it gets no reserve and no effort and its default thinking eats a short surface’s budget), and gpt-6-astra rejects reasoning_effort: "none" with a 400 (low/medium/high/xhigh only, measured 2026-09-07), so openai_reasoning sends low there instead (_NO_EFFORT_NONE / min_effort); every gpt-5.x id accepts none. Sampling: every 5.5 / 5.6 / 6 id 400s on temperature, and no caller passes one to the OpenAI path. complete(*, model, system, user, max_tokens, purpose, temperature=None) returns a ChatResult (text + finish_reason + token counts), emitting an openai_api event (model/purpose/tokens/finish/preview, never the prompt; ops-monitor keys it openai:<purpose>). The router (claude_client._call → _call_openai) maps it onto ClaudeResult, so a GPT-routed surface is invisible to every downstream guardrail. Two paths: tool-free → complete (Chat Completions, max_completion_tokens; o-series rejects max_tokens); tools / server-side web_search → create_response (the Responses API) driven by claude_client._call_openai_responses’s function-call loop, with the pure parse_responses_body / responses_function_tool helpers here (split out + unit-tested, alongside _decode_chat_completion). The built-in web_search tool returns url_citation annotations + a sources field that flow straight onto ClaudeResult.web_search_urls for the link guardrail. Image input (vision) is served on the Responses path: responses_input_content maps an assembled Claude vision message onto input_text/input_image parts (_image_part_url recovers a URL or repacks a base64 source as a data: URI), so a GPT-routed surface with images answers on GPT instead of falling back to Claude.embeddings.py, EmbeddingsClient for OpenAI text embeddings (the semantic-recall sibling of image_gen.py): an async aiohttp wrapper gated on the same OPENAI_API_KEY, fail-open (None on any miss → keyword/FTS fallback), model env-overridable (OPENAI_EMBEDDING_MODEL, defaulting to text-embedding-3-small, 1536-dim). embed(text) returns a float vector (emits an embedding event with chars/dims, never the text). Plus the pure, unit-tested helpers the recall path uses: encode_vector/decode_vector (the JSON ⇄ list bridge for the memory_notes.embedding TEXT column), cosine, and rank_by_similarity (cosine-ranks candidate notes against a query vector, drops anything under SIMILARITY_FLOOR, returns the same (tier, summary, span_start, span_end) shape the FTS methods do). Chunked retrieval (#865, the searchability lever): a whole daily note (~13k chars) embedded as ONE vector averages its many topics into a diluted centroid, so a narrow query (“tay keith”) scores weakly against the blur (~0.27) and sometimes the WRONG day’s note edges out. So besides the prose + tag vectors, the write path also stores per-chunk vectors (chunk_for_embedding splits the note into moment-sized pieces → embed_batch → encode_chunk_vectors → memory_notes.chunk_embeddings), and rank_by_similarity does a two-stage rerank: rank all candidates by whole-note vector, then re-score the top-chunk_rerank_top (#50) as max(whole-note, best chunk cosine). A narrow query then matches its specific moment-chunk (~0.59-0.75 vs 0.27), surfacing the RIGHT note — a measured ~+70% top-hit recall on real prod notes, and a correctness fix (the diluted whole-note vectors were mis-ranking days). A chunk hit can only RAISE a note’s score, never demote it, so it’s strictly ≥ the whole-note baseline; bounded to the top-_CHUNK_RERANK_TOP candidates to cap decode+cosine; fail-soft (a NULL/bad chunk column falls back to the whole-note vector). Backfilled onto existing notes by the /remember reindex (notes_missing_embedding now also picks up chunk_embeddings IS NULL). The constants are grounded in the live corpus, not guessed (the dry-run caught a naive first cut): MAX_CHUNKS_PER_NOTE=256 sits above the real max (prod daily notes: median 45 chunks, p99 96, max 194) so a busy day is never truncated; _CHUNK_RERANK_TOP=300 covers ~6× the worst observed whole-note rank of a right note (47 of 190 — the diluted score this stage gates on can run deep, exactly the dilution chunking fixes); and the chunk vectors are stored base64-packed float32 + scored with numpy (not JSON like the single prose/tags vectors), because at recall the JSON parse of dozens-of-vectors-per-note was the bottleneck (~1.8s/recall over the full corpus vs ~143-185ms for base64-f32, ~2× smaller too). JSON-in-TEXT + in-process cosine, not pgvector, deliberately: at this data scale retrieval is the constraint, not storage, and a CREATE EXTENSION vector that isn’t installed on the host would break the every-startup schema init. Embedding fenced prose is purely additive — it doesn’t touch the constitutional memory fence.image_signal.py, parses the model’s <image>…</image> (generate) / <remix>…</remix> (edit) candidate tags off a composed line (sibling to voice_signal.py), splitting into the caption (text outside) and the prompt/instruction (inside), with is_remix distinguishing the two. strip_image_tags scrubs any malformed/stray fragment from text replies. Pure, unit-tested. Only the typed-mention ask path teaches the tags (allow_image/allow_remix).utils/media_edit.py + utils/media_signal.py, the mechanical file edits behind the <media op=...> tag on the same path. media_signal is the pure parser (the op plus its numbers; it tolerates 1:12 clock times, quoted values and the near-miss spellings a model reaches for, and strip_media_tags is the leak net for a truncated tag). media_edit is the executor: an op registry that pairs each op with the input kind it accepts and its own output ceiling, PURE ffmpeg argv builders returning a LIST of passes (a gif is two – palettegen then paletteuse, because a one-pass encode bands every gradient on the default web palette), and Pillow for the photo ops. Two params are MODIFIERS rather than ops, because a person asks for them alongside an edit: mute drops the audio on any clip edit and factor retimes it (a gif takes it too), and to=png|jpg|webp names the output format on any photo edit. That is measured, not guessed: asked for two things at once, the model wrote an invented mute=true, or TWO tags of which only the first runs, and then SAID it had done both – a claim that did not match the file. Only the first tag ever runs, and the prompt says so. Three decisions worth keeping: a gif counts as VIDEO (every useful op on one is a video op); trim STREAM-COPIES from zero but RE-ENCODES from a start offset, because h264 only lets a copy begin at a keyframe and a measured copy of “2s to 4s” came back 4.09s long; a copy KEEPS THE SOURCE’S OWN CONTAINER (_COPY_CONTAINERS) and falls back to a re-encode once if it still fails – every copy used to write toots.mp4, and mp4 cannot hold VP8, Vorbis or GIF, so “trim this” on a webm or a gif returned None and did nothing (2 of 5 REAL inputs; the synthetic test clips could never show it, which is why the real-file matrix earns its place); the image ops apply the EXIF ORIENTATION first, because a phone writes landscape pixels plus a tag, Discord honors the tag and our output carries no EXIF, so a resize used to come back rotated 90 degrees from the picture the person was looking at; and the speed ceiling bounds the OUTPUT, so a 0.25x slow-down reads a quarter of the window. kind_of reads the magic bytes (the ftyp brand splits an iPhone HEIC from an mp4) and only falls back to the name, because a Discord upload can arrive with an empty content type. Emits media_edit + media_reply.market_cards.py, self-built Discord embed cards for market posts (the link-unfurl fix). kalshi.com sits behind a Vercel anti-bot wall that serves Discord’s link crawler a “security checkpoint” page instead of OpenGraph tags, so kalshi.com links never unfurl in chat (Polymarket’s native unfurl is inconsistent too). Rather than depend on either site’s crawler, cogs/market_drop + cogs/market_alert build their OWN embed (build_market_card → (content, embed, file)): the take is the embed description (her voice lives IN the card, so the post is ONE self-contained card and content comes back EMPTY — nothing is re-listed as message text, since the chart owns the ranked numbers and the take owns the read), the embed carries the clickable market link (on the embed url, NOT a bare link in the text, so Discord doesn’t also try+fail to unfurl it) and an image. A SETTLED market attaches NOTHING, on every surface (owner steer, #1851): when the card’s leading CHARTED outcome sits at or above SETTLED_LEADER_PCT (0.95 — literally market_drop._LOCK_LEADER_PRICE, imported so there’s ONE definition of “effectively decided”), market_settled drops the link — no clickable heading, no link Button, and, since the crosspost takes its link off card.url, no link in the tweet either. The race is over, so pointing the room at a 97% locked market is a dead CTA. The rule lives in this shared builder rather than in a cog, so it holds across every market surface (market_drop, market_alert, betting_board, betting_alert, betting_value, music_alert, music_desk) instead of each one remembering it — the hoist earned by the music desk + music alert having already hand-rolled it (luminate_snapshot(link=...) drops the Kalshi link on a SETTLED reading; those stay, since a RESOLVED reading is settled by its CLOSE, not by a 95% leader, so they catch a case the price test can’t see). It’s judged off the CHARTED outcomes — what the card actually shows — so a locked PROP leg inside a bundled Polymarket game can’t unlink a live moneyline (_chartable_items already dropped it) and a leg inverted to the FAVORED side (_favor_outcome) is judged as displayed; a scalar FORECAST line is skipped (its pct is a normalized trajectory, not a probability) and a market with no charted probability falls back to its binary yes-side (a 3% YES is as decided as a 97% one). A card showing exactly ONE line is judged as a BINARY too (max(p, 1-p), #1876): a lone leg has no field to lead, so its own extremity IS the verdict — taking the raw max there read a leg WIPED to 0% as “still live” and shipped a go-bet-on-it link to a market with nothing left to bet (the BTS #1-song alert). It’s the same rule the no-outcome fallback already applied, reached one step earlier; a real multi-leg board still leads with its top leg. The market_alert settled-OUT report (#1876) is the editorial half on that side of the line: a candidate whose move ENDED the question — it fell to 1 - SETTLED_LEADER_PCT (5%) or below from a real prior — stops being a market read and becomes a sewn-up FACT, the mirror of market_drop._is_report_lock (97% is sewn up FOR, 0% is sewn up AGAINST, and neither has a live line left). It routes to the report_out compose angle, is graded on drop_score(report=True) (generalized to score a settlement in EITHER direction — the winner-shaped rubric was docking a settled NO for “not naming what DID happen”), heroes “NO” on the number card instead of the “0%” the take was just told to drop, and passes attach_market=False. THE CARD REPORTS THE OUTCOME, NOT THE QUESTION AND NOT THE PERCENTAGE (owner steer, 2026-09-06). The steer came off a live pair of posts on the same release: the market card titled itself "Will Rod Wave's 'Don't Look Down' debut at #1" and its tweet led with “now a 99% lock”, while the music card beside it read "Don't Look Down - Rod Wave" over “No. 1 on the Billboard 200” — both reported the same fact and only one of them said it. A question is what the exchange needed to open a contract, not what a reader wants read back. So outcome_split(question) splits a market question into its SUBJECT (the card TITLE) and its OUTCOME CLAUSE (the sub-caption), and the percent survives only as a live-market QUALIFIER on that clause ("55% to debut at #1"), never as the caption’s whole content and never once the outcome is DECIDED ("debuts at #1"). It is a VERB REGISTRY, not a parser: Kalshi words a question as “Will <subject> <verb> …?”, so the split is one boundary — the first word that is a verb — and _OUTCOME_VERBS is keyed base-form → third-person, which is also the conjugation the decided caption needs, so ONE registry answers both. THREE GATES keep the anchor out of a NAME (a noun that doubles as a verb: “the Sahm Rule trigger”, “‘A Quiet Place Part III’ be released”, “Boise Airport set a new passenger record”), each measured rather than reasoned in: CASE (a real verb is lower-case in Kalshi’s sentence-cased questions, an all-caps one is real — “Ramp or Brex IPO first”), SUBJECT SHAPE (a subject cut mid-phrase ends in a function word or a possessive, or leaves an opening quote unclosed), and REMAINDER (a verb right after the anchor, or a leading "of" — a NOUN takes “of” where these verbs do not, which is what keeps “take control of any AI company” anchored on “take”). The scan then tries the NEXT anchor rather than giving up, and that is what turns those same questions into CORRECT splits. The fail direction is absent over invented: a question that clears no gate keeps the title it always had, rather than ship a subject cut out of the middle of a name. scripts/dryrun_market_outcome_title.py is the sweep that measures it against the whole live board — 2026-09-06: 519 of 544 live “Will …?” questions split (95.4%), no wrong split in the printed output, and 1241 of 1448 question-titled cards (85.7%) no longer ask. That second number is the one the rule is judged on, and getting its DENOMINATOR wrong flattered the result badly. The sweep first measured over EVERY card on the board, but most of the board is already titled with a plain subject ("Kylian Mbappe Total FIFA World Cup Career Goals"), so counting those as wins measured nothing — it read 9% where the real figure on question-titled cards was 19.5%. Only a card whose market title actually ENDS IN A QUESTION MARK can say a question, which is also the test production stamps titled with. Reading that corrected report is what found the two shapes that carried the rest of the reach, and neither was a “Will …?” question at all: 762 of the 866 misses were a NOUN PHRASE wearing a stray question mark ("US real GDP growth in 2036?" — already a subject, so statement_title drops the mark, gated on no interrogative word appearing ANYWHERE so a real question is never stripped into a statement it is not), and 78 were "When will ...?" — a FIELD question whose legs are dates, so when joins who/what/which in _FIELD_HEAD, and its verb may END the sentence ("When will Joe Flacco retire?" → "to retire"). A bare COPULA ending one is the exception that stays: "Who will the next Pope be?" would card as "to be", which says nothing. The 207 that still ask are the How much / How many / How high families, prefixed questions, and verb-at-the-end shapes — a question beats a mangled statement. Re-run it before adding a verb, and read it for the WRONG splits, not the coverage number: a new anchor can only move the boundary EARLIER, so it can only create that failure, never fix one. The second interrogative shape, a FIELD market ("Which country wins Eurovision 2027?"), yields the CLAUSE only — its legs are the candidates and the card already titles itself with the leading one. decided is the CALLER’s to set and is never inferred from pct: only the caller knows which SIDE settled, and a market decided AGAINST its question is just as extreme a price (that lane heroes "NO" through the settled-out report). On the leader rung this matters most — the hero there is a NAME and a leader can lead on 22%, so live it reads "to be the next Pope" and only a decided caller gets "wins Best Picture", with the percent dropped entirely. market_card_hero’s folded titled field is the rate that watches it (see docs/OBSERVABILITY.md). The rule reaches 93.5% of question-titled cards, and getting there took three shapes that are NOT “Will …?” questions at all (measured 2026-09-06, scripts/dryrun_market_outcome_title.py): a NOUN PHRASE wearing a stray question mark ("US real GDP growth in 2036?" -> statement_title drops the mark, gated on no interrogative word appearing ANYWHERE so a real question is never stripped into a statement it is not); a "When will ...?" DATE field (when joins who/what/which in _FIELD_HEAD, and its verb may END the sentence – "to retire" – though a bare COPULA ending one keeps its title, since "Who will the next Pope be?" would card as "to be"); and the QUANTITY family (how_much_title: "How many House seats will Democrats win in Alabama?" -> "House seats Democrats win in Alabama", which hands its tail back to outcome_split as a "Will ..." question so it inherits all three gates rather than re-rolling them). The DENOMINATOR is the lesson here: the sweep first measured over EVERY card and reported 9.0%, but most of the board is already titled with a plain subject, so counting those as wins measured nothing – the real figure was 19.5%. Only a card whose title ends in a question mark can say a question, which is also the test production stamps titled with. A leg that NAMES NOTHING never titles the card either: market_subject_image._is_generic_outcome already decides that for the ART path (“the card’s subject should come from the title, not this leg”), so the title wires that home in rather than re-rolling one – without it a bucket board carded "10+ / Between 2025 and 2035", a date range standing where the subject belongs (the spelled between X and Y range was added to _GENERIC_OUTCOME_RE for both consumers). The proposition is dropped from the caption when the TITLE already came from the question, so a card never says the same thing twice, once rewritten and once raw. subject_hero(question, label, pct) is THE one “the subject heroes the card, the % is the subtitle” ladder (owner steer #1918; it lives here, beside proposition_clause, because it is the same rule on every market surface): the hero is the subject’s OWN number when the market’s own words carry one — the chart RANK (#2 off a Billboard question) then the THRESHOLD (80+ off “clears an 80”) — else the SUBJECT itself, with the % always on the sub-caption. A ranking AUTHORITY named in the question rides the source PILL, not the prose (split_rank_authority, owner steer “that attribution is what the pill is for”): Kalshi titles a rank market after the site that decides it — "TelevisionStats: #1 Movie on Aug 26, 2026" — and that authority is a SOURCE, so subject_hero lifts the "<Authority>: " prefix out of the caption into number_figure['source'] (the ✓ TelevisionStats pill, the way a music card pills Luminate) and the leftover proposition frames the rank as the authority’s own CHART, "#1 on the most-popular-movies chart on Aug 26, 2026". The lift is general in SHAPE but gated twice — the remainder after the colon must be a named_rank (so "Spider-Man: Brand New Day" and "First Week Sales: 358K" are left alone) AND the prefix must be a REGISTERED authority (_RANK_AUTHORITIES), which is what stops a retained sports-seed board ("Pro Football Playoffs: NFC #2 Seed", whose #2 clears named_rank) from crediting the event name as a source (Codex #2612); a new authority is a one-line registration, earned only after a dry run proves its chart phrasing is take-safe. The CHART framing (not a bare "#1 movie") is dry-run-load-bearing: the real composer turned a naked "#1 movie" into "#1 at the box office" 4/4 (the market is an attention-score popularity list, NOT box office), and the chart phrasing held 8/8 clean while keeping the owner’s most-popular word. The same split_rank_authority also cleans the authority out of the alert’s model-facing blob (market_alert._ending_soon_subject) so the TAKE never names it either; the caller merges the lifted source LAST over its Kalshi/venue default (_alert_number_figure, market_drop._report_number_figure, leader_figure). market_alert._subject_figure is a thin snapshot adapter onto it (question = event title, label = the moved leg), and market_drop._report_number_figure’s locked-BINARY branch calls it with subject_fallback=False — a binary’s label IS its question, so the subject rung would hero a whole sentence; None there falls back to that surface’s % hero. The label handed in must be the BARE leg name (the Dai Dai card, 2026-08): the ending-soon lane used to pass its BLOB subject (_ending_soon_subject, “Dai Dai (YouTube Charts: Weekly Top Song USA)”) as the card subject, subject_hero heroed the whole composite, and render_number_card’s shrink loop bottoms out at ~size 56 — the figure drew past the canvas and the tweet shipped “Dai Dai (YouTube Charts: Weekl” clipped mid-word at the frame edge. The override is gone (every priced kind heroes _swing_label; the question already rides the card TITLE), and the renderer now ellipsizes a still-too-wide figure to the text width after the shrink loop — the worst case is an honest “…” inside the margin, never ink past the frame. Two lessons are baked in. The drop shipped a rank-only COPY of this ladder (#1936) before it was folded back here (#1939) — the copy silently lacked the threshold rung, which is the standing argument for wiring the home in rather than re-deriving a rung. And there is deliberately NO negation guard, which is the more interesting one: the copy carried a cue-list guard against “Will X miss #1?” (where a locked YES means X did not hit #1, so heroing “#1” states the opposite), and scripts/dryrun_subject_hero.py swept it over the live board — it fired 57 times across 45,295 questions and was wrong all 57, because the cues are ordinary names (“Ole Miss” ×44, “Not Like Us”, “Lose Control”, “YoungBoy Never Broke Again”, “members lose their primary”), with zero genuine negations in Kalshi’s vocabulary. The guard cost 57 real cards their hero to defend a case that doesn’t occur, so it was removed; the residual risk is bounded because the question stays on the card as the title under the hero. Re-run that sweep before re-adding one — it is the worked example of measuring a fail-safe guard against real data instead of reasoning it in from the fail-direction rule. The blob is rebuilt WITHOUT percentages (_settled_out_blob), so there’s structurally nothing left to quote — the shipped post had read “cratered to 0% from 22%, wiped fully off the board”, i.e. a post about the price of a question rather than its answer. Rare by construction (exactly 2 of 30 days of production alerts landed ≤5%) and never a NEW alert — it only reframes one that already qualified. Dry-run-measured on real market families (scripts/dryrun_settled_out.py, n=5): 20/20 lines clean of price/market language, and the gate ships the sound cases 15/15 while holding a report whose underlying EVENT hasn’t happened yet (a “will X be evicted” market at 0% says the room thinks X survives, not that the episode aired) — the fail-closed direction working, not a gap to tune out. Two callers are CARVED OUT via link_when_settled (owner steer, #1856), because “decided” doesn’t make their destination dead: a value read whose link is a resolved SPORTSBOOK slip rather than the prediction market (the book decides whether that bet is still open, not us — with no book override it falls back to linking the market and the rule applies as everywhere else), and a bet_board card for a game that hasn’t STARTED, where a 98% favorite is still an open, tradeable book (resolved off snap.live inside _card, so both board lanes are covered and a game decided IN PLAY keeps the rule). A caller can also veto explicitly with attach_market=False — which still WINS over the carve-out, since the veto is an editorial call the price test must never override: market_drop’s sewn-up report angle (_is_report_lock) does, as the EDITORIAL half of the same rule (this post reports a fact, which is reason enough even if the price test ever stops agreeing). The source still gets its footer/pill credit — attribution, not an attachment; a contested market keeps its link exactly as before. market_image carries market_attached so a surface quietly going link-less (or the rule over-firing on live markets) is a queryable rate. The image is a priority ladder, first hit wins — charts-first, but a chart only earns the slot with real movement, so a no-history market prefers a real native image over a flat line: chart (real history) (the self-rendered card from utils/market_chart.py with actual price-history lines — Kalshi candlesticks AND, now, Polymarket via the CLOB /prices-history per outcome, #polymarket-chart) → native image (the source’s own curated image, Polymarket S3 — used when a Polymarket market has no plottable history) → chart (flat) (a flat-line chart still carrying name + outcomes + %s, better than a generic banner when there’s no native image, e.g. Kalshi with no candles) → banner (a committed branded floor banner, assets/market_cards/banner_default.png, regenerated by scripts/gen_market_banner.py — the never-blank floor). (Wikipedia/iTunes topical images slot in around the native-image rung in a later PR.) build_market_card builds the chart’s Outcome list (_chart_outcomes) from snap.meta['chart_specs'] (a per-outcome {label,pct,series_ticker,ticker} list the Kalshi leaderboard threads → a real Kalshi candle line per outcome via get_candle_summary’s series), else snap.outcomes (a multi-outcome event — a Polymarket game/futures now renders a REAL line per outcome when snap.meta['poly_tokens'] carries the per-outcome YES CLOB token ids: _polymarket_series → PolymarketClient.get_price_series → the CLOB /prices-history points; else flat lines. The token id is the clobTokenIds[0] captured in _poly_event_to_snapshot/_poly_yes_token — verified live that /prices-history?market=<clobTokenId> returns points while the gamma market id returns 0), else probability/last_price (a binary/swing alert → the one yes-side, real Kalshi line). Candle fetches are capped at _CHART_MAX_OUTCOMES (5, the render row cap). Per-surface chart ZOOM (owner report: alert charts weren’t zoomed to see the change): the price-history window is chosen by SURFACE (_chart_hours) instead of the source defaults (Kalshi 72h / Polymarket 24h) that squished a recent move into a wiggle at the right edge — MOVE/EDGE-now surfaces zoom TIGHT so the change fills the chart (bet_alert/bet_value 6h, alert=market_alert’s 24h move window), CONTEXT reads stay wide (drop 72h, bet_board 48h), default 24h; the window threads into both get_candle_summary(lookback_hours=) + get_price_series(hours=), and the y-axis then auto-scales to whatever’s IN that window, so a tighter window zooms BOTH axes onto the change. Primary-leg filter for bundled game events (_chartable_items): a Polymarket US-sport GAME (MLB/NBA/…) bundles its moneyline PLUS every prop (spread / O/U / 1st-5-innings / NRFI …) into one snapshot’s outcomes, so charting them all drew ~18 unrelated lines spanning 1-100% and no zoom could make the game legible; the moneyline leg is named EXACTLY like the event title ("Cleveland Guardians vs. Miami Marlins"), so when a title-matching outcome exists it’s a bundled game → chart ONLY that head-to-head win-prob line. A clean multi-way market (soccer’s home/Draw/away, a futures field) has NO title-matching outcome → charted in full (the fail-safe). Scalar ‘Kalshi forecast’ line (_forecast_outcomes, #charts-vision-caption-kalshi): a numeric-ladder music market (first-week units/streams) collapses its per-rung candle histories into ONE forecast line (the MEAN E[X] Kalshi plots). Two deterministic guarantees keep the LINE matching the caption + figure: (1) zoomed tight to the move — the forecast plots the TIGHTEST window (ladder 48h→7d→14d→30d, tightest first) that still contains a real move (E[X] swing ≥ _FORECAST_MOVE_FRAC of the figure), so the change fills the chart; a fresh move reads tight while an older one still gets found by widening (this fixes the original flat-at-24h bug from both directions — a fixed 24h window either squished or missed a weekly move); and (2) time-grid-aligned, full-ladder E[X] (luminate.forecast_series_grid) — the prior index-right-aligned rebuild (mean_forecast_series) read E[X] off whatever INCOMPLETE rung subset returned candles (values diverging from the headline figure — the Kanye 18.6B-vs-11.2B bug) and collapsed to ONE point when any rung was short (the flat-at-24h pts=1); the grid rebuild reads E[X] at each common-grid time from EVERY rung (forward/back-filled) and anchors the final point to the exact figure (forecast_specs['mean']) so line-end == legend == caption. With NO candle history even at the widest window, _chart_outcomes returns nothing and build_market_card falls back to the big-NUMBER card (a figure with no trajectory can’t contradict a ‘moved’ caption — no fabricated flat line). The candle fetch now returns series_ts (per-point timestamps aligned to series via the shared _downsample_indices) so the rebuild can grid-align on real time. Fail-open throughout (a render/fetch error degrades to the next rung). Emits market_image with the rung that answered + outcomes/with_series (how many lines had REAL history vs flat). (Polymarket’s image/icon is threaded onto the snapshot meta in utils/markets.py; utils/bot_logs.py:post takes an optional embed/file so a staged audition shows the same card.) BARS are for SPORTS only; every other lane is a NUMBER report (owner steer, 2026-08-08: “no bar charts except on sports this should be a number report like everything else”). The reported card was a music-video runner-up board: two ranked bars, “petal” at 17% over “Choosin’ Texas” at 6%, on a post whose job was to say where one track stands. _is_sports_card(surface, category, meta) decides — a bet_* surface is always sports (every betting card reads a game line), a drop/alert card is sports when snap.meta['category'] is Sports, and the MARKET’s own sport signal counts too (#2164: Kalshi’s meta['sport'] series label / a Polymarket sports tag slug — the GLOBAL drop lane deliberately stamps no category, so a sports market it pulled used to read as not-sports and shipped a number card; the drop’s two regroup folds now carry sport forward like catalog_hint). A card that is not sports goes to leader_figure, which stamps number_figure off the TOP-priced leg. A field heroes the SLOT the market names (owner steer: “should be #2 as big numbers”) — “#2” off a runner-up board, “314M+” off a threshold — with the LEADER as the subject line and its percent in the caption. That is the shared subject_hero ladder, so a field card and a lock card come from one rule. With no rank or threshold in the market’s words an unranked field heroes the LEADER’S NAME, the percent riding the caption (owner steer, 2026-08-19: “if something is a clear leader by far it should just report the leader vs the percentage” — the “Tires: Season 3 / 20%” card, where a giant 20% over a scattered field read as a headline number). The ladder runs for a SINGLE leg too, but only when that leg is the FAVOURITE (the 2026-09-03 sweep, _SINGLE_LEG_HERO_MIN_PCT = 50). The rank and threshold rungs read the market’s TITLE, so they answer a one-leg market as well as a field, and fencing them behind a field meant a single-leg “above 8M daily views” market heroed its percent while the identical multi-leg board heroed the threshold. The favourite floor is what the LIVE DRY RUN added, and it is the whole reason the change is safe. The first cut lifted the guard outright; run over 216 real Kalshi/Polymarket markets it changed 176 of them, and two classes were plainly wrong — it carded “Avengers: Doomsday” for a 2% Best Picture leg and “90+” for a 1% Rotten Tomatoes rung, each reading as the thing HAPPENING under a number saying it will not. The owner steer behind the name rung is “if something is a clear leader BY FAR”; a FIELD’s charted leg is its top-priced one, so that is true there by construction, while a one-leg market leads nothing and is as often a longshot. Below the floor the percent keeps the hero, which is also the shape those cards already shipped. With the floor the same 216-market run changes 63 (percent heroes 183 → 120) and every change is the market’s own number or its favourite. Two shapes changed, both toward the rule. A single-leg market whose question names a rank or a threshold heroes that number, with the QUESTION as the subject line and the percent as the whole caption — subject_hero builds those rungs around the LEG label, which is never a subject on one leg (“Yes” on a binary; a ladder rung’s “Above 60” restating the figure itself), so the caller takes the title and the caption back rather than carding “60+” over “Above 60” with the question repeated underneath. And a bundled GAME charted as one leg heroes the TEAM with the percent in the caption, which answers the #1835 attribution gap at the top of the card instead of in the caption — and retires the title-ORDER coin-flip the old caption rule had to special-case, since heroing the leg settles attribution in either order. It passes subject_fallback TRUE for a real name; the fence still holds for a leg with no name to hero — a bare Yes/No, or a label restating the title (redundant) — which keeps the percent hero, the shape a single leg / binary ships. leader_figure is the old single_leg_figure widened, because both rules pick the same leg by the same caption rules — one function, not two. The leader-name card carries a MARKET-IMPLIED RANK strip (field_rank_standing, owner ask 2026-08-27 “add rank to all cards”, narrowed to “only count cards, not already-ranked cards”): the clear-leader card never said WHERE the leg sits, so the strip reads the rank off the market itself — the priced legs sorted high-to-low, the leader is #1, and the margin over the runner-up is named so #1 is not a bare word that reads the same on every card ("#1 in the field · 13-point lead", rendered through render_number_card(standing=), the same context-strip slot the music desk’s Spotify standing uses). It is deliberately narrow: only the leader-NAME shape gets it, so a card already heroing its own rank (#2 off a runner-up board) or a threshold (80+) keeps that figure and is never doubled; a binary / single leg has no field to rank and gets nothing (prefer absent over an invented #1); a tie is not a lead; and a forecast value_text leg is excluded (it carries a figure, not a probability). The FIELD SIZE is not printed (#1 of N) because the card is built off the top few priced legs (_CHART_MAX_OUTCOMES), so N would understate a large market — the margin needs no field-size and is always exact. A caller that stamps its own number_figure upstream (the drop’s lock/frontrunner report, the alert, the forecast) is untouched, so the strip only reaches the cards the shared builder itself heroes. named_rank is the one home for reading a rank off a market’s words: an explicit “#N”, or the word “runner-up”, which is how Kalshi titles a second-place board and carries no “#N” for the old regex to find. title_rank (moved here from market_drop) wraps it for the callers that need an int with a winner-market default of 1 — that default is why the two are separate, since a caller reading an arbitrary market must get None rather than a fabricated “#1”. The DECIDED tiers no longer take that default (2026-09-09). market_drop._report_number_figure’s clear-winner lock and the dominant-frontrunner report below it used to hero #{title_rank(title)}, and the default printed “#1” over Jean Smart on an “Emmy Winner: Outstanding Lead Actress in a Comedy Series” frontrunner card — a rank the market never named (owner: “shouldn’t say #1, should say Emmy Winner and category”). Both tiers now go through one helper, market_drop._slot_figure, which heroes what the market’s own words name, in order: winner_rank (here) — named_rank, or “#1” for a Top <region/period> <show|movie|…> board (_TOP_BOARD_RE, the field-side twin of the drop’s _RANK_WINNER_TITLE_RE), else None, never a default; then award_slot (here) — an "<Award>: <Category>" title whose head names a winner/award/champion splits into the AWARD as the hero, the leader as the title line and the category + standing as the caption (“Emmy Winner / Jean Smart / Outstanding Lead Actress in a Comedy Series · frontrunner at 90%”; gated on the head so “Spider-Man: Brand New Day” and “Grady Emerson: Debut Date” stay whole); else the LEADER’S NAME heroes, titled with the outcome clause or the statement — the same shape the shared ladder’s last rung gives (checked against 152 live Entertainment/Sports fields: 14 award, 138 leader, identical figure + title to subject_hero). The locked single-leg BINARY branch is untouched — its _RANK_WINNER_TITLE_RE gate means the title names the slot, so title_rank is honest there. The settled FINAL card (market_outcome.winner_card_fields) reads the same winner_rank: a question that names a rank heroes it with the winner as the title and the statement question as the caption (“#2 / Boston / #2 on the Billboard Hot 100 chart for the Week of Sep 19, 2026” — owner, same day: “art should say #2”; the card had heroed “Boston” over the question), while an award FINAL keeps the winner’s name as the hero (owner call 2026-09-06). Whether the award FINAL should take the award shape too is an open owner question, not a decision this change made. The bars renderer stays for the sports field, and _BARS_ONLY_SURFACES is unchanged — that set governs the price-history FETCH, which stays skipped on every surface. The number card brands the LANE, not the surface key (_wordmark_lane): the number rung used to hand render_number_card the raw surface, which printed itself — “tootsies drop”, “tootsies alert”, “tootsies bet_value” — beside cover cards reading “tootsies predictions”. That was a rare path while the number card was the exception, and making it the default for every non-sports market made it the normal wordmark, so both rungs now share one lane rule (the MUSIC family passes through, since the renderer maps that family to its own ‘music’ lane, and number_figure['brand'] still overrides). The number card’s accent TAG is a LADDER, so the block is never lost (number_card_tag, #2711): the card used to tag itself with snap.meta['category'] alone, which is the ROOM’s configured drop category, and two lanes stamp none — the GLOBAL drop lane skips it on purpose (“Global” is not a useful word on a card) and an alert with no routed category has none — so those cards shipped with NO accent block at all, which is the plainness the owner reported (“why don’t these markets have a green bar”). THREE rungs sit ABOVE that ladder. First the tag the FIGURE’S OWN BUILDER stamped (number_figure['tag'], owner report 2026-09-14): every rung under it reads the card’s TITLE, and a card whose number is read off ANOTHER event has a title that does not name the number. The drop’s companion-ladder projection is that shape — the card titles itself “Miley Cyrus’s ‘Bass Persuades’” and heroes the units ladder’s forecast, so the chart rung read no chart, the event rung read no event, and the block fell to the room category and shipped ENTERTAINMENT over a first-week-units number. The builder is the one place that knows what the number measures, so it stamps the words: the units rung takes the companion event’s own metric through market_chart_tag (FIRST WEEK ALBUM EQUIVALENT UNITS), and its sibling RANK rung takes hits.projection_eyebrow — the same block the desk’s own called-shot card wears (BILLBOARD 200 PROJECTION - WEEK OF OCT 03). A companion title naming no metric stamps nothing and the card keeps the ladder it had (absent over invented). Then the two rungs in chart_card_fields: the CHART the market settles on (owner steer 2026-09-08 — see the RANK card entry under music_alert.py): when the market’s own words name it, the chart is the block. Then the EVENT the market is part of (owner steer 2026-09-10, against a live alert card that read “MARKET ALERT / Alba Rohrwacher / Venice Film Festival: Coppa Volpi for Best Actress”: “the blocks should say what the event is — Emmy winner, YouTube chart, just like the others”): Kalshi titles its award / festival / election families “market_chart.py, the Pillow renderer for the market chart card (render_market_card → PNG bytes). The look is matched to a real Kalshi reference card (owner steer “make ours this crisp”): a near-black card with a small letter-spaced category eyebrow (optional, eyebrow=, threaded from the drop’s category via snap.meta['category']), the market name big + bold, a stacked legend below it (one row per outcome: a colored dot + label + bold %), the co-brand top-right “Tootsies” (big, red) on {source} (small, source color), and a full-width price chart at the bottom — crisp thin STEP lines (_step, the prediction-market close-price look), dotted horizontal gridlines (_dashed_hline), each line ending in a glowing dot, volume bottom-left. Crispness is the point: the whole card is drawn at _SCALE×2 and LANCZOS-downsampled, so the thin lines + text are anti-aliased and sharp instead of jagged single-pass draw.line output; bold weight is a real bundled font (Liberation Sans Bold), not a stroke_width fake. Colors are a consistent semantic schema (_outcome_colors): the favorite (highest %) is green, the longest-shot red, the middle field cycles a DISTINCT palette (one color per line) so each line maps to its legend row by color. The y-axis auto-scales to the data range so movement fills the chart. A no-history market (no price series — Polymarket, or Kalshi without candles) renders horizontal probability bars in the bottom band (length ∝ implied %, color-matched to the legend, on a faint track) instead of the old dead flat lines, so the no-history card’s bottom half is a clean ranked magnitude strip rather than empty horizontals. Pure + dependency-light (Pillow only; the type is bundled Liberation Sans in assets/fonts/ — SIL OFL 1.1, full extended-Latin coverage so accented names like Pérez/Mbappé/Dončić render instead of tofu boxes (Pillow’s built-in Aileron default has no diacritics — the bug this fixed), real Regular + Bold weights, fail-safe to load_default if the file’s missing) and does no fetching itself (the cog/market_cards supplies the data), so it’s unit-testable. The runtime base is python:3.11-slim (no system fonts) — bundling the TTF (vs leaning on a system font that isn’t in the slim image) is what makes the diacritics fix hold in prod. ART FIT — a LOGO is CONTAINED, a photo is COVER-cropped (#wrong-moment): the portrait cover (render_market_cover) full-bleeds its subject art via _cover_crop (object-fit: cover), which is right for a poster/album cover/press photo but MANGLES a logo — a wide 960×260 wordmark scaled to fill the 3:4 frame is blown up ~4.6× and center-cropped, leaving one illegible letter filling the card with the title text stranded on top of it. So render_market_cover(art_fit="contain") instead keeps the branded dark panel and centers the mark WHOLE (_contain_fit, the aspect-preserving sibling of _cover_crop) in the band the art owns — below the wordmark, above the bottom-anchored content block (measured first, so the band’s height is known). market_cards._art_fit maps the resolved image RUNG to the fit (_CONTAIN_SOURCES = the logo rungs — wiki_logo + the raw team crest team_logo, whose square badge loses its top and bottom to a 3:4 crop; every photo rung stays cover), so it follows the resolver instead of guessing from pixels. Default is cover, so every existing caller is byte-identical. figure is OPTIONAL on render_number_card (#wrong-missing-images): pass an empty string and the number hero is omitted. When there is no number, the TITLE becomes the hero instead (#inconsistent-sizing): it renders as a big auto-fit WRAPPED headline over the cover (_fit_title_hero picks the largest size, up to 128px logical, whose word-wrap fits 3 lines; it shrinks in 8px steps to a 40px floor and then keeps every line, never cutting one – rule (3) under the 2026-09-10 entry above), bottom-anchored under the accent TAG with the caption below it – and on this headline card a caption that does not fit two lines whole is DROPPED, not ellipsized (owner steer 2026-09-02, the Troye Sivan live card: “i’d rather nothing on the photos”; the take under the card carries the sentence; the caption itself is gone since 2026-09-10). This is the right shape for an ANNOUNCEMENT — a release or a tour opening carries no number — and it reads at the SAME visual weight as a stat card’s giant figure, instead of the small fixed 40px caption it used to get. Two lanes therefore produce two card SHAPES from the one renderer: a stat card heroes its short figure token near the 188px cap, and a no-number card heroes its title. Over a photo the big headline gets the figure’s own depth treatment (a soft dark blurred shadow under the crisp fill) so it reads; the small 40px stat title keeps only its hard drop shadow. A caller with NEITHER figure NOR art must still return None; a figure-less, art-less card is an empty panel. An optional CONTEXT STRIP sits under the caption (standing=, ranking-context): one short accent line with a thin rule over it — the artist’s Spotify standing (their most-streamed RANK + monthly listeners, per kworb) — so a raw streams number carries the SCALE a reader needs to gauge whether it is major or middling (“hard to gauge if each card is major news”, owner). It draws only when set; every other number card passes None. The music desk resolves + gates it in ONE home (cogs/music_desk.py:_artist_ranking_strip), reached from TWO card paths: the Luminate streams card (_streams_standing → luminate_snapshot’s number_figure['standing'] → market_cards._num_render) AND the Spotify streaming-milestone big-number card (the _watch_milestone_units deliver path → build_number_card(standing=…), #charts-ranking-display). Two honesty rules are BAKED IN, not prompt-side. (1) It is LIVE — the music_stream_ranking experiment defaults PRODUCTION (owner call 2026-08-25 “add to spotify … turn it on now”), so the strip attaches on both cards; a mod can still dial it OFF/STAGING on /menu, and the strip adds a PUBLIC claim that can mirror to X and can be wrong (a stale rank, a name-fold mismatch), so it stays fail-open. (2) The labels are SELF-DESCRIBING (‘most-streamed’, ‘monthly listeners’) on purpose: the card’s figure is usually a YEAR-TO-DATE or a per-track streams count while this rank is a CAREER-total position (kworb has no YTD leaderboard), so the strip must read as the act’s overall standing, never as a rank OF the headline number. On the LUMINATE path it is gated to ARTIST streams readings only (annual / weekly-artist, whose subject IS the artist); an album reading gets no strip (subject mismatch, prefer absent). Every milestone rung is a Spotify streaming metric (streams / career_streams / monthly_listeners); a per-TRACK streams milestone shows WHERE THAT SONG’s number ranks instead of the artist (owner ask 2026-08-27 “a count card should show where the number ranks”), via a LADDER in _song_ranking_strip — the song’s ALL-TIME most-streamed rank (kworb.find_song_entry + format_song_standing_tag, “#42 most-streamed song of all time”), else its DAILY Spotify chart position (kworb.standings(strict=True) + format_chart_standing_tag, “#5 on Spotify’s US Daily · 1.2M plays” – STRICT so a published position never attaches an unrelated same-named track’s rank, “Future” vs “Future Islands”), else the artist strip; every OTHER metric keeps the artist strip keyed to the milestone’s artist. Fail-open (kworb miss / down → no strip, the card still ships), and every outcome emits music_ranking (with song_attached / daily_attached reasons for the new rungs) so a silent “never attaches” is visible. Formatters: kworb.format_artist_standing_tag / format_song_standing_tag / format_chart_standing_tag. A YouTube DAILY-CHART market card carries the VIDEO’s live standing too — market_subject_image.youtube_chart_standing looks the featured leg’s video up on kworb’s most-viewed chart (find_video_entry, the same refuse-when-ambiguous lookup the art rung uses) and build_market_card stamps number_figure['standing'] = “#3 on YouTube today · 12.3M views” (format_video_standing_tag). The pos is NAMED by the page it ranks on (VideoRow.chart, stamped on merge): the global top-500 reads “on YouTube today”, the realtime-anglo cut “on YouTube’s anglophone chart”, so a position from one page is never presented as the other’s (they rank a video differently – Codex #2636). kworb parses no all-time video ranking, so this is a DAILY read only, and it rides the shared chart_fetch health (the same feed the youtube-art rung reads) rather than its own event. An ARTIST COUNT market card carries the ACT’s standing instead (owner ask 2026-08-30, “lets add artist ranking”, off a bare Michael Jackson YouTube-views card): Kalshi mints one event per act in the KXYTVIEWSHIGH / KXYTVIEWS families, the card heroes that act’s forecast count, and nothing on it said how big the act is. market_subject_image.artist_subject_name reads the act off the exchange’s own title – both shapes it writes, “clip_pick.py, the YouTube moment picker + clip cut (owner ask 2026-09-08, “clip youtube videos, MVs and trailers and discourse youtube, as part of the skill”): where video_trim keeps the START of a wire clip, this finds the MOMENT of a music video / trailer and cuts a window around it with the same stream-copy + shot-boundary machinery. Signals in order, each a measurement and none a model guess: YouTube’s own replay heatmap (most_replayed_peak, read through ScrapeCreators /v1/youtube/video; present only on a well-viewed video), the loudest window of ffmpeg’s ebur128 momentary loudness (parse_loudness + loudest_window: the chorus, the drop, the climax), else the start. window_start places the peak ~30% into the clip; the start snaps BACK to the last shot change within 4s so the clip opens on a cut and the end snaps per #2215. The download is video_fetch._ydl_download_video (avc1 + m4a mp4, <=720p, max_filesize-capped) so the cut is a stream copy X accepts. It runs ON THE BOT behind the token-gated POST /debug/clip (utils/healthcheck.py), because a session’s egress cannot fetch YouTube media (measured 2026-09-08: googlevideo 403 from a session; yt-dlp’s android client shows a 360p format but the download 403s too; production downloads YouTube audio 100-190 times a day). The owner asked for an AI prompt to find good clips: deliberately not yet – a music video has no usable transcript and stills carry neither motion nor audio; a caption-timestamp prompt for a talk video is the third rung if the two signals prove thin. The music-drop skill’s --clip calls the endpoint and hands the owner the mp4; nothing auto-posts (a native re-upload of a label’s video is a copyright call the owner makes per post). The heatmap’s opening is skipped (CLIP_HEATMAP_SKIP_SECONDS, 10): measured on “Not Like Us”, marker 0 scored 1.0 and the chorus at 99s scored 0.91, so a pick that honoured the opening would clip the intro every time. The first live run (2026-09-08, after #3083 deployed) failed on every video: the bot wall on the first info read, then a CDN HTTP Error 403 on the media download once the info read passed – the same 403 a session gets, the signature of formats that need a proof-of-origin token. Two levers followed: a player_client knob on the request (a yt-dlp client list, so a session can try clients from production’s IP without a deploy per attempt) and YTDLP_COOKIES (video_fetch._ydl_cookie_opts, a Netscape cookie file’s text as a Railway variable, threaded into every yt-dlp call; unset = today’s behaviour). Emits clip_pick. Pure parts unit-tested in tests/test_clip_pick.py.watch_link.py, the watch-link resolver (#music-video-linking, #trailer-lane, #lane-commentary-video): the VIDEO twin of entity_image.resolve_desk_image, and deliberately the same shape – classify the post into a structured subject, route its KIND to an authoritative source, search-and-judge only below that. RUNG 0 sits ABOVE the classifier (#watch-link-source, owner steer “trust the link”): if the SOURCE POST already links a video in its own text (“Watch: youtu.be/…”), trust THAT link and skip the classify-and-search entirely – the source’s own pointer to the exact clip needs no model and no search, and it catches the case the classify path refuses by design, a LIVE PERFORMANCE the wire linked (the reported Taylor Swift Grammy-Museum post: the classifier correctly declined not_music_video, so nothing linked, while the youtu.be link sat in the post body). The link comes from SourcePost.link_urls – the expanded entities.urls, captured at the twitterio boundary because the tweet body carries only the t.co shortlink – and source_video_link keeps only the YouTube/TikTok/Instagram hosts X unfurls, never an x.com quote. Below rung 0: claude.video_subject (Haiku) reads the SOURCE POST (not her take: the take names the people, the wire post names the release) into {kind, title, artist}; music_release routes to a YouTube search + claude.pick_music_video, film/tv to TMDB /videos and then, on a miss, the same YouTube search + claude.pick_trailer. The question stays POST-LEVEL – “is this post ANNOUNCING a video”, never “does a video exist for this subject”, which would be true of nearly every film and song and would attach a link to almost every take, at X’s ~$0.20 link price against ~$0.015. CALLERS: discourse, all three wire desks (cinema_news, pop_desk, sports_desk), and the music DROP (cogs/music.py, #2555), each stamping its own surface; the desks ask only when the source post carries no clip of its own, since re-sharing the wire’s own mp4 natively beats any link. discourse gets rung 0 for free (#2553): fxtwitter expands the t.co in the tweet text, so extract_urls(source.text) yields the real video link with no link_urls plumbing. The music drop is a LISTEN recommender that links the song on Apple Music; #2555 makes a VIDEO drop ALSO link the video – it resolves off the source tweet the model names (XPOST: ... <url>), and the watch link rides LAST so X cards the video (the payoff) while the Apple listen link stays tappable. Fail-open: no source tweet / no video -> the drop ships listen-only, unchanged. That caller list is the #trailer-lane fix: the resolver already handled trailers and was wired into ONE surface, so a cinema-desk take reading “the Awarapan 2 trailer is out” shipped a poster and nothing to watch, with no watch_link event at all because nobody asked. The film lane’s SECOND rung exists because TMDB’s /videos is empty exactly when the post is most current (see tmdb.py); pick_trailer runs THREE candidate tests where the music picker runs two – title identity, IT-IS-THE-TRAILER, provenance – and the third is earned, since a SONG off the film’s soundtrack is uploaded by the studio’s own channel and so passes identity AND provenance. Query shape is measured, not guessed: "<title> trailer" puts the studio trailer in the top few, "<title> official trailer" demotes it and pulls in an AI-made farm upload, and the bare title leads with soundtrack songs. MEASURED (n=5/case, live lookups, scripts/dryrun_watch_link.py): the reported Awarapan 2 post resolves the studio’s real trailer 5/5 via the search rung, the two titles TMDB does carry still resolve 5/5 via the lookup rung (the fallback did not swallow the deterministic one), off-lane holds 35/35 with every case declining at the SUBJECT stage, and pick_trailer on its own is 10/10 on the two real candidate lists – taking the studio trailer over the official teaser ranked above it, over three reaction videos, and over both soundtrack songs. FAIL-CLOSED throughout – it only ever ADDS a link to a post that would otherwise ship without one, so every uncertain path returns None and leaves the post exactly as it was. On a hit the link rides INSTEAD of the photo (X gives the media slot to an uploaded image over a link’s unfurl, so shipping both renders a still where the player should be). A sports GAME clip is out of scope as a KIND (a clip lookup needs both teams and the date, which wire prose doesn’t carry – the commentator posts those from a live game object); the sports DESK still calls it, for documentary and series trailers. Emits watch_link with the source that answered (tmdb_trailer |
youtube_trailer |
youtube |
youtube_moment), so a deterministic rung going silent is visible as its share collapsing. Dry run: scripts/dryrun_watch_link.py. A THIRD LANE, moment (#lane-commentary-video, owner ask 2026-09-14 “why not use youtube to find the actual videos for all lane commentary”). The resolver used to answer only for a music video or a trailer, so the ordinary thing the three wire desks post – an interview answer, a press conference, a performance, a viral clip – had no video lane at all; those takes shipped whatever clip the WIRE account had attached, unchecked. The moment kind captures {anchor name, the moment in searchable words} and routes to a YouTube search judged by claude.pick_moment_clip, whose three tests are SAME OCCASION / it-is-the-footage-not-talk-about-it / it-is-that-moment-alone – a moment has no owning channel, so the provenance test that carries the trailer lane cannot carry this one. Its deterministic rung is a RECENCY filter, and that is the load-bearing half: fresh_moment_results drops every candidate over 14 days old, and every undated one, BEFORE the judge sees it. A name-plus-words search returns old footage of the RIGHT person, which reads as a fine candidate to anything comparing titles – and an eight-year-old clip of the right person is the shipped failure this lane answers. A moment a wire reports today cannot have been filmed years ago, so the filter settles that class with no model call. The post-level restraint that replaces “is this ANNOUNCING a video” is the CAMERA TEST in the classifier prompt: a post that would read the same with no camera in the room (a transfer report, a chart position, a written statement, an injury) is refused. A sports GAME HIGHLIGHT is still not a LOOKUP (Highlightly needs both team names and the game date, which wire prose lacks); this lane searches by name instead, and its judge rejects a highlight compilation. The clip the WIRE itself attached is not this module’s job – a wire post with its own mp4 never reaches the resolver, because re-sharing it natively beats any link. It is checked at the delivery chokepoint by x_crosspost._clip_gate, whose judge asks about the OCCASION rather than the identity – the blind spot the 2026-09-14 report exposed, where the photo gate returned ok=true on a still it had itself described as an “‘F2 vs Odell’ soccer trick shot video” under a take about a Giants press conference. The reported post came through the FOUND-PHOTO path, which keeps the identity question for now; this lane is what fixes that post, by giving it the real interview clip so the found still is never the media. |
wire_lanes.py’s per-desk breaking config (BreakingConfig / BREAKING_LANES / breaking_config(surface), owner steer 2026-08-10): the breaking lane’s tuning is keyed by SURFACE instead of living in module globals. Why: the desks’ wires sit on different engagement scales, so one magnitude number cannot mean the same thing on all of them — measured live across each desk’s top 10 handles (600 posts / 568 ranked stories, 2026-08-10), engagement VELOCITY per minute of age ran p50/p90/p99 of 4/62/186 on pop_desk, 2/40/468 on sports_desk and 1/35/121 on cinema_news. Sports peaks ~4x cinema, so a single threshold lands at a different PERCENTILE per desk and behaves as three rules while reading as one. window_hours + half_life_hours are live today and every desk starts on the SAME values the old globals used (order_for_breaking(config=…); explicit window_hours/half_life_hours still override, so a test pins one number without building a config) — the shape lands with zero behavior change. min_velocity is DECLARED but not yet read: it is the floor a trigger-driven lane will apply, landed ahead of the event lane (#1742) so that change plugs a value into an existing shape instead of introducing per-desk config and a new posting path at once. Its values are a deliberate uniform placeholder — the measurement above CANNOT set them (its sub-10-min buckets held 3/1/9 stories, since ten handles yield one or two posts per 10 minutes, and one snapshot cannot measure a tail), so the real numbers come from a shadow-mode run, the same measure-first step #1741 used for break latency. Every wire desk is registered explicitly even at default values, pinned by a test, so a missing desk is a visible omission; breaking_config itself fails OPEN (an unregistered surface gets today’s behavior) so a lane can never go dark on a map miss.sports_stories.py’s sport DIVERSITY re-weight (classify_sport / sport_penalty / apply_sport_diversity, #sports-news-diversity, owner report 2026-09-04 “soccer dominates our sports news cause it has the biggest posts”). The sports desk ranks moments by ENGAGEMENT, and engagement is not spread evenly across sports: football wires post far more often than any other beat and draw far bigger numbers, so ranking on magnitude alone hands football nearly every slot. Measured over the 14 days to 2026-09-04 (120 shipped posts, Axiom sports_desk_scored | where shipped == true): about 73% football, 13 NFL posts, 2 NBA posts. The desk read as a transfer wire. The fix is a re-weight, not a quota. classify_sport(handle, headline) tags each moment’s beat – the HANDLE first for a wire that covers one sport (@fabrizioromano is always soccer, @shamscharania always nba), then a word-bounded keyword scan of the headline for the general aggregators (@sportscenter, @espn, @bleacherreport post every beat). apply_sport_diversity counts how many of the desk’s last _SPORT_HISTORY_LIMIT posts (inside _SPORT_HISTORY_HOURS) share a story’s beat and multiplies its magnitude by SPORT_DIVERSITY_DECAY ** n – 0.5 after one recent post on that beat, 0.25 after two. So the beat that just posted has to be MUCH bigger to post again, while a genuinely huge moment still wins: a Messi retirement beats a full six-post penalty, a “Dibu Martinez takes shirt number 1” transfer note does not. It runs in _pick_stories and _event_candidates, BEFORE the lane orderings, so the biggest lane and the breaking lane both read the re-weighted score – the same place wire_lanes.rank_stories applies the emergence boost, and the same kind of edit to .score. A round-robin was the alternative and is worse: a strict rotation hands the slot to a tiny StatMuse stat line over a real break just because the rota says “not football”. The penalty keeps magnitude in charge and only changes the price a beat pays to repeat. An UNKNOWN beat pays no penalty and is never counted (fail-open: prefer a miss over suppressing a moment the classifier could not read), and unknowns are never pooled into one bucket that would penalise them together. That carve-out is what the model fallback exists to shrink, after the owner re-reported the same symptom on 2026-09-13 (“i only see soccer news, never any other sport … we’re only reposting Fabrizio”). An unknown does not merely escape its own penalty: it carries a FULL 1.0 into a field where every correctly-tagged rival has been marked down, so it outranks them. Read the live sports_diversity leaders over the four days to 2026-09-13 and the winner is an unknown in six slots of ten — one slot ranked ?:1.0, soccer:0.0625, ?:1.0, soccer:0.0625, where the re-weight had driven soccer to the floor (0.5^4) and two untagged stories beat it anyway. The penalty was working; the unknowns were routing around it. So sports_desk._resolve_unknown_sports asks cheap Haiku (claude.classify_sports_beats) for the beats the rules leave blank, batched, and cached durably by story SIGNATURE so a wire post that is re-ranked across ~32 slots a day costs at most ONE classification. The rules stay the fast path — they are free and right about the easy cases; the model is asked only about the residue, which measured 9 of 42 shipped posts in the 8 days after the re-weight shipped. A real Haiku dry run on those exact live headlines tagged 16 of 16 correctly, and correctly OMITTED both a non-sport post and a genuinely ambiguous one (“Mainoo pulling the strings”) rather than guessing. Every failure path — no cache, DB error, model error, unparseable answer, a tag outside SPORT_TAGS — leaves the story exactly as the rules left it, so the fallback can only ever ADD tags. apply_sport_diversity honours a stamp already on the story instead of re-deriving it, which is how the answer survives into the penalty math. Measured on a live 480-post wire batch, 87% classify and the remaining 13% are mostly college clips – no football leaks through the unknown bucket. The keyword table is word-bounded BY CONSTRUCTION rather than by hand, because substring matching is this rule’s failure mode: the live dry run caught nfl matching inside “influencer” and a bare “world cup” tagging a FIBA basketball post soccer. college is checked before the pro leagues (a college story routinely name-drops the NFL; a pro story rarely says NCAA), and the WNBA is its OWN beat rather than a sub-case of the NBA, so an NBA post cannot suppress a WNBA one. The desk remembers the beat it posted in the sport column on sports_desk_events, written beside the dedup key on a successful ship. The two knobs are env-overridable (SPORTS_DIVERSITY_DECAY, SPORTS_DIVERSITY_LOOKBACK) rather than /menu tunables, matching the neighbouring lane constants. Read the result on the sports_beats panel (shipped posts per beat) and the sports_diversity event (which beat led each ranked batch, and the penalty its leader paid). This is an OUTPUT-side fix and does not touch SUPPLY: SPORTS_WIRE_ACCOUNTS is still football-heavy and _FETCH_LIMIT still pulls 20 posts per handle, so a football item is still the most common candidate – the re-weight only decides which one wins the slot.entity_image.py, the SINGLE image resolver — image_subject (the ONE Haiku classifier, claude_client.image_subject → {kind, name, artist?, sport?}) + the _deterministic_art router serve BOTH the wire desks (resolve_desk_image, used by pop_desk/sports_desk) AND the market/betting cards (market_subject_image.resolve_market_image), replacing the old per-surface market_image_subject + music_release_subject extractors (both deleted). The classifier tells us WHAT the one visual subject is AND which authoritative source to hit; _deterministic_art(bot, subj) routes by kind: music_release → catalog COVER by ARTIST (resolve_catalog_art, whose row pick is described under catalog_art.py below), Deezer ARTIST photo as the unreleased-album fallback (fed artist_watch.lead_artist(...), never the raw credit — see the joint-credit rule under music_markets.py); musician → the Deezer ARTIST photo (a music person lives in the music catalog, not a movie DB — the “what type of person lookup” distinction; the most-POPULAR name match by nb_fan, since Deezer’s search ranks tiny homonyms first, and the downloaded bytes are compared against Deezer’s no-photo placeholder — the CDN 200s a broken image hash with the same grey-silhouette pixels, the BTS-silhouette ship — with a placeholder match rejected so the walk falls onward; a SECOND placeholder shape — a solid-BLACK square at a real md5 URL, Deezer’s no-photo fill for an artist it dropped (Kanye West) — passes both the URL and byte-compare guards, so _deezer_photo_bytes also rejects a near-solid image by CONTENT (catalog_art.is_blank_image, the SAME check the music-desk path had; #ye-card was fixed there only, and the newsroom’s shared resolver shipped a black-box card until the check moved to this one chokepoint). Rejecting the black square then left a JOINT-credit card bare (a “Kanye West ft. Ms. Lauryn Hill” chart card shipped a number card with no art, owner report), because the lead-act portrait was the only one tried; _credit_portrait_bytes now falls to the FEATURED act’s real portrait on that miss (artist_watch.featured_credit, the inverse of lead_artist — the featured act is who the card is also about, and Ms. Lauryn Hill has a real Deezer photo), each candidate content-checked so a placeholder never ships); team → the API-Sports LOGO (api_sports.team_logo) composited into the broadcast card; person → the TMDB person PHOTO (tmdb.search_person); film/tv → the TMDB POSTER, with an OMDb poster fallback (omdb.poster, the “does OMDb have images” fallback for a title TMDB missed); org → the WIKIDATA LOGO (P154, wikidata.logo_image → wiki_logo) FIRST, falling back to the Wikipedia lead image (#wrong-moment): the lead image is the wrong picture for this kind twice over — a fair-use CORPORATE logo is excluded from Wikipedia’s pageimages entirely, so a major brand resolves to NOTHING (Netflix → no image, which fell all the way to the open-web search, which matched the Kalshi market page and put the market’s own QUESTION TEXT on the card), and where a lead image does exist it is routinely the headquarters BUILDING (P18) or, for UEFA, a member-associations MAP. Returned as a RASTERIZED Commons URL (?width=) since most logos are SVG, which the card renderer can’t decode. A bare logo NEVER ships plain (#card-logo-center, owner steer “add the logo to the center of a square tootsies card like we do with sports teams”): on the DESK path _card_bare_logo renders the resolved mark into the SAME square branded card a team crest gets (market_chart.render_logo_card — the mark centered on a radial glow in its OWN identity color + the tootsies wordmark; render_team_card is now the brand_suffix=" sports" flavor of it, and a sports desk’s org brands the same way), returning logo_card. A dark org mark flips the desk card LIGHT (#too-dark), never onto a plate, because an org logo is typically MONOCHROME and near-black (SpaceX, UEFA, HBO), invisible composited bare on the near-black card. The first cut seated such a mark on a rounded contrast plate (plate=True, #1841); the owner rejected the plate look both times it shipped — on the market cover (“bordering is ugly, would rather a light card”) and again on the desk card (the plated SpaceX pop card: “this should use light version”) — so render_logo_card now does what the market covers already do: _mark_is_dark (mean opaque luminance under _LIGHT_CARD_LUM_FLOOR, the ONE gate shared with _needs_light_card) flips the field to the light palette (_branded_floor(light=True)) and the mark composites BARE — no plate, no box, no drop shadow (a blurred black smudge reads as dirt on the light field); the red tootsies wordmark carries over, the suffix dims to _LIGHT_SURF. A light or multi-colored mark (a team crest) clears the floor and keeps the dark identity-glow card byte-identical to before — the transparent-background UEFA mark still can’t crosspost as an EMPTY BLACK FRAME, it now lands on the light field instead. Only the desk path cards: the market/betting cover calls _deterministic_art DIRECTLY and composites the bare mark into its own branded panel (art_fit="contain"), so it keeps receiving raw wiki_logo rather than nesting a card in a card. A render miss returns no art (falling to the vision search) instead of shipping the mark that just failed to decode; video_game → STEAM (steam_art.game_art_url, #2741) BEFORE the lead image, because the lead image cannot serve one at all: pageimages EXCLUDES non-free files and a game article leads with its copyrighted box art, so the rung returns nothing for EVERY game (measured 2026-08-31 on Counter-Strike 2, Minecraft, Half-Life 2, Elden Ring, GTA VI; Eiffel Tower and Taylor Swift still return one). This is why #2663’s _title_names_subject guard, which refused the WARDOGS mis-resolve, could only ever stop wrong art — it could not produce right art, and the two 2026-08-30 cards then shipped bare. The dividing line for the whole thing kind is whether a FREE PHOTOGRAPH can exist: hardware (PlayStation 5, iPhone 17) and venues (Sphere, MSG) are photographable, so they keep Wikipedia and never reach this rung. video_game is its OWN classifier kind, split out of thing (#2741), and the gate is that kind — never a name match. The first cut ran Steam for every thing on the theory that exact name matching was self-limiting. It is not: exact equality proves the STRINGS match, never that the SUBJECT is the game. Measured over 27 adversarial non-game things, “Uno” (the card game) matches the Ubisoft video game exactly — and reordering Wikipedia first does NOT save it, because UNO’s article has no free lead image either, so a card game would have shipped a video game’s art. The prompt already named video games inside the thing clause, so splitting them out was a REWORD, not an added counter-rule (the docs/PROMPT_OPTIMIZATION.md discipline). Dry-run on the real Haiku classifier, n=2 each over 9 real market texts: 4/4 game markets classify video_game, and thing/place/org/musician are unchanged — an A/B against the baseline prompt confirms only the game rows moved (iPhone 17 and Labubu stay thing; a pre-existing [] on “Will Taylor Swift win Album of the Year?” is identical before and after, so it is not a regression from this edit). The 400px cover floor is enforced on the Steam bytes in _deterministic_art, because resolve_market_image’s deterministic rung does NOT size-check what the router hands back (unlike its market_leg rung): Steam’s screenshot fallback is not always large — Portal’s first screenshot is 640x360, and a 360px short edge upscales visibly on a full-bleed cover — so an undersized asset falls through instead of shipping soft. The match is EXACT on a folded store name (pick_app), never Steam’s top hit — storesearch is a fuzzy ranker, so “Counter-Strike 2” also returns CS2D and a tractor DLC, “Minecraft” (not on Steam) ranks Minecraft Dungeons, and “Grand Theft Auto VI” (unreleased) ranks Vice City; all three wrong answers are refused, and a refusal just falls through. Art is the portrait library_600x900_2x capsule, else the first SCREENSHOT — not background_raw, which is the store page wallpaper and renders as a near-empty starfield (judged by LOOKING at the rendered card, and guarded by a test since it passes a naive size check). Live dry run 2026-08-31: 15/15 games resolved, all above the 400px floor, 9/9 non-games refused. film/tv titles are taken LITERALLY (#2741). The classifier substituted a recognized FRANCHISE for an unfamiliar title, and the film rung then resolved a real but WRONG poster — worse than no art. Measured on the real Haiku classifier with the production extraction shape: “Coyote vs. Acme” → “Looney Tunes” 10/10 (shipping the Back in Action poster), “Wicked: For Good” → “Wicked” 3/3, and “Ransom Canyon: Season 2” → “Ransom Canyon” 3/3 — the prompt’s OWN worked example. The trigger is the Market ticker: line in _extraction_text: the same title with a neutral ticker classified correctly 4/4, with the real ticker 0/10. That line is NOT cut, because it is load-bearing for the opaque sports matchup it was added for (“Team Spoon vs Team Coop” + KXWNBAALLSTAR → WNBA); instead the film/tv kind line is REWORDED to demand the title exactly as written. A/B, n=3 each: 9/18 → 18/18, every previously-correct title unchanged and the sports matchup still resolving to WNBA. The film rung has no title verification of its own — unlike the music rung, whose _artist_matches refuses rather than ship a wrong-artist cover — so a misnamed title has nothing downstream to catch it; that asymmetry is the open follow-up. place/thing (everything else) → the WIKIPEDIA article’s LEAD image (reference.wikipedia_lead_image, a bounded pithumbsize thumbnail — the canonical venue/landmark/festival/product/game photo or a governing body’s crest — the kinds with no dedicated catalog; org is a governing body/league/company that ACTED in the story (UEFA/FIFA/a label), the class that classified to nothing and shipped a bare tweet; Wikimedia requires the descriptive UA catalog_art._UA_HEADERS sends). The resolved article must NAME the subject (reference._title_names_subject, #2663): MediaWiki search ranks by article TEXT, so it answers every query with something, and the rung took that on trust – a Kalshi leg named “WARDOGS” (a Steam game with no article) resolved to “Dogs in warfare” and put a US Air Force military working dog on a prediction card. The guard is a two-directional token-subset test, so a title that ADDS words (“Coachella” -> “Coachella Valley Music and Arts Festival”, “Sphere” -> “Sphere (venue)” – a disambiguation suffix needs no rule of its own) or DROPS them (“Mortal Shell II” -> “Mortal Shell”) still resolves, while a title sharing no name is refused; punctuation is REMOVED rather than split on, so “S.T.A.L.K.E.R. 2” matches its own article. Dry-run over the live API: 8/8 real venue/product subjects keep their image, and of the six Steam legs only WARDOGS is refused by the guard (the other five have no lead image at all). A refusal returns None like any miss; a null/miss falls to vision (resolve_subject_photo) on the CLEAN entity name (not the raw hype tweet). RANKED SUBJECT LIST (#thin-ahead-art, owner steer “extract a list of subjects in relevance order and try the existing resolution on them in order — no new branch”): subject capture is ONE classifier call, claude.image_subjects — the RANKED list of the text’s visual subjects (main subject first, then the other entities with authoritative art), parsed by _parse_image_subjects — and resolve_desk_image is just a LOOP: each subject, best-first, runs the SAME full resolution (_resolve_primary, incl. its own open-web photo search — owner steer “multiple searches are fine, we want the best image”) until one yields art; an empty list runs the loop once subject-less (the open-web search on the raw headline, the old null path). The fix for the “Bilge Ebiri revisiting Scorsese’s The Age of Innocence” bare tweet — the critic (no photo) was the pick while the film’s poster sat right in the headline — and the whole “resolvable entity present but not the primary pick” class, rather than a per-mis-classification prompt rule. (Two intermediate designs were tried inside the same PR and folded away per owner steers: a separate image_alternates second query, then a deterministic-only cheap-tail walk.) Fully fail-open (a Haiku blip → the raw-headline search; no art at all → (None, None, "none"); the take then ships as TEXT on both surfaces — owner steer “if the post is good don’t skip”, so no boundary filter drops a good artless take). image_subject (the single-subject shape the market-card path + market_drop catalog hint use) is now a thin first-of-list accessor over the same one prompt, not a second query. RANKING an INDIVIDUAL-at-an-event (#wrong-moment): a red-carpet/premiere headline (“Zendaya at the Spider-Man: Brand New Day premiere in a Tamara Ralph gown”) ranked the FILM first, so the walk hit the TMDB poster on iteration 1 and shipped the MOVIE POSTER instead of a photo of her — the person was in the list but never reached (the existing #wrong-premiere-photo occasion-gate only fires once person is already the pick, so it couldn’t help). Fixed by GENERALIZING the classifier’s existing statement/quote rule (“a line that is a PERSON’S statement has that SPEAKER as its main subject”) to cover what a named individual said, did, wore, or showed up to — so the work/event they were there for goes SECOND — rather than bolting on a premiere-specific counter-rule (the docs/PROMPT_OPTIMIZATION.md discipline). The rule says “INDIVIDUAL … still kinded by who they ARE (musician/athlete/person)” because an earlier wording (“leads with THAT PERSON”) was read as the KIND person and misrouted a red-carpet MUSICIAN off the deterministic Deezer catalog. The paired examples deliberately show BOTH orderings (person→film for the carpet, film→person for the Ebiri essay) so it can’t over-correct into person-always-first. Dry-run-verified n=3 on the real Haiku classifier: baseline 0/3 on both premiere cases (film first), fixed 9/9 across the premiere cases + every control (essay-about-film, film news, music release, athlete, org). The market’s OWN artist beats asking the model (#thin-ahead-art): a Kalshi music market names the crediting ARTIST in its market SUBTITLE (":: Morgan Wallen" under the “I’m The Problem” leg), so market_subject_image.subtitle_catalog_hint reads {title=yes_label, artist=subtitle} straight off the payload and feeds the verified-cover rung — deterministic, no model. Every lane that builds its OWN art snapshot must carry market_subtitle onto it (the drop/alert leaderboard fold stamps the leading leg’s hint as catalog_hint; the entity outcome lane’s winner card carries the raw subtitle, 2026-09-07) — a snapshot that names the leg without its subtitle sends a chart market to the platform-logo rung, and the card ships the Billboard wordmark instead of the record’s cover. This replaced asking the classifier who made a record, which it answers from MEMORY and gets confidently wrong (“Lizzo” for Morgan Wallen’s album — shipped twice as a wrong-artist photo, since a missed cover falls back to photographing the artist). An upstream luminate hint still wins; a non-music subtitle yields None; resolve_catalog_art still verifies the artist, so a bad read misses to a photo, never a wrong cover. The wire desks resolve on the wire headline, pre-compose, so the take can react to the picture (the ranked-list capture handles noisy text — the Ebiri essay classifies [film, Scorsese] at the source); cinema_desk’s news lanes and discourse’s crosspost resolve from the composed take (their only text). A TAKE RETRY is the wire desks’ LAST rung (resolve_desk_image_from_take, #chart-roundup-art) — this REVERSES the earlier resolve-ONCE rule, which held that ranking the headline’s subjects handles a noisy headline at the source. It does, for a headline that names an entity; it cannot reach a headline that names NONE, because there is nothing in it to rank. @tootsiesbar shipped “Ella Langley’s ‘Choosin’ Texas’ is projected to hold No. 1” bare off a “Midweek Billboard Hot 100 Predictions” headline: the classifier returned [], the wire’s own image was a rankings table the source gate correctly rejected, and the open-web search had only “Hot 100 Predictions” to search on — while the take names a record with a catalog cover. So each wire desk re-runs the SAME resolution on the composed take when, and only when, the headline pass returned none, after the self-gate (only a line that ships pays) and with NO source_url (the wire photo already had its first refusal — re-offering it just re-buys the same paid verdict). Live dry-run scripts/dryrun_take_retry_art.py: the shipped miss now lands its catalog_cover, two same-shape roundups land catalog_cover/tmdb_poster, and a control whose headline already resolves never reaches the rung. The take cannot SEE this image (it is already written) — the accepted cost, since the alternative on this path is no picture. Rare by design (2 of 179 pop-desk posts over 7 days), pinned across all three desks by tests/test_wire_lanes.py, and watched via desk_art phase=take_retry; a residual miss still ships as text (the *_posted source=none rate). The SOURCE photo’s gate is framed by PROVENANCE, not identity (#source-media-reuse): the wire’s own photo gets first refusal (#1832), but it was judged by the same test as an unknown open-web hit — “does this genuinely SHOW <entity name>”, with a hard reject bias — so the picture the wire published WITH the moment was thrown away whenever the subject wasn’t face-recognizable in it, and the desk went and found a stock portrait instead. A post about the first Street Fighter clip shipped a red-carpet headshot of Jason Momoa because the clip still of him in full Blanka prosthetics failed “is that Jason Momoa” (measured 0/5); the same framing killed a Rashford-holding-the-trophy photo on his Barcelona-goodbye post (0/5). So pick_subject_image(source_post=True) keeps the identical reject list but flips the default to TAKE IT, and judges against the MOMENT (the wire headline) for every kind — a real photograph counts in costume/character/prosthetics, as a film or clip still, masked, in kit, mid-action, or shot wide. The junk bar is unmoved and re-measured: a text card, a chart, a bare logo, and a rankings/stat table (even laid over a photo) all still reject 0/5, as does a real photo attached to a plainly different moment. Both tail clauses were ABLATED, not assumed (docs/PROMPT_OPTIMIZATION.md discipline, n=5 on the real images): the “rejects still stand / DESIGNED GRAPHIC” paragraph is LOAD-BEARING (without it a rankings table over a photo is taken 5/5), while a drafted clause saying a photo “with a caption/score bug/logo laid over it” counts was CUT as bloat-without-payoff — dropping it changed nothing (7/7 either way, a real photo under a “HERE WE GO” overlay still taken 5/5) and it was the clause pulling toward the rankings table the paragraph then had to push back on (two rules fighting). The ablation also caught a near-miss worth remembering: a first pass “proved” the paragraph was scar tissue, but it had been measured on a tidier moment string than production passes — feeding the real _clean_text headline flipped the verdict, and the leaner wording turned out to sit on a knife-edge (a trailing period alone flipped it 0/5 ↔ 5/5) where the shipped wording is stable in both forms. gated_source_photo also now takes EVERY fetchable photo on the post in the ONE existing call and returns the one it picks, so a multi-photo moment whose first still is the least legible isn’t abandoned on the strength of that frame (image_urls on each wire story; image_url stays the first for the single-photo callers). Measured on a live 20-post wire slate: the wire’s own photo lands 17/20 vs 10/20 (scripts/dryrun_source_photo_gate.py, the labeled 10-case A/B). Pure-ish router; unit-tested tests/test_entity_image.py. The music cards’ OWN ladder (music_markets.resolve_art_url) now has a PORTRAIT FLOOR under its title+artist path (#2274). It used to return None the moment its three cover attempts missed — its artist-level rungs sit BELOW that branch and were unreachable whenever a title was present — so a track the catalog lacks resolved to nothing at all and the card’s only remaining hope was the model-judged web search. It now falls to the LEAD artist’s Deezer portrait, and ONLY the portrait: the artist-level ALBUM rungs stay unreachable from here on purpose, because a different record’s sleeve under a card titled “Is It Cool?” is exactly the wrong-cover class pick_album_art_url’s title guard exists to stop. A photo of the lead act is not a wrong cover — it is who the card is about. Measured effect on the reported miss: the art now resolves deterministically at the deezer_artist rung (3/3 runs), skipping one Perplexity search, two Haiku picks and two Sonnet vision calls. The portrait rung retries the FIRST party of a “+”-joined credit (#2544). lead_artist strips a trailing feature clause but deliberately keeps a co-billing credit whole. Mediabase’s radio joint credit “Busta Rhymes + J Dilla” reached Deezer whole, matched no act, and the +14 most-added card shipped bare (desk_art no_art). The shared deezer.artist_picture_for_credit now retries first_credit_party (“Busta Rhymes”) ONLY after the whole-credit lookup misses. This is the ONE definition of “get a Deezer portrait for a music credit safely” (#2547): music_markets.resolve_art_url (the music-card resolver) AND entity_image (the newsroom/market art path, which had the identical lead_artist-only gap) both call it, so a joint-credit entertainment subject no longer ships bare on either path. The retry splits ONLY a spaced “+” whose TAIL is a proper name (first_credit_party, beside lead_artist in artist_watch.py). Two guards keep a real band from being mis-split on a miss (a lookup MISS does not prove a collaboration – Deezer also misses on a placeholder image or an outage, so a wrong portrait would be worse than no art): “&”/”,” are excluded outright, because they live in single band names far more than they join acts (“Earth, Wind & Fire”, “Tyler, The Creator”, “Aly & AJ”); and a “+” splits only when the part after it is NOT an article phrase, because a “+”-band’s tail is an article (“Florence + the Machine”, “Nick Cave + the Bad Seeds”) while a real co-billing’s second act is a proper name (“Busta Rhymes + J Dilla”, “Chris Brown + Tyga”). So a “+”-band is left whole and ships absent on a miss, never a wrong “Florence” portrait. The definitive guard is CORROBORATION: the retry passes the full credit as deezer.artist_picture(first, within=credit), which keeps a match only when every token of the matched artist’s name appears in the credit. So a proper-name band (“Dan + Shay”) that DID get split can never resolve to an outside homonym (“Dan Bull” is rejected); the real band passes if the search returns it, and the first act of a true co-billing passes because its name is in the credit. Corroboration closes the joint-vs-band ambiguity by construction, so no connector heuristic has to be exhaustive. Because the retry is miss-only, it can only ADD art to a card that had none, never mangle a working lookup. The portrait rung has a SECOND provider under it (#deezer-403). Deezer’s artist picture was the only source of an artist photo anywhere in the repo, and on 2026-09-14 Deezer began answering 403 to Railway’s IP — an IP-level block a descriptive User-Agent does not lift (the integration probe sends one and still gets 403 in 27ms, an edge reject). Deezer was therefore dark for every surface at once. The card that exposed it was the Urban-radio most-added “+8 Where You From? — David Banner” on 2026-09-15: a new radio single is exactly the case the cover rungs cannot answer (Apple does not carry the title yet, and the title guard correctly refuses every other sleeve), so the portrait was the only rung left and there was only one of it. resolve_art_url now falls one rung further, to wikidata.artist_portrait (wiki_portrait), in BOTH branches. It stays BELOW Deezer rather than beside it: Deezer answers from the music catalog and keeps a current press shot, while Wikidata’s P18 is whatever Commons holds, often an older live photo — so it is a floor, not a replacement. The guard is what makes it safe to add at all. A name search alone ships the wrong picture and ships it loudly: the live entity search ranks the Marvel Hulk FIRST for “David Banner” and the ape first for “Bonobo” (with a typeface second). So a hit is used only where a name it answers to — its LABEL, or the ALIAS the search matched, since Wikidata files many acts under a legal or de-stylised name (“P!nk” matches the alias “P!NK” on an item labelled “Pink”) — is exactly the queried name, AND Wikidata’s own claims say the entity is a music ACT. WHAT the entity is decides first: a GROUP passes on its P31 class alone, and anything else must be a HUMAN (P31 Q5) carrying one of three music-only signals — P264 record label, P1303 instrument, or a music P106 occupation. The kind test is not decoration, it closes a hole the signals alone left open: an ALBUM carries a record label too, so a signals-only guard passed the 2024 album “Tyla”, and a SELF-TITLED album carries the act’s own name and a P18 of its SLEEVE — a record’s cover shipping as the artist’s photo, arriving through the guard built to stop exactly that. It was caught by an in-session /code-review pass, because the automated reviewer was in the #3316 failure state. The union covers different shapes of act (a brand-new rapper has P106 and no label claim; a band has a class and no occupation) while none of the four fires on a non-music entity: measured across bands, DJs, rappers and singers P264 alone was present every time and absent on the ape, the typeface, the film, the Hulk and the archaeologist. An entity passing none of them resolves to NOTHING, which is the standing fail direction — the ape is a worse card than an empty panel. Measured live on 15 names, 15 correct, in 0.3s to 1.5s each: the rapper resolves, the musician Bonobo resolves ahead of the ape, P!nk and Tyla resolve through the alias and the wider search window, and “Hulk” / “Various Artists” / a nonsense name all refuse. The whole rung carries ONE 8-second budget (_WIKI_PORTRAIT_BUDGET_S), because it is up to four sequential 12s reads on the last rung of the ladder every card takes while Deezer is dark, and none of its three callers imposes a timeout. The search window is 50 hits and the CLAIMS reads stay at 3: the hit count is one call whatever its size, the claim reads are one call each. The hit’s own one-line description (“American rapper”) only ORDERS the candidates, never decides one, which is what keeps the normal case at one claims call instead of three. The cover guard is SUBJECT-KIND aware (#2278). pick_album_art_url takes subject_kind (“album” default, “song”), and it decides which field of a SONG-ENTITY row the title guard reads: a song-entity row carries the TRACK in title and its release in album, so the album-subject reading asks “Billie Jean” to equal “Thriller” and can never match. Every song that exists only as an album cut therefore resolved NO cover, and the desk’s song lanes could only ever match a track also released as a same-named single. The kinds cannot be merged into “match either field”: for an ALBUM subject a same-named TRACK is exactly the A*POP borrow the guard was added to stop, because the album is missing from the catalog precisely when a same-named track is what would match. billboard.chart_unit(key) reads the chart’s own unit and card_fields/exit_card_fields stamp it as art_kind, so the Billboard lanes pass their real kind and every other caller keeps the “album” default. Live A/B over real chart rows: 24 of 45 song rows moved from artist_portrait to the true catalog_cover, 18 already had covers and kept them, and all 20 album-chart rows were unchanged. The PLATFORM-chart lane was left on that default, and it shipped a wrong cover (the Mac Miller dog card, 2026-08-31). artist_watch.chart_story_card_fields stamped no art_kind, so an Apple Music US songs-chart card resolved its art as an ALBUM. The unsatisfiable comparison does not always end in no art: it rejected Mac Miller’s real “The Divine Feminine” row and the walk continued to a junk compilation, so the card carried a stock photo of a golden retriever to X. The fix stamps the chart’s own unit (already “song” |
“album” on every _WATCH_CHARTS row, the same vocabulary subject_kind takes), which is what the Billboard lanes have done since #2278. The second half of that card is in apple_music._title_matches, and it reaches every title guard, not this lane. _normalize_title drops every character outside [a-z0-9 ], so a title in a non-Latin script normalizes to EMPTY, and the matcher’s “empty means unverifiable, accept” branch passed it. Every non-Latin row therefore cleared every title guard, and the first one in the search results supplied the cover – here a Thai compilation carrying the track. A row that shares no alphabet with a Latin request is a positive MISMATCH, not missing data, so a present-but-unreadable name now returns False; a genuinely blank or absent name keeps the fail-open True. Both halves are needed and each is a guard on its own: with the matcher fix alone the album default falls to a Mac Miller PORTRAIT (right person, wrong picture); with art_kind it resolves the true Divine Feminine cover. |
entity_image._artist_rung): deezer_artist_exact when the winning Deezer row’s normalized name EQUALS the name the take uses, deezer_artist for the loose token-overlap match. Only the EXACT rung is exempt from the delivery-time media gate (x_crosspost._IDENTITY_VERIFIED_ART). Why the split exists: a Kalshi card carrying Taylor Swift’s own official Deezer portrait — her “The Life of a Showgirl” press photo — was flagged by the vision judge as “a woman submerged in water who does not appear to be Taylor Swift”, twice, so the confirmed drop shipped the card bare (crosspost mode=number_fallback, 2026-08-29). That is #2238’s false positive on the rung #2238 did not cover. An exact catalog-name match settles identity upstream and deterministically, which is the same standing an iTunes credit has; the LOOSE match does not — it really returns a namesake (“Michelle Hadley” → “Michelle Marly”, live-checked), so it keeps the gate’s full authority. Three things are never reported exact, because in each the FULL requested identity was not the thing that matched: a FEATURED act’s portrait (the take is about the lead); the first-party retry on a joint credit (Busta Rhymes + J Dilla → a portrait matching only Busta Rhymes, and first_credit_party is a heuristic that can cut a real “+”-named act like Florence + the Machine); and a name that differs by a stopword — the exact test uses deezer._norm_artist_name, which keeps every token in order, NOT the album-title normalizer that collapses “The Band” and “Band” to the same set. The exempt rung also needs the name GROUNDED in the source text (artist_grounded, stamped by claude_client._mark_grounded_artists, which now covers musician on its own name as well as music_release on its artist). The musician stamp uses the STRICT, case-sensitive _artist_named_in_strict: the permissive _artist_named_in documents an ordinary-word false match as “the SAFE direction”, which held while grounding only decided whether to ATTEMPT a portrait, and stopped holding once it also grants the gate exemption — “Future”, “Common” and “Air” all match ordinary prose. Only musician can reach the exempt rung at all: the music_release missed-cover portrait is always deezer_artist, because the release→artist pairing is the classifier’s assertion rather than a verified credit, so a mispaired artist on a take naming two acts would otherwise hand back the wrong act’s exact portrait. An exact Deezer match proves the portrait is of the name we ASKED for; it says nothing about whether the classifier asked for the name the TAKE uses. Without this a drift the gate used to catch — a take about Ella Langley classified as Ella Fitzgerald — would resolve Fitzgerald’s portrait exactly and ship it unchecked. Grounding does NOT gate the portrait for a musician: an ungrounded name still gets its picture, it just keeps the gate. The market_subject_img cache namespace was bumped to v14 with this change: a cache hit returns its stored src verbatim and never re-runs _deterministic_art, so v13 portraits would keep the old label — and keep being dropped — for up to the TTL. A FOURTH thing is never reported exact: a match on an OBSCURE act (#2810). An exact name match is only an identity check when the act it names is the one the world means by that name. Measured live 2026-09-01: a short or nickname credit exact-matches a tiny namesake far more often than it finds the real artist — “Kendrick” → a 73-fan Kendrick, “Bey” → 10 fans, “Cudi” → 10, “Ari” → 59, “Doja” → 342, “Snoop” → 3,668 — and all six came back exact, so all six would have shipped a stranger’s real face past the media gate. The genuinely correct short names sit orders of magnitude higher (SZA 1.32M, ZAYN 2.70M, Diddy 606k, Kanye West 4.54M), so deezer._EXACT_MIN_FANS (50,000) is the floor a row must clear before its name match counts as identity. LENGTH is the wrong discriminator and was the first thing tried: “Kendrick” is eight characters and still collides, while “SZA” is three and is right — audience separates the two cleanly with about two orders of magnitude of headroom either side. Below the floor the portrait still SHIPS; it is just reported deezer_artist, so the gate keeps its authority. The ALIAS table is now read on the art path too (artist_watch.canonical_credit, applied in deezer.artist_picture_match_for_credit — THE one definition of “a Deezer portrait for a music credit”, so every caller is covered by one line). It had a single consumer, the watchlist tier match, and that split was the live misattribution: Deezer holds a separate artist literally named “Ye” (id 4099199, ~20k fans; Kanye West is id 230, ~4.5M), and the alias is what lets a “YE”-credited wire post through the tier gate in the first place — so the alias imported the story and the art path then misattributed it, exactly, on the exempt rung. Both halves read one table now, and a test pins that they cannot drift apart again. The candidate FILTER now folds too (#2693). It ran on _norm_title, the ALBUM-title helper, which strips a diacritic instead of folding it — so “Beyonce” ({beyonce}) shared no token with Deezer’s “Beyoncé” ({beyonc}) and the real act was dropped BEFORE the exact test ever saw it. The issue recorded that as “no portrait resolves”, and that was a fixture artifact: live, Mc Beyonce (14,537 fans) does share the bare token, so it won the row and the card carried the WRONG artist’s portrait (measured 2026-09-01; the real Beyoncé has 12.8M). The same strip emptied a wholly non-Latin name, so those acts matched nothing at all. deezer._norm_search_tokens is _norm_artist_name’s folding (diacritics + the Unicode-aware word class) with _norm_title’s set-and-stopword semantics, because the filter needs both halves and neither helper alone has them. This is the SAME lesson album_cover’s artist-id rung already learned and wrote down — “the tokens come from _norm_artist_name, NOT _norm_title” — applied to the portrait filter, which was left behind on the old helper. Folding only ever makes MORE rows candidates, and the guards behind it are what keep that safe: the exact test still decides the rung, _EXACT_MIN_FANS still gates the exemption, and the existing most-popular preference is what then beats the namesake (12.8M over 14.5K). The fold went in at the ROOT, _norm_title itself, because the portrait filter was one of FIVE sites and fixing only it would have repeated the sweep miss it came from. _norm_title’s [^a-z0-9 ] strip DELETES an accented character rather than folding it, which both splits a token where the accent sat (“Björk” -> {bj, rk}) and leaves an accented name sharing no token with its plain spelling. A wire writes “Beyonce” and Deezer answers “Beyoncé”, so every comparison in the module that runs on these tokens failed for every accented act, always in the direction that REJECTS the right answer. Measured 2026-09-01: _artist_overlaps read Beyoncé, Rosalía, Björk and Céline Dion as 0% overlap with their own names and refused their own albums; album_cover’s credit check refused the right row (live: RENAISSANCE by Beyoncé scored artist_ok=False before, True after, while the imposter renaissance, but lofi by Lucille Hurt stays refused both ways); _split_billing handed the contributor test mangled tokens; and album_title_matches refused a title whose accent the source omitted (“Mananas” vs “Mañanas”). NON-ASCII letters are still stripped, which is load-bearing rather than an oversight: album_cover reads an empty artist token set as “cannot token-verify” and refuses rather than risk a wrong cover, while the portrait FILTER wants a name it can still search and so uses _norm_search_tokens. The same gap existed in eight more normalizers across five other modules and is now fixed too (#2821) — deezer._initialism, music_markets._album_key / _art_norm / metric_slug, billboard._norm, hits._norm_name, radio._norm, milestone_qualifier._fold. Each returned a different value for the accented and plain spelling of the same name, always failing in the direction that rejects the right answer. utils.names.fold_accents is now THE one definition of the fold primitive and all eight route through it: the idiom was hand-rolled in ~8 places already, and the reason these eight drifted is that no shared primitive existed — every existing helper folds and then immediately does something specific (strip to [a-z0-9], split to tokens, keep only ASCII), so a caller needing plain “same name, no accents” had to re-roll it. milestone_qualifier._fold is the instructive one: it carried a comment saying it deliberately omitted the accent step kworb._fold has, because “a title rarely needs” it — wrong in the direction that MISSES, since a plain spelling then never meets an accented one. The KEY-churn risk was checked, not assumed: metric_slug feeds the persisted proj:<release>:<metric>:<day> key (cogs/music_desk.py), but its input is registry metric text (“Album-Equivalent Units”) and the key’s other half is a Kalshi ticker suffix (PET26AUG06), both ASCII by construction — so the fold is a no-op on every real input and no stored key changes shape, pinned by a test. _album_key is in-memory only (seen_albums within a call, fired_albums within a tick). The one residual: _sales_legacy_seen compares _album_key against a persisted LEGACY set, so an accented album carded in the narrow window before deploy can miss its fading legacy key and re-post once — accepted, and those keys fade by design.Music LINKS belong to two surfaces only (owner steer 2026-08, #2216): the music DROP (cogs/music.py — a take plus its Apple Music / Spotify link, into the links-only channel) and the GUESS GAME’s reveal (cogs/games.py / cogs/x_game.py). Everything else reports rather than recommends, so it sends a reader nowhere to listen. The newsroom broke this: music_news._build_card passed url=listen on EVERY story kind, so a market / milestone / chart / cert / touring card carried an Apple Music button, and the crosspost put that link in the tweet (measured: 186 of 215 music_news crossposts over 14 days, 87%). That is why a Drake PREDICTION-MARKET post had an Apple Music album link at all — and, because the link comes off the same catalog row as the cover, the mis-picked row pointed it at KAROL G’s album. The link is now gated on kind == "release", the one music-drop-shaped lane. Dropping it on the stat lanes retires a whole wrong-link surface rather than one instance of it. A TOUR is gated out BY NAME on top of that (#tour-announcement). The first pass said a tour needed no special case, because no catalog row matches a tour NAME. That reasoning only covers a tour the SUBJECT names. A tour whose subject is the bare ARTIST matches that artist’s own catalog, so on 2026-09-01 a card about three announced Kanye West concerts carried a link to “808s & Heartbreak” (music.apple.com/us/album/heart...) in the tweet. url and button_label now both read raw_kind == "release" and not is_tour. The release lane then lost the card instead of the link (owner steer 2026-09-12: “new releases shouldn’t have a card and music link. only a music link, probably, if it exists. it’s one or the other”). A drop is the one newsroom story a reader can act on, so the LINK is the post; the card competed with it, because Discord draws the card image and hides the Apple Music unfurl behind a button, and on X the baked cover and the link preview showed the same record twice. _build_card now RETURNS None for a release that resolved a link (stamping it on composed.meta["listen_link"]), and _sched_deliver sends the take plus the link on its own line in the room and passes the link — with no image — to the X mirror. So NO newsroom card carries a url any more, and build_number_card is called without url/button_label at all. The “or” side is unchanged: a release with no link keeps the card, which covers a TOUR and a LIVE moment (neither resolves a link by design) and a drop the catalog has not indexed yet. The split is queryable as desk_link surface=music_news phase=release: reason=link_only against no_link, plus no_card for the double miss (no link AND no art, so the take ships bare). It also warns that music_news_posted carded=false no longer means a degraded post on this lane. ART used to HIDE the link, so the link is resolved FIRST now (Codex review on #3188). Both image-carrying lanes return before _artwork_and_link runs, which is where the link came from: the deterministic new-release candidate carries its own cover, and a first-party feed item carries the outlet’s editorial photo. Each arrives as feed_image, which the card path prefers – so the two lanes most likely to BE a drop resolved no link and carded. _build_card now asks _release_listen_link BEFORE any art: it reads the same artist-verified row _artwork_and_link picks and takes the same link off it, and a drop with a link returns there, so no art is fetched at all. The new-release lane skips even that read – _sched_compose stamps the candidate’s own album page (collectionViewUrl, off its permalink) as release_link, and only that lane: a wire post’s permalink is an article or a tweet, not a record. _release_listen_link REFUSES two shapes, both prefer-absent-over-invented: a subject with no artist credit (_artwork_and_link resolves a representative row for ART on purpose, but a representative LINK points a reader at a different record), and a row from another era (_row_is_a_different_record, #3181 – as wrong a link as it is a cover). Each keeps the card. The drop route is NEW records only (Codex review, round five). Two more shapes ride release and are not a drop, so each keeps its card: a THROWBACK (“Music Box released 33 years ago today” – the era guard deliberately KEEPS an old row for these, since the 1993 sleeve is the right art, so the link resolves and the look-back would have lost the period cover it is built around; read off the composer’s retro flag and off the claim, since the card build sees only the claim), and a FILM / documentary / merch announcement (_claim_is_nonaudio), where the catalog’s same-titled album is a different product from the thing announced – the Eras Tour shape, one name over a tour, a concert film and an album. Those cues are the tour ANTI-cues minus the three that are audio: a concert album, a live album and a tour edition are records and keep the link. The throwback test is the ANCHORED one (retrospective_frame, the same question the composer’s retro flag asks of the wire post), NOT the era guard’s states_past_interval: that one is deliberately unanchored, so it also fires on an anniversary clause inside fresh news – “to celebrate the album released ten years ago today, she announces a deluxe edition” is a new record and keeps its link. Wording alone cannot settle it, so the ROW’s age does (Codex review, round seven). The anchored frame misses a subject-led anniversary (“Music Box was released 33 years ago today”), and the unanchored one over-fires, so _release_listen_link tests the matched row with _row_is_an_old_record – the age half of the era guard WITHOUT its throwback carve-out. An old row is never a new record, whatever the wording, which closes the subject-led shape and also stops the 2006 album being linked as if it were the deluxe edition a story announces. An UNREADABLE date keeps the row: iTunes leaves release_date empty on plenty of genuinely fresh rows. Every desk_link row also carries delivered (room / staged), because the card is built before the stage branch and a STAGING audition runs the same route. A TITLE TRACK links the record, not the song (#3192). resolve_music_row is song-first, so an album whose title track shares its name (“Enigma POV”) matches the TRACK, and the request matches that track name, so _listen_link’s song branch made ONE song the whole destination – which matters more now the link IS the post. When the request matches the row’s ALBUM as well as its title, the subject names the record, so the album page wins. The collection name must be PRESENT to count: _title_matches answers True for a missing one (right when REJECTING a row, wrong here), and without that check every album-less row went to the album page. A single loses nothing either way – its collection is that song’s own release, so the album page is the same page minus the track parameter. Deliberately NOT read off the claim’s wording: “the lead single from her upcoming album” is a SONG story whose claim says album, and a claim-driven rule would send it to the record. The collection test is an IDENTITY one (_names_the_collection), NOT apple_music._title_matches: that is the fuzzy catalog-SEARCH predicate (substring containment, a difflib ratio as low as 0.6), which is right for “could this row be the request” and wrong for “does the request name the record” – the song “Love” on the album “Love Yourself” contains it, so the first cut sent a song story to the whole album (Codex review on #3206, caught after merge). Both sides lose a trailing single/ep so a single still names its own release, and a parenthesised edition needs no case since _normalize_title already drops it – a deluxe IS the record.
The NEW-RELEASES BOARD (cogs/music_releases.py + utils/release_board.py, #release-board) is the roundup sibling of the music DROP — where the drop recommends ONE track with a link, the board posts a scannable LIST of the night’s fresh drops (artist - title), the “who’s getting your first listen tonight” roster. It rides ScheduledPoster (master switch + mood + /menu calendar + slot pacing + dedup), targets the guild’s music_channels (shared with the drop — both are music-room posts, on their own cadences), and gates on its own music_releases experiment (STAGING by default, so it auditions in #bot-logs before a room). Data: utils.apple_releases.top_albums alone (Apple Music US Top Albums; NO Deezer since 2026-09-05, owner steer “never use Deezer”). The history matters because it is why: Deezer’s public API is OPEN (no key, no OAuth) and its chart is POPULARITY-RANKED, which is exactly what the board needs: recent_releases reads the album popularity chart (/chart/{genre}/albums), looks up each album’s exact release_date (/album/{id} — a chart entry carries no date), and keeps the ones inside the window. The chart is ranked, so the notable acts arrive already sorted — no known-artist filter is needed (that was a workaround for a source with no popularity signal). Release dates are immutable, so the per-album lookups are cached in-process (~one lookup per charting album cold, ~zero after). This is the THIRD source pivot (#2447), and the RIGHT one. The first design read Spotify’s /browse/new-releases, but that Browse endpoint is 403 for any app created after Spotify’s Nov-2024 Web API deprecation. The second read MusicBrainz’s RELEASE date feed — complete, but MusicBrainz has NO popularity signal, so the board was either obscure noise (no filter: “Septic Mutilation - Visions of Despair”) or too thin (a blunt top-1000 known-artist filter that still missed rising acts the source tweet featured). Deezer solves both at once: it knows what is popular AND what dropped, so a real drop (Denzel Curry, PARTYNEXTDOOR) outranks a bedroom release with no filter at all. Deezer alone was not enough, and the board went fully DARK on it (2026-09-04). Its all-genres chart is LONG-TAIL, so the set “albums in this top 100 that dropped today” is nearly always empty: the board ran 35 times between 2026-08-27 and 2026-09-04 and posted NOTHING, every run phase=source_thin. On Friday 2026-09-04 the chart held three albums released that day (Sleep, Rubber Band Gun, Paris Paloma) — under the Friday floor, which was 6 at the time — and it MISSED Beyoncé’s B’DAY (20th ANNIVERSARY DELUXE EDITION), the day’s biggest drop. So #2950 added a SECOND chart: utils.apple_releases.top_albums, Apple Music US Top Albums — 100 rows ranked by current plays, each carrying its own releaseDate, in ONE keyless request (no per-album date lookup at all). Apple held four of that day’s drops, Beyoncé’s among them, and the two were merged rank by rank. Then Deezer came OUT (owner steer 2026-09-05, “never use Deezer”): beside Apple’s rows Deezer’s were the long-tail noise on the card (Sleep, Rubber Band Gun, Paris Paloma), and its tracks chart was weeks stale (#2956). The board now reads Apple alone — a thin Apple night is a thin night — and deezer.recent_releases, its album-date cache, the deezer_fetch event, the monitor branch and the panel entries were removed rather than left dormant. The stream-stat’s song-date check (_standout_stats) moved from deezer.track_release_date to the iTunes Search API (apple_music.song_release_date, strict title + billed-artist match, fail-open to no date). Apple source-health rides the apple_releases_fetch event. No provider still serves a dated RELEASE RADAR this bot can read (re-measured 2026-09-04): Spotify’s /browse/new-releases returns 403 for this app even with a valid client-credentials token, Deezer’s editorial /releases feed returns an empty list, and Apple publishes most-played only — its new-releases / most-recent / coming-soon feed names all 404 and the legacy iTunes RSS newreleases path 400s. So the board reads POPULARITY charts and keeps what dropped inside its window, and reading two of them is how it sees a release day. A second unit rides the same slot on Fridays (#2956, owner ask 2026-09-04): the TOP DROPS card. _sched_compose_units yields up to two Composed units in order — the albums board, then the top card — each with its own lane, label (= the telemetry category), floor, roster-dedup key (<lane>: prefixed) and source_thin skip, so a thin unit never costs the other and a second slot re-posts neither. _compose_top_drops takes the night’s albums roster, fetches kworb’s Apple Music US Top Albums chart once (apple_albums, revalidated) and matches each drop through kworb.album_chart_entry — the STRICT match, artist credit + exact folded title (#2520: a near-name or a same-titled reissue lower down is a wrong rank on a public card, so an unmatched album is left off, never guessed) — then ranks by the drop’s chart position (rb.rank_drops_by_chart), cuts to TOP_DROPS_ROWS (5) with a floor of TOP_DROPS_FLOOR (2), and renders #5 Top Albums (the chart’s name, owner steer; the platform rides the source stamp, and the caption reads Apple Music Top Albums (US)) + the drop’s date on each row; the take stays numberless through the same compose_music_roundup + release judge. Why the album’s chart position and not the artist’s streams (owner steers 2026-09-05): the first cut ranked by the artist’s Spotify career total off kworb’s all-artists page and printed it on the row. That figure is not the drop’s — Beyoncé’s 35.6B is twenty years of catalog, not B’DAY’s first night — and beside an album title it read as the album’s number, the same misattribution class as #2950’s “Deezer” source stamp; a bare “#41 on Spotify” then read as the album’s chart position. And only a top-1,000 act resolves, so four Friday rosters yielded 2, 0, 2 and 1 rows. The albums chart is the drop itself, the same day: 6, 0, 5 and 5 of those rosters sat on it (past weeks read against today’s chart, so they understate). Streams-so-far was measured and left off: the existing album-streams reader (stream_totals.album_total) returned 1.99B for B’DAY deluxe — the 2006 original’s lifetime total, matched by title, the reissue misattribution — and nothing for Wisin or Nemzzz (no kworb artist page); a per-release figure needs a per-release source. The X-reception pulse stays on the albums board only, so a two-unit slot spends one metered grok call. A SINGLES board was built for the same slot, measured, and CUT (#2956, #2512): no source this bot can read sees a single the day it drops. Deezer’s tracks chart (/chart/0/tracks, 68 of 100 rows pass a single-vs-album-track test) carries release dates weeks old — a 22-day replay found an in-window single on ONE day; kworb’s Global Daily held ZERO fresh debuts on 2026-09-04 (192 of 200 rows were 8+ days on chart, and the eight sub-week rows were catalog re-entries: Bee Gees, Cranberries remasters, TLC); Apple’s most-played songs feed held none in the Thu+Fri window (its twelve fresh rows were one Aug 27 album’s tracks). A lane that cannot see its subject is the dark surface #2947 was, so it did not ship. It needs a dated release feed (#2506), not another popularity chart. The pure core utils.release_board picks the fresh drops: fresh_releases keeps only rows with a parseable release_date inside the plan’s window, in the SOURCE’S POPULARITY order (NOT re-sorted by date — so a big drop dated a day earlier, like PARTYNEXTDOOR’s Thursday P3, is not buried under same-day obscurities and cut at the cap), deduped — fail toward FEWER, an undated or stale row is DROPPED, never guessed onto the board. It also DROPS a side version — a live / remix / sped-up / acoustic / instrumental / edit re-cut of an existing song — via the shared utils.release_type.is_side_version classifier: a watched artist has no notability floor on the music lanes (a watched act posts at any depth), so without this a throwaway re-release (“Mercy (Live From Montreal)”) posts as if it were new music (owner steer 2026-08-21). The classifier is CONSERVATIVE (markers matched only in a title’s parenthetical / bracket / trailing-dash suffix, never the body) and keeps a DELUXE edition and a RE-RECORDING (“Taylor’s Version”) as genuine. It is the ONE definition of “is this a new release?”, reused as a SKIP on the other announce lanes (music_alert’s Spotify-releases, Apple-debut, and radio-entry lanes — a side version never leads them, and the debut lanes count it as its own side_version disposition so a re-cut-only day is not misread as a Deezer no_date failure) and as a DOWN-RANK in MusicDesk._pick_chart_moves (a side version sorts below every genuine move of equal notability, so with the per-slot cap it posts only when nothing genuine competes — a re-cut that genuinely charts is a real chart event, deprioritized, not skipped). The single-candidate music_alert crown/jump movement lanes are left as-is: a lone charting side version there is the real event. The board lists at most CARD_ROWS (10) drops — the card renderer’s own cap — and the card, the composer’s grounding, and the telemetry count all read that SAME capped set, so a take can never name a drop the card omits. board_plan is DAY-AWARE (owner steer 2026-08-23): Friday posts the NEW MUSIC FRIDAY batch (floor 3), every other day a DROPS TODAY board of just today’s drops (floor 2) that honestly SKIPS when nothing notable dropped — most non-Fridays, since the release cycle bunches on Friday. Both read a 1-day window anchored to today (a Deezer release_date is a plain calendar label, so “today” needs no timezone slack); the weekday picks the label + floor, not a second lane. The Friday floor was 6 and that was too high (owner steer 2026-09-04, cut to 3). A high floor cannot make Friday fat — the SOURCE does that and CARD_ROWS bounds it — so all it can do is SUPPRESS a Friday board, which is backwards: on a week when four notable albums dropped, four IS the news. Measured on Apple’s chart, the recent Fridays carried 5, 8, 5, 5 and 3 rows in the Thu+Fri window (still charting weeks later, so each understates the day itself) and Deezer adds roughly three more, so a floor of 6 rejected a genuine 4- or 5-album Friday outright. Friday still sits above the two-row list minimum because the label promises a batch and a two-name board under that headline reads thin. The take composes through compose_music_roundup — a NUMBERLESS multi-title roundup composer (the music twin of compose_cinema_roundup), NOT the number-desk compose_market_drop, which would mandate a chart position or stream count the roster does not carry and so either reject the clean post or force a fabricated figure (#2069/#2076: 0/13 shipped on the number path) — self-gated on music_desk_score(kind="release") at the shared music-desk 0.6 floor (fail-CLOSED). Early X reception (#2510): when grok_search is provisioned, the cog fetches grok_search.release_reception_pulse for the top 2 standouts (one grok call per shipped board) and passes it as the composer’s x_context — SENTIMENT ONLY, so the take LEADS with the buzziest drop and reacts in voice, but still names drops only from its grounded roster and quotes no fact/name/number from the pulse (the composer’s x_context block enforces this). Fail-open: no key / a miss just drops the garnish. Verified first-night STREAMS on the CARD (#2510): the cog’s _standout_stats credits a drop with its biggest FRESH-charting SONG on kworb’s Spotify Global Daily, drawn straight from data onto the card — the model never quotes it. The board reads Deezer’s ALBUM chart while kworb charts SONGS, so a new album’s own songs are what chart, under titles the album name never carries: for each of the top few drops (_STAT_SCAN), kworb.fresh_artist_entries finds the artist’s fresh-DEBUT rows (1 <= days <= _CHART_DEBUT_DAYS 3 — a blank kworb Days cell is 0/unknown and fails CLOSED) by a STRICT lead-credit artist match (so “Future” does not admit “Future Islands”, a “Future & Metro” collab still matches, and a solo name that itself carries a separator like “Tyler, The Creator” matches as the whole string), and keeps the top one whose Deezer date equals the drop’s release date (so an old song, or a same-day but different release by the artist, cannot borrow the slot). A REISSUE / anniversary edition is skipped (_REISSUE_RE on the drop title) — its songs are years old and re-chart off the reissue, not a first-night number (“deluxe” is NOT a reissue, it routinely carries new songs). _stat_card_rows renders the figure as the drop’s row value (abbrev(streams)/day) with the charting song as the sub, and RANKS the card by streams (owner steer): the drops with a verified figure lead, biggest first, the biggest highlighted; a drop with no charting song has no free day-one number and trails in popularity order. It rides the CARD, not the take, deliberately (owner steer): the release JUDGE rejects a song’s chart figure woven into album news (measured on real data: a faithful take scored 0.15–0.20 every time because the two-part figure + the album-vs-song framing confuse it), while a figure rendered straight from data needs no judge and no composer — grounded by construction, with no fabrication surface (which is why the earlier take-prose approach’s whole deterministic-backstop + rubric-rework machinery was removed). The take stays a NUMBERLESS roundup through compose_music_roundup + music_desk_score(kind="release") at the shared 0.6 floor (fail-CLOSED, unchanged). Both underlying calls (kworb.global_daily, deezer.track_release_date) are already instrumented, so the stat path needs no new event. Coverage limit (#2524): a Friday release’s first-day Global Daily rows are not published until Saturday, after the window moved, so the stat mainly fires for a THURSDAY drop on the NMF board — a same-day drop has no settled row yet (on live data this is narrow: a typical week has ~3 fresh debuts in the Global Daily top 200, so 0–2 board drops get a stat). board_blob carries the release DATE (- artist - title (out YYYY-MM-DD)): the release rubric hard-fails a status claim it cannot ground, so a dateless roster made the judge read “dropped tonight” as a fabrication and reject even a clean numberless take (#2520 dry run: 0/5 dateless → 3/6 dated). The card is build_board_card (the ranked-list chart-graphic). Slot pacing rides its own release_board_slots table; post history rides the shared post_dedup_history under surface music_releases, plus a per-night ROSTER identity (music_releases_key = channel : stage : date + sorted drop keys) so a second slot the same evening does not re-post the same drops — scoped by CHANNEL (a guild with several music channels posts to each, not just the first) and by STAGE (a #bot-logs audition must not block the same roster from shipping once a mod promotes the experiment to production). NOT wired (deliberate, proportional): no dedicated /menu channel picker (reuses music_channels), no X crosspost (a one-way external door — a later change), no numeric tunable (caps are code constants; cadence is the calendar).
cogs/x_mentions.py + utils/x_mentions.py, #x-mentions) answers an @tootsiesbar MENTION that names a music artist (or song) by POSTING that artist’s numbers — the first EVENT-DRIVEN music surface where the trigger is an inbound tweet, not a schedule. The wire in: a standing twitterapi.io filter rule (@tootsiesbar, catching mentions + replies) delivers to /x/webhook, which now routes BY rule_tag — xgame_* → the game, xmentions → this cog’s handle_webhook_tweets (utils/healthcheck.py). The rule is RECONCILED on a 15-min loop against the x_mentions experiment: created when the master guild has the surface on, DELETED when OFF, so a dark surface spends no twitterapi.io read credits (unlike the game’s per-round rules, this one is standing). Detection is two-stage, the pattern every entity consumer uses: a deterministic scan of the tweet text against the top-artist watchlist (x_mentions.scan_watched, on names.name_in_text — whole-word, decoration-robust) UNION a Haiku extractor (claude.scan_music_mention) that resolves a stage-name alias (“Hov” → JAY-Z) and pulls the SONG title. Recognition is BROADENED beyond the top-100 watchlist (owner steer): a watchlist hit runs every lane, but a LITERALLY-NAMED off-list act the classifier reads is accepted too — the boards lane confirms it by chart presence, so an act like Ellie Goulding (outside the top 100) still gets answered, and a name that charts nowhere is a silent skip rather than a bad post. Two lanes, both REUSING the desk/alert primitives (no re-roll): the BOARDS lane posts the artist’s PER-CHART cards, across the Billboard Hot 100 / Billboard 200 / Global 200 (first-party utils.billboard, wrapped to the platform duck-type by XMentions._BillboardWatchRow so the ONE tested builder serves both) and the live Spotify + Apple charts (kworb). The kworb rows carry no release date, so a catalog re-entry marks NEW exactly like a fresh drop — the #2045 class — and a mention about a just-passed or catalog-surging act would have drawn “NEW”/”debut”. XMentions._mark_platform_reentries DATES the mentioned act’s NEW-flagged rows (the shared release_age_days, fail-open per row) and wraps an older title in _ReentryRow so _entry_marker/watch_artist_board draw “RE”; Billboard rows already carry is_reentry, so this is the platform half (#2597, the port of the desk lane’s #2595 fix; the shared boundary is chart_boards.NEW_RELEASE_MAX_AGE_DAYS). A chart with 2+ of the act’s titles is a chart_boards.watch_artist_board (all their titles on it), composed by the source-aware chart_boards.artist_chart_board_compose_inputs and rendered by build_board_card; the board’s right-hand column carries a NUMBER wherever one exists, and the move marker only where none does (owner ask “add streams column”, then 2026-08-28 on a shipped Bad Bunny Billboard 200 card: “this should be streams not move on the right”). Three tiers, in order: (1) the chart’s OWN play count for its period, which only Spotify’s daily/weekly kworb rows publish (the leader’s plays ride the headline as “N plays that day”); (2) the title’s LIFETIME Spotify total, which the CALLER resolves and passes as totals — the Billboard boards use this, since a printed Billboard row publishes no streams at all (utils.stream_totals.board_stream_totals, keyed by song_key; the leader’s total rides the headline named as lifetime); (3) the move marker, on the rank-only Apple/iTunes boards and on any Billboard board whose totals did not resolve. A row the caller could not resolve draws a BLANK cell, never its move — one column carries one meaning, the same cell the desk’s individual-artist board leaves for a title with no published figure. Every number is abbrev‘d with photo-finish precision like platform_streams, and no board ever shows an invented one. The lifetime column also changes the COMPOSE inputs, because the same blob is the self-gate’s ground truth: artist_chart_board_compose_inputs brackets the figure, names it as lifetime, bounds the take to a row that HAS one, and says the rows are CONTEXT. Without that last line the Haiku judge read every row’s total as a figure the take had to report and failed correct posts for “omitting” the others — measured 2026-08-28, n=8 per case, real charts + real compose + the real gate: the caption alone passed 6/16, naming the bracket 12/16, naming it plus the context line 15/16, against 16/16 for the same boards drawing the move column; a chart with exactly ONE title is a single-entry big-number card (chart_boards.ArtistChartEntry + artist_chart_card_inputs + build_number_card, owner steer “single row turns to card”), so a chart is never dropped for being thin. XMentions._unit_for_chart builds whichever fits, and both ship through one path (_ship_unit), which resolves the card’s COVER ART at ship time via the same shared ladder the desk uses (XMentions._art_bytes → music_markets.resolve_art_url → catalog cover / artist portrait, with entity_image.resolve_desk_image as the never-blank fallback) — a board resolves by ARTIST, a single card by title+artist; a miss renders on the branded floor, never blank. Up to 3 boards per mention (owner steer “a few — cap at 3”), STRONGEST CHART FIRST (ranked by the act’s best position), so a prolific act never fans out into a card storm; a board needs 2+ of the act’s titles on a chart, so a thin act yields fewer boards or none (prefer absent). The BOARDS lane runs for ANY charting act; the PREDICTION lane (the artist/song’s live Kalshi read, music_markets.reading_for_subject → number_card_hero – a live first-week market heroes the amber forecast, a MEASURED read (a partial-resolve annual streams market, e.g. Bruno Mars 2026 with the 12.5B bracket finalized) heroes the green Luminate count so far and drops the pace line from its blob, the desk’s rule; the lane used to hero the forecast unconditionally, which shipped a “~17.7B market projection” card under a “cleared 12.5B so far” take, 2026-09-09) is WATCHED-ONLY, since a music market exists only for the big releases. Each composes with compose_market_drop, self-gates at the shared 0.6 floor (music_desk_score), delivers, and crossposts (maybe_crosspost, surface x_mentions). The REPLY lane + parent context (owner steer 2026-09-08, the mention-reply check). Both lanes above post STANDALONE tweets, so the person who asked never saw the answer in-thread: three real questions under one Miley Cyrus Kalshi post (“What about week 1 for Miley?”, “Is CRJ gaining ground since your previous post?”, “what about the debut?”) got one standalone Party In The U.S.A. card and two no_artist scans. Two changes. (1) When the mention replies to HER OWN post (x_mentions.reply_parent_id – only her post, never a third party’s thread), the cog fetches the parent once (FixTweet, keyless, fail-open) and, when the tweet alone names no act AND asks a question (x_mentions.asks_question: a “?”, a leading question word, or a request-SHAPED phrase such as “update on the debut” / “give me the week one numbers” – never a bare noun, so “sales are insane” and “Temazo!!” alike stay no_artist; the parent is fetched only at that point, so a reaction costs no FixTweet round), _resolve_subject re-runs on the parent’s text under the SAME watched/literal rules; that second paid Haiku read claims a second scan allowance, so X_MENTIONS_SCAN_DAILY_CAP bounds model calls, not tweets. The scan hit folds via=parent, and a parent-derived act feeds ONLY the reply lane below – the automated boards/prediction lanes run solely for an act the mention itself named, so the first reaction under a Miley post can never publish three standalone Miley cards (the PR #3081 Codex review’s P1). (2) A REPLY lane (XMentions._reply_lane) drafts the answer she would post UNDER the mention and queues it in #bot-logs for the OWNER to post by hand – the reply is manual for now, the standalone posts stay automated. The facts block (x_mentions.format_mention_facts) is her parent post, then the live Kalshi reads, then the act’s strongest chart blobs (the per-chart units are read ONCE in _maybe_act and shared with the boards lane). The market match is readings_for_subject(max_lead_days=None): the pre-release window LIFTED (the desk already priced that week in public and a human posts the answer; the AUTOMATED prediction lane keeps the 2-day window), the metric read off the question (luminate.metric_from_text, “pure sales” -> that ladder) but applied ONLY when a live ladder for the release carries it (“first week” parses as the First-Week Units label and “sales” as a bare Sales label, which no ladder is titled – a hard filter on those blanked the market fact for the commonest ask), and with no usable metric EVERY live ladder for the release rides, each named by its metric – where the automated unique-hit read would return nothing for a release carrying both a Pure Sales and an Album-Equivalent Units ladder. readings_for_subject returns ONE release’s ladders: two releases at the same tier (an artist-only lookup while two of the act’s albums are live) is [] rather than both mixed into one block, and the chosen release’s SIBLING ladders join on release_pair_key (the event-ticker code), not on subject text – Kalshi titles the two sales series inconsistently, so a sibling can sit at another subject tier. The parent post is reply context only when it is ABOUT the resolved act (the act came off it, or it names the act): a Drake question under her Miley post carries no Miley number into the facts block. The market lines come BEFORE the chart rows and are never cut (a format_reading line ends with its forecast, so a sliced line is a malformed fact – a line over budget is dropped whole); the chart rows take only the room left. No facts -> no draft. The compose is its own prompt, claude.draft_mention_reply (NOT draft_x_reply: that one is ADD-don’t-echo under a third party’s tweet with the numbers fenced off; here the number IS the answer, quoted as written from the facts block, EMPTY otherwise). Delivery is the reply-draft queue’s two-message shape (x_reply_draft.format_draft_header + the mention’s fixup link, then the bare draft, no ping), the draft text cached under x_reply_text for the nightly owner-edit join. bot_logs.post now RETURNS whether it sent (every older caller ignores it): #bot-logs is this lane’s only destination and the tweet is already claimed, so a silently dropped post (no channel, a Discord 403) is send_failed, never shipped; the bare draft is sent only after the header landed (a draft with no target above it could be pasted under the wrong tweet); and the x_reply_text entry is written only after both messages sent – a draft the owner never saw must not be scored by the nightly owner-edit join as the source of a reply they wrote themselves. X allows the account to reply to a post that mentions it (the #1732 403 is for posts where she is not mentioned) and x_poster.post_tweet already takes reply_to_tweet_id, so the automated in-thread reply is one wiring step away when the owner wants it. Emits x_mentions kind=reply. Gates: SINGLETON (one @tootsiesbar) → master guild only; the x_mentions experiment (OFF default — ships DARK like x_crosspost/x_game, since it posts NEW content to the public timeline; STAGING auditions the cards in #bot-logs, PRODUCTION posts to the music channels — both lanes → music_market_channels — + crossposts); master kill switch; a durable per-tweet dedup; the BOARDS lane’s WEEKLY cooldown on the unit’s dedup_id (owner steer “per chart / per artist”, keyed on the ISO week) — a board keys per artist:chart (a later mention that week can surface a DIFFERENT chart but never repeats one), a single card keys per ROW (row:chart:song_key) so a collaboration is not double-posted under two different mentioners’ names; the prediction lane’s per-(artist,lane) cooldown; and cross-surface topic dedup (it writes/reads post_dedup_history under x_mentions, in the music-desk set). X crossposting is OPT-IN (x_crosspost.OPT_IN_SURFACES), so an unset picker never tweets. Fully fail-open: any lane miss drops that lane, never the webhook ack. Instrumented via the x_mentions event (see docs/OBSERVABILITY.md). The MANUAL companion is the artist-sweep skill (.claude/skills/artist-sweep/, scripts/artist_sweep.py): a one-off that renders EVERY per-chart board for one artist (across the whole Billboard family + the platform charts) for the owner to post themselves — the retroactive tool for a mention the live lane never fired on (the Ellie Goulding case). It never posts.catalog_art.py, the ONE home for “the real cover art for this catalog title” — a song/album → its Apple Music cover, a film/show → its TMDB poster. resolve_music_row(title, artist) searches iTunes and _pick_artist_row decides which row answers; a different INSTALLMENT of a numbered series is a positive mismatch (apple_music.sequence_mismatch, #slime-language-3): a release card for the hours-old “Slime Language 3” shipped the “Slime Season 2” cover and linked “Slime Language 2”, because the installment number is one character in a long shared string and both fuzzy tests swamp it (the sequel scores 0.938, “Slime Season 2” lands exactly on the 0.6 floor, and “Slime Language” is a clean substring). The album was hours old and not yet in iTunes’ search index, so the right answer was NO row; the number is now compared on its own before either fuzzy test runs, and the card falls to the artist photo instead of a sibling’s cover. utils.deezer.album_title_matches reuses the SAME rule – its 60% token overlap read “Slime Language 3” vs “Slime Language” as a match and dated an hours-old record 2018-08-17; resolve_catalog_art takes that row’s cover bytes and cogs.music_news._artwork_and_link takes its cover AND its “listen” link off the SAME row, so the card’s picture and its link can never disagree. A DEEZER album cover is the SECOND cover source (#album-cover-fallback): a fresh release often lands on Deezer before Apple’s search API indexes it (Channel Tres’ “Enigma POV” had a Deezer cover while iTunes returned nothing, so the release card shipped a bare panel), so when the iTunes row misses resolve_catalog_art falls to deezer.album_cover(title, artist) — artist-verified inside album_cover (album_title_matches + _artist_overlaps), so a same-title album by another act is refused, never a wrong cover. The Deezer rung is what SAVES a card the iTunes index has not reached yet (#2683) — it is the whole reason the rung exists, and on the Slime Language 3 card it fired and still returned nothing, so the card had no cover at all. Two causes, both fixed: album_cover searched ONE term (title artist), and appending the artist ranked the real album out of Deezer’s results, so the bare title now runs as a second term; and a COLLECTIVE bills its record under the collective’s name (“Young Stoner Life”), so when the one top-level credit misses, the album detail’s contributors[] — the release’s own billed acts — decides. Net effect on the reported card: the RIGHT cover, and still no listen link, because the link rides the iTunes row and iTunes has no Slime Language 3 row to give. A row from a DIFFERENT ERA is refused on a RELEASE story (#3171). _rank_rows prefers the clean ORIGINAL over a live / remix edition when the requested title does not name one, which is right for a cert or chart story and wrong for a drop: Linkin Park’s “Faint [Live in São Paulo]” came out 2026-09-11 off the UNSHATTER film soundtrack, the wire named only “Faint”, and the card baked the 2003 Meteora cover under a NEW RELEASE kicker (owner report, 2026-09-11). The same shape shipped the 2006 “IRREPLACEABLE” cover on five B’Day 20th-anniversary-edition cards, the 2009 “Fear” cover on “Fear of Missing Out”, and the 2025 “NEVER ENOUGH” cover on “NEVER ENOUGH: VERSIONS”. music_news._row_is_a_different_record refuses a row older than _RELEASE_ROW_MAX_AGE_DAYS (365 days) and the card takes the act’s PHOTO instead — prefer absent over invented, the same call the tour lane makes. Four smaller fixes were dry-run against the live catalog and all four fail, which is why the guard sits here and not upstream: a classify-prompt rule has nothing to keep (the Perplexity source says only “a live performance of ‘Faint’”; the classifier keeps a qualifier 5/5 when the input HAS one); falling through to _fallback_art(subject) returns the SAME Meteora cover because the unified resolver kinds “Faint - Linkin Park” as a music_release and runs its own catalog search; a claim-grounded retry is worse (“Faint UNSHATTER” returns the 2025 From Zero track); and preferring the newest row cannot work because an iTunes song search for “Linkin Park Faint” returns only 2003 rows — the right row is not in the candidate set at all. So the portrait resolves off the ARTIST CREDIT ALONE, the same reason the LIVE lane passes the credit by itself. Three carve-outs keep it narrow: only a NAMED “Title - Artist” release is tested (a bare artist-level subject asks for a representative cover on purpose — Randy Travis announcing his first album in 18 years correctly took a 1987 cover); a claim stating an explicit backward interval keeps the row, because a THROWBACK’s old cover IS correct (“Music Box released 33 years ago today” wants the 1993 sleeve — retrospective.states_past_interval, which is unanchored unlike is_retrospective because here the two mistakes cost the same one degraded card); and an anniversary EDITION states no such interval, so it is correctly caught. The whole catalog path is skipped, Deezer rung included, because deezer.album_cover searches the same bare title and answers with the same old album. Measured over 30 days before shipping: 19 of 71 catalog-matched release cards fire it, about 13 of them plainly wrong art; release_art_stale makes the rate AND its cost queryable (a source == 'none' row shipped a bare card). _artwork_and_link runs the same Deezer rung directly on its already-split (title, artist) — but ONLY for a real “Title - Artist” release, since the unified fallback re-kinds the terse subject and returns [] for it. The listen link stays iTunes-only (Deezer answers bytes, not a link). The music_news RELEASE card also drops the figure outright (_build_card): a release is an announcement, so the renderer heroes the TITLE, but the classifier emits an unreliable grab-bag as the figure (a noun “album”, a date, a year, an ordinal) that would hero over the release name — no digit-shape test separates a real count from a date cleanly, so a release drops its figure and the album/tour NAME is the headline (the count a tour states rides the claim caption). A RECORD worded as a superlative heroes the count the RESEARCH confirms, never the word (#lame-most). The newsroom carded Cardi B’s Diamond-singles record on 2026-08-30 and heroed the word “most” over the caption “most RIAA diamond-certified songs by a female rapper in history”. Owner report: “most thats lame use the actual number”. The wire post (@mymixtapez, 00:48Z) stated NO count — “Cardi B surpasses Nicki Minaj to become the female rapper with the most RIAA diamond-certified songs in history” — and the classifier may only quote the post, so no prompt on it can find a number that is not there (reproduced 3/3 on the real post text: the old prompt answers most every time). The RESEARCH had the count: the verify judge’s own note that night read “with 4 diamond singles”. So verify_music_claim now returns a fourth value, figure — the short number the research states behind the claim — and the desk heroes it. The hero carries its UNIT (owner steer: “should say 4 Diamond certified… or something”): a bare count reads as nothing in the big slot, so a COUNT takes the NOUN for the thing it counts (“4 Diamond singles”, “402 Hot 100 entries”, “87 weeks at No. 1”) while a figure whose own format already says what it is stays as it is (“8B”, “#1”, “$141.8M”, “7x Platinum”). The unit is a NOUN, never a description: the first pass produced “4 Diamond certified”, which the owner read back as “sounds wierd” — “certified” is a past participle, so the phrase has no head noun and the 4 counts nothing. Both prompts now say the noun rule outright and name the bad form. _parse_verify_claim caps it at 28 characters, trimmed on a WORD boundary (_trim_words) so a long answer never loses half the unit it exists to show, and the renderer auto-shrinks and then ellipsizes on top of that. The researched figure must also CARRY A DIGIT: figure_is_rank_word rejects only a pure ranking phrase, so without the digit test a wordy judge answer (“Diamond certified singles”) would trade one wordless hero for another. That test applies to the research fallback ONLY — a named state the WIRE stated (“Diamond”) is a real figure and still heroes. The final hero is settled ONCE, in card_figure, and both the story meta and the music_news_verified event read it, so a live reading that outranks the wire (a “top 10” claim settled at “#8”) cannot leave the event reporting a hero the card never drew. Two kinds take NO figure at all and skip the fallback entirely: a no_chart story says a release MISSED a chart, so there is no charting number by definition and its research is full of digits that are not one (the chart’s own name, a year); and a release is an announcement whose NAME heroes, so clearing it here keeps the event honest about what the card draws (the _build_card clear stays as the renderer-side backstop). No extra API call: the judge already reads the research, so the count rides the verdict it already returns, grounded in the same research the take is grounded in. Three rules keep it honest. The wire’s own figure always WINS when it is a COUNT (the research figure only fills a gap, so a normal card is unchanged); the one exception is a THRESHOLD the wire states (“surpasses 100 career entries”), which is a rung the count passed and yields to the verified research’s larger count for the same unit (figure_is_passed_threshold + count_past_threshold, the Rod Wave card of 2026-09-09, described with the other hero guards above). It is used only on a VERIFIED verdict, and the judge returns '' on a false one, so an unconfirmed count can never hero. And with neither, the figure goes EMPTY and the card falls to headline mode (the artist title heroes over the art) — prefer absent over invented. classify_music_news also now states that a figure is ALWAYS a number and never a ranking word, and music_news.figure_is_rank_word is the deterministic backstop under both. The guard is ONE rule: a ranking word sinks the figure unless a NUMBER follows it immediately. That separates every case — “top 10” and “top 5 debut” keep their rank word because it introduces a real number; “most”, “all-time best”, “most RIAA Diamond songs” and “most Hot 100 entries” do not. Two earlier passes each got one case wrong, both caught in review: the first demanded that EVERY word rank (so “most RIAA Diamond songs” slipped through), the second took any DIGIT as proof of a real figure (so “most Hot 100 entries” slipped through on the CHART’s own number). A flat “reject digits in a chart name” rule would have killed the real “402 Hot 100 entries” with it; reading the word AFTER the rank word is what tells the two apart. What survives is a figure with no stranded ranking word: a count with its unit, a self-describing format (“8B”, “#1”), or a named state (“Diamond”, “OUT”). The cost is a named state that CONTAINS a ranking word — a Grammy “Record of the Year” hero would be dropped — and that is the right way to be wrong: a card heroing nothing degrades one post, a card heroing “most” is the bug. It DROPS the figure instead of digging a count out of the claim prose: a claim names other numbers (“did not enter the Billboard Hot 100”), so a scan would hero a chart name as a count, and a wrong number is worse than none. Live dry run of the whole path (2026-08-30, real Perplexity research): the real Cardi B wire post runs classifier -> research -> judge -> hero 4 Diamond singles over the caption “most RIAA Diamond-certified songs by a female rapper in history”; “Drake, the most Billboard Hot 100 entries in history” heroes 402 Hot 100 entries; “Taylor Swift, the most weeks at No. 1 by a woman” heroes 87 weeks at No. 1; and “Rihanna, the most RIAA diamond-certified singles” comes back unverified (the research reports 7, and “most by a female artist”, not “any artist”), so the card heroes nothing. A release that announces a tour gets its OWN card copy (_tour_card_copy + _tour_kicker, owner report on the “Long Way Home Tour - Stella Lefty” card): a tour is not a record drop, so the “MUSIC · RELEASE” kicker relabels to tour · announcement (or tour · update when the claim adds/extends/reschedules dates), the TOUR NAME heroes on its own instead of the run-on “Tour - Artist”, the ARTIST rides the card’s BYLINE over that headline, and a purple tag tells a tour apart from an album at a glance (via render_number_card’s tag_accent override, which never moves the figure/stamp off brand green). Every story KIND now names itself in that tag colour, not just the tour (_tag_accent + _KIND_ACCENT_KEYS, #2798): the tag is the card’s one colour anchor, and with only the tour keyed, a week of cards read as one green wall with different words on it. release green / cert gold / chart blue / milestone cyan / first_week pink / touring coral / novelty mint, each LOOKED UP from chart_cards.ACCENTS rather than copied – those values were measured for contrast against real cover art, _equal_tag draws near-black on the block, and keying by the palette’s own name means a later re-measure reaches these tags for free. It is the same rule the BOARD cards already state: “the genre family gets its own colour each, so two genre boards in a day are obviously about different charts”. Two rules constrain the map. AMBER IS RESERVED: the renderer paints a projected=True figure amber to warn that a number is HYPOTHETICAL and lets that amber outrank any tag_accent, so a SETTLED figure must never wear a near-amber tag beside it – touring takes the coral, NOT the rap amber it was first given, and market (always projected here) maps to None and keeps the amber it already has. And no_chart STAYS NEUTRAL (a near-white block): it is the one lane that states a negative about a real act, reported straight (“report facts, it doesn’t have to be negative or hate”), so a hue would editorialise the miss. An unmapped kind keeps the default green rather than borrowing another lane’s meaning. EVERY figureless announcement now carries that BYLINE, not only a tour (_announcement_card_copy, owner report “the tour name - artist is pretty bland or album name - artist is pretty bland”): the subject arrives as one “Title - Artist” string and shipped whole it read as one long name with a dash in it, at one weight, with the dash wrapping onto its own line (“Bass Persuades - Miley Cyrus”, “Hidden in Pieces Live at the Royal Albert Hall - Yeah Yeah Yeahs”). The act splits OFF the headline and render_number_card’s new byline= sets it on its OWN LINE UNDER the title, so an announcement reads TAG -> WORK -> ACT -> caption – the hierarchy a number gives a stat card. The work then gets the whole wrap, so it sets LARGER (measured: the Yeah Yeah Yeahs title goes from 72px to 96px). The placement and the styling are BOTH owner calls, and the first pass got both wrong: it shipped the act ABOVE the headline as a tracked uppercase ACCENT line, and the owner read the result back as “those are ugly too, also put artist name below”. So the byline moved below the headline, dropped the tracking and the uppercasing, and took the TITLE’s own colour at 40px. Three lessons hold it there: an accent byline goes muddy over a pale photo (the tour purple against a near-white field is about 1.5:1); a third coloured element under the accent tag and beside the accent stamp reads busy; and 40px against a 96-128px headline separates the two by SIZE, which needs no colour at all. It is still drawn into the SAME blurred dark plate the headline uses over art – at 40px the hard 3px shadow alone left it muddy on a busy or pale frame. It applies in HEADLINE MODE only – a stat card’s title is a small credit line under the number with no room for a second one, so byline is a no-op there. The headline names the act ONCE (music_news.strip_artist_prefix, owner report 2026-09-02): a concert film is billed under the act’s own name, and the classifier appends the act again as the subject’s artist part, so the Katy Perry card read “Katy Perry: The Lifetimes Tour – Live From Paris” over a byline that said “Katy Perry”. _announcement_card_copy now drops a leading “
Five guards decide the row, and each one closes a shipped bug. (1) The row must CREDIT the artist (_artist_matches), else an unreleased album fuzzy-matches a stranger’s record — Gracie Abrams’ “Daughter From Hell” once returned a metal demon cover. (2) A music lookup with NO artist is refused outright, because the title-only top hit is how a Dua Lipa take shipped Jack Harlow’s album cover (his SONG is called “Dua Lipa”). (3) The row must match the TITLE, by its own name or by its ALBUM, so a track recovers a heavily-styled album iTunes cannot rank (“NUEVAYoL” carries “DeBÍ TiRAR MáS FOToS”). (4) _rank_rows then orders the survivors, because crediting the artist and matching the title is still not enough — the art a row carries is its COLLECTION’s cover, not the artist’s, and a remix is not the recording the story is about. (5) On the ARTIST-ONLY path the artist must LEAD the release whose cover the row carries (_cover_credits) — see the guest-spot bug below.
The bug that added guard 5 (#not-drake). An ARTIST-LEVEL story names no release (music_news._artwork_and_link passes title == artist for “Drake”, a career or market number), so guard 3 does not apply and there is no title left to verify. That left “credits the artist” as the only test, and a GUEST SPOT passes it while carrying somebody else’s artwork. The iTunes song search for “Drake” returns “Ahí” by “KAROL G & Drake” FIRST; its collectionArtistName is “KAROL G”, so the cover is her album. @tootsiesbar shipped a Drake prediction-market card with her album cover baked in and a “listen” link into her album (owner report: “this isn’t drake”). _cover_credits asks the question guard 4 already answers for the ROW’s artist, but about the REQUESTED one, in two reads. WHICH credit owns the cover: the collection credit, falling back to the row’s own credit when iTunes left it empty. Then WHERE the artist sits in it: they must be the LEAD act (apple_music._lead_credit splits a joint billing on its first separator), not a second-billed one — owner steer: “keep going till you find the art of the artist as a lead artist”. Crediting alone is too weak twice over: “KAROL G & Drake” credits Drake, and so does “DJ Snake & Future” for a Future card, which the live search returned before the lead rule landed. So the walk keeps going to a row he headlines (“In My Feelings” / “Scorpion”), and no such row means None — the caller then resolves a real PHOTO of him instead.
Splitting a band’s own name is harmless, and that is not luck — it is why the split result goes through _artist_matches, whose substring test runs BOTH ways. “Earth, Wind & Fire” splits to earth, which is contained in the requested earth wind fire, so the band still matches itself; the same holds for “Simon & Garfunkel”, “Tyler, The Creator”, “Florence + the Machine” and “Above & Beyond”. What the split rejects is a requested artist billed SECOND, where the lead matches nothing. Live dry run over 40 artists (the separator-named bands included): 39 resolve, each now led by the requested act, and the one miss (“Hall & Oates”, which iTunes bills “Daryl Hall & John Oates”) already missed before the guard. The guard is a strict tightening of one branch — it can only drop a row whose cover provably belongs to another act, so a joint release the artist LEADS (“Drake & Future”) and every title-specific lookup are unchanged.
_rank_rows sorts on two keys, version first, then collection. The CLEAN ORIGINAL outranks a remix / sped-up / live edition (apple_music._is_clean_original, the same preference apple_music._track_url applies to its own hits), unless the requested title names an alt version itself (apple_music.targets_alt_version — then the story IS about that edition). The artist’s OWN collection then outranks one credited to somebody else (_own_collection): iTunes leaves collectionArtistName empty on an artist’s own album or single and fills it only when the collection carries a different credit, so a filled credit that does not name the artist is an exact signal that the cover belongs to a third party. Both are PREFERENCES, not filters — the same rule apple_music._is_compilation states, so a track that only lives on a compilation keeps its cover.
The bug that added the ranking (#wrong-missing-images). @tootsiesbar posted a Charli XCX certification card carrying a cartoon bear. “Vroom Vroom (DJ Fingerblast Remix) [Mixed]” is a real Charli XCX track, so guards 1 and 3 both passed it, but the track sits on Alex Chapman’s compilation “Songs To Have Gay Sex To (DJ Mix)” — the card baked THAT cover and the listen link pointed into the mix. It loses on both ranking keys to her own “Vroom Vroom - EP”. Ordering the version key FIRST is load-bearing: “Speed Drive” exists only on the various-artists “Barbie The Album”, which is foreign-collection but IS the song’s real release, and it must still win over a remix single of the same title. A pure collection filter got that case wrong in a live dry run.
subject_image.py, the open-web photo finder (find_subject_image) — Grok + Perplexity candidates, interleaved, downloaded, then gated by a TWO-STAGE vision gate (fail-CLOSED at every step): the Haiku group pick RANKS (all candidates, one cheap call), then a SONNET single-image confirm_subject_image on the winner DECIDES; a confirm-rejected winner is dropped and the pick re-runs on the rest (a real photo may have lost the group rank to a flashy graphic), two rejects → no image. The two stages exist because they fail differently, measured separately on a saved corpus of real X candidates (#find-subject-image): shown N candidates and told to pick the best, Haiku picks the least-bad even when EVERY candidate is junk — that group dynamic is most of how the Grok leg’s revival shipped 42% junk (screenshots, a FanDuel betslip, stat graphics, a meme carrying a homophobic slur) and was REVERTED within the hour (#1894 → #1897). And even single-image, Haiku accepted a stylized fan graphic, a photo of a trading card in a slab, and a real photo the subject wasn’t in — 3/3 votes each — while Sonnet rejects all three (8/9) and keeps every real photo (12/12). The MODEL was the lever, not the prompt: the reject list already named those classes and Haiku accepted them anyway. The two stages share ONE prompt builder (_subject_pick_prompt) so they can’t drift; the reject list includes the merchandise/collectible class (a trading card in a slab, a vinyl being held, a shirt without the person — a photo of an OBJECT depicting the subject is not a photo of the subject), added measured: it took the card class from 1/3-flaky to 3/3 reject with all controls intact, and the source-photo eval stays 9/9 with its golden. The GROK LEG IS ALIVE ONLY BECAUSE OF THE CONFIRM — its query asks for POSTS ("recent posts with pictures of X"; the literal "a photo of X" reads to Grok as an image-generation request, it answers “I can’t display images” and never searches, returning zero 100% of the time — and plain “posts about X” surfaces photo-less posts, so the “with pictures” clause is load-bearing). If the confirm is ever removed, the leg goes back to dead (_grok_candidates’ docstring says so). Acceptance was judged on SHIPPED IMAGES, never a success counter (a counter cannot tell a photo of Ohtani from a slur meme, which is exactly how the #1894 regression shipped): 18/20 builds shipped with ZERO hard junk on the junk-prone slate, and the confirm loop’s cost is one/two Sonnet vision calls per build. The Perplexity leg is separately flaky UPSTREAM: return_images answers a valid code-built payload with HTTP 400 “invalid request” ~52% of the time in prod (115/223 calls, 108 of them 400) while every PROSE purpose on the same key fails ZERO times across 6,000+ calls. Not a malformed request, not the account, not the model (sonar vs sonar-pro measured identical), not the recency filter, and not our own burst (~16 calls/day). It IS partly time-sensitive: back-to-back 5/8 fail, 15s-spaced 2/8. The fix is ONE SLOW retry (_IMAGE_RETRY_DELAY_SECONDS=10) — measured, a retry at ~10s recovers 4/10 real 400s (end-to-end 4/14 → 8/14) while 0.5-3.0s recovers 0/2 and a further +30s attempt recovers 0/10, so there is deliberately no backoff ladder. The pick’s PARSE is fail-safe and its token budget must cover a narrating judge (#1935, the Skubal wrong-photo): the Dodgers-acquire-Skubal wire post shipped with a photo of a different person because of a PARSER accident, not a model judgment — Haiku narrated instead of answering (“Let me analyze each photo: Photo 1: A headshot…”), the old 60-token budget truncated it before any {"pick": N} existed, and _parse_gif_pick’s fallback scraped the first digit out of the prose, turning “Photo 1” into pick=1 (the Sonnet confirm then trusted the poisoned caption — Seattle U’s own site serves that stranger’s photo as Skubal’s roster headshot, so no vision gate could save it; 4 of the 5 candidates were genuinely him and an intact pick had every chance). Two invariants hold from that: the bare-integer fallback parses ONLY a reply that IS a bare integer (prose/truncation → None, fail-safe — regression-tested on the incident’s literal reply), and every {"pick": N} judge runs _PICK_MAX_TOKENS (300, matching the curator picks; 60 was sized for the bare JSON and starved any reply that narrated first). The ops-monitor’s scorer_truncated finding watches the *_pick/*_confirm purposes too, since a truncating pick judge now loses picks instead of mis-picking. The source-photo rung records whether we CHOSE (2026-09-13, the “Buddy” drop). gated_source_photo returns source_photo when the wire post carried exactly ONE fetchable photo and source_photo_pick when it carried several and the gate picked one; is_source_photo is the test every caller uses, since both are still the wire’s own picture. The music newsroom’s first-party FEED image takes its own feed_photo rung: it is downloaded with no vision check, so it must not share a label whose exemption was earned by the checked path. The split exists for the DELIVERY coherence gate, which trusts the unpicked rung and keeps full authority over the pick (x_crosspost._IDENTITY_VERIFIED_ART) — the same unique-vs-pick shape as deezer_artist_exact/deezer_artist and youtube_chart/youtube_chart_pick. It separates all three of the rung’s confirmed drops in 30 days (211 checks): the two single-photo drops were FALSE (a costumed orange unicorn — the film’s own title character — dropped as “not the dog from ‘Buddy’”, and Mastantuono celebrating in the Fiorentina kit dropped as “he plays for Real Madrid”, which API-Sports contradicts for 2026), and the two-photo drop was a TRUE catch (the wire attached Billy Barratt AND a 2002-film still of Christian Coulson, and pick_subject_image took the reference). With one photo there is no selection step to get wrong; with several the mispick is ours, which is exactly what the gate should still police. Note what the Barratt case also says about the PICKER: the correct photo was on the same post and the picker passed it over, so the drop cost that post its art too. The OPEN-WEB tail now closes on TWO tests, not identity alone (#riaa-card-headline). A music-news card reading “Sweet Boy - Malcolm Todd is now Platinum, per RIAA” shipped a screenshot of a trendrod.com article PAGE whose headline announced a Manila tour date. The failure class is not the wrong person: it is a real photo of the RIGHT person wrapped in someone else’s layout — a tour admat, a single-show poster, a page screenshot — whose burned-in text states a story the post does not tell. The cause was one sentence. The tail used to end “only pick a number when you can clearly SEE the actual subject in it”, which is a STRICT SUBSET of the reject list above it, so it read as the decision rule and cancelled the list; the picture passes an identity test, so it shipped. The source-photo tail already carried the matching rejects-still-stand clause, ablated and load-bearing there, and the open-web tail did not. It now names the layouts, names the PUBLICATION FURNITURE that identifies a page (masthead, nav bar, headline set in type, byline, date line, comment count), and names the one layout that is NOT a reject — a RELEASE COVER, which is the work itself and which this rung legitimately returns. Ablated on 30 REAL images, three arms, n=3 on the live Sonnet confirm: 20 photos and covers from the live search, 7 designed layouts (3 tour admats/posters plus 4 rendered article pages), 3 controls. The old wording accepted 20/21 designed-layout builds; the shipped wording accepts 0/21 and still keeps 60/60 of the photos and covers. The page-furniture sentence is the load-bearing half: an intermediate draft that named the layouts but not the furniture cleared the admats and still took the article pages 12/21, so naming the class alone was not enough. The release-cover exception stays NARROW, and that was measured. Six subject kinds reach this rung — music_release, musician, film, tv, video_game, and place/thing/org all fall through _resolve_primary on a deterministic miss — so a review asked the exception to name series and game art as well (Codex, #3295). A second ablation on 9 real TMDB posters and Steam store artworks says no, twice over: the narrow wording already keeps all nine 3/3, so the ask fixes nothing, and the wide wording (“the work’s own artwork … a game’s box or store art”) BROKE the merchandise reject — measured at n=5 with the candidate’s real source context, a vinyl-box shot that rejects 0/5 under the narrow wording was taken 3/5 under the wide one, because a boxed record reads as the work’s own artwork. The merchandise class is an earlier fix in this same list, so the narrow list stands and the poster kinds are covered by measurement rather than by naming. scripts/dryrun_designed_graphic_gate.py re-runs the SHIPPED arm over that whole corpus plus the artworks and the controls; it does NOT reproduce the arm comparison, which was a one-off harness. The tour lane loses nothing measurable — over 30 days desk_art records ZERO music_news tour builds resolved by this rung, the catalog and Deezer rungs answer every one.market_art.py, Polymarket’s own curated leg portraits, as the LAST-RESORT image source for cards — rung 4b of the market_subject_image ladder, under the vision search and above the ungated native icon. What it’s actually for is DETERMINISM, not coverage — and that distinction is the whole finding (#poly-leg-art): the pitch was “it covers niche named people the catalogs miss”, and a first eval measuring the ladder ONCE per subject demolished that (0 of 14 sampled subjects would have hit the branded floor — the existing rungs placed all 14), while as a HIGH rung it displaced a working found result. But re-running the same ladder 3× per subject showed the rung above it is a coin flip: find_subject_image is non-deterministic, and for subjects that do have usable leg art it whiffed on 9 of 18 builds (50%) — the same subject resolving ['none','none','found']. So the card was going out bare half the time, and the leg portrait (deterministic, exact-name-matched, size-gated) fills exactly those. The n=1 eval hid it: this is the docs/PROMPT_OPTIMIZATION.md “n≈5 not n=2, report the spread” lesson applying to a resolver rather than a prompt. Demoting it is what makes it safe — down there it can only ADD art to a card headed for the floor, never take one away, and the Gamma search only spends on that same failure path instead of on every build past the catalogs. Polymarket, NOT Kalshi: Kalshi exposes no art at all (verified against 200 open events pulled with nested markets — zero image URLs, no image field on the event/market/series shape or in its undocumented v1 API; the thumbnails on kalshi.com are frontend-only), so this borrows Polymarket’s art for ANY market card including a Kalshi-sourced one. Three guards, each closing a paid-for failure: (1) distinct from the event icon (distinct_leg_images) — every leg has an image but on most markets it’s just the EVENT icon repeated (one League of Legends logo across all 33 legs, one crude-oil icon per price bucket; measured on the top 100 open events by volume, only 4 carried genuinely distinct per-leg art, all 4 named-entity fields), so without this the “backup” would mostly re-serve the generic native-image rung; (2) exact name match (find_leg_image, accent/punctuation/case folded) — a loose match is how the wrong person’s face reaches a card, the axis this resolver documents as its recurring bug class, so a near-miss falls through instead; (3) minimum resolution (big_enough, MIN_COVER_PX=400 on the SHORT edge) — leg art runs 183×275 to 3500×1741 in practice and the portrait cover is a full-bleed 1800×2400, so a 200px source renders visibly soft, trading a clean branded card for a blurry one; live-verified that a 400×400 source still renders sharp while the 199×254 Ethiopia thumbnails are correctly refused. The size gate is also WHY the rung fires rarely, and the tension is structural: a subject niche enough that TMDB/Deezer miss it tends to have TINY leg art (the Ethiopian field runs 183-200px) which the gate then refuses, while the legs carrying big art are the famous politicians TMDB already has — so the win is the flaky-vision backstop above, not raw coverage. One keyless Gamma search per lookup, wrapped in the shared retry_http — Gamma throttles under a burst and a throttled call is indistinguishable from a genuine no-match (both empty), which silently turned a 429 into “no art” until the retry landed. Pure matching + the size test unit-tested; live-verified against the real Brazil/Ethiopia/2028 fields.market_subject_image.py, the card BACKGROUND-image resolver for every market/betting/desk card (resolve_market_image → (png, source)), and the home of the standing image rule: a DETERMINISTIC source per kind FIRST, an open-web vision photo search LAST (owner steer, applies to every desk + genre from now on — a wrong/misleading image is worse than a plain one). The ladder, best-first: (1) structured catalog_hint {title, kind, artist} → utils.catalog_art.resolve_catalog_art (music→iTunes/Deezer cover by ARTIST, movie/tv→TMDB poster); (1b) a YOUTUBE-CHART market’s leg → that video’s own THUMBNAIL, via utils.kworb.find_video_entry + video_thumbnail_urls (see the paragraph below); (2) structured game_teams {home, away, sport} → _resolve_matchup_image → API-Sports team LOGOS composited by utils.market_chart.render_matchup_card into a broadcast half/half card (each half a soft blurred wash of that team’s OWN logo, real colors, no guessed hue; logos via utils.api_sports.team_logo); (3) the unified classifier — with no structured hint, hand image_subject the combined extraction text (question + leading answer + event/ticker context) and route its result through entity_image._deterministic_art (person→TMDB, musician→Deezer, music_release→catalog, film/tv→TMDB+OMDb, team→logo); (4) vision (utils.subject_image.find_subject_image, Perplexity+Grok, vision-gated by pick_subject_image which rejects a map/infographic/boxscore/odds-graphic/wrong-artist) — on the CLEAN name; (4b) a PREDICTION MARKET’s own curated LEG portrait (utils.market_art, see below — the LAST-resort deterministic backstop under the non-deterministic vision search); (5) native image; else the branded floor. The two failure axes to check on any card (the audit lessons): WRONG SOURCE (a sports game hitting vision instead of API-Sports — the Spanish GradaMas58 boxscore fix) and WRONG SUBJECT (a clean source fed a polluted query — the Charli “Music, Fashion, Film” Kalshi-category-as-album, stripped in utils.luminate._subject; the Big Brother contestant-as-show — the image_subjects classifier led with the SHOW (“Big Brother”) for a “who is evicted” market, so rung 3 resolved the show’s TMDB poster, which is host Julie Chen, not the leading houseguest; the X media gate caught it and dropped the card to a bare number. FIXED by the classifier rule that a who-wins/eliminated/evicted competition market leads with the CONTESTANT named in the “Leading answer” line, not the show/award/league — the show poster there is the host or a logo, so it goes second and never wins rung 3; cache namespace bumped to v10 to flush the host posters). The awarding-body-as-subject variant (v11): the SAME classifier led with the awarding ORG (“Emmy Awards”) for “Emmy Winner: Outstanding Drama Series” (leader “The Pitt”) — Haiku does not know the 2025 show, so it kinded the whole thing as the ceremony, rung 3 got no org logo, and the vision search on “Emmy Awards” cached a red-carpet PERSON photo (the media gate then dropped the card to a bare number). FIXED in the SAME rule, SCOPED to the category: a WORK category (Best Picture/Documentary, Outstanding Drama/Comedy Series, Album/Song/Record of the Year) names a WINNING WORK, so the classifier leads with that title kinded film/tv (EVEN when it does not recognize it — a poster needs only the name) or music_release (only when the text names the artist, else the item is skipped and the market floors — absent over invented); a PERSON category (Best/Lead/Supporting Actor, Actress, Director) still leads with the PERSON, so a lesser-known nominee’s name is not mis-read as a title (Codex review on #2604). The awarding body (the Emmy Awards, the Academy, the Recording Academy) goes second or is dropped. Dry-run n=4 (11 cases): [org: Emmy Awards]→[tv: The Pitt], work controls held (Oscar→film, Grammy→music_release, artist-less album→floor), and person controls held even for obscure nominees (Coralie Fargeat, Katherine LaNasa→person); cache namespace bumped to v11. The featured-leg collapse (v12): the v11 classifier fix was correct but never fired for the DTF St. Louis Emmy card, because the classifier never got the nominee. A leaderboard/binary snapshot can carry its ranked legs ONLY in chart_specs (the per-leg {label, pct} the card draws) while its own outcomes collapses to a single {event_title: pct} entry with no usable yes_label — so _featured_label picked the event title, and _extraction_text handed image_subject only the bare category “Emmy Winner: Outstanding Limited or Anthology Series”. The classifier then correctly returned [] (an award category names no imageable work), the card floored, and the DTF St. Louis cover shipped art-less (confirmed in Axiom: image_subject → [], market_subject source=none, and the media gate said the number card was fine — the art was never resolved, not dropped). FIXED in _featured_label: when its own pick is empty OR merely restates the market title, it reads the leading NAMED leg off chart_specs (else outcomes), skipping a generic label (“Tie”) or one that just repeats the title. Dry-run on the live Kalshi board: the collapsed snapshot went source=none → source=tmdb_poster (the real DTF St. Louis poster), and a healthy leaderboard is unchanged (still Beef); cache namespace bumped to v12. The THIRD failure axis, and the one the two above hide: NO RESOLVER CALL AT ALL (2026-08-31). The axes above both assume the ladder ran. A lane that never calls resolve_market_image produces the same screenshot — a bare number card on the branded floor — with a different cause and a different fix, and the tell is in Axiom: the post emits NO market_subject and NO market_image event, where a genuine ladder exhaustion emits market_subject source=none. Absent = the code path skips art; source=none = the ladder ran and found nothing. Two lanes shipped this way and BOTH were caught by the owner, not by monitoring, because card_art grades the attempts it sees and an un-attempted card is simply missing from the rate (see docs/OBSERVABILITY.md → card art): the RT Final card (market_alert._post_reconciliation, #final-numbers) and the X-mentions market-projection card (x_mentions._prediction_lane). Each had a SIBLING in its own file that already resolved art — the decided-ladder outcome lane (_post_outcome, wired at #2655) and the chart lane’s _mk_card — which is what marks these as sweep misses rather than decisions: the owner steer was “every single path has art”, and #2655 wired one path without asking which others built a card. Both now resolve. The RT lane stamps a DETERMINISTIC catalog_hint {film, movie} rather than letting the classifier re-derive the subject from the market question (we know it is a film, and an award/category question is exactly where that classifier has floored before — see the v11/v12 cases above); it resolves on rung 1, the TMDB poster, under surface="market_reconcile". The X-mentions lane partitions the market subject on the ' -- ' release/artist separator and uses the cog’s shared _art_bytes music ladder, so a bare-artist album ladder resolves a portrait and a release resolves an artist-verified cover. Both are fail-open to the branded floor. The standing check when adding ANY card-shipping lane: does it call an art resolver? A card builder that takes artwork_png and is never passed one is the whole bug. The betting surfaces (betting_board/betting_alert/betting_value) all stamp game_teams so a game card gets logos first; cinema_desk/music.py use catalog/TMDB with clean titles. The drop/alert PREDICTIONS lanes carry the same Kalshi games but never stamped game_teams, so a sports card shipped art-less (owner report “sports markets aren’t shipping with art”, a WNBA “Minnesota vs Las Vegas: Spread” number card on the branded floor). resolve_market_image now DERIVES game_teams for a Kalshi game when no caller stamped it (utils.sportsdata.names.kalshi_game_teams): the event TICKER names the league (KXWNBASPREAD → WNBA, the only signal that tells the Minnesota Lynx from the Timberwolves — both are “Minnesota” in the bare-city title), then each side of the A vs B title resolves to a FULL team name inside that league’s roster. Both sides must resolve UNIQUELY or it returns None and the card keeps the floor — a city that is two teams in its league (“Los Angeles” = Lakers + Clippers) is never guessed (prefer-absent). A derived game is is_game too, so it follows the logos-or-the-branded-floor discipline (no open-web/native fallback). Only US team leagues have rosters (NBA/WNBA/MLB/NHL/NFL); soccer + everything else is unchanged. The derived rung reports source=sports_api like a stamped one (identity-verified, so the X media gate trusts it). A market with NO subject skips rung 4 entirely (#question-card): when the classifier names NOTHING — its documented verdict for “an abstract trend/topic naming no imageable entity”, the honest read of “Will the #1 Movie on Netflix have at least 21 million views?” (no film is named) — rung 4 would search the open web on subj, which with no classified subject is the RAW MARKET QUESTION. The web’s best literal match for a market question is the MARKET: the exchange’s own OG preview card (kalshi.com/api-app/preview/...), which renders that exact question as text over a price line, and it shipped as the background cover-cropped into an unreadable slab of the question. The gate is not the thing to fix (measured n=5 on the real card): judged against a CLEAN entity it rejects that card 5/5 even as the lead candidate beside a real photo, and 5/5 alone — it only takes it when the SUBJECT IS THE QUESTION, where the card is the one literal match. So the QUERY is the bug and a counter-rule in pick_subject_image would be scar tissue for a case that stops arising. No subject → no photo hunt → rung 3b, the MEDIA-PLATFORM logo (detect_media_brand + the org→Wikidata path), then the branded floor. Rung 3b (owner steer “art on every path — netflix and youtube, never the exchange”): a subject-less market often still NAMES a platform — “the #1 Movie on Netflix”, “the top Music Video on YouTube”, “#2 on the Billboard 200” — and the platform IS the imageable subject there, so a deterministic word-boundary scan (_MEDIA_BRANDS: Netflix / YouTube / Spotify / Apple Music / Billboard / HBO Max / Disney+ / Hulu / Prime Video / Paramount+ / TikTok / Twitch / Rotten Tomatoes / IMDb / Instagram, each verified to resolve a clean P154 logo) hands the canonical name to the SAME entity_image._deterministic_art({"kind":"org",...}) rung a classified org would take, yielding source=wiki_logo (contain-fit, light-flipped for a dark mark like the Netflix red). GATED on no_subject, so a market that DOES name a person/title/team keeps that better, more specific art — the platform logo only ever RESCUES a card that was otherwise heading for the floor, never displaces a real subject. This is the fill for the empty number/percent cards (the “32.5M Netflix views” card the owner flagged); it is NOT the exchange’s own Kalshi/Polymarket mark (an earlier take, rejected). When 3b also misses (an abstract macro question naming no platform), the floor still holds. A classifier BLIP lands here too (the client swallows its own errors, so an empty result is indistinguishable from a real empty verdict) and that’s the safe direction: a miss is never cached, so the next build re-resolves. Verified end-to-end on the live markets — the reported card resolves to the floor, its #1 Show sibling to the Netflix wiki_logo, and the film/person/musician/team controls keep their art (deterministic art wins for person/musician; a team still reaches the search on the clean name). The durable image cache is keyed by market AND SUBJECT (_cache_key = _market_key + _subject_key), and the subject half is load-bearing (#wrong-leg-cover): a multi-outcome BOARD — “which album is #1 on the Billboard 200 the week of Aug 8?”, a Netflix top-title bracket, a Big Brother eviction field — is ONE market whose cover is art for whichever LEG currently leads (market_alert._race_snapshot folds N legs into one aggregate and stamps the LEADING leg’s catalog_hint). Keyed by the event alone, the first leg carded pinned its art for the whole 4-day TTL and every later post about a DIFFERENT leg served it: a board led by Olivia Rodrigo’s “you seem pretty sad for a girl so in love” cached her cover under kxtopalbum-26aug08, and the next day’s alert — “Morgan Wallen’s ‘I’m The Problem’ is tracking toward another week at #1” — shipped her album art on his card, to Discord and to X (owner report, verified straight out of the live kv_cache row). Note the resolver itself was never wrong: the drop 64 minutes earlier resolved the correct Wallen cover on a cache MISS, so this was invisible to every gate — the art was right, the key was too coarse, which is the failure mode a “does the cover match the artist” check can’t see. _subject_key reads the same three inputs the ladder resolves from (catalog_hint artist+release, a caller’s subject_hint, the featured leg), contain-deduped, and ends in a DIGEST rather than a bare truncation when long — two long-titled legs of one board share a prefix, and a truncated key rebuilds the same collision. A single-subject market (yes/no, threshold, plain artist) yields an empty subject half and keys exactly as before, so the cache stays as warm as it was. The namespace is also VERSIONED (market_subject_img_v12) — bump it on any resolution-logic change so cached garbage re-resolves on deploy (v6 flushed the preview cards the raw-question search had already cached, which the Netflix-views alerts kept re-serving; v9 flushed every wrong-leg cover the event-only key had pinned; v12 flushed the award boards that had cached a none floor when the featured leg collapsed to the event title). Emits market_subject (source rung + cache_hit).
A YOUTUBE-CHART market gets its leg’s VIDEO THUMBNAIL (rung 1b, #youtube-chart-art). Kalshi runs four daily music-video series — top and runner-up, USA and Global — and they were the one market family the whole ladder missed. The card shipped with the branded floor to Discord and to X (owner report, “still needed art”: “Runner-Up Daily Music Video USA on YouTube: Aug 5, 2026”, leg “ICONIC BY MISTAKE”). The cause is not a broken rung. A leg is a VIDEO TITLE and the payload names no artist anywhere: Kalshi writes no :: subtitle here, so there is no catalog_hint, and image_subject then returns NOTHING — correctly, because its prompt requires a music release’s artist to be NAMED IN THE TEXT and forbids guessing one (the guard that stopped the wrong-artist ships). That empty verdict sets no_subject, which switches off the photo search, and the card lands on the floor. Reproduced live before the fix (classified: None → source=none). The missing input is the chart the market settles on. kworb mirrors YouTube’s most-viewed music videos, and each row links to the YouTube VIDEO ID — so a leg resolves to one specific video and to that video’s own thumbnail, which is the picture the chart itself shows. find_video_entry takes the MOST-VIEWED charting video whose title contains the leg label as a WHOLE PHRASE. Both halves are load-bearing. The phrase test covers both leg shapes — the raw video title (“Rod Wave - Hustle (Official Music Video)”) and the bare song name (“petal” inside “Ariana Grande - petal”) — while _fold_phrase pads each side so word boundaries still hold (‘peta’ does not match ‘petal’). Views beat an exact title match, and that was measured, not assumed. An exact-first rule looked right and was wrong on the reported card: the chart carries the official “ICONIC BY MISTAKE” MV at 1.26M views, a dance practice at 622K, and an auto-generated ART TRACK titled exactly “ICONIC BY MISTAKE” at 452K — so exact-first shipped the art track’s pillarboxed album cover while the real music video sat higher on the same chart. The label is the SONG’s name there, so the exact match is a coincidence. The tie-break is what the market IS: a daily chart market exists for a video because that video is near the top today, so among candidates it means the one charting highest. Today’s view count is the right comparator — one metric, one day, and unlike pos it stays meaningful across the two merged pages. video_thumbnail_urls asks for maxresdefault (1280×720) then sddefault (640×480) and deliberately omits hqdefault (480×360): every id serves that size, so including it would turn a genuine miss into a blurry card. Measured over all four live events, 56 of 60 legs resolve, and the rule picks the OFFICIAL video every time it differs from exact-first (26 of 60 legs). The 4 misses are videos outside the fetched slice, and each falls through to the rungs below exactly as before. Gated on the market TITLE (is_youtube_video_market: it says both “music video” and “youtube”), not on a ticker prefix, so a new series in the family works with no code change — and no other market pays the chart fetch. market_subject reports source=youtube_chart; the eval art_resolution grades the rung against the live chart and carries a golden for the whole-phrase test. A TRAILING QUALIFIER on the leg is dropped, but only for a UNIQUE match (#2286). Kalshi labels a leg with the CHART’s song name while kworb carries the RAW UPLOADED YouTube title, and the two disagree about the parenthetical: the Aug 10 runner-up leg read “KALYANI (Remix)” where the charting video is titled “KALYANI (with Shreya Ghoshal) OFFICIAL MUSIC VIDEO |
ARJN | KDS | …”, so the whole phrase was absent and the card shipped art-less to Discord AND to X. A label ending in a parenthetical now retries WITHOUT it — and that retry demands exactly ONE distinct charting video, where the full-label pass takes the most-viewed of several. The asymmetry IS the safety: dropping “(Remix)” WIDENS what the label means, so with an original and a remix both charting, most-viewed would hand back the ORIGINAL’s thumbnail for a card about the remix — the wrong-art class this rung exists to avoid. One video means the chart holds one reading of that name and the leg can only be it; more than one stays a miss. The strip is END-anchored, so a real leg whose parenthetical sits mid-string (“Tera Mera Rishta Continues (Film Ballad) | Awarapan 2 | …”) keeps every word after it. Whether the two names meant the same video was CONFIRMED before the widening, not assumed: the live event KXYTTOPVIDEOG2D-26AUG10 carries ONE KALYANI leg and kworb’s chart ONE KALYANI video, and Kalshi builds its legs from that chart. The same no-artist gap bites the TAKE, not just the art (#youtube-take-fabrication). The leg names a video and no artist, so the drop compose has to name the act from nothing — and Opus invented a whole different song: it shipped "After You" by Stray Kids is locked as the No. 1 on KXYTDAILYTOPVIDEOG-26AUG23, whose #1 leg was “Dai Dai” (Shakira & Burna Boy). The art rung had resolved the CORRECT “Dai Dai” thumbnail; the delivery media gate then read the take (Stray Kids) against the image (Shakira), called a mismatch, and dropped the correct art — so the post shipped as a bare number card (the reported card). Two fixes in cogs/market_drop._compose, both scoped to is_youtube_video_market. (1) Ground the take. _youtube_grounding_block reads the same kworb chart the art rung uses and appends each leading leg’s real upload title (“Shakira, Burna Boy - Dai Dai (Official Video)”) to the market blob, as DATA next to the numbers, not a new rule. A real-Opus dry run (n=4) went from 3/4 (one “KATSEYE’s Gnarly” fabrication, one wrong-artist “BLACKPINK’s Dai Dai”) to 4/4 correctly crediting Shakira & Burna Boy. (2) Fail-closed gate. market_subject_image.take_references_leg checks the take names the leading leg; a take that names a DIFFERENT song (the “After You” vs “Dai Dai” case) is dropped via emit_decline(reason="leader_fabrication") rather than shipped over its own correct art. A SHORT, clean song title (≤ 3 distinctive tokens) must appear as a WHOLE PHRASE, so a fabrication that merely shares one ordinary word with the leader (“Love Is a Game” vs a “Love Me Again” #1) is still caught — a longer raw upload title (artist + song + credits) keeps the softer any-token test, since a real take names the song succinctly and would fail a whole-phrase match against the full label (Codex review, #2525). The fold keeps Unicode word characters of any script, so a non-Latin #1 is checked instead of folding to nothing and fail-opening. A possessive is dropped before folding (“Rod Wave’s” → “rod wave”), so a grounded take is not rejected over the stray “‘s”, and the check runs against EVERY leg tied at the top price, so a take naming a co-leader is not dropped either (both Codex review, #2525). A short label’s required phrase is the CLEANED identity (“Golden Official Lyric Video” → “golden”), not the raw label, so un-parenthesized upload noise is not put back into the text a take must repeat. Deterministic, so it costs no model call and cannot misfire on a take that does name the leader; the grounding handles the wrong-ARTIST case the token gate cannot see. The gate (a SUPPRESSION path) is scoped by is_youtube_daily_chart_market — the STRICTER predicate that requires the daily-chart framing — not the broad fail-open is_youtube_video_market the art rung and grounding use, so a plain “release a music video on YouTube” binary (whose leg label is the whole question) never reaches it. Both the grounding and the gate key on the REAL leg label via _leading_legs, which reads meta['yes_label'] FIRST: the single-leg binary shape (_kalshi_binary) rebuilds outcomes as {event_title: price} and keeps the song only in yes_label, so reading outcomes alone handed back the event title (“Top Daily Music Video Global on YouTube”) — the grounding then looked that up and found nothing, and the gate validated the title, which a fabricated take echoes (“…global YouTube music video…”). That was the actual incident shape, so keying on yes_label is what makes the fix fire in production, not only on the multi-outcome board. The SAME fabrication hit the NETFLIX winner series (#netflix-take-fabrication). The market_drop compose shipped “My Brilliant Career: Season 1” — a 1% leg — as the “top global Netflix show this week”, while the 99% leader was “Outer Banks: Season 5” (event kxnetflixrankshowglobal-26aug24, verified from live Kalshi candlesticks: Outer Banks bid 0.98/ask 1.00, My Brilliant Career 0.00/0.01). The card carried the CORRECT Outer Banks art (rung 1’s tmdb_poster); the delivery media gate read the take (My Brilliant Career) against the image (Outer Banks), called a mismatch, and dropped the art — so the post shipped as a bare 99% number card (the reported card). The deterministic gate now also fires for is_netflix_rank_market (the Kalshi “top Netflix show/movie this week” winner series, matched off the title), but ONLY at a CLEAR-leader angle (report for a ≥ 95% lock, or frontrunner), where the take must state the leader as a fact; a contested Netflix leaderboard keeps the drop_score self-gate, so the suppression path stays narrow. A Netflix leg is a full media TITLE, and a deterministic token check could not tell which title a take DECLARES the winner. Three rounds of token matchers each drew a new edge case (Codex review, #2538): a one-shared-word leak (“My Brilliant Career” shares “my” with a “My Life with the Walter Boys” leader), then a board-aware overlap rule that still leaked a wrong franchise EDITION (“Love Is Blind: France” vs a “Love Is Blind: UK” leader) and SUPPRESSED a correct comparative take (“Outer Banks stays #1 over My Brilliant Career”) for naming the runner-up. Token overlap cannot read which winner a sentence crowns, so the Netflix family asks a cheap Haiku judge instead — claude.market_take_names_leader(take, leaders), a market_take_check call that returns true (names a leader as the winner), false (declares a different title — a fabrication to drop), or None (could not judge). It FAILS OPEN: the gate drops only on an explicit false, so a Haiku outage never suppresses a real post, and the delivery media gate stays the backstop for wrong ART. The YouTube family keeps the deterministic per-leg take_references_leg its clean song-name legs suit. Cinema and sports winner locks have the same shape and are the open sweep (a follow-up issue). Other markets keep the drop_score self-gate as their only check. The winner card heroes the #1 CALL, not the 97% odds (owner steer: “post forecast not probability”). A “Top Daily Music Video” market arrives as a single-leg BINARY lock, so _report_number_figure takes the subject_hero path — which read a rank only from an explicit “#N” or “runner-up”, so the unranked “Top Daily …” title heroed the market’s PROBABILITY (the 97% Dai Dai card). market_cards.named_rank now reads a TITLE-LEADING “Top Daily <thing>” as the #1 slot, the twin of its “Runner-Up Daily <thing>” → #2 rule, so the card heroes “#1” with the winner as the subject and the percent demoted to the caption. Anchored to the title start, so a market that merely uses “top daily” as a metric (“top daily streams reach 10M”) keeps its own threshold hero. The X media gate TRUSTS this rung (2026-09-09, the “Dai Dai” drop). x_crosspost._IDENTITY_VERIFIED_ART now lists youtube_chart: the thumbnail is fetched by the video id the chart itself carries, so the picture IS the video the market ranks, and the vision judge cannot check it anyway — a thumbnail is a frame with no title to read, so the judge’s only lever is recognizing a famous face, which fires on exactly the biggest artists (“That’s Shakira … not ‘Dai Dai’” dropped the correct Dai Dai thumbnail off a settled market_outcome winner line). 30 days: 0 catches, 2 flags, both that. The trust is for a UNIQUE match only (Codex review on #3109): kworb.find_video_match reports whether one charting video carried the phrase on a COMPLETE read of both kworb pages (youtube_videos_read; a half read never reports unique) for a CURRENT chart day (youtube_chart_is_current: within two days of the date the title names, since the settle sweep can render a dated market late and today’s list may have moved on), and a most-viewed PICK among several is named youtube_chart_pick and stays gated. A wrong TAKE on this family stays gated upstream (take_references_leg, winner_line_score). Detail in docs/OBSERVABILITY.md under media_coherence. |
github.py, GitHubClient for filing issues/PRs via the GitHub APIrailway.py, Railway API for /undo rollbackshealthcheck.py, aiohttp server at /health (Railway healthcheck). Also optionally exposes two token-gated debug routes, both disabled unless DEBUG_QUERY_TOKEN is set: POST /debug/query (#652), a read-only single SELECT inside a READ ONLY transaction with a statement timeout — so it physically can’t mutate data — the sanctioned way to inspect prod DB state from a Claude-on-the-web session, whose sandbox proxies all egress over 443 and so can’t open a raw Postgres socket on Railway’s public proxy port; and GET /debug/integrations, the generic outbound-integration smoke test (utils.integration_probes) — probes every (or a ?names= subset of) upstream from the bot’s own datacenter IP and reports provisioned/reachable/ok per source, the only way to tell a real upstream outage from a Claude session’s own egress block; and GET /debug/usage (#727), the metered-API usage/quota read — pulls SGO’s entity cap (/account/usage, quota-exempt), API-Sports’ daily request cap (/status), the Odds API credit budget, and Highlightly’s daily cap (passive headers) off the bot’s own clients and returns spend-vs-cap per source plus a low rollup, so “what’s our usage / are we near a cap” is one authoritative read instead of inferring from 429s.usage.py, the cross-integration usage/quota core (#727, extended across every metered integration in #753): pure per-source parsers (parse_sgo_usage over /account/usage’s requests+entities rate-limit tree, parse_api_sports_status over /status’s daily requests, odds_usage_report over the captured credit headers, highlightly_usage_report over the passively-captured daily headers, parse_elevenlabs_usage over /v1/user/subscription’s character_count/character_limit monthly cap (the voice-budget SGO-entities analog), parse_github_rate_limit over /rate_limit’s REST-core used/limit hourly budget, **giphy_usage_report over the passively-captured X-RateLimit-*-Day daily search cap, openai_usage_report over the passively-captured per-minute x-ratelimit-*-requests RPM) → a normalized UsageReport/UsageMetric (used/limit/remaining/pct, is_low at LOW_PCT=0.85), plus the fail-open async collect_usage that reads every provisioned source by its cheapest authoritative path. Consumed by the health-watch quota poll, the /debug/usage endpoint, and (the pure parsers) unit tests. The single home for “how much of each metered budget is spent” — the answer to the SGO-entity-cap blind spot that took /bet dark. Coverage (#753 epic): the keyed metered sources with a readable budget are polled (SGO, API-Sports, Odds API, ElevenLabs, GitHub, twitterapi.io — its prepaid credit balance via parse_twitterio_usage over /oapi/my/info, #1255; a prepaid wallet, so the metric carries the remaining balance with limit=None and the low signal is a balance floor flagged by the ops-monitor, not a pct) or captured passively (Highlightly daily, Giphy daily, OpenAI per-minute RPM); Giphy’s search endpoint returns NO X-RateLimit-*-Day headers in practice (verified live 2026-07-10, on cache HIT + origin MISS), so collect_usage SKIPS Giphy when the passive capture is empty rather than emitting a recurring ok=false error=no_headers quota event every tick (#1118) — self-healing if Giphy ever starts sending them; OpenAI’s per-minute window is informational only (period="minute" — the ops-monitor renders it but never flags quota_low, since a rolling rate can’t fill ahead of time; its hard signal is the 429 in integration-health). Sources with no machine-readable budget (Perplexity prepaid balance + Genius, both dashboard-/limit-less; OpenAI $ spend, which needs an org Admin key the owner declined) are covered reactively by the existing integration-health rates + auth probes, not polled.integration_probes.py, the /debug/integrations registry: one Probe per outbound integration (Genius, Apple Music, OpenAI, ElevenLabs, Perplexity, Giphy, API-Sports, SGO, twitterapi.io, The Odds API, Polymarket, Kalshi, Highlightly, MusicBrainz, Wikipedia, Wikidata, GitHub, Railway), each the cheapest real read that proves host reachability + (where keyed) auth. Returns ProbeResult(provisioned, reachable, ok, status, duration_ms, detail) — reachable isolates the network axis (DNS/egress/timeout) from ok (status<400, the auth/quota axis). PROBES is the extension point; reach_only=True marks a POST-only host where reachability is the honest ceiling. Fail-open, bounded by a short per-probe timeout, runs all probes concurrently, and emits no events (diagnostic-only, must not skew ops-monitor health rates).polls.py, the pure validate/normalize/format core behind the polls cog (#888): build_poll_spec (clamps a raw request to Discord’s 2-10 options ≤55 chars / question ≤300 / duration 1-768h, recording what it trimmed in notes), normalize_options (strip/dedupe/cap), clamp_duration + parse_duration_hours (“3 days” → 72), to_discord_poll (the one discord touch — building a discord.Poll value object, no network), and format_poll/format_poll_list (render live results + the recent-poll list). Side-effect-free so it’s unit-tested with no live client; the discord I/O lives in cogs/polls.py (mirrors the utils/starboard.py split).m!, case-insensitive (M!Play), also @Jockie Music Premium play … (a user mention, <@id>), a ROLE mention (<@&id> play tyla), m!p, and slash /play. Up to four instances answer the same prefix: “Jockie Music (2)” replied “You are not in a voice channel” to a command the Premium instance was already serving, so the listener parses by embed shape, never by author name. WHERE Jockie answers is the whole quirk: “Started playing …” and “There are no more tracks” go to the VOICE channel’s chat; the “Added Track” embed (posted when something is already playing: a Track field **[Title by Artist](url)**, a footer Requested by <username>, the queue position) and the “Title by Artist has been skipped by <@id> (requester privilege)” line go to the channel the COMMAND was typed in, which on the first hosted night was #casino. So _watch reads Jockie in every channel of the server: an Added Track puts {title, requester} on MatchState.queued (the username maps to a player through MatchState.handles, every name Discord has for the two players), a skipped / empty / left line ends the current song (only from a bot whose Started-playing lines the voice chat has shown, MatchState.music_bot_ids, never from Toots’ own embeds), and when a queued title starts in the voice chat it is credited to its requester by title (_title_key, loose), ahead of the play-command window, which stays the rule for a track that starts at once. Other lines: “Skipping, 1/2 (2 votes required)” is a vote, not a skip (a non-requester’s m!skip needs a majority; the requester skips at once); “You are missing the required session permission leave” is m!stop by a non-owner; “Track has been wound to 02:08/03:25” is m!seek/m!forward; “No one has been listening for the past 3 minutes, leaving” and “No tracks have been playing for the past 3 minutes, leaving” are idle exits (read as left); “I have been summoned” is m!join. A free-text m!play kelly dilema resolves on Deezer (the Deezer icon, a deezer.com link), a Spotify/Apple link keeps its own link and title (a Spotify title carries “(feat. X)” and “- 2014 Remaster”); the same query typed twice queues the same track twice.versuz_catalog.py, the Versuz song-credit lookup (#2874): Deezer contributors (their role: Featured acts first, then the title’s credit, then the co-leads last-billed first, #2885), then iTunes billing, then Genius’s featured_artists when both leave the cast blank (a posse cut both catalogs credit to the lead alone, #2885 item 2; gated on GENIUS_ACCESS_TOKEN, the catalog’s lead and link stay), all behind the shared title/artist matchers; song_from_link reads a pasted Spotify / Apple Music / Deezer / YouTube link into a title and artist for the card’s Fix forms; base_title drops edition noise (“- 2014 Remaster”, “(Radio Edit)”) but keeps a remix or a live take; one cache per song (hits 30 d, misses 1 h), L1 in process over the durable kv_cache tier bound at boot (versuz_catalog.bind_db in bot.py, #2885 item 4: a redeploy mid-night does not re-read the catalogs for the songs already on the board). tests/test_versuz_catalog.py.scripts/music_drop_manual.py + the music-drop skill (.claude/skills/music-drop, owner ask 2026-09-08): the HAND-FED music drop. The owner pastes a song link (Apple / Spotify / Deezer, plus an optional YouTube video); the script resolves the track (versuz_catalog.song_from_link), pulls a Perplexity read on it, composes N posts on the guild’s /menu music-model label (read from the settings table via /debug/query – owner steer: use the DB entry for the model) in one of two STYLES: a song link alone gets Toots’ TAKE (the SAME music_post prompt the live drop uses + discourse_score(surface="music"), floor 0.35); a song + VIDEO link gets a REPORT (the newsroom’s release lane, compose_music_record(kind="release") + music_desk_score(kind="release"), floor 0.6 – owner steer: “I wanted a report style for the video drop”), grounded in a claim built from real reads only (the YouTube title’s kind + channel, the iTunes release date; press rides as context, and the claim LEADS with the video, since a single-first claim made the gate score a ten-day-old release “out now” at 0.2), and prints the pick in the two shapes Music._sched_deliver ships (room: take, Apple link, video last; X: one line, video last) plus the cover art. A dry run; it never posts. Pure parts (split_links, default_style, room_blob, video_kind, report_claim, pick_best, render_shapes) are unit-tested in tests/test_music_drop_manual.py.scripts/versuz_card_manual.py + the versuz-card skill (.claude/skills/versuz-card, owner ask 2026-09-03): the RETROACTIVE card for a night Toots did not host. Reads the host’s polls out of the channel (or a JSON dump), infers each round’s sides from the running score in the poll titles, matches the options to the music bot’s Started-playing lines by title, resolves credits through versuz_catalog, renders the same card. A dry run; it never posts. tests/test_versuz_card_manual.py. **--sql (owner ask 2026-09-10, the 2026-09-02 night on the archive and the leaderboard): the run also writes the night as ONE SQL statement (archive_sql) the owner runs in the Postgres service shell, the rows a hosted finish writes (versuz_matches ended/archive, a versuz_rounds row per poll with the poll’s message id, a game_scores row per player with the finish’s detail shape, the winner’s game_champions best streak; the current reign is not touched); it inserts nothing when a match of the two players already started at that second. A match the owner wants OFF the archive (a demo night, a false start) gets status = 'hidden' by hand (2026-09-10: matches 2, 7 and 15 of the master guild): the Archive picker, the Reopen offer and the merge offer all read status and skip it, and its rows stay for the record. The channel walk starts at the window’s end (a snowflake carries its time) and waits out a 429, since paging from today to a week back hit the rate limit.scripts/versuz_recap.py + the versuz-recap skill (.claude/skills/versuz-recap, owner ask 2026-09-10): the X CAPTION over a Versuz card, generated (utils.versuz.recap_caption / recap_caption_for) in the one shape the shipped posts use (<Theme>, <A> vs <B>. <N> rounds, <V> votes, and <winner> takes the night <w>-<l>. + the invite line; a tie is said, a dead-even night says so). A hosted night is read by --match-id through the read-only /debug/query (the round rows give the round count and the vote total: the 2026-09-08 post said 19 rounds over an 18-round card, a retyped number); a night with no row takes the fields by hand, and the manual card script prints it with --caption. Never posts.versuz_card.py, the drawn Versuz scoreboard (#2877): reuses the desk card’s chrome (chart_cards’ floor, bloom, wordmark, tag, fonts) for a two-column round list, up to 24 rows, winner lit, each title over a small credit line (versuz_card.credit_line: <artist> ft <first feature>, either half alone when the other is missing; owner ask 2026-09-05 – three of the eight rounds that night were carried by an artist who was neither player’s, and the older feature-only line hid it). Deterministic; tests/test_versuz_card.py renders the real night.versuz.py, the pure Versuz match core (#2874): parse_jockie (the music bot’s Started-playing / skipped / no-more-tracks / left lines), MatchState (the slot rules, round readiness, poll title + options under the 55-char cap, the tally, ties, first-to, void, re-vote, the resume payload) and the text renderers the card uses. No discord, no network; tests/test_versuz.py replays the real 2026-09-02 voice log through it and requires the same 18 pairings and Old 10-8.discord_info.py, the pure core behind the read-only discord_lookup cog: the row shapes (MemberInfo/ServerInfo/RoleRow/ChannelRow/ReactorRow/PinRow), the format_* renderers (member/server/roles/role-members/channels/emojis/reactors/pins), and humanize_age (compact “3y 2mo” duration math). ASPECTS is the dispatch set; the MAX_* constants the list caps. Side-effect-free + unit-tested; the discord I/O (reading guild/member/channel objects, name resolution, reaction/pin fetches) lives in cogs/discord_info.py.scheduled_events.py, the pure core behind the scheduled-events cog (#888): build_event_spec (validates name/future-start, defaults end to +1h and location to TBD, clamps to Discord’s limits with notes), build_event_edit (a sparse {field: value} of only the provided edits for ScheduledEvent.edit), parse_event_time (ISO 8601 / unix / naive → aware UTC), and format_event/format_event_list. Side-effect-free + unit-tested; the discord I/O (create/edit/cancel/fetch, all needing Manage Events) lives in cogs/scheduled_events.py.message_search.py, the pure core behind the message-search cog: the SearchQuery/MessageFields shapes (the latter carrying the RICH per-hit detail — body, flags, reaction tallies, attachment (name,kind,url) rows, embed titles), the pure message_matches(fields, query) predicate (AND across filter categories incl. from_type + role/everyone mentions, AND within keywords), the model-arg parsers (normalize_keywords, parse_has with friendly aliases, parse_author_type, parse_order, parse_date for ISO/YYYY-MM-DD/epoch, clamp_limit), the is_empty() constraint guard, sort_results (newest/oldest), and format_results (a rich multi-line block per hit + a by-author/by-channel breakdown header). HAS_KINDS/AUTHOR_TYPES are the supported filter vocab; CONTENT_CHARS + the scan bounds (GUILD_SCAN_*, SINGLE_CHANNEL_SCAN) the caps. Side-effect-free + unit-tested; the discord I/O (name→id resolution, channel/thread/forum walks, has: kind + reaction + attachment extraction off a discord.Message) lives in cogs/message_search.py.social_search.py, the unified cross-platform social DISCOVERY core (#1272): one normalized SocialResult shape (platform/title/link/author/stat/published/engagement) + pure per-platform mappers (tiktok_search_result over /v1/tiktok/search/top items[] + the /v1/tiktok/get-trending-feed aweme_list[] — id/desc/statistics/create_time with _tiktok_date normalizing the unix-int-on-trending vs ISO-on-search split, canonical tiktok.com/@handle/video/id link fallback; youtube_search_result over /v1/youtube/search videos[] + /v1/youtube/shorts/trending shorts[] — id/title/channel/viewCountInt/lengthText-or-durationFormatted; instagram_reels_result over /v2/instagram/reels/search reels[] — id/caption/owner.username/like_count/comment_count) + three async orchestrators: search_socials (keyword search, TikTok + YouTube + Instagram) + search_hashtag (the #-tag sibling, sharing a _keyword_fan_out helper) + discover_trending (no-keyword trending, TikTok region-scoped feed + YouTube shorts), each fanning out via the ScrapeCreators client, MERGING + engagement-ranking, rendered by format_social_results (title · @author ✓ · stat · duration + link). Each SocialResult also carries a duration (TikTok video.duration ms / IG video_duration s / YouTube lengthSeconds, rendered once as m:ss) + a verified author badge. _SEARCH_ENDPOINTS/_HASHTAG_ENDPOINTS/_TRENDING_ENDPOINTS are the extension points (add a platform’s path/param/key/mapper). Fail-open per platform (a source miss contributes nothing), emits social_search. Mappers + formatter + orchestrators are pure/unit-tested (tests/test_social_search.py); the ScrapeCreators fetch is guarded in utils/scrapecreators.py. Wired as the /ask search_socials + discover_trending tools (handlers cogs/ask.py:_search_socials/_discover_trending) so the model finds fresh social content uniformly — the “search all social platforms uniformly” ask.playercard.py, the PURE scoring core behind the /card cog (#570), no I/O: takes a guild SNAPSHOT (every active member’s raw PlayerStats) and renders one member’s Card (OVR + tier + three percentile-ranked RATINGS + badges + archetype + chase line). _STATS is the single source of truth for the named face-stats (key/label/emoji/blurb/weight/terms) — the OVR weights, the rubric (rubric_lines), and the card labels (stat_labels) all derive from it. Only the 3 RATING_KEYS (clout/humor/social) are percentile-ranked + feed the OVR (face_stats/overall_rating/rubric_lines default to them); the other dims (presence/taste/degen) are NOT ratings — archetype_for reads the wider ALL_STAT_KEYS so a one-lane regular still gets their label (a degen cards “The Whale”), and their raw numbers surface in the tape/badges. pct_rank (ties-split percentile) makes every number relative to the room. CLOUT = the three per-post RATES (reactions_per_msg + replies_pulled_per_msg + mentions_per_msg) blended with the three received TOTALS (reactions_received + replies_received + mentions_received) — six inbound signals balancing rate + volume; HUMOR = laugh_share + bangers (the #570 rework — magnitude vs flavor; the three ratings are weighted EQUALLY, 33/33/33). Also exposes the PURE detail_pages(tape) (→ CardPage/StatLine): the four scrollable detail pages that cover EVERY counted stat, formatting a RESOLVED tape dict (skip-zero, name/dict/date/duration helpers) — the cog supplies the Discord render + name resolution. Plus the PURE about_member_text(summary, names) for the memory hype-up — keeps only the sentences of a memory note that NAME the member (word-boundary, NFC, case-insensitive over every alias), so a whole-room note collapses to its about-them signal — and spread_evenly(pieces, budget, max_items=), which picks a budget-fitting subset of the time-ordered notes spread EVENLY across the range (anchored on the first), so the hype-up reflects the member’s whole arc from their first days to now, not just the recent stretch. Fully unit-tested in tests/test_playercard.py; the data-gathering + Discord render live in cogs/playercard.py.engagement.py, the shared engagement-scan engine (#570) — the per-message tally /stats (cogs.stats, the full-history scan) runs to build the seed /card reads, so the counting logic lives in ONE place. The cog walks each channel oldest-first and calls ScanState.tally_message(msg, channel_id=, start=, end=); because the walk is ordered + intra-channel, a reply’s target is already seen, so replies + reply-latency + the conversation-flow stats (closer/reviver/burst) resolve INLINE. ScanState.finalize then folds embed-fixer webhook reposts onto the real user BY USERNAME (the repost’s avatar is the webhook’s, not the user’s — the proven utils.starboard name-fold; a genuine bot like MEE6/Toots is skipped) and emits the per-user stat BLOB the seed stores (a JSONB dict, so a new stat is just a new key — no migration). The blob is comprehensive: volume + cadence (messages/chars/longest-message/days/longest+current streak/longest-absence “ghost”/first+last/hour+weekday+monthly histograms/busiest-month/busiest-day/late), reactions (split laughs→HUMOR vs hype→CLOUT, by-emoji, bangers, top post; bot reactions are excluded — Toots’ own via r.me (she reacts via chime-in) AND MEE6’s music-link auto-reaction via fingerprint (mee6_autoreaction_counts mirrors MEE6’s owner-confirmed, live-verified automation: it reacts 🔥 + 🆒 + ✌️ — all unicode, the automation UI’s “:cool:” is the unicode 🆒 (U+1F192), NOT a custom emoji — on any message carrying a music domain, so a trigger-domain message carrying all three has one of each subtracted) — a bot’s reaction must not inflate a member’s reactions_received/laughs/bangers; both are free (no per-reactor users() fetch, prohibitive over a ~300k-msg rebuild) and SAFE (requiring all three emoji means a music post lacking the full fingerprint is never touched, so it under-corrects rather than ever stripping a real human reaction; a dry run over 200 real music posts confirmed 80/93 fire)), social (replies sent/received, ride-or-die outbound + top-fan inbound + best-duo, mentions made/received + distinct-mentioned reach + #1-mention, most-replied post, quickdraw + avg latency), flow (closes/revives/bursts), content persona (links bucketed apple_music/spotify/youtube/x/tiktok/instagram/gif/image/video/other — gif = animated shares (tenor/giphy/direct .gif), image = a pasted still picture (imgur/direct .png/.jpg), video = a raw clip URL (streamable/direct .mp4), each distinct from an attachment which is counted separately as images_sent/videos_sent — attachments split images/videos/files + stickers sent, voice notes sent, forwards + cross-server forwards (the “mole”, a native Discord forward whose source guild differs), got-pinned, first-of-day opener, signature emoji via own-text emoji scan, total emoji-messages + emoji count, questions, caps, edited), roasts (#570: own-laugh rate per message via is_own_laugh, left-on-read = posts with no reaction AND no reply), and per-channel (channel_messages + home channel). The per-message trackers behind most-replied / left-on-read are bounded (only posts that GOT a reply or reaction are held, not every message). IDs of other people (ride_or_die/top_fan/best_duo/#1-mention) are stored so the reader resolves the current name. Pure, unit-tested in tests/test_engagement.py. Incremental catch-up (#570): every acc field is additively mergeable (it’s how the fixer fold works), so the seed stores a round-trippable durable merge state beside the display blob and a CATCH-UP scan folds a delta into it via the same EngagementAcc.merge. The public surface: finalize_accs() (the folded {uid: acc} + token→uid resolver, the raw material), acc_to_state/state_to_acc (the JSON-safe round-trip — reply_targets/repliers resolved token→uid, names dropped), merge_into_state(stored, delta_acc, resolve) (the fold: stored state + delta → one merged uid-keyed acc), and seed_row(uid, acc, resolve?) (→ {"display", "state"}; resolve defaults to identity for an already-merged catch-up acc). ScanState(opened_through_ordinal=) is the catch-up’s first_of_day guard. The member_engagement seed is the canonical per-user engagement store going forward — new per-user engagement features read it, and the message-engagement counting lives HERE in one engine (don’t re-implement a tally elsewhere). The starboard reactions + bangers boards (in the /leaderboard stats hub) now read this seed directly (reactions_received / bangers, no scan; #957, #949 closed) — the only #wall-of-fame SCAN left is /star + the /card’s real-MEE6 “wall bangers” flex (lifetime_totals), which need the actual wall the seed can’t capture.engagement_window.py, the pure windowing core for weekly/monthly engagement stats (#1017). The seed is all-time cumulative, so a windowed view = the seed MINUS a daily SNAPSHOT from N days ago (db.engagement_snapshots, frozen by the /stats daily catch-up). Period (all_time/weekly/monthly, .days = None/7/30, .label); window_display (per-member numeric counter deltas, clamped at 0 — bangers/reactions/the OVR rating inputs); window_state (the reply_targets/mention_targets graph deltas for windowed Besties); extract_display/extract_state (unwrap the {display, state} snapshot blobs); and reconstruct_snapshot (the INVERSE, for the /stats period: backfill — window_display/window_state over (current, scanned_delta) to build a past-dated snapshot whose forward diff reproduces the window). No I/O — the cog reads the live seed + get_engagement_snapshot_near and hands the blobs here; the SAME board builders rank over the windowed blob exactly as over the all-time seed. One daily snapshot serves BOTH windows by subtraction (no extra scan jobs); forward-looking (no snapshot yet → empty window, never all-time mislabeled). Unit-tested in tests/test_engagement_window.py.awards.py, the pure award-selection core behind the weekly/monthly awards cog (#1017). Side-effect-free: the cog builds the windowed boards (utils.engagement_window) + the windowed OVR ranking (utils.playercard) + closest-pairs (utils.social_graph) and hands the already-ranked inputs here; compute_awards(keys, period, …) picks each award’s winner under two dedup layers: cross-category (_pick_single’s taken set — a person is never crowned two single-winner awards in ONE post, so nobody sweeps; keys order is priority, so the headline takes the top contender and later awards fall to the next distinct one; an award with no untaken candidate is omitted) + rolling anti-repeat PER CATEGORY (recent_winners[key], the set the COG feeds — the last few winners OF THAT AWARD, so a person can’t repeat the SAME award for a few periods but CAN win a different one — skipped for a fresh runner-up, falling back past it only if every untaken candidate is recent). AWARDS is the registry (key/emoji/title/blurb/metric/blurb_off_top; AwardSpec.title(period, month_name) fills a {month}-templated title, and AwardSpec.measure(period, rank) returns what the award measures — the superlative blurb only when the winner is FIRST on the board, else the blurb_off_top wording, because the anti-repeat below deliberately crowns runners-up and AwardResult.rank carries their real place so no post claims a place they did not take). rank is COMPETITION ranking — 1 + how many scored strictly higher — not the row index: _rank_by_metric breaks ties by uid, so when the dedup skips the first of two level members the second would otherwise read as “second” and be recorded as an off-top crown though nobody outscored them (the week of 2026-08-30 had two members level on 934, 414+520 against 514+420): WEEKLY_KEYS = (mvp,) (the mvp award titled “Player of the Week” weekly) vs MONTHLY_KEYS = (mvp, funniest, besties) (the mvp titled “{month} MVP” monthly, e.g. “June MVP”) — both the SAME engagement metric, NO OVR/”Player” award. A metric award ranks a windowed display counter, or a +-joined SUM of them (reactions_received+replies_received → MVP = most engagement, the detail breaking the sum down “4,931 reactions · 812 replies”; laugh_reactions → Funniest); the one rating-style special is besties (windowed closest pair — a relationship axis, so it doesn’t claim taken and softly prefers a pair bringing a fresh face). bangers (wall-clearing posts) stays defined + tested but is off the live key sets; the player/OVR + breakout AwardSpecs were removed (no OVR/Player award — OVR is the /card rating; breakout never live). Unit-tested in tests/test_awards.py.resumable_scan.py, the shared resumable-long-scan spine (#570) — the foundation for a backfill that walks a lot of history UNIT BY UNIT and must survive an interruption (a redeploy SIGTERM, a Railway restart, an OOM, a transient snag). A scan that holds everything in memory and writes once at the end can run far longer than the gap between redeploys, so it may NEVER finish — each attempt is a gamble against the next deploy (exactly what stranded the /stats rebuild over the ~300k-message server). It wraps a scan into UNITS (a channel for /stats; a day for /remember, the planned PR-B adoption) processed one at a time and CHECKPOINTED as done, so an interruption loses only the in-progress unit, a resume skips the done ones, and memory stays bounded. ScanProgress (a done-unit set[str] + a small meta carry, e.g. the high-water snowflake) is the pure shape; parse_progress/encode_progress are the jsonb-safe round-trip; ResumableScan(db, guild_id, kind) is the per-(guild, kind) checkpoint over the settings KV (load/mark_done/update_meta/clear, plus mark_done_local → the encoded value for the atomic path where the caller persists the checkpoint inside its OWN transaction, alongside the unit’s data, so a fold + its checkpoint commit together and an interruption can’t double-count). One tiny per-(guild, kind) settings row, no new table. Pure + unit-tested (tests/test_resumable_scan.py); the feature supplies the per-unit scan+persist and calls mark_done after each unit lands durably. (#570’s /stats is the first adopter; memory’s /remember backfill is the planned second, hence “shared”.)/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:
Tunable to utils/tunables.py:TUNABLES (with group/mood/field if it should merge into a multi-field row; give it a one-line help when the name and unit alone do not say what the number does – the tune card shows it as small text under the value, in the same component, so it never costs a component against the V2 cap; the versuz knobs carry one, #3146) + a live resolver beside the others. The tune editor (cogs/tune.py) renders TUNABLES automatically as modal-rows — no UI code, no settings.py change. This is the default and lightest path.discord.ui.ChannelSelect/RoleSelect on a MenuView page (the _DiscourseSelect/_WatchedSportsSelect pattern).discord.ui.Select. It can’t be a tune modal-row (Discord modals are text-input-only, no dropdown), so it’s a select either on a MenuView page OR on the tune view’s reserved top row (the _AskModelTuneSelect precedent — keeps it grouped with the other tunables).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):
discord.ui.Select whose callback sets self.parent_view.selected["<key>"] = ... and await self.parent_view.autosave(interaction, "<key>").add_item(...) it on a page’s _render_page branch (mind the row budget; the nav row is always the last)._load_initial_state into state["<key>"].elif key == "<key>" branch in autosave’s persist step (most write db.set_setting)._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).
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).