tootsies

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


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

Observability: the full detail

CLAUDE.md carries the binding rules (the operability bar, the field-budget core, the ops-monitor summary); this file is the full narrative those rules compress – the complete field-budget history + tooling, and the ops monitor’s findings catalog. The DETAILED event ledger (the full table) lives in this file; utils/events.py’s module docstring carries the compressed quick index – keep both updated.

The event stream + field budget (full narrative)

Every metric-worthy thing emits a JSON event line via utils.events.emit(kind, **fields). Each line is prefixed with the literal EVENT so Railway log queries can isolate dashboard data from operational logs.

Existing event kinds – the DETAILED ledger (the compressed quick index lives in utils/events.py’s module docstring; keep BOTH updated when adding an event):

| event | source | fields | |—|—|—| | command | utils/metrics.py (@track_command) | cmd, user_id, guild_id, duration_ms, ok, error | | claude_api | claude_client.py (_call) | model, purpose, input_tokens, output_tokens, duration_ms, stop_reason, ok. On failure: error (the exception CLASS name) plus detail.api_error — the API’s own message, truncated to 300 chars (#2174), read with parse_json(detail)['api_error']. The class name alone is not diagnostic: a robots.txt refusal, an image the API could not download, a malformed tool schema and a blown context window all arrive as BadRequestError, so 22 such events over 30 days said a call failed and nothing about why. A 400 is deterministic and caller-caused — the body is the answer. Parity with openai_api, which already stored its response body, and it was that stored body that made #2171 solvable in minutes. On success, detail.cache_creation_input_tokens / detail.cache_read_input_tokens (#2659) — the Anthropic prompt-cache write/read token counts for this turn, read with parse_json(detail)['cache_read_input_tokens']. input_tokens on its own is the UNCACHED remainder ONLY (it excludes both), so before this pair shipped there was no way to tell a cache hit from a miss, or to add up what a call actually cost — the gap a low org-wide cache-hit-rate report (Console billing alert, 2026-08) surfaced: the mechanism (cache_control: ephemeral on the persona + per-surface system blocks, see ARCHITECTURE.md) was already live and, verified live against the running persona prompt, DOES cache warm for the high-volume Sonnet/Haiku surfaces (cache_read_input_tokens > 5000 on the very first probe call) — the org just had no per-call visibility into it. The low-volume Opus /ask surface (~3 calls/day) is a genuine, expected cold-cache surface: its call gaps run ~8h, past even the 1-hour TTL, so there is no TTL choice that would warm it — accept the miss rather than pay a pre-warm write nobody would read back. scripts/axiom_setup.py’s cache_hit_stat/cache_hit_purpose panels and the ops-monitor’s per-provider “LLM spend by provider” line (cache hit N% (reads / writes)) both read this pair; the cost panels/_spend_cost price a cache write at ~1.25x and a read at ~0.1x the model’s input rate, so the estimated spend actually reflects a cache hit’s savings now instead of silently excluding cached tokens from the sum entirely. On success, detail.image_retry (#backpage, 2026-08-30) – the API rejected one of the attached images, so _call dropped EVERY image and retried on the text alone. Read the rate per surface with ['tootsies'] | where event=='claude_api' | summarize calls=count(), retried=countif(tostring(parse_json(detail)['image_retry'])=='true') by purpose | extend pct=round(100.0*retried/calls,1). Match on the VALUE, never on the substring — emit() keeps the null keys when it stringifies detail, so every claude_api row literally contains the text "image_retry":null and a detail contains 'image_retry' filter matches 100% of calls (measured 2026-09-03: 45,590 of 45,590 over 7 days, against a true 476). The same trap applies to every marker in this row. Measured correctly, curator_pick retried 475/2454 calls (19.4%) over the 7 days to 2026-09-03. It sat near 10% of curator_pick calls for months, climbed past 50% in August 2026, and nobody saw it, because the retry still reports ok=true. That is the fail-open trap: the vision fit-judge kept answering about images it could not see. A caller whose TASK is the images now passes images_required=True, which turns the retry off – the call FAILS instead, and the failure carries detail.api_error naming the image. So watch image_retry as a degradation rate, not as noise. Since #2732 every vision JUDGE sets it (curator_pick, fighter_likeness, media_coherence, media_text_evidence, gif_pick, subject_image_pick, subject_image_confirm; since #4145 also clip_coherence, tour_admat, wire_identity, image_match), so those purposes never show image_retry. curate_route does not set it yet (named on #4145); an image they lose shows as an ok=false call whose api_error names the file. The LLM board’s vision_image_loss panel counts both shapes by purpose (retried and refused). Full account: ARCHITECTURE.md -> curator.py. detail.thinking_exhausted / detail.thinking_recovery (2026-09-03) – a thinking-enabled turn that stopped at max_tokens with NO text (every token went to thinking blocks, which are dropped) is stamped thinking_exhausted=true, and the one low-effort retry of that turn is stamped thinking_recovery=true. Before the retry existed the surface shipped its empty-answer fallback (“lost my train of thought”) and answer_length read as a normal 45-char reply, so the miss was invisible. Query ['tootsies'] | where event=='claude_api' | where tostring(parse_json(detail)['thinking_exhausted'])=='true' | summarize count() by purpose, model — match the VALUE, not the substring (see the image_retry note above: a detail contains filter matches every row, because the null keys survive into the stringified detail); a recovery turn that ALSO stops at max_tokens is the still-empty case — that is what the ops monitor’s reasoning_exhausted finding flags (#2934), and the llm_exhausted dashboard panel graphs both providers’ markers. | | openai_api | utils/openai_chat.py (complete/create_response) | model, purpose, duration_ms, ok; on success: input_tokens, output_tokens, stop_reason, response_chars, response_preview (Chat Completions); Responses round-trips carry responses=True; on failure: error (HTTP <status>/no_text_in_response/<exc>), detail, category (#973 — the failover class: provider/quota/auth/request/malformed; provider+quota fail over to Claude, the rest surface; category=quota = OpenAI out of credits, a 429/insufficient_quota NOT retried), detail — one OpenAI call, the optional OpenAI TEXT backend (#openapi-parity), the provider twin of claude_api for a surface routed to a GPT model (cost-spread / Claude-credit failover). Two paths: Chat Completions (tool-free) + the Responses API (tools/web_search, one event per round-trip). Ops-monitor keys these openai:<purpose> (parity with claude_api’s per-purpose split) so each GPT-routed surface’s cost/latency stays separately visible. On success, detail.reasoning_exhausted / detail.reasoning_recovery (#2869) — the openai_api twin of claude_api’s thinking_exhausted pair. A reasoning model (the gpt-5 family) spends its thinking out of the SAME output budget as the visible message, so a turn that spends it all comes back with no text — status="incomplete" on the Responses path, finish_reason="length" on Chat Completions — and the surface ships its fallback line. Such a turn is stamped reasoning_exhausted=true, and the one lower-effort retry of it is stamped reasoning_recovery=true. Query ['tootsies'] | where event=='openai_api' | where tostring(parse_json(detail)['reasoning_exhausted'])=='true' | summarize count() by purpose, model — match the VALUE, not the substring (see the image_retry note in the claude_api row); a recovery turn that is ALSO stamped exhausted is the still-empty case — the ops monitor’s reasoning_exhausted finding flags it (#2934) — and a sustained rate on one purpose says that surface’s budget floor is too low for what it asks the model to do | | typesafe_api | utils/typesafe_client.py (TypeSafe._ask, via @instrument) | purpose, model (the pinned jev-1.13.0, or the versioned id that answered), ok, duration_ms, count (questions asked), api=typesafe; on success: input_tokens; on failure: error (HTTP <status> / breaker_open / no_questions / the exception class) and http_status for an API answer. A caller’s @scoped_surface adds surface + path (<surface>.typesafe_api). One event per ask — the final outcome after up to 3 attempts, not one per attempt (#4310). ask fails OPEN (returns None, the caller keeps its current judge), so this ok-rate is the only signal of a dead key or an outage: ops-monitored as the typesafe health line, p99 ceiling 2 s, and the Axiom monitor “TypeSafe Jev calls failing”. Jev spend lands on the typesafe provider line at $0.042/1M input tokens | | typesafe_shadow | utils/typesafe_client.py (emit_shadow) | purpose, ok (= Jev gave the same verdict as the current judge); current + jev (the two verdict LABELS, cut to 40 chars, never message text) ride detail — read them with parse_json(detail)['current']. Emitted by a surface that compares Jev’s verdict with its old judge’s (#4310; first user #4311), and ONLY when Jev answered: a None from ask emits no shadow row, so agreement measures real answers only (the failure is already a typesafe_api ok=False row). REPORT-ONLY (owner direction): the ops monitor renders a typesafe_shadow:<purpose> agreement line and raises NO finding, and no Axiom monitor reads it, because disagreeing with an old grader is expected when that grader is the bug being fixed. /guess credit (CB-15): purpose=guess_credit, one row per artist or guest credit the deterministic rule nominated and Jev answered; current = the rule’s credit and jev = the credit after Jev, each both / artist / guest / none. Panel guess_credit_shadow (jev_dropped = chat the rule credited and Jev did not). Mention shadow (#4406): purpose=abuse_gate (labels abuse / ok: the Haiku gate vs Jev’s Noul) on every mention that reaches the abuse gate, and purpose=mention_route (labels ask, icebreaker, music, recap:<1h\|1d\|today>: the Sonnet router vs Jev’s Choice; discourse reads ask) only where the router ran. Both carry surface=ask. The route comparison is judge against judge: it uses the router’s raw verdict, before the cog’s other-channel recap -> ask override (_names_other_channel). That override is deterministic and would apply to a Jev verdict in the same way, so it is left out of both sides. A recap on a mention that pings another channel is therefore not what shipped. The one Jev request is typesafe_api purpose=mention_shadow; a 3 s timeout is an error source=mention_shadow, recoverable, with surface=ask. Shadow only: the reply never waits for it Wire admission (CB-09): purpose=wire_admission, one row per wire post the newsroom’s cue lists drop (mn.cue_dropped: no numbers cue, or a culture post with no domain cue) that Jev answered; current is always drop, jev is story (P >= 0.5 that the post reports a music number) or drop, and detail.external_id is the post id, so the shadow week can fetch and label the story rows. Each post is asked once per process, at most 150 a slot, in a background task with an 8 s timeout per call and one 4-call cap shared by every pass; the typesafe_api request has the same purpose and surface=music_news. A timeout or an unexpected error is an error source=wire_admission_shadow, recoverable, surface=music_news. Every post that gets no shadow row (a timeout, no answer, an answer without the key, an error, the key gone) leaves the memo, so a later slot asks again. Panel wire_admission_shadow. It changes nothing that posts | | curator_frame_fetch | cogs/curator.py (localize_vision, called by the curator, the artist curator and the curate feed) | count (images that downloaded to inline base64), ok (at least one downloaded), duration_ms; dropped (# that did not download) folds into detail; surface=curate when the curate feed called it. Since #4287 the vision judge gets NO image URL: every candidate frame and channel example is downloaded with an 8s deadline per image and scaled to a 1568px long edge, and a miss drops only that image. Since #4303 dropped also counts an image the download REFUSED: a body over 5MB, a header over 50M pixels, a non-http(s) URL, or a host with a non-public address. Before, the API fetched every host outside a TikTok/Instagram allowlist and failed the whole call on a robots.txt refusal or a slow host (69 + 6 of 169 curator_pick calls in the 7 days to 2026-10-16). Its OWN kind, not curator_fetch, so a CDN block cannot corrupt the twitterio signal. Since #4298 retried and recovered (both in detail) count the Discord embed proxy fallback: an embed image whose url gave no image (a kkinstagram url redirects to HTML) and had a proxy_url is retried from the proxy; retried counts those tries and recovered the ones that gave an image. A url that works never touches the proxy. Panel: “Vision-frame fetch health” (proxy_tried, proxy_ok). Ops-monitored as integration source_cdn, and gated by a 10s p99 ceiling. | | openai_vision_fetch | claude_client.py (_localize_openai_vision) | purpose, count (images that downloaded to inline base64), ok (none dropped); dropped (# we couldn’t download) folds into detail — OpenAI’s Responses API can’t server-side-fetch an image URL (#1365), so the bot downloads it and passes bytes. An image that WON’T download is dropped (#2171): passing its URL made OpenAI reject the whole call with a 400 (Error while downloading file), and a 400 is category=request, so the router re-raises instead of failing over to Claude and the surface posts its canned error quip — one unreachable image killed the whole answer (the 2026-08-08 ask_mention outage: a reply to a message whose image had expired, host 401). Dropping costs that one image and keeps the reply; the paired [image N, posted by …] label block stays, so the model is not told an image is present when it isn’t. The drop raises NO exception (just a thinner prompt), so this ok-rate is the only signal our IP stopped reaching an image host — ops-monitored as integration vision_cdn. retried / recovered (in detail, #4298) count the Discord embed proxy fallback, as on curator_frame_fetch. A low steady rate is NORMAL (an expired Discord attachment URL); only a blackout flags | | provider_fallback | claude_client.py (_call) | purpose, requested (the model that failed), fell_back_to, reason — one provider failed and _call failed over to the OTHER (#openapi-parity, now BIDIRECTIONAL). FORWARD (a GPT surface → Claude): fell_back_to = the GPT’s Claude sibling (gpt-6-sol→Opus, gpt-5.6-terra→Haiku; an unmapped id→the frontier Opus), reason = the failure CATEGORY (#973: provider/quota/unprovisioned — the FALLOVER classes; an auth/request/malformed OpenAI failure is NOT failed over, it re-raises so the surface’s own canned fallback handles it, the mirror of the reverse direction re-raising a Claude 400/401) — the graceful degradation that makes gpt safe as a per-surface menu choice. REVERSE (a Claude surface → OpenAI, #973): fell_back_to = the Claude’s OpenAI sibling (Opus→gpt-6-sol, Sonnet/Haiku→gpt-5.6-terra; an unmapped id → the frontier gpt-6-sol), reason=credit_exhausted (the Anthropic balance ran dry — a 400 naming the credit balance, permanent until topped up) or claude_unavailable (a persistent 529/5xx/429/timeout that survived the retry loop) — credit-exhaustion failover, so the bot keeps talking instead of going dark on every surface at once. Each direction calls the OTHER provider’s method DIRECTLY (never back through the router), so the two can’t ping-pong. A rising FORWARD rate = OpenAI flaking; a rising REVERSE rate = Anthropic out of credits / overloaded. (Vision is served on the Responses path now, so it no longer triggers a fallback by itself.) | | order_state | cogs/order.py | order_id, issue_number, guild_id, user_id, from, to, via (reconcile, or expired when the sweep burns a PREPPING order with no PR after ORDER_STALE_DAYS, #4684) | | order_served_ping | cogs/order.py | guild_id, user_id (the reporter), order_id, issue_number, delivered, reason (None on delivered; no_guild/no_channel/no_perms/bot_off/send_failed) — when a reconcile flips an order to SERVED, Toots pinged the original reporter back in the channel they filed it from (“completing the circle”). Fires once per order (guarded by orders.served_pinged_at); fail-open, honors the master kill switch (bot_off leaves it pending so it lands once she’s back on) | | rate_limit_hit | utils/rate_limits.py | scope, command, user_id, guild_id, count, cap | | deploy_event | bot.py | kind (boot/shutdown), guilds | | guild_join | bot.py (on_guild_join) | guild_id, ok, + folded detail (name, members, owner_id, inviter_id) — Toots was ADDED to a server. Captures the guild NAME + who added her (the facts that were unrecoverable once she’d left an unknown server), AND pages the bot MASTER in the MASTER guild’s #bot-logs (utils.permissions.MASTER_GUILD_ID) so an unrecognized add is surfaced the moment it happens, not discovered weeks later from a stale DB row. Fully fail-open. tier=games_only (2026-10-18) when the server never ran /menu: that server is in GAMES-ONLY mode (only the arcade runs, cogs/arcade.py gate_open), and the #bot-logs page adds a line that says so and names the two dashboard panels. tier is absent on a configured server, and absent when the is_configured read fails (the page still goes out) | | guild_left | bot.py (on_guild_remove) | guild_id, ok, tier, + folded detail (name, members, owner_id) — the mirror of guild_join: Toots was REMOVED from a server. A durable record of WHEN she left + a master #bot-logs page. tier=games_only and a page line when the server never ran /menu | | command_refused | bot.py (_GatedTree.interaction_check) | guild_id, command, reason (arcade_only), tier (games_only) — a slash command refused in an ARCADE-ONLY server (2026-10-18, owner: “other than those two the only thing that should work is /fight”). Only Tootsie’s and the master guild get the whole bot (utils.permissions.full_bot_allowed); every other server is synced /fight alone (_sync_guild), and this event fires when a client still has another command cached and runs it, /menu included. /fight never emits it. Counted on the games_only_servers panel as refused_cmds. No monitor: a refusal is the intended outcome | | error | cogs/* + bot.py error handler | source (e.g. ask, order_preflight, undo), error (exception class), guild_id, user_id, optional context, optional status (the HTTP status where the call site stamps it; source=deezer does since #4756, so a 403 IP throttle and a 5xx outage are separable), traceback (last 3 frames), + folded detail.error_message — the exception’s own TEXT, capped at 300 chars, and OFF unless the call site passes include_message=True (#2816). The default is a data-minimization rule: emit_error is the catch-all around provider / HTTP / Discord / database calls, so str(exc) is UNCONTROLLED input — an asyncpg unique-violation prints the offending key VALUE, an aiohttp error prints the request URL with its query string, an LLM SDK error can print a response body — and none of that may reach a durable 30-day store. Opt in ONLY where the text is ours: a constructed exception, written to describe a fail-open miss, which has no traceback either, so its message is the only diagnosis it has. Seven such sites (billboard, pollstar_charts ×2, industry_feeds ×2, link_enrich, music_desk) are opted in. Query with parse_json(detail)['error_message'] Two sources added 2026-09-28 after a silent week-long newsroom blackout: <surface>_scheduled with recoverable=false is a ScheduledPoster slot whose compose RAISED, so the whole slot was skipped (it was a log line only before, and the music newsroom crashed there every slot from 2026-09-21 to 2026-09-28 with no finding); music_news_compose is one newsroom story whose compose raised, now contained to that story so the rest of the slot still posts. Either one repeating every hour means a surface is posting nothing. music_news_feed_watch (2026-09-28, retired by #3834) was one pass of the newsroom’s own 2-minute feed watcher loop failing. That loop is gone: the RSS feeds are now the second source of the newsroom’s EventPoster lane, so a failed pass of that lane (both sources) emits music_news_breaking (the spine’s <surface>_breaking source, recoverable=true), and one feed read failing emits music_news_feed. The same source every pass means the lane is dead and first-party stories wait for the hourly slot again. music_desk_source_watch (2026-09-28, recoverable=true) is one pass of the music desk’s 2-minute source watcher failing; the next pass retries. The same source every pass means the watcher is dead and every desk story waits for the hourly slot again. | | recap | cogs/recap.py (produce_recap) | guild_id, period (1h|1d|today), count (messages used), decision (ceiling|rate_limit|size|full) — one recap built, with its coverage. A non-full decision means the recap covers only the most-recent slice of the window, and the card’s header (and the @-mention reply) state the REAL span (“the last ~3 hours”) instead of the period label. The cause is recorded as ONE reason so the dashboard reads the ceiling signal alone: ceiling = the window held more than the per-period message ceiling (the fetch asks for one message past the ceiling, so a cap is only recorded once a further in-window message actually exists — never inferred from how close the span looks); rate_limit = a global Discord 429 cut the history walk short (recent_messages’ interrupted signal); size = the rendered blob overflowed the token budget (_MAX_BLOB_TOKENS, which drops the oldest messages — and clips a single oversized message — so a transcript-heavy channel degrades instead of hard-failing on a context-length error). Only a high ceiling rate on 1d/today says the ceiling is too low (the signal behind the “/recap today only recapped the last hour” regression fix); a size/rate_limit cap is a different problem. Dashboard: the recap_cov panel on the ask/conversation board breaks the rate out by cause. Not an error (a capped recap is honest, just partial), so no monitor. | | recap_deflected | cogs/recap.py | guild_id, user_id, period, channel_id, channel_name, reason (no_permission/no_messages), can_read_history, total_messages | | discourse_fallback | cogs/discourse.py | guild_id, user_id, category, source_count, recent_topic_count, reason (claude_returned_empty = the model returned the EMPTY skip sentinel, a deliberate decline, not an error; claude_returned_blank = no text at all; self_correction = the shape guard dropped a redrafted line). What follows depends on the caller: a manual /discourse gets the quip, a scheduled slot gets an icebreaker, the X-only lane skips. The #bot-logs line names the same reason and destination (it used to say empty_claude_response + quip on every exit, 2026-09-20). Retired 2026-10-15 (#1871): the discourse loop is dormant, so no new rows after that date. | | wire_read | utils/wire_sources.py (gather_wire), called by cogs/discourse.py and cogs/music_desk.py | surface (discourse | music | music_desk), guild_id, category, count (handles asked for), kept (posts returned), ok, duration_ms — the direct read of the CODE-OWNED wire lists that a compose UNIONS with the guild’s feed channels (#wire), so a server with no MEE6 mirror still has linkable material. count is the breadth the surface allowed (music_wire_accounts for the music surfaces, 0 = the direct read is off for that guild and no event fires at all; retired discourse reads the code default wire_sources.DEFAULT_PER_LIST, its knob was deleted in #4175). The music DROP deliberately does NOT read this (PR #2202): a live A/B showed news material pulls the compose onto the news subject and it fabricates a track, so surface never carries music. The wire desks’ breaking EVENT poll (cogs/event_poster.py, #news-breaking-speed) also emits this kind with surface = the desk and category = breaking — the same fail-open read of the head wire handles, on the aligned 15-minute poll. The fail-open tripwire is kept: the fetch never raises (a dead handle, an exhausted twitterapi.io balance, and a missed deadline all return []), so a kept=0 with count>0 across many composes is the SILENT degradation to watch — the surface keeps posting off the feed channels alone and nothing errors. duration_ms covers the whole concurrent fan-out, not one handle — and is near-zero on a warm cache, since the desks’ own fetches serve most composes. The kind is surface-neutral on purpose: one shared integration read by three surfaces stamps surface rather than splitting into per-surface kinds, so one panel and one health branch cover all of them. Ops-monitor health: rolls into misc_health["wire_read_<surface>"] — keyed PER SURFACE so a healthy discourse cannot mask a music read that has gone dark — and scored on kept>0 rather than on ok for exactly the reason above. | | discourse_scored | cogs/discourse.py | guild_id, channel_id, score, reason, must_post, category, user_id, post_preview; on category="trending" also (#1272 silent-degradation tripwires): shipped (bool — the trending-clip reaction cleared its high solo bar and won the slot vs fell through to a news take), platform (the clip she reacted to), framed (# pool clips that carried a vision cover frame — a framed=0 while TikTok is in the pool means the frame plumbing broke, e.g. TikTok changed its cover-URL shape or Anthropic stopped fetching tiktokcdn, so she’s back to reacting caption-blind), pool (the balanced platform mix, e.g. instagram:2,tiktok:2,youtube:2, confirming the per-platform balance holds). #trending-visibility: it ALSO fires on the PRE-score DECLINES (shipped=False, score=0.0, reason=no_clips|model_declined|error) so EVERY trending attempt is countable — the ops-monitor renders a “Surface decisions” line (news vs trending shipped + the decline breakdown) and flags trending_dark when trending is attempted repeatedly but never ships on a fixable reason (error = the GPT-vision Responses 400 that took it dark; no_clips = the gather finds nothing on-theme; model_declined is by-design restraint, never flagged). The GPT-vision failure now degrades caption-only in react_to_trending (drop the frames + retry once) rather than falling through to a news take every slot. Retired 2026-10-15 (#1871): the discourse loop is dormant, so no new rows after that date. | | discourse_dedup | cogs/discourse.py | guild_id, channel_id, decision (similarity_gate), signal (same_link/text_similarity/shared_run/content_overlap), post_preview Retired 2026-10-15 (#1871): the discourse loop is dormant, so no new rows after that date. | | music_desk_scored phase=projection | cogs/music_desk.py (_compose_line, #3913, 2026-10-25) | no new event: the sales-projection lane’s score, its pre-judge drops (ungrounded_number, cut_qualifier, the ranking guard) and its claim_arbiter rows now carry phase=projection. Before, _compose_line set the phase from the reading (reveal if settled, else tracking), and a projection reading is always live, so every projection score read phase=tracking: 4,349 of the 4,845 tracking scores in the 30 days to 2026-10-25 were projection lines. music_desk_posted and post_dedup already said projection. The two shape_reject rows that _compose_line emits (reason=metric_word:*, reason=release_title) keep phase=shape_reject and now name the lane in detail.lane. Query: where event == 'music_desk_scored' and phase == 'projection'. No dashboard panel or monitor filters phase == 'tracking'; the ops monitor’s per-lane self-gate check now shows music_desk/projection as its own lane. | | music_desk_scored phase=chart genre | cogs/music_desk.py (2026-09-30) | no new event: a chart card that the GENRE DEBUT rule let through (a Billboard 200 debut that also debuts top 15 on a genre album chart) carries genre = <genre chart>#<rank> (folded into detail). Query: where event == 'music_desk_scored' and phase == 'chart' and isnotnull(parse_json(detail)['genre']). See docs/BILLBOARD_ALERTS.md. | | music_desk_scored (any phase, shipped=false) – the COOLDOWN NOTE | cogs/music_desk.py (2026-09-06) | no new event: a shipped=false row from _compose_board_unit, _milestone_candidate or _compose_record_unit now also stamps fail:<dedup key> in music_desk_events, and every lane skips that key for MUSIC_DESK_FAIL_COOLDOWN_HOURS (12). So a pick that fails reads as at most ~2 rows a day instead of one per slot (before: one radio board 165 rows in 7 days, one record card 61, one chart-peak card 73). Read a lane’s failure rate as dcount(matchup) alongside count() – the same failing pick no longer inflates the count. A skip emits nothing (it is derived state, and a per-slot skip row would re-inflate the very count this fixes). reason=stale_score, phase=reconciliation, shipped=false, score=0 (2026-09-17): a scored chart call older than the music_reconcile_post_max_age_days knob (default 10 days after the chart date) was recorded in the ledger and on called_shot_scored and deliberately NOT composed — a late re-score (the re-opened EP verdicts, a queue drained after an outage) is not news. Emitted so a stale queue reads as “scored, not posted” rather than as composes that never ran | | music_desk_scored phase=shape_reject | cogs/music_desk.py | guild_id, reason, matchup (the lane), post_preview — a composed line DROPPED by a deterministic guard before the self-gate. reason=self_correction through ship_guard.drop_self_correction (the 17 lanes on the shared guard, every <surface>_scored kind) carries detail.via since 2026-10-18 (B4 of #3410): the phrase lists are the trigger, Haiku ClaudeClient.shows_working decides (claude_api purpose=shows_working), and via=rule means the model gave no answer and has_self_correction decided. Measured: 4 of 11,294 real composed posts in 30 days trigger a call. A rise in via=rule is the model failing; the desk_shape_reject panel splits by via. reason=identity_subset (#3019, every lane) means the take ranked or picked out acts by gender (“SZA is the only woman in the top five”) and the lane’s source does not state that category; constitution.py forbids identity inference, so the guard (output_checks.has_identity_subset_claim) fails closed and detail.phrases names the wording. The source is the lane’s own input where it is open text (the board blob, the milestone and reveal VERIFIED qualifier, the chatter thread, the record card); the structured-feed lanes pass none, because their rows carry no gender field. Measured before shipping: 0 of 6,493 distinct shipped desk lines (30 days) flag, so a non-zero count is a real attempt – read the phrase and the lane’s block. It shows on the existing desk_shape_reject panel (split by reason); no monitor, because the guard already drops the take and the panel shows any rate. reason=self_correction means the response showed the model redrafting in the open (a draft, then “Wait, I can’t say that”, then another draft — the 0.6 gate scored that 0.90 and was right to, since it grades whether claims are GROUNDED, not the SHAPE of the response). market_drop_scored reason=decline_narration is the market DOWNWARD-move guard (ship_guard.drop_market_decline, owner steer 2026-09-18: “Never say dropped don’t narrate negative changes just say is at”): the take narrated a fall in a market number (“first-week forecast has dropped to roughly 61.5K”, shipped to X 2026-09-18 at a 0.9 gate score, grounded in the surface’s own earlier line), detail.phrases names the words; the same reason on market_filtered is the alert’s copy (has_market_decline). The cause was cut first (the drop blob withholds a downward move, the compose no longer allows “the direction as color”, the self-dedup history says the earlier figure is not part of the new line), so a NON-ZERO rate here means the model is still writing the decline off something it is handed – read the phrases. market_drop_scored reason=cross_source_comparison is the same guard on the drop surface – its projected RANK card reports HITS’ rank and units for a Kalshi market’s release, so it is the one other surface holding both publishers at once. music_desk_scored reason=cross_source_comparison is the one guard with NO lane set (owner steer 2026-09-16: “never compare kalshi and hitsdd numbers”): the take weighed a prediction-market first-week figure against HITS Daily Double’s figure for the same release. Both numbers are grounded, one in each block, so the self-gate ships the shape – “priced at ~78.4K first-week units on Kalshi, ~6.4K above HITS’ projection” scored 0.88 and posted on 2026-09-16, and the projection lane shipped ten more over 2026-09-05..07. The compose blocks are the first fix (each board now composes on the ONE source it publishes, and _PROJECTION_FRAMING no longer leads on the divergence), so a NON-ZERO rate here means a lane is still handing a model both publishers’ numbers – read it as a block to find, not only as a guard firing. reason=career_claim means a bare-row lane (chart / radio / called_shot / reconciliation / watch_chart / debut_sales) ranked a release against the artist’s other work, which its block can never support; measured over 40 hours of real posts, 2 of 38 desk posts leaked one from the chart lane and both reached the room. NOT applied to reveal / milestone, whose context stack can genuinely ground a catalogue fact. reason=decline_narration is the chart-POSITION lanes’ second shape rule (owner steer 2026-09-05 “never call it a slip just say where it is”, then 2026-09-06 “never say sliding down, big fall, dont narrate the downwards movement just say where it is”): a take on chart / watch_chart (the desk’s _POSITION_LANES, via _unshippable) or the newsroom’s chart lane (music_news_scored, via ship_guard.drop_decline_narration) narrated the DOWNWARD move (“sliding down from its #8 debut”, “drops four spots to #16”, “went from #8 to #16”) instead of stating where the title sits; the matched wording rides detail.phrases. It exists because the judge grades whether a claim is TRUE and a narrated decline usually is: the 2026-09-06 leak scored high for its “grounded context (debut peak included)” and shipped to X. Not on chart_exit, whose story is the departure. Measured on the real inputs with the real model, n=6 per arm: the old newsroom framing leaked 5/6, the cause reword 0/6 with or without a banned-word list (the list was cut as scar tissue), and the final framing plus the chart lane-hint reword 0/8, so expect a rate near zero; a sustained rate on one lane means that lane’s framing lost to its own context (the newsroom hands the model the debut and peak in its BACKGROUND block, which is where the path narrative comes from). The desk_shape_reject panel groups by reason, so the new reason appears with no dashboard change; no monitor, for the reason given below – a guard firing is the guard working. reason=prior_position is the FALL take’s third shape rule (owner steer 2026-09-18: “Don’t narrate previous position if it was higher. Just say current position.”): a take on chart / watch_chart whose row FELL (the desk passes fall_rank, the take’s own rank, for a _FALL_KINDS / top_fall row only) or the newsroom’s chart lane on a settled weekly row whose last_week is the smaller number, or, with no settled row, on a post whose own single move marker states a fall (“#3(-1)”, “falls 3-5”; mn.wire_fall_position, #3859) (music_news_scored, via ship_guard.drop_prior_position) named the previous position, the earlier week, where the title sat before, or any chart position other than its own – the PEAK included; the matched wording rides detail.phrases. It exists because the decline guard matches a decline VERB and “Man I Need by Olivia Dean sits at No. 10 on this week’s Billboard Global 200, after ranking No. 9 last week” (2026-09-18 08:10Z) has none; the judge scored it 0.85 and praised the clause as “correct per ground truth”. Its own value, not decline_narration, so the two leaks stay separable on the panel: a decline verb and a bare previous position have different framing fixes. The cause was cut first (the two desk fall framings no longer hand over the previous rank or the peak, measured 0/6 and 0/5 on the real row after), so expect a rate near zero; a sustained rate on one lane means that lane’s framing lost to its own blob, which still prints last week’s rank and the peak because the self-gate grounds on them. Same panel, no monitor, same reason as above. The board-shaped lanes (standings / chart_board / chart_proj) take only the ALL-TIME half of that check (has_alltime_claim): a ranked board hands the model every position an act holds and names the highest, so “her highest entry sits at #13” is read off the block, while “her highest-charting album yet” is still invented. reason=attribution_tag is the publication-card lanes’ credit guard: the take names its source in the banned press-release form (“, per HITS” — has_attribution_tag) and survived the one name-the-phrase re-ask (_detag_credit); the dry run measured the raw leak at 5/5 HITS-credited takes and the re-ask cleared all 5, so a nonzero steady rate here means the re-ask stopped working. reason=row_tally grounds rather than bans on the music desk’s board lanes (#2662): the guard rejects a count of a board’s rows because the block hands over each row alone and never a total, but the STANDING board states its top-10 count in the block and leads with it. Measured on four real composes, all four matched the flat ban and would have died before the judge, so the board would have shipped nothing. _drop_before_gate now takes the block and ungrounded_row_tally passes a tally the source states; a count it does not state still drops, and every lane that passes no source is unchanged. A rising rate here on watch_board means the compose started naming a top-10 count the board never handed it. reason=row_arithmetic is the board lanes’ own guard (#2031) — a take that SUMS the board’s rows (“more than the next two combined”). The board gives each act its own number and never a total, so a combined claim is arithmetic the model did itself, and the live dry run caught it doing one that was false (11 spots “more than Drake and Ella Langley combined”, off rows reading 7 and 5) while the 0.6 judge scored it 0.92 and printed the arithmetic in its own reason. Same shape as career_claim: every INDIVIDUAL number is grounded, so only a deterministic rule catches the derived claim. reason=self_correction now rides THIRTEEN more surfaces (#2755 + its class sweep). The checker was always shared, but only three cogs CALLED it – music_desk, cinema_desk and sports_boards, each through its own private _unshippable. Every other surface that composes model prose and cross-posts it to X ran no shape check at all, so a self-correction on any of them shipped. utils/ship_guard.py is the one wiring home, and it deliberately emits on the CALLER’s own <surface>_scored kind with this same phase=shape_reject reason=self_correction shape, so market_drop_scored, music_news_scored, pop_desk_scored, music_alert_scored, sports_desk_scored, cinema_news_scored, bet_alert_scored, bet_board_scored, bet_value_scored, discourse_scored and music_scored join the desk_shape_reject panel and the ops monitor’s NON_SCORE_PHASES rule with no dashboard change and no new field against the budget – every field it writes is already in SHARED_FIELDS. matchup carries the lane, or lane: subject where the surface tracks one. TWO cogs are deliberate exceptions and do NOT appear on that panel, because neither has a <desk>_scored event for the helper to emit on. x_mentions counts its declines by reason on the single x_mentions event, so it calls the checker directly and emits reason=self_correction there beside row_tally and declined. market_alert has no *_scored event at all, so each of its three compose lanes records the drop on the event that lane already uses: market_filtered reason=self_correction on the main alert lane (the same pre-ship drop event its #2547 number backstop uses), rt_reconcile on the reconcile lane, market_outcome on the outcome lane. Watch all three – a gap in one lane is the whole bug. discourse runs FOUR compose lanes and the ICEBREAKER is the fourth – its drop rides discourse_icebreaker reason=self_correction, not the panel, because that is where that lane’s other drops already are. discourse_scored DOES ride the panel, but note its drop does not silence the slot: a self-corrected line takes the same fallback path an empty compose does (discourse_fallback reason=self_correction, then the quip or the icebreaker), so the shape reject and the fallback are two events about one moment. Expect a rate near zero: over the full 30-day population of 10352 real composes on the helper-wired surfaces the guard fires 5 times. Four are market_drop and were already stopped downstream. The fifth SHIPPED, and it is the reason the class sweep mattered: on 2026-08-16 a discourse take carrying “Wait, let me pull the real link.” between two drafts scored 0.78 against the 0.6 floor and crosspost surface=discourse fired four seconds later. The one surface with a measured live leak was the one #2755 did not list. Read a sustained rate the same way as on the desk – a lane’s framing losing to its own context. This rate was NOT observable before the change, so the first weeks of it are a baseline being established, not a regression. The one shape to watch is a take that OPENS with a marker word. The line-leading arm anchors on ^ as well as a newline, so a take whose FIRST word is wait / hmm / correction / scratch that / hold on / on second thought / actually, no is read as a self-correction. On a chart desk that never happens; on the CONVERSATIONAL surfaces this change newly covers (discourse, music) a rhetorical “wait, …” opener is much closer to the persona’s real voice. Measured before shipping: an adversarial set of 20 hand-written legitimate lines flagged 8, but zero of the 10352 real composes in the 30-day window open with a marker word, so the risk is real in principle and has no observed incidence. If a compose-prompt change ever makes her open that way, this guard starts eating good posts and the tell is a shape_reject rate that climbs on discourse_scored / music_scored while the desks stay flat. No monitor, for the reason stated below: a shape_reject firing is the guard WORKING. reason=metric_word:<word> (#2161) is the same shape on the ALBUM-METRIC axis, and it is the one shape_reject reason music_alert_scored also carries (phase=shape_reject, the alert cog’s first use of this marker). One album carries a Pure Sales ladder AND an Album-Equivalent Units ladder at the same time, and the two figures differ by an order of magnitude — live on 2026-08-08 the Karol G album read ~3.1K pure sales and ~45.8K equivalent units in the same tracking week. A take that quotes its number exactly as given but names the OTHER metric (“3.1K units” off the Pure Sales ladder) reads as fully grounded, so the 0.6 gate ships it — it scored that exact take 0.72. output_checks.metric_foreign_word (since CB-12, #4557, through utils.album_metric.foreign_metric_word: the word table finds the word and Haiku claude_api purpose=album_metric reads the line’s metric; only a read of its own metric clears it, and no answer keeps the drop; panel album_metric_read) keys off the READING’s metric rather than the lane, which is why it sits beside _unshippable instead of inside it (_drop_before_gate only ever sees the story name). Measured against the 34 real shipped album-market posts in music_alert_state: it flagged the 2 wrong ones and left 32 alone. The desk_shape_reject panel (added in the same change) is where every reason on this phase is graphed, split by surface — until then nothing graphed shape_reject at all: desk_quality filters it out as a non-verdict and desk_declined counts it in neither column, so a take the model DID write and a guard refused was the one compose outcome with no dashboard home. No MONITOR yet, deliberately: a shape_reject firing is the guard WORKING, and there is no calibrated rate that separates “the guards are earning their keep” from “a prompt regressed” — the panel builds that baseline first. Expect a rate near zero — the live rate was 2 of 15 pure-sales alerts, so a SUSTAINED nonzero rate means a compose prompt started conflating the two metrics again, and a jump to most pure-sales posts means the metric label stopped resolving (check luminate.event_metric). A rising rate means a lane’s framing is losing to its own context. reason=ungrounded_number (#2081) is the same shape on the other figure surfaces – music_news_scored, cinema_desk_scored, music_desk_scored (every number lane, #2543/#2559), music_alert_scored (both compose paths, #2547), market_drop_scored (#2547), sports_board_scored, sports_desk_scored, pop_desk_scored, bet_alert_scored, and (as market_filtered reason="ungrounded_number", its existing pre-ship drop event) market_alert (#2547) carry it, under the lane’s own phase/kind rather than shape_reject, with the flagged values in detail.numbers. On the music_desk REVEAL/tracking lane there is a SALVAGE step before the drop (_reask_ungrounded, owner call 2026-08-25): a production check found good debut cards (ENHYPEN, Stella Lefty) suppressed slot after slot because the model kept adding an invented comparison (“ahead of the 110K the chart accounts project”), so the lane now re-asks ONCE – naming the exact figure and asking for the same read without it – and ships the retry only when it comes back clean AND still carries a MARKET-SCALE figure the draft had (a grounded value >= 1000, so the retry cannot drop the headline units/streams number and ship a numberless take beside a number card; #2571 review) (verified salvaging 5/5 on a real-model dry run). A successful salvage emits its OWN event – music_desk_scored reason="ungrounded_salvaged", shipped=True, the flagged figures in detail.numbers (#2571 review round 4) – so the compose-fabrication rate the salvage fixes stays visible to the ops monitor instead of being masked. A music_desk_scored reason="ungrounded_number" on the reveal lane therefore means the re-ask ALSO failed, so a nonzero rate there is now a stronger signal; a rising ungrounded_salvaged rate means the compose prompt is inventing figures more often even though the card still ships. The market-family surfaces (market_drop / market_alert / betting_alert) and the desk surfaces ground through the shared output_checks.numeric_grounding — the source plus a bare copy of every digit-run it holds — so a compact token (“24h” paraphrased as “24 hours”, a milestone “50th” written as “50”) is not a false-positive drop; market_drop/market_alert also add the market title/topic. market_alert + betting_alert deliberately WITHHOLD the prior price from the blob (#1140), so the backstop there also deterministically enforces “no movement number the model was not handed” — a take narrating “cratered from 50%” drops. A real-model dry run over all five surfaces flagged 0 false positives. It fires when the composed line states a number the ground-truth block does not (output_checks.ungrounded_numbers). It exists because a fabricated figure that SHIPS is the one failure this ledger cannot otherwise report: it shipped because the judge scored it HIGH, so there is no low score to flag and no error to triage, and no after-the-fact check is possible either – these events carry post_preview but deliberately never the ground truth (data minimization), so the comparison exists only at compose time. Measured against the real #2069 and #2076 fabrications it caught 4 of 4, and against the real posts that shipped in both dry runs it flagged 0 of 13. A NON-ZERO rate is the interesting signal here: it means a compose invented a figure and the judge would have been the only thing standing in front of it. reason=career_ordinal (#2154) is its words-not-digits twin, on music_news_scored only, with the flagged counts in detail.career_ordinals. ungrounded_numbers reads DIGITS by design, so a career tally written as an ordinal was invisible to it – and the #2152 re-roll actively SELECTED for that phrasing by rejecting every digit-carrying take, which is how “Cardi’s fourth Diamond single and Megan’s first” shipped to X at a judge score of 0.92. The check is GROUNDING, not phrasing (output_checks.ungrounded_career_ordinals): a wire block that states “her sixth consecutive #1” grounds the take’s “sixth”. That distinction is load-bearing – banning the possessive-plus-ordinal SHAPE was measured against 900 real shipped takes and would newly block 23, all but the WAP post true and sourced, repeating the mistake has_alltime_claim had to undo in #2031. Shipped as a MEASUREMENT first and promoted to a gate on its own rule: it flagged 1 shipped take in 74 over 30h (~1.4%), the low-rate outcome that makes the gate cheap. Like the digit guard it marks the post SEEN, so the story is not re-rolled until a take omits the count. Expect a rate near zero; a sustained one means a compose lane started inventing career tallies. phase=chart_run is the per-release chart-run milestone that replaced a long-runners board (#2031): measured on the real Billboard 200, that board’s top ten was 10/10 identical week to week, so it was cut in favour of a rung that fires once per release. It rides the shared songstats_milestone_state store under a bbrun: entity prefix with metric=chart_weeks, so no new table; a rate of ~3/week across the Hot 100 + Billboard 200 is normal, and 0 for weeks on end means the seed pass never completed. phase=chart_board / phase=debut_sales / phase=chart_proj are the PUBLICATION card lanes (owner steer 2026-08-06, utils/chart_boards.py): the weekly top-10 + debut-sales cards, the individual first-week-units cards, and the four daily projection boards. They ride this same event with no new fields and join the per-lane lane_dark health automatically (music_desk is in LANE_PHASE_SURFACES). Expected cadence: chart_board ~4/week (three top-10s + one sales board), chart_proj 4/day (the HITS variants, each once daily, rotating least-recently-posted first; the owner’s “weekly and daily ofc” steer) — a silent chart_proj day means the HITS read is dark (check hits_fetch) or the document has no chart date. phase=lifetime_board is the LIFETIME record-boards lane (owner ask 2026-08-19, utils/lifetime_boards.py): the evergreen career-tally leaderboards (“most Rhythmic Airplay No. 1s”). It rides music_desk_scored/music_desk_posted with NO new event or field — the desk ship-rate and lane_dark panels group by phase and pick it up automatically. Its data is CURATED (a cited registry, guarded by lifetime_boards.validate), so there is no fetch to fail and no fail-open integration to watch — no dedicated MONITOR is warranted; the shared desk ship-rate covers a compose regression. Expect it QUIET: at most one board a slot, and a board posts ONCE on first sight, then never again until its RANKED ROWS change. The lane keeps a DURABLE per-(guild, experiment-stage) signature — a hash of the ranked artist:value rows (lifetime_boards.signature, which deliberately EXCLUDES as_of, so a date-only refresh does NOT re-post) — in kv_cache, and skips a board whose stored signature still matches. A real change (a new figure, a new leader, a new act) is a new signature and posts again. The state is written from the successful-delivery / intentional-dedup path, not at compose, and the STAGING stamp is separate from the PRODUCTION one, so a #bot-logs audition never suppresses the room after promotion. It is on _BOARD_LANES for the row-sum/row-tally guards but EXEMPT from has_alltime_claim (_ALLTIME_EXEMPT_LANES), because stating the all-time record IS the surface — the same carve record_card gets, but this board keeps the row checks. | | cinema_desk_scored phase=source_thin reason=opened_film | utils/cinema_numbers.py (fetch_cinema_stories) | guild_id, shipped=false, score=0.0, reason, matchup (the skipped films, 80 chars) — the tracking lane skipped a pull that names a film whose opening result is already out (#4475). No compose ran, so source_thin keeps it out of quality stats; source_dark counts it against the guild’s cinema_desk_posted rows, and the desk posts other lanes, so it does not fire. Expect it on most Friday-to-Sunday slots, and rarely mid-week. A mid-week run of it means the dated query is answering for the wrong weekend again. | | cinema_desk_scored phase=source_thin reason=critics_posted | cogs/cinema_desk.py (_pick_stories) | guild_id, shipped=false, score=0.0, reason, matchup (the film, 80 chars) — the scores story lane dropped a film whose critic scores already posted, on the critics board or as an earlier scores story (#2517). Both lanes read and write the one critics_board milestone stamp. No compose ran, so source_thin keeps it out of quality stats. Expect it rarely: only when the week’s lead new release is on both the weekend chart and the TMDB now-playing list. | | <surface>_scored reason starting IDENTITY: | claude_client.py (_IDENTITY_RULE) | no new field. music_desk_score scored the post 0.0 because it sorts acts by gender in a way its ground does not state (#4261). Every lane that calls music_desk_score emits it: the desk’s music_desk_scored, the newsroom, the alerts, releases and X mentions on their own *_scored events. Since #4276 drop_score carries it too (_with_drop_fences, with a pass line for sports event names), so market_drop_scored, music_alert_scored, bet_board_scored, bet_alert_scored, sports_board_scored and the market_alert / market_outcome rows can carry it. Count them with ['tootsies'] | where event endswith '_scored' and reason startswith 'IDENTITY:' | summarize count() by event, bin(_time, 1d). | | <surface>_scored reason starting WORKING: / HEDGE: | claude_client.py (_SHOWS_ITS_WORKING_RULE, the music-desk CONFIDENCE DISCLAIMER line) | no new field. The self-gate judge (music_desk_score, drop_score, cinema_desk_score, and winner_line_score since #4228, on the market_outcome row) scored the post 0.0 because the post shows the model’s working (a refusal, a search, a self-correction, talk about its task) or doubts its own figure (#3427). A forecast stated as a forecast is not a HEDGE: (#4289); a HEDGE: row on phase=='tracking' whose post has no doubt word is the regression to watch for. Count them with ['tootsies'] | where event endswith '_scored' and (reason startswith 'WORKING:' or reason startswith 'HEDGE:') | summarize count() by event, bin(_time, 1d). The wheel’s form of the same judge is a claude_api row with purpose="decline_narration". | | music_desk_scored phase=riaa_plaques reason=plaques_recent | cogs/music_desk.py (2026-10-23, #4683) | guild_id, shipped=false, score=0, matchup (the act), detail{chart, last} — a per-act NEW PLAQUES board skipped BEFORE compose because the same act and format (detail.chart) already posted a board in the last 7 days (_RIAA_PLAQUES_ACT_GAP_SECONDS; detail.last is that board’s UTC date). The board window is 3 days, so one new award used to redraw every award still in it: Zach Bryan and Wiz Khalifa each posted their singles and album boards on two days out of three (2026-10-21 to 10-23). The mark is <guild>:<stage>:riaa_plaques:recent:<chart> in lifetime_board_state, written on a confirmed delivery with a 7-day TTL. A steady rate on a busy cert week is the guard working; a board that never posts again for an act means the TTL is not expiring. | | music_desk_scored phase=riaa_plaques reason=plaques_twin | cogs/music_desk.py (2026-09-05) | guild_id, shipped=false, score=0, matchup (the skipped act), detail{chart, twin} — a per-act NEW PLAQUES board skipped BEFORE compose because its signature already shipped under ANOTHER credited act of the same collaboration batch. 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 on 2026-09-04 and again as Tito Double P at 00:02Z, one signature under two act keys; only the text dedup stopped the twin. The board now keeps a second fire-on-change state keyed on the signature alone (<guild>:<stage>:riaa_plaques:sig:<sig> in lifetime_board_state, value = the chart that shipped it, written on the same confirmed delivery or dedup close-out as the act’s own key). detail.twin names that chart. Expect it rare (a collaboration-only batch between two recognised acts); a steady rate is the guard working. The SAME night also showed the other way a plaques board repeats: #2957 renamed its chart key (riaa_plaques:<act> -> :single), so every open batch read as never posted and Nickelback’s board went to the room twice (15:07Z and 02:08Z, the same signature under both keys). No event marks that class; the tell is a music_desk_posted phase=riaa_plaques for one act twice inside the 3-day window with no new award between them. | | music_desk_scored phase=catalog_stat / phase=daily_hits reason=rank_names_act / rank_wrong_place / rank_wrong_metric / rank_unchecked | cogs/music_desk.py (#3517, 2026-09-20; CB-11 #4557, 2026-10-19) | guild_id, shipped=false, score=0, matchup (the act), post_preview — a composed CATALOG-COUNT or DAILY-HITS caption dropped by the lane’s own rank fence (music_desk._catalog_caption_reason: the exact checks in catalog_stats.rank_claim_mismatch, then Haiku catalog_caption_check, purpose catalog_caption, for the open shapes; rank_unchecked = Haiku gave no answer, so the caption drops, fail closed; handed to _milestone_candidate as caption_check and run right after the shape guard, BEFORE the number backstop, the ranking guard and the paid self-gate, so a rejected line is recorded once, here, and never also as a shipped score). rank_names_act: the caption names an act the field holds above or level with the subject (exact match), OR Haiku reads a named comparison with any name (“trailing only X”, “right behind X”, “second only to X”, “only X has more”; the phrase regex that read these until CB-11 missed “right behind” and falsely hit “after only six years”) — the shape the owner cut on 2026-09-20; the material states the place only, so the comparison is invented, and checking the name alone would pass an act the model made up. rank_wrong_place: the caption states a COUNT place the lane did not prove (“3rd most among …” off a 4th-place rank, “the most among …” off anything but first); the place syntax is scoped to the count ranking (“Nth most among / of any”, “#N among”), so the standing block’s “#6 most-streamed artist” and the top song’s “most-streamed” are not read as places. rank_wrong_metric: Haiku reads a place put on another metric (“Spotify’s fourth-most-streamed artist”, “fourth in monthly listeners”). The shared ranking guard (ungrounded_ranking_claims) keys on a ranking CONTEXT and passes all of these shapes (Codex review on #3517, measured), which is why the lane reads the place itself. The fence runs ONLY on the deterministic-rank path: a ROUND count composed off a reported web qualifier states that source’s place, not the field’s, and gets no check. The rung is recorded on delivery, so a dropped act re-composes next slot. Expect it rare (the live dry run on #3517 gave 0 of 4); a sustained rate means the compose is reaching past its material — read post_preview. No panel or monitor of its own: it rides the existing music_desk_scored shipped=false drop panel and the per-reason breakdown there. | | music_desk_trim | cogs/music_desk.py (#3563, 2026-09-21) | reason, kind (the lane), tier, metric, guild_id, and channel_id on streams_tier; detail{artist, rung} — ONE event per card the NOTABILITY TRIM refused. The owner asked for it with the trim itself (“make sure we have metrics for this — what we’re cutting and how much we’re cutting each”), so the change can be reviewed a week after it ships rather than argued about. This row was rewritten once because successive fixes left it contradicting itself; keep it coherent rather than appending. The reasons. unwatched_move is a plain chart MOVE by an act the watch does not carry, from the 2026-09-21 volume review of a single day’s posts; measured live it refuses about 16 a sweep against 38 stories kept, naming the acts the owner had deleted by hand. That review’s other cut, the surge band from #50 to #20, is deliberately uncounted — a counter for it measured 116 refusals a sweep, because without the band’s previous value it counts every big mover anywhere on the chart, which reads as cards cut and is not. Read its volume from the before-and-after watch_chart posting rate, exactly as with chart_exit. streams_tier is the artist-tier gate on the STREAMS family only — a first-week, album-equivalent or pure-sales forecast never meets it, by owner scope. light_milestone is a 100M/250M/500M track-streams rung on an act outside the daily top 50. tenure_floor is a chart-tenure rung under 730 days or 100 weeks. release_age (2026-10-20, utils/recency.py) is the release-age cut: a single-title chart card or chart milestone outside the top 5, or a 100M/250M/500M streams rung, whose title is more than two years old. Its tier names the half that refused it: deep (below No. 10 and not a debut, cut on position with no lookup), old (dated, older than MUSIC_DESK_RECENT_MAX_AGE_DAYS), no_date (the resolver had nothing). kind is the lane: watch_chart and chart count OFFERS (the cut stamps nothing, so a held position is offered every slot; read dcount of the act per day), and the milestone stories count CARDS for deep / old because the rung settles. Since the same day the ALERT cog’s daily lanes (cogs/music_alert.py _recency_cut) count here with kind = the lane (apple_jump, radio_riser, itunes_debut, …): the climb lanes take a top-20 line (MUSIC_RECENT_CLIMB_MAX_POS), every other lane the top 10, and a cut marks the title out of tries for the day (_refuse_today), so each cut is offered once a day. The newsroom’s chart stories count on music_news_filtered reason=release_age instead; the release_age_cut panel on the desks board reads both. fall (2026-10-23, owner steer “only rise”) is the fall cut: a title that FELL gets no single card. On the watch lane (kind=watch_chart) it is a top_fall inside the top ten; on the Billboard lane (kind=chart) it is a fall / top_fall that passed _move_is_cardworthy, so a deep fall the rank cap already refused is not counted. On both lanes _count_fall_cut applies the release-age rule first: a fall that rule refuses still counts as release_age, so fall holds only falls that would otherwise have posted (Codex on #4679). A chart with a top-10 board (platform_top, chart_board) shows the fall there, and the artist boards still read falls; Global Excl. US, Spotify Global Weekly, US radio and US YouTube have no top-10 board, so their falls are not shown at all (about 10 cards a week before the cut). It counts OFFERS: nothing is stamped, so a held fall is offered again each slot; read dcount of the act per day. Its before-and-after volume is the watch_chart posting rate (108 position-only cards in the 7 days before the cut). itunes_depth (2026-10-23, owner steer “lets cut itunes to top 5”, “for both”) is a single iTunes card below #5 (x_gate.ITUNES_TOP_N, which the X gate reads too): the desk’s watch lane counts it with kind=watch_chart (OFFERS, no stamp), and the alert cog’s iTunes lanes count it with kind = the lane (itunes_jump, itunes_debut, …). Neither stamps anything, so both count OFFERS (about one per tick while a title sits below #5), and a title that climbs into the top 5 later the same day still posts (Codex on #4679). The newsroom’s iTunes stories take the same cut as music_news_filtered reason=itunes_depth. The iTunes top-10 boards still post. Measured on X before the cut: 28 of 77 iTunes posts in 2 days sat at #6-#10. What counts as a card cut, and what does not. tenure_floor settles its rung on the way out (_settle_rung_quietly), so it fires once per crossing and its raw count IS cards. light_milestone settles only when tier=off_top_tier, a DECIDED cut: the gate runs only once ranked_to proves the watch reached the tier, so an act placed inside the daily prefix past the line, or absent from a WHOLE page, is genuinely not in that tier. Its other three tiers are UNDECIDED, do not settle, and therefore repeat per slot and per channel: listeners_only (the act was placed only among names the monthly-listeners page contributed, pos > daily_count, so that rank says nothing about daily standing) and short_page (the act was absent from a daily page that reached the TIER but not the watch size, so absence is not evidence — an act at real rank 61-100 is simply missing from a 60-row page), and total_top (the act’s kworb TOTAL-streams rank is inside the tier, so a daily position past it may be a “Daily” under-read, #3868). streams_tier never settles anything: it fires inside _pick_readings, which re-reads the live Kalshi set every slot, and the compose path runs once per configured channel plus once more on a 429 retry. So: group by tier, and read dcount of the act per day rather than a raw count for streams_tier and for the three undecided milestone tiers. The desk_trim panel groups by reason, tier, kind per day for exactly this reason. Adding the tiers together reads a watch-resolution failure as noise removed, which is backwards. What the streams_tier count excludes, each for a measured reason: the pick loop stops at its per-slot limit, so counting inside it would measure where a refusal ranked rather than how much the gate holds back; the raw sweep carries stale reveals and duplicate tickers rank_desk drops, which this lane could not post anyway; and an already-carded event (reveal_seen / exclude) is held by the dedup, not by this gate, so counting it would re-bill the gate every slot of the dedup window. Two lanes are deliberately uncounted. The CHART EXIT lane returns before it reads Billboard, so a counter would mean doing the work the switch turned off; its volume is the 48 music_desk_posted rows from before 2026-09-21 — 12 a week — which ride phase, not kind: where event == 'music_desk_posted' and phase in ('chart_exit','chart_exit_board'). The ALERT cog applies the same tier gate and counts nothing: its tick polls every 10 minutes ahead of every eligibility check and the per-tick post cap, so its refusals are not cards. Be precise about what that leaves uncovered — the desk counts what THE DESK could post, so a streams market rank_desk drops is refused on the alert lane and lands in no count at all. The miss-verdict change has no counter either: it changes what a card says and cuts no card. Baseline, measured on the 28 days BEFORE the change: the desk posted 1531 cards (383 a week); the tier gate cut 11 of 111 live Kalshi readings and 0 of 42 units/sales readings; 23 of 30 tenure cards sat under the floor; 13 of 26 light track-milestone cards named an act outside the top 50. No monitor, deliberately — a gate firing is the gate working, and the failure this trim could cause is the desk going QUIET, which the ops monitor’s existing surface_dark / lane_dark findings already cover. The signal to watch is the opposite of a spike: all three reasons at ZERO for a week means the kworb watch is failing open (an empty watch reads every act as recognized), not that the noise stopped — read chart_fetch before reading a zero as success. | | music_desk_clause_cut | cogs/music_desk.py (#3257, 2026-10-17) | post_preview (the line BEFORE the cut, first 120 chars); detail{clause} — ONE event per take whose trailing clause _cut_flourish removed: output_checks.trailing_clause found a digit-free last clause, and Haiku (claude_api purpose=flourish_check) answered FLOURISH. The post still ships without the clause. No guild_id: _detag_credit does not carry one, so a count over two destinations counts each take once per destination. Expected under 1 a day (18 of 5,939 shipped lines in 30 days, from about 37 flourish_check calls a day). Panel desk_recompose_cut. No monitor: a cut is the check working, and a check that stops working fails toward today’s behavior (the clause ships). | | music_desk_scored reason=dedup_memo (score 0.0) | cogs/music_desk.py (#3581, 2026-10-17) | guild_id, phase (the board lane), matchup (<kind>: <subject>), shipped=false — a MARKER, not a judge verdict: _compose_board_unit skipped a board whose same headline the post-dedup gate dropped within _DEDUP_MEMO_HOURS (4h). No line, no judge; ops_monitor.NON_SCORE_REASONS keeps it out of quality stats. Read it against post_dedup on the same phase (panel desk_recompose_cut): the platform lane’s drops fall from about 50 a day to a few, and these skips take their place. | | music_desk_scored reason=census_told (score 0.0) | cogs/music_desk.py (#3155, 2026-10-17) | guild_id, phase (standings or artist_catalog), matchup (<kind>: <subject>), shipped=false — a MARKER, not a judge verdict: _census_told skipped a census board or an artist board whose fact key (chart_standings.census_fact_key: the act, the chart, the printed chart week and the act’s set of titles) is already in the SHARED wire_chart_census store. The desk’s census and artist boards, the X-mentions boards lane (room posts) and the newsroom’s whole-chart count relay (production) write that store. No line, no judge; ops_monitor.NON_SCORE_REASONS keeps it out of quality stats. Query: where event == 'music_desk_scored' and reason == 'census_told'. | | music_desk_scored phase=pollstar_board / phase=radio_format_board reason=stale_chart | cogs/music_desk.py (2026-09-06) | guild_id, shipped=false, score=0, matchup (N of M boards, dated <oldest chart date>), detail{charts (the stale chart keys), age_days} — ONE row per Pollstar lane per slot when a board’s chart is older than pollstar_boards.MAX_CHART_AGE_DAYS (10); the boards yield BEFORE compose. This is the freshness signal for a feed that stands still: pollstar_fetch reads ok=true because the data arrives (it is just old), so without this row a stuck upstream looked like a lane that was merely deduped. Measured 2026-09-06: every chart at Aug 14/17 for three weeks, and “this week” boards posted off the Aug 17 chart on Aug 30-31. A stale_chart count that persists across days USED to be read as “the upstream, not us; the lane resumes on its own when the feed moves”. That was wrong, and it cost 15 days of two beats (#3357). The lane could not resume: Pollstar publishes every weekly issue under its OWN chart id and never updates one, so the pinned registry ids kept serving 8/14 and 8/17 forever at HTTP 200. The reader now resolves the current issue off each chart’s config endpoint, so a sustained stale_chart count means the upstream genuinely has not printed. It is also no longer silent: the rows are counted by the ops monitor’s lane_stale_upstream (high) finding, and they are excluded from judge-quality stats (NON_SCORE_REASONS) — left in, 304 markers a week pulled the music desk’s measured mean from 0.83 to 0.74 | | music_alert_scored / music_alert_posted phase=artist_market / phase=artist_market_move / phase=artist_market_leader / phase=artist_market_settle | cogs/music_alert.py | the alert’s ARTIST-MARKET lanes (#2800 line E, #2833), riding the alert’s own scored/posted events with no new field. artist_market is a NEW Kalshi event market about a watched act (listed inside 10 days, 50+ contracts, the leader priced 5%-95% by the shared kalshi_price rule); artist_market_move is the biggest week move on such a market (15+ points, 300+ contracts, off Kalshi’s public daily candlesticks read in ONE batch call per event – market_fetch query=candles_batch:<n>). Both ride the music_artist_markets experiment (STAGING by default: delivered=staged in #bot-logs) and dedup on Kalshi tickers in post_dedup_history (music_alert_artist_market, the event ticker, 1 year; music_alert_artist_market_move, <market>:<up|down>, 30 days; music_alert_artist_market_leader, <event>:<hero label>, 30 days), never the song reuse block. artist_market_leader is the STANDING card, the companion to a move: a shipped move post writes its board to the QUEUE (music_alert_artist_market_leader_queue, <event>:<moved market>, 5 days) and the standing lane – registered BEFORE the move lane, so it reads only an earlier TICK’s queue and the card lands on the NEXT SLOT, 10 minutes on – reports what that board says now. It withholds on a board whose leader is the leg the move card already reported, on a board that has left the open catalog or that the listing lane posted in the last 3 days, on a board whose raw read came back a full page (truncated, so unrankable), on a standing this board already reported last, and on any leader that is not BID-SUPPORTED. A cumulative DATE board (kalshi_ladder.is_date_ladder) states the soonest window, the first even-money window and the widest one instead of a bare ‘leader’. Expect each QUIET: at most one card a day per lane per guild, the standing lane only ever follows a board the move lane already posted, and the move lane fires only when a market about a watched act actually moves (measured 2026-09-02: 3 of 168 open act markets moved 20+ points in a week). A music_alert_scored row with phase=artist_market_move and a preview naming a price the blob did not state is the ungrounded_number guard’s job; a day with neither phase is normal. music_alert is NOT in ops_monitor.LANE_PHASE_SURFACES, so neither lane joins the per-lane dark rule; the silent-failure tell is market_fetch ok=false on query=candles_batch:* or raw_markets:*, which the integration-health rates already count. | artist_market_settle (#2800 line E): the follow-through on a market the listing lane announced – its every market finalized/settled inside 7 days, the YES outcome (or a single market’s NO) as a big-number card; the roster is the listing surface’s event tickers (held a year), each reported once on music_alert_artist_market_settle (permanent), at most 12 event reads a tick; a resolved market whose title names no watched act is skipped. Expect it rarely (a listing resolves months later), so a quiet lane is normal while artist_market posts are young. | market_news_read | utils/market_outcome.py, cogs/music_alert.py, cogs/market_alert.py | guild_id, surface, phase, ok, reason (found / no_web_client / no_question / web_failed / web_no_result / web_no_publisher / web_unusable / no_event_date / outside_window / moved_leg / news_take_dropped / news_off_subject / news_check_failed), detail.ticker, detail.topic. ONE Perplexity read (pplx_market_move_news, recency week) per market card about to compose, on every lane that reads the news: surface=music_alert phase=artist_market_move (after the daily gate) and phase=artist_market_leader (the standing card, #3760), and surface=market_alert phase=<alert kind> (swing, lead_change, ending_soon, conviction breaks; not the forecast lanes or settled_out). A found, press-cited report LEADS the blob and the take runs the market_news angle; on a date board the standing card moves to the window that CONTAINS the news date. no_event_date / outside_window are the #3760 date-window check. Added 2026-10-02 after the Drake remix card shipped “at 94%, up 65 points in five days” a day after Drake announced the remix (#3783), widened the same day to the standing and market_alert lanes (#3786). Fail-open: ok=false ships the card as before. news_take_dropped is the FALLBACK: a news-led take was refused before delivery (a guard or the self-gate), and the card recomposed once from the price-only blob, so the news never costs a post. A high news_take_dropped share on one lane means the gate and the market_news angle disagree there. A found rate near 0 while pplx_market_move_news stays ok means the parser is rejecting real reads; read the raw Sonar text. No monitor: a miss degrades one card to the old shape, it never posts a wrong one. news_off_subject / news_check_failed (market_alert only, #4682): the Haiku judge market_news_on_subject (claude_api purpose=market_news_subject) reads every found line before the qualifier check. It drops a line about another song, artist, team or chart (the shipped “John Mayer announced ‘Tom and Jerry’ … while Boston overtakes” Hot 100 card). It fails CLOSED: no verdict is news_check_failed, and the card ships price-only. A run of news_check_failed means the Haiku call is failing. | | music_desk_scored / music_alert_scored reason=ungrounded_age (score 0.0, shipped=false, any lane) | cogs/music_desk.py + cogs/music_alert.py _drop_ungrounded (2026-09-06, owner ask “cut those last sentences on age of the record”) | guild_id, phase (the lane), matchup, post_preview, + folded detail.claim (the phrase). The age-in-WORDS twin of reason=ungrounded_number (#2081): output_checks.ungrounded_age_claim flags a take that states how old the release is (“thirteen years deep”, “a track over a decade old”, “sixteen years after its release”) when NONE of its sources state an age. The digit check already dropped “14 years old”, so the model wrote the age in words; 20 watch-chart takes and 2 Spotify jump posts shipped that way in the 30 days to 2026-09-06, every one off a kworb row that carries no release date. PERMISSIVE by design: a source that states any age in any form (the Luminate head’s catalog, out 4.2 years, a dated debut blob’s came out 18 days ago, an Anniversary edition title, the oldest-songs board) stands the check down – it checks that the source spoke to age, not that the two ages agree. Measured over 30 days of shipped takes (n=5774): the claim pattern matches 67, of which the tracking + lookup_board lanes (18) are grounded and stand down; the rest (watch_chart 18, chart_run 15, milestone 12, chart 2) now drop. Root cause fixed in the same change: the shared _CONTEXT AGE / STAGE paragraph asserted “the head names the release’s milestone” as a fact about every post, and the chart lanes have no age in their blob, so the model supplied one (reworded to a condition; real-model n=6 dry run 3/6 -> 2/6, so the guard carries the rest). In NON_SCORE_REASONS with its twin, so it never reads as a judge score. Expect a low steady rate; a SUSTAINED rate on one lane means that lane’s blob should state the age (or the prompt regressed). | | music_alert_scored phase=settle reason=settle_retry / settle_retry_cap / settle_rejected (score 0.0, shipped=false) | cogs/music_alert.py _settle_retry_left / _settle_consumed (#3822, 2026-10-04) | guild_id, matchup, + attempt on settle_retry. The Kalshi readings loop’s SETTLE outcome markers. settle_retry = _fire failed for a reason a fresh attempt can pass (a compose or score exception, a Discord delivery of 0, or a deterministic shape guard), so the baseline is kept and the next tick composes again; attempt counts to _SETTLE_MAX_RETRIES (3). settle_retry_cap = every retry failed, so the lane advanced the baseline and gave the settle up. settle_rejected = a verdict (a score under 0.6, a topic repeat, an empty take) consumed the settle; its judge or topic_dedup row is emitted separately. A dropped settle used to leave no row of its own. All three are in ops_monitor.NON_SCORE_REASONS, so they do not count as judge scores. Ticker (#3889, 2026-10-06): every settle row (the phase=settle score row, these three markers, and music_alert_posted phase=settle) carries the Kalshi event ticker in detail.ticker (ticker is a _FOLD_FIELDS name, so no new column). One album has up to three settle markets (streams, units, pure sales) with the same matchup, so the ticker names which market missed. Query: parse_json(detail)['ticker']. Dashboard: music_alert_settles. Query: ['tootsies'] \| where event == 'music_alert_scored' and reason startswith 'settle_' \| summarize count() by reason, bin(_time, 1h). | | music_alert_scored / music_desk_scored phase=shape_reject reason=release_title (score 0.0, shipped=false) | cogs/music_alert.py _fire (SETTLE only) + cogs/music_desk.py _compose_line (settled reveal only) (#3835, 2026-10-05) | guild_id, matchup (the subject), post_preview. A settled take that names no release title (output_checks.missing_release_title). It reads as the artist’s number, and the 0.6 gate cannot see that because the figure is grounded. On music_alert it is retryable, so a settle_retry marker follows it, and a settle_retry_cap follows if every attempt drops. No new field. The desk_shape_reject panel groups by reason, so it shows with no dashboard change. No monitor: a guard firing is the guard working, and a settle that never ships already shows as settle_retry_cap on the music_alert_settles panel. Expect a low rate: 0 of 200 dry-run settles composed with the real history, and up to about 45% of composes on one album (Popstar units, gpt-cheap) with an empty history; a sustained rate on one album means a title that does not fold to what the takes write (read post_preview against matchup). | | <surface>_scored phase=declined | utils/events.py (emit_decline), called by the five wire desks (cogs/pop_desk.py, cogs/sports_desk.py, cogs/cinema_news.py, cogs/music_news.py, cogs/cinema_desk.py) and, since #2086, by cogs/music_desk.py, cogs/market_drop.py, cogs/music_alert.py and the three betting surfaces, and since #3163 by cogs/commentator.py (commentary_scored, too_short only) | guild_id, phase (declined), shipped (always False), score (always 0.0), reason (model_declined | empty_after_link_strip | too_short | throwback_composed – on pop_desk, cinema_news, sports_desk and music_news the composed take states a throwback frame (utils.retrospective.date_hook(look_back_only=True)) that the source-post cut did not see, #4697; it carries post_preview + chars, and music_news also marks the story seen; on market_drop_scored also leader_fabrication | leader_check_unavailable, the chart take fabrication gate, #3442: the regex pre-check did not confirm the take names the leader, then the Haiku judge claude_api purpose=market_take_check said False or errored – the gate fails CLOSED, so a judge outage reads as a run of leader_check_unavailable), matchup (the subject, else the lane), and on too_short, throwback_composed, leader_fabrication and leader_check_unavailable also post_preview + chars — the compose model DECLINED to write a take, so nothing shipped (#2051). too_short is the one reason here that is a DEFECT rather than editorial restraint (#3160). It is emitted by the SPINE (cogs/scheduled_poster.py), and since #3163 also by the two autonomous posters outside the spine that ride a *_scored kind: cogs/music_alert.py (both compose sites) and cogs/commentator.py. cogs/market_alert.py has no *_scored kind, so it records the same drop as reason=too_short (with post_preview) on each lane’s own pre-ship event: market_filtered (the alert lane, terminal), rt_reconcile and market_outcome (transient, retried next sweep like an empty compose). Over 30 days to 2026-10-18 the shortest real line on these three surfaces was 51 characters (market drops), 56 (music alerts) and 192 (commentary), so the 15-character floor drops no real post there. The cause the floor answers: the compose stopped mid-word and returned a fragment, and utils.output_checks.too_short_to_ship dropped it before the delivery walk. The floor is 15 visible characters, measured — over 30 days of shipped posts the shortest real scheduled post is 22 characters (“Clairo turns 28 today.”). A post carrying a LINK is exempt at any length, so the three curator surfaces, which ship a bare fixup link as the whole post, are untouched. The cause: on 2026-09-11 compose_pop_take returned the two characters “EM” (a cut-off EMPTY decline). is_empty_response compared against the whole sentinel word and passed it, and pop_desk_score scored the fragment 0.62 — over the 0.6 ship floor — with a stored reason that graded the SOURCE wire headline, because the take gave the judge nothing to grade. The fragment shipped to the pop channel and crossposted to X. The judge is not a reliable guard on this input: re-run live on the same fragment and blob it returned 0.2, so the same input scores either side of the floor. That is why the floor is deterministic. Since these ride the existing *_scored kind, the desk_declined panel and the compose_declined finding cover them with no new event kind and no new field names; compose_declined flags any reason other than model_declined/all_seen, so a run of too_short raises on its own. A decline is a real editorial decision: the model returns EMPTY for a charged or out-of-lane moment, or one with nothing to state. It used to be the ONLY outcome in the whole funnel that left no trace — the self-gate emits *_scored, the repeat check emits post_dedup/topic_dedup, the staleness judge emits news_stale, delivery emits *_posted, and this path just returned None. So “the desk never saw the story” and “the desk saw it and passed” looked identical from telemetry, and they need different fixes; that gap is what made the missed Drake posts (#2033) hard to diagnose. Rides the surface’s own *_scored event rather than a new kind, the same shape music_desk_scored phase=shape_reject already uses, so it costs nothing against the field budget. It is NOT a judge score — score=0.0 here means “there was no take to grade”, so ops_monitor.NON_SCORE_PHASES excludes it and the desk_quality panel filters it out; counting it would report a surface that DECLINED as a surface whose writing is bad, and at a high rate would trip surface_dark and point triage at the judge instead of the compose prompt. surface_dark cannot cover this class at all: it reads judge scores, and a declined take never reaches the judge, so a desk refusing everything shows no scores and reads as a quiet news day — which is why the ops monitor gained its own compose_declined finding (medium, at 60% of at least 6 attempts; deliberately loose, because some declining is correct and only a lurch is a signal). Watch the desk_declined panel: a decline rate that jumps after a prompt change means the compose prompt started refusing real material. #2086 closed the class: the music desk’s nine lanes read is_empty_response(line) or self._unshippable(...), and or short-circuits, so an EMPTY response emitted neither this marker nor shape_reject — silent on the surface that scores more rows than any other. All nine now go through _drop_before_gate, which keeps the two drops as SEPARATE events because they need different fixes: declined means the model refused to write (fix the prompt or the material), shape_reject means it wrote something a deterministic guard forbids (fix the guard or the framing). bet_alert_scored joined ops_monitor.SCORED_KINDS in the same change — it was the one *_scored kind missing while both its siblings were in, so betting_alert also gains the self-gate quality monitoring it lacked. Still NOT emitting: cogs/market_alert.py (it has no *_scored event to ride; its too_short drop is on its own events, above) and the discourse opener path (it carries its own decision events). The reply surfaces (ask, x_mentions, discourse openers, chimein) stay out of the floor: real ask_answered replies run as short as 15 characters (#3163). | | music_news_chart_lookup phase=first_week | cogs/music_news.py | ok (a projection was settled), phase (first_week), matchup (the subject), + folded detail: charts (hits_projection), chart (the document: midweek-20 / hits-top-50), pos (the projected rank), reason — the newsroom SETTLED a first-week projection claim against the HITS Daily Double document (#2049). The desk already read this document first-party for the called-shot lane, and it is cached, but the newsroom could not reach it — so a first-week projection was the one declared story kind the settler had to relay on account trust, and it is the kind most likely to be wrong, because a projection MOVES through the tracking week. A wire quoting Tuesday’s midweek number on Friday quotes a number the document has since changed. Settles on UNITS rather than a position, so it carries its own reading (ProjectedUnits) rather than a CHART_SOURCES row; latest_projection() is the same call the called-shot lane makes, so both lanes read one number. The extra refusal reason is not_first_week, and it is the WRONG-WEEK guard: an album stays on the document for many weeks, so matching on title and artist alone answers a first-week question with a later week’s number. Caught in the dry run on the real midweek document for 2026-08-15 — Olivia Rodrigo’s album sat at #3 with 75,000 units and last_week=1, while its actual first week read 79,863 on the building chart for 2026-08-08. A sustained not_first_week stream is the guard working. A sustained absent here means the document is not carrying what the wires talk about. STREAM claims settle here too (#2771). #1890 wired kworb into the CHART branch and left the MILESTONE branch reading the open web, where both 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” and the story was dropped three times, while the artist page the bot already fetches ~20x a day listed exactly 8 over the rung, Stay at 2.027B and her highest at 2.648B. Drake’s “140 billion” was dropped four times over four days against a leaderboard reading 140,255,100,000. Rows carry phase=milestone, detail.source=kworb_streams, plus shape (catalog | track | career), rung, claimed, count and total. Since #3415 the row also carries via (classifier | regex): whether the classifier’s stream_shape / rung / count decided the shape or the claim regexes did; the entry-history rows (detail.chart + claimed) carry the same via for entry_count. Read the settled rate by via: a classifier row that reads not_settled far more often than a regex row says the model is shaping claims the page cannot bear out. ok=False is NOT a refutation – it means the page was read and the claim did not bear out, which happens legitimately when a wire count is one ahead on the day a song crosses, so the reading is dropped and the web path decides. Scoped to a claim naming BOTH Spotify and a stream metric, so an Apple Music or YouTube claim is never answered with Spotify numbers. Watch the RATE of ok=True on this phase against music_news_filtered reason=refuted: the fix is working when refuted-stream drops fall and this rises; both flat means the settler is not being reached and the claim shapes have moved. detail.reason=stale (2026-09-30): a track that crossed the claimed rung more than FRESH_CROSSING_DAYS (7) days ago at its daily pace, a track with no daily that is further past than 25M a day could reach, or a career total more than 10% past the rung means the wire re-posted an OLD milestone (@chartmastersorg’s “You Rock My World” at 100M, which kworb read at 366,235,376 and which shipped as “has reached 366.2M”). The row reads ok=False, and the story drops with music_news_stale via=stream_rung_passed. detail.reason=unbounded (#2787): a CATALOG claim read off a page whose every row clears the rung. kworb lists an artist’s songs biggest first and cuts the page off (500 rows at most), so the page may be missing qualifying rows and its count is only a lower bound. The reading settles in neither direction (ok=False) and the web path decides. catalog_stats.count_catalog owns the rule; the settler reads it. Measured 2026-10-17 over every artist this lane and the desk’s catalog qualifier saw in 30 days (64 artists): no page came near it, the closest being Drake at the 100M rung with a smallest row of 15.7M. A sustained unbounded stream means a lower rung went live or kworb shortened its pages. detail.reason=subset (#4583): the classifier’s stream_shape=subset, a total or count scoped to a part of the act’s credits (“as lead artist”, “lead-credit songs only”) or to one period (“in 2026”). kworb is not read, the row carries no rung or total, and the web path decides. Before it, kworb’s all-credit total settled these: 9 fresh @chartmastersorg lead-artist milestones in 30 days were cut as stream_rung_passed (The Weeknd’s “90 billion lead-artist streams” against a 99.5B all-credit total), and 5 more had their claim rewritten to drop “lead”. A sustained subset rate on non-scoped posts means the model is over-calling the shape. detail.reason=undated: the page shows no daily, so the crossing cannot be dated; the story is deferred (music_news_stale via=stream_undated) and not marked seen, the desk’s rule for an undated crossing. music_news_stale via=career_count_passed (#4745): when kworb does not settle a career claim, the verify judge reads Songstats’ career total, and it now CONFIRMS a rung that total sits at or above (it used to refute “8 billion” off 8.6B, 45 of 63 true milestones on the 30-day replay). Songstats reads up to 15% above kworb, so a confirmed count more than 25% past the rung (mn.SONGSTATS_CAREER_STALE_RATIO) is an old milestone: the story drops and is marked seen (folded detail: claim, via, ratio). A confirmed count above the rung never reaches the card or the gate; the post states the wire’s rung. Panel: newsroom_refutes. Web research is withheld from the compose once a stream reading settles, like Songstats. A settled track or career story now states the RUNG (“at or past 100M”), never the live total. reason=hits_older (#3827): a number authority (_post_text_grounds) posted AFTER HITS last edited the document (Projection.edited_before, off _updatedAt), and the post states a count the document does not round to (mn.wire_units_disagree). The post is the newer number, so the reading is dropped and the post keeps its own number. Live case: chartdata’s “#6(new) @Tinashe, Popstar 35,500” (Billboard’s final, 2026-10-04 21:02Z) against a document edited 2026-10-02 22:25Z that still projected 32,386 at #9. A post that agrees (“32K” for 32,386) still settles. | | music_news_chart_lookup phase=chart | cogs/music_news.py | ok (a position was settled), phase (chart), matchup (the subject), + folded detail: charts (the chart keys read, comma-joined – the classifier’s chart_key when it named a readable chart, else charts_named off the claim’s own words, #3414), via (classifier | regex: which of the two chose the keys – read the ok-rate by via, and a rising absent under classifier on a LIVE chart is the failure this field can add: a wrong single key drops an aged post as stale, so compare it against regex before trusting either), chart + pos (the settled reading), reason (ambiguous / absent / unresolvable / unavailable / wrong_chart; wrong_chart (#3916) = the title is absent on the one Billboard flagship the claim names and present on the OTHER flagship, and the wire does not name the claimed chart: an album called a song or the reverse, the Showgirl “No. 1 on the Billboard Hot 100” of 2026-10-04; the compose drops it as music_news_filtered reason=wrong_chart), is_debut (the DEBUT verdict off chart_presence for a live platform chart: false = established title, true = genuinely new, null = weekly chart / unknown title / memory down -> UNKNOWN, and since 2026-09-01 unknown no longer means the wire’s framing stands: the compose’s debut guard asks Billboard’s artist chart-history page and reports the bare position when that cannot confirm a debut either, see music_news_debut_framing) – the newsroom SETTLED a chart claim against the live chart before web research (#1890). The is_debut verdict stops a wire’s loose “debuts at #N” being relayed for an established title (ICEMAN by Drake, days on the Apple albums chart, shipped as “debuts at #2”): _settle_debut_framing reads the shared memory and, on false, hands the compose established_framing_note so a climb is told as a climb. charts covers EVERY chart we can read since #2258 (owner steer 2026-08-10, “should work like that for every chart”): the kworb platform dailies, all 21 Billboard charts, and the ten Mediabase radio panels (keys radio_*, read off the same hits.radio_building() document the desk’s radio lane uses). The table held 7 charts before, while the process already fetched ~38, so a claim about any of the other 31 kept the wire’s THRESHOLD wording – live on 2026-08-10, “enters Top 20 on US Rhythmic Radio (Mediabase)” shipped for Mac Miller and Ty Dolla $ign’s “Cinderella” against a real #19, and “Top 20 on US Urban Radio” shipped for Future’s “California Girls” against a real #14. WATCH the ok-rate by charts: a radio_* or Billboard key with a sustained absent means our title matching is missing that reader’s credit style, NOT that the songs fell off – those panels are weekly, so the staleness gate deliberately ignores them. A rising unavailable on radio_* is a HITS fetch problem. | reason=wire_ahead (#3824): the live WEEKLY chart is last week’s while the wire posts the new week (chartdata’s Sunday Billboard 200 lines, mn.wire_is_next_week); the reading is dropped and the claim goes to the web verify. Since #4394 a COUNT row carries detail.window (top_n = the claim’s own top-N window, whole = a whole-chart count with no window, read over the printed Billboard chart), and reason=count_mismatch = the whole-chart count is not the figure’s count, so no reading (it arms no staleness gate). reason=week_unproven = the counts agree but the post was not written in the printed week the reading carries (mn.post_in_chart_week: a Sunday or Monday post can mean next week’s unprinted chart), so no reading either. | source_change | cogs/music_desk.py (source watcher, 2026-09-28) | surface, count (sources changed), + folded detail.sources (the names: billboard:hot100, billboard:billboard200, hits:midweek-20, hits:hits-top-50, hits:radio_adds, hits:radio_building, kworb:<page>, riaa:recent, pollstar:<chart>) — the music desk’s watcher saw one or more of its data sources publish new content, and ran the desk’s pass off-slot for every guild it may post to. Owner target: a music story reaches the room within ~5 minutes of the source changing. The watcher looks every 2 minutes; the FIRST look after a deploy is a baseline and a failed read is never a change (utils/source_watch.py). Posts it ships carry fast=true on music_desk_posted. Since #3823 the pass runs at any hour (every watched source is a numbers source); outside the calendar span the commentary lane (ktt2_chatter) composes nothing, so a fast post overnight is expected and never a chatter post. Read it on the Desks dashboard (source_changes + fast_posts). The alarm is silence: a Billboard flip week (Tuesday) with no billboard:* row, or a week with no riaa:recent / hits:* row, means the watcher or that source’s read stopped. A pass failure emits error source=music_desk_source_watch. | | music_news_filtered | cogs/music_news.py | guild_id, phase (story kind), reason (release_age = the 2026-10-20 release-age cut on a chart story, utils/recency.py: below No. 10 and not a debut, or No. 6-10 / a debut whose title is over two years old or undated; detail.tier deep | old | no_date, detail.pos, detail.chart; a no_date cut is NOT stamped seen; on the release_age_cut panel. not_a_story = the classifier’s settled no-story verdict, stamped seen; title_ungrounded = the classifier named a work the post never names (#3824, the GUTS case: mn.title_grounded_in_wire), stamped seen; chart_ungrounded = an RSS feed item’s story names a chart its headline does not name (#3834, _feed_chart_ungrounded: Chart Beat’s “The Life of a Showgirl Returns to No. 1”, a Billboard 200 album, shipped as “No. 1 on the Billboard Hot 100” on 2026-10-04), stamped seen; X posts are not checked, because an authority often names no chart and the classifier’s chart is then right; wrong_chart = the settle read the claimed Billboard flagship as absent and found the title on the other flagship, and the wire never named the claimed chart (#3916, MusicNews._live_chart_position): the chart cannot be confirmed, so the story is dropped and stamped seen, detail.chart = the chart the claim named; replayed on the 30 days to 2026-10-06, it fires on the Showgirl shape only; off_genre = the new-release lane found a watched artist’s in-window album outside pop, rap / hip-hop and R&B, detail.genre = Apple’s primaryGenreName, the house policy’s exact-tag test, stamped seen so it emits once per guild, 2026-10-02; shortlist_full = the compose shortlist filled with count candidates still unclassified behind it – the newsroom’s one formerly silent loss, #2800 line D; ladder_rung/market_unread/market_longshot/refuted/unverified_wire/chart_cut/cert_reported/milestone_reported/chart_debut_reported/chart_repost/novelty_distribution/lane_dark/charting/nochart_unverified; cert_batch_deferred = an act with more RIAA awards in the 3-day window than its cards per act per slot share: matchup the act, count the awards waiting for a later slot (not stamped seen; they age out of the window), detail.per_act the knob, detail.headline the act’s best; owner call 2026-09-03: diversity across acts, not a cap), the desk’s plaques board lists them all; not emitted when nothing is left to fold; cert_act_cooldown (2026-09-05) = the act took a cert card inside cert_act_cooldown_hours (detail.cooldown_hours, the /menu knob, default 24, 0 = off) so it sits this slot out: matchup the act, count the awards held (not stamped seen), detail.headline its best. The per-slot share alone let a catalog batch take one card of EVERY slot: Nickelback, 2026-09-04, 15 awards, one card composed an hour for twelve hours, five reached the room and six were stopped only by the text dedup. Watch it next to cert_batch_deferred: an act should appear under cert_batch_deferred once, then under cert_act_cooldown for the rest of the day; the same act under cert_batch_deferred every slot means the cooldown came unwired); cert_batch_capped (2026-09-17) = the act’s window awards already carry cert_cards_per_batch seen stamps (detail.per_batch, the /menu knob, default 5, 0 = off; detail.carded the stamps) so the batch takes no more cards whatever the pacing: matchup the act, count the awards cut (not stamped seen), detail.headline the best of the cut. The two pacing reasons never END a batch: Skillet, 2026-09-16, 11 awards in a day, six cards in five hours at 3 a slot and a 1h cooldown, the rest still queued. Watch it as the terminal state of a batch: an act should move from cert_batch_deferred (and cert_act_cooldown) to cert_batch_capped and stay there until the window drops the batch; a cert_batch_capped act that still shows music_news_posted cards means the count came unwired), matchup, source, + folded detail (claim, note, family, key, pos, prev, chart) — a wire numbers story was DROPPED before compose. The three no_chart reasons (phase=no_chart, #top-100-debuts-no-chart) fence the one lane that states a NEGATIVE about a real act — a tracked artist’s named release that missed a chart. reason=lane_dark = the guild has not opted the lane in: its own music_news_nochart experiment is OFF (the default), so the story is dropped and marked seen. A steady stream here is the lane being dark as designed, not a fault; it goes to zero for a guild once a mod flips the experiment on. reason=charting = the VETO fired — we read the named Billboard chart first-party and the release IS on it, so the “did not chart” claim is FALSE and nothing ships (detail.pos/detail.chart name where it actually charted). This is the “prefer absent over invented” fail direction inverted for a negative claim: a false miss is the fabrication to fear, and a first-party read that finds the song kills the story. A hit here is the veto working; marked seen. reason=nochart_unverified = the lane is FAIL-CLOSED — a no_chart story ships ONLY when the independent research CONFIRMS the miss (verify_music_claim verified=true), never on source trust like a positive number, because you cannot prove an absence on trust. NOT marked seen (the tracking week is still closing, so the research may confirm it a slot later). Watch the three together as the lane’s whole funnel: lane_dark should dominate until a guild opts in, then nochart_unverified should be the main drop (most feed “flop” chatter is an insult with no verifiable miss) and a charting hit means the veto caught a wrong claim. reason=unverified_wire (#2040) means the story came from a music WIRE — an account that REPOSTS numbers rather than measuring them (may_relay_number false) — and the research did not confirm the figure, so it is dropped rather than relayed. That is the standing rule for a wire’s hard number: verify-or-drop. It is NOT marked seen, unlike a refutation, because “the web has not caught up yet” is a state that changes and a wire often posts a number ahead of the outlets. An AUTHORITY’s unconfirmed number is a different case and still ships — she measures it first-party. Watch it as a share of the wire count in music_news_slate: a rate near 100% means the widened poll is spending verify budget on accounts whose claims the web never carries, and the account list should shrink rather than the rule loosen. reason=refuted (#2035) means the verify judge reported a DIFFERENT number for this claim, so nothing ships. The judge verdict used to be one boolean and the desk read every false as “we could not corroborate it”, which RELAYS the wire’s number in her own voice; that is right for a claim the research does not cover and wrong for one the research CORRECTS. Live: the wire said Drake had 6 songs in the top 11 single-week stream list, the judge answered “7 distinct songs, not 6”, and the post shipped reading 6 on Discord and on X. The self-gate cannot catch the class — it grades the take against the claim, and the claim is the wrong number. A hit here is the guard working. Watch it as a SHARE of music_news_verified verified=false, not on its own: only the contradicted subset drops (~1 in 4 of the false verdicts, by a 30-day read; the rest are “no corroboration found” or “that figure is a projection” and still ship). A refuted rate climbing toward the whole false population means the judge is calling missing corroboration a refutation, and the desk is going quiet instead of getting accurate. A refute a week behind a number authority does not drop (#3827): when the judge’s note puts the title at the wire’s own LAST-WEEK position (the post’s “#1(+20)” against research at #21, mn.refute_is_last_week), the research is older than the post. The verdict becomes unconfirmed, the story is not marked seen, the stale research and X context are withheld from the compose, and music_news_recover fires with source, detail.via=stale_refute + detail.note (panel newsroom_refutes, beside the refuted count per source). Live cases: chartdata’s Taylor Swift and Ella Langley lines of 2026-10-04. reason=ladder_rung = the wire quoted ONE RUNG of a Kalshi scalar ladder and we could not re-read the market ourselves to replace it with the ladder’s forecast (utils/kalshi_ladder.py); compare against market_filtered reason=ladder_rung on the same tickers. reason=chart_cut (#2127) = the story is about a chart the owner cut from the newsroom’s scope (music_news.CUT_PATTERNS); detail.family names which one. The newsroom was the only chart surface with no chart list, so it relayed whatever chart a trusted account posted about — 135 chart stories over 30 days named ~20 chart families, including ARIA, the U.K. Official Albums Chart and a K-pop fan tally. Watch detail.family=unnamed separately from the named families: a named family is the cut working as asked, but unnamed means the gate could not recognize the chart at all and failed CLOSED, so a real chart appearing there is a missing pattern, not a cut. It measured 1 of 135 (a preview-truncation artifact) before shipping; a rate climbing past a few percent means the wires moved to wording the patterns do not cover, and the desk is going quiet rather than getting narrower. Re-measure with scripts/dryrun_chart_filter.py. reason=deep_chart (owner cut 2026-08-07) = a PLATFORM-chart (Apple Music / Spotify / iTunes daily) position below the top-MUSIC_NEWS_CHART_POS_FLOOR (default 10) with no debut call in the wire wording, OR a debut call past the artist’s tier debut floor (music_news.deep_platform_cut with artist_watch.debut_depth_floor: none for WATCHED, top 50 KNOWN, top 25 UNKNOWN – added 2026-09-08 after a #199 Spotify “debut” by Ashe shipped on the unconditional carve-out; a re-entry is not a debut). detail.pos is the position tested, detail.settled says whether it was the live reading or the claim’s own number (kworb down), detail.debut whether the wire called a debut (detail.event the resolved chart event and via how it was read: classifier = the chart_event field inside its bound, bounded = the field named a debut or re-entry the wire’s words do not carry, so the regex decided, regex = no field, #3418; the chart_repost row carries the same via + detail.event for its re-entry read) and detail.tier the artist’s recognition tier (a cut with debut=true is the tier floor firing; tier=known on every row means the recognition lists are empty and the floor relaxed to 50). Measured cause: popbase’s deep-chart chatter about one song shipped as hero cards two days running (#148, then #125). The desk’s watch lane is the intended supply for top-of-chart moves. A hit here is the floor working; a SUSTAINED zero alongside deep positions in music_news_posted previews means the gate came unwired. reason=chart_repost (#chart-repost) = the desk ALREADY reported this song’s position on this WEEKLY chart and it has not made a real move, so the re-report is dropped before the verify spend. A weekly chart (a radio Mediabase panel, a Billboard weekly) holds a position for the whole week, and the text dedup cannot hold it — 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 high-water store (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 and re-ship) holds 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” — detected off the original wire wording via music_news.claims_reentry, since the settled rewrite strips “re-enters”). A plain FALL to a worse-or-equal position with no re-entry cue stays suppressed like a hold. detail.pos is the settled position, detail.prev the last reported one, detail.chart the chart key. It gates a SETTLED reading on ANY known chart — WEEKLY panels AND live DAILIES, because a held #1 repeats on both (Ariana Grande’s “hate that i made you love me” shipped ELEVEN times holding #1 on the global Spotify daily chart); the deep_chart floor runs FIRST and trims a deep daily position, so only a top-of-chart daily position reaches this gate. A genuinely new chart or a big jump still ships. Written on PRODUCTION delivery only, so a staging audition never raises the mark. Dropped BEFORE the compose spend, and the post is marked seen. A hit here is the gate working; a sustained zero alongside repeated same-song positions in music_news_posted previews means it came unwired. | reason=cert_reported (#2153) means the desk ALREADY reported this exact certification: a certification settles, so it is news once, and the text dedup cannot hold that (its memory is bounded by rows, a 36h window and 72h retention, while the WAP Diamond repeat arrived 113h later from a third account). The folded key is the music_news.cert_story_key identity (the folded subject + the model’s cert_id, <multiplier>x<level>|<body>) matched against the music_news_cert rows in post_dedup_history. Like the milestone rung, the certification identity comes from the classifier, not a regex over the claim: cert_id (e.g. 7xplatinum|riaa) so a reworded repeat keys the same, empty when the post is not a clean certification. Dropped BEFORE the research spend, and the post is marked seen. reason=milestone_reported is the SAME fix for a STREAMING MILESTONE (a track crossing a fixed stream rung): a crossing settles once, and the text window could not hold it — the J. Cole “No Role Modelz” 3B card posted twice ~54h apart, past the 36h window, because the topic judge overturned a shape-only hit against a DIFFERENT milestone while the real twin sat out of window. The folded key is the music_news.milestone_story_key identity (the folded subject plus the model’s milestone_id) matched against the music_news_milestone rows in post_dedup_history (1-year retention). The rung comes from the MODEL, not a regex over the claim prose: classify_music_news already reads every post and returns milestone_id — the canonical value|platform|metric (e.g. 3000000000|spotify|streams) — so a reworded repeat (“3B” and “3 billion”, “streams on Spotify” and “Spotify streams”) keys the same. The ONE metric held is a cumulative all-time STREAM count, and a wire’s “plays” folds to “streams” so the same crossing keys the same whichever word the wire used. The model returns an EMPTY id for a figure that must not dedup — a year-to-date/annual/period total, a territory-scoped figure, an aggregate across platforms, a YouTube or other VIEW count (per-video, not per song), or a monthly-listener figure (a moving snapshot that rises and falls) — and a throwback (retro) also fails open (#2353 review). The key builder only validates the id’s shape and folds the subject; a regex over the prose could not hold the wording variance (grouped digits, hyphenated qualifiers, post-metric platforms, territories) and each broke a different branch (#2352). Dropped BEFORE the research spend, and the post is marked seen. reason=chart_debut_reported (#market-dedups) is the SAME fix for a WEEKLY-chart DEBUT, and the FIRST cross-surface one: a song enters a chart once, so “BbY WOW debuts at #73 on the Billboard Hot 100” is not news again days later — but it shipped from music_desk on Aug 19 and from the newsroom on Aug 22 (+53h) and Aug 23 (+85h), each past the ~14h effective wire window, and the topic judge even overturned a shape-only hit against a different Karol G debut still in the window. The folded key is the music_news.chart_debut_story_key identity (the folded subject + debut + the chart key) matched against the SHARED wire_chart_debut rows in post_dedup_history (1-year retention). It is shared because music_desk reads Billboard first-party while the newsroom relays the same debut off @billboardcharts — both surfaces write the key (on PRODUCTION delivery only, so a staging audition never suppresses a real post) and both read it. Only a DEBUT keys (chart_debut_story_key returns None for a re-entry or a move, and for a LIVE daily chart that churns), so a jump (“surges to #47”) still ships. music_desk runs the same key: it emits music_desk_scored shipped=false reason=chart_debut_reported when it suppresses a debut a wire surface already posted. Dropped BEFORE the compose spend, and the post is marked seen. reason=novelty_distribution means a novelty story was a per-work DISTRIBUTION, not a single unusual count, so it is dropped before the verify spend (music_news.novelty_is_distribution). A novelty is one number (“Kid Cudi hums 1,814 times across his albums”); a per-album breakdown has no single headline figure, so the classifier put a RANGE in the card’s hero slot (“47.0%—89.4%”) and a comma list in the claim, and the card heroed a meaningless range over a caption that overflowed. Live: “Kanye West’s lyric-credit share by album runs from 47% on DONDA all the way to 89.4% on 808s & Heartbreak” (2026-08-15, @hiphopnumbers, X-only). The two upstream gates missed it — the source is a trusted relay, so may_relay was true and verify-or-drop did not fire, and the slop self-gate passed it at 0.0. The guard fires on a range figure OR a claim listing three-plus percentages, and the post is marked seen. A SUSTAINED zero here is fine — the shape is rare; a rate climbing means the wires are posting more per-work breakdowns and the novelty lane is spending drops on them. music_news_verified via (#3416): judge_count / regex_count when the card hero was a wire RUNG swapped for the research’s count (Rod Wave’s “100 entries” -> “107 career entries”), by the verify judge’s own threshold_passed + count or by the regex pair; absent otherwise. A judge_count share at zero says the judge never sets the flag; a regex_count share that stays high says it sets it too rarely. music_news_verified card_headline (#2825, folded): on a market story with no hero figure, the rung of _market_card_copy that names the card headline: award (the prize read off the claim), outcome (the classifier’s short name), act_fallback (neither, so the ACT is the title and the clause is the caption), none (no clause). Absent on every other story. The fallback is correct but raises nothing, so this is its only record. Query: where event == 'music_news_verified' and phase == 'market' | summarize count() by tostring(parse_json(detail)['card_headline']). Panel: newsroom_market_headline. No monitor: about 20 market stories reach the event in 30 days (19 with no figure, measured 2026-10-25), too few for a rate. | music_news_slate | cogs/music_news.py | guild_id, count (the slate size), + folded detail: authority + culture (how many of each source kind the slate carries), authority_pool + culture_pool (the fresh, cued, unseen candidates each kind offered), artist_pull (proactive watched-artist pull candidates — see artist_news), new_releases (deterministic Apple album-drop candidates, leading the slate — see artist_news detail.mode=newrelease) — the per-slot CLASSIFY SLATE the newsroom built (#2031). The desk polls the whole music wire list (38 handles) rather than the 13 numbers authorities, because a chart stat only an aggregator carried could not otherwise reach it, and _MAX_CLASSIFY (12) is a PER-KIND budget so neither kind can take the other’s places. Either count persistently short of its budget while its pool is healthy means the split broke. culture_pool is the pool BEFORE the cue-strength filter, so a healthy culture_pool with culture at 0 means chart_cue_strength rejects everything, while both at 0 means the wire fetch is not reaching those handles (separable against curator_fetch). Watch culture against music_news_scored: the classify loop stops at _MAX_COMPOSE_ATTEMPTS (6) stories and the authorities lead, so a slate carrying music wires that never produce a scored story means the authorities are filling the shortlist first — measured at 6% of slots, and a materially higher rate is the signal to reserve compose places per kind. detail.rank + detail.moved (#3448): who ordered the culture tier. model = one Haiku call (claude_api purpose=music_news_slate_rank) ranked the first 40 posts of the cue order and returned indexes into them; cues = the call or the reply failed, so the cue order stood (the order before #3448); skip = the pool fit the 12-place budget, so no call was made. moved = the ranked places the cue order alone would not have given. Panel newsroom_slate_rank. No monitor: a cues slot costs ranking quality, not a post. | | apple_releases_fetch | utils/apple_releases.py (top_albums, #release-board) | The releases board’s source: Apple Music US Top Albums, 100 rows ranked by current plays, each carrying its own release_date, in ONE keyless request. source=apple_releases, ok, count (rows parsed), duration_ms, error (fetch_failed after the retry layer | no_albums for an empty or changed body — an empty Top Albums feed is a failed read, not a real “nothing charting”). WHY IT EXISTS: the board read Deezer’s all-genres chart alone, that chart is LONG-TAIL, and the board went fully DARK — 35 runs between 2026-08-27 and 2026-09-04, 0 posts, every one phase=source_thin. On 2026-09-04 Deezer’s top 100 held three albums released that day and missed Beyoncé’s B’DAY (20th ANNIVERSARY DELUXE EDITION); Apple held four, Beyoncé’s among them. Fail-open like Deezer: an ok=False raises nothing (the board just reads fewer drops and skips the slot as a quiet night), so this rate is the ONLY sign the feed died — it feeds misc_health["apple_releases"] → the integration_unhealthy finding. On the outbound_calls and data_sources panels. All fields are shared vocabulary — zero new field names. | | spotify_fetch | utils/spotify.py | LIVE again as action=search_artist (#3678, the first-week lane; since 2026-10-02 every StreamTotals board, as the artist-id fallback when kworb’s leaderboard misses an act; measured 2026-10-02: 658 ok, 13 fetch_failed over 7 days). Also action=search_release (2026-10-20): the release-date resolver’s LAST rung (utils.release_age._spotify_release_date), a fielded /search that runs only when MusicBrainz, Deezer and iTunes all missed; count = dated candidates, ok=True reason=no_match when none; its own action, so its long-429 block is its own. Also action=artist_albums and action=album_tracks (#3897): the first-week Songstats fill reads an act’s albums list and an album’s tracklist through _api_get. A failed call now carries http_status. reason=rate_limited (#3896 follow-up, 2026-10-06): Spotify answered artist_albums with 429 QUOTA_EXCEEDED and Retry-After 26542 (about 7.4 h) after the fill read the albums list again on every 20-minute pass. A 429 whose Retry-After is over 60 s now blocks that action in the process until the time passes; each skipped call emits ok=False reason=rate_limited with folded detail.retry_after_seconds and makes no request. It counts against the integration-health rate, so a long lock flags. action=search_artist honours the same block since #3920. It had its own request code, so retry_http retried a long 429 and the next call sent a request again (2 fetch_failed at 16:30 UTC on 2026-10-06, next to the artist_albums lock). It now goes through _api_get and keeps its shape: count 1/0, ok=True reason=no_match when no exact name matched. cache_hit=True (#3934): artist_albums (24 h) and album_tracks (7 days) are kept in kv_cache, and a read answered there emits ok=True cache_hit=True count with NO duration_ms, so it stays out of the latency tables. A live read carries no cache_hit field, so the hit rate is countif(cache_hit == true) / count() per action. Each action’s long-429 block is stored too (kv_cache namespace spotify_block), so after a deploy the first call emits reason=rate_limited with no request instead of one 429. A DB error on that row emits emit_error(source=spotify_block, recoverable=True) with context.op load | store. The ops-monitor’s misc_health["spotify"] branch skips a cache_hit event, like the other integration branches: a hit sent no request, so counting it as ok would hide real failures. Dashboard: the media dashboard’s spotify_reads table shows, per action and hour, calls, cache_hits, live (calls minus hits), rate_limited and failed. No separate Axiom monitor: a lock or an outage already lowers the spotify integration-health rate, which the ops monitor flags as integration_unhealthy. The rest of this row is the new_releases history — the releases board moved off Spotify (/browse/new-releases is 403 for a new app) to Apple’s Top Albums feed (see apple_releases_fetch), so nothing imports the client and nothing emits this event; re-measured 2026-09-04 against the live credentials — the token call returns 200, the browse call 403; utils/spotify.py + this row await removal in a follow-up. Historical shape: ok, duration_ms, count (releases returned), action (new_releases), reason (on ok=False: unprovisioned = no keys | auth = creds present, token refused | fetch_failed = HTTP error) — one call to Spotify’s Web API /browse/new-releases, the releases board’s source (client-credentials auth). Fail-open: ok=False count=0 on any of those, and a real failure also emits emit_error(source=spotify\|spotify_token, recoverable=True). Joins the ops-monitor latency read AND its integration-health read (a dedicated branch feeds misc_health["spotify"] → the integration_unhealthy finding) — but reason=unprovisioned is EXCLUDED there, so a dark-by-default install (no SPOTIFY_CLIENT_ID/SECRET) is not perpetually flagged; a creds-present auth/fetch_failed rate over the window IS a real upstream/quota issue and flags. This is the ONLY sign of a silent Spotify outage, since a thin board is logged as source_thin (out of the quality/decline findings). | | music_releases_scored / music_releases_posted / music_releases_dedup | cogs/music_releases.py (#release-board) | The NEW-RELEASES BOARD — a scheduled roundup of the night’s fresh drops (artist - title, one card), the roundup sibling of the music DROP. Source: apple_releases_fetch – the board reads Apple Music’s US Top Albums feed and keeps the records that dropped in the window. It reads NO Deezer (owner steer 2026-09-05): Deezer’s chart alone left the surface dark for its whole recorded life (35 runs, 0 posts, every one source_thin – long-tail, missed the day’s biggest drops), and paired with Apple (#2950) its rows were the noise on the card; deezer_fetch and deezer.recent_releases were removed with it. (#2447; the Spotify /browse/new-releases source is 403 for this app, and the MusicBrainz date feed had no popularity signal so it was noise-or-thin.) music_releases_scored: guild_id, score, reason, shipped (bool >= the shared music-desk 0.6 floor – the board self-gates on music_desk_score(kind="release"), the numberless release rubric, so the ops-monitor’s 0.6 read is honest), count (fresh drops found, capped at the card’s 10 rows), category (new music friday | drops today for the board; biggest drops for the Friday-only TOP DROPS card — the two units one slot yields since #2956, each with its own floor, roster-dedup key and source_thin skip, so a per-unit read is a category filter), post_preview. A thin/empty skip rides this event with a marker phase + reason: phase=source_thin reason=thin (below the plan’s floor, NO compose attempted – a source/volume skip, kept out of BOTH the quality average and the compose-decline count, since the model never ran; an Apple outage is the apple_releases_fetch event’s job) vs phase=declined reason=empty (compose ran and the model returned EMPTY – a real compose decline the decline-rate finding counts). is_judge_score drops both 0.0s from the quality average. music_releases_posted: guild_id, channel_count (0 on a staged audition), delivered (room | staged), count, category, post_preview. music_releases_dedup: guild_id, channel_id, decision (similarity_gate), signal, post_preview. Since #4350 a text-dedup drop on the board is read again by Haiku (claude_api purpose=release_take_new): a take that names a new release ships and emits dedup_overturned surface=music_releases instead of this event, and the desk_repeat_gate panel shows both. A rise in claude_api purpose=release_take_new ok=false means the check is down, which keeps every drop (the behavior before #4350). On the shared desk_quality/desk_posts panels (it is in _DESK_SCORED/_DESK_POSTED) and in ops_monitor.SCORED_KINDS – and because it ships at the 0.6 desk floor, the monitor’s 0.6 SCORE_FLOOR reads it honestly (no false surface_dark from sub-0.6 shipped boards). All fields are shared vocabulary — zero new field names. No dedicated MONITOR: it ships dark (the music_releases experiment defaults to STAGING) and the shared self-gate + decline-rate findings already cover it. The FIRST-WEEK card (category first week, 2026-09-22) rides the same events: music_releases_posted on delivery, and music_releases_scored ONLY for its source_thin skip (fewer than 2 drops resolved). Its line is built from the card’s top row, so no judge runs and a built card emits no score. Its per-drop stream reads emit album_total with surface=music_releases, phase=page (reason no_artist_id / no_match / no_kworb_albums on a miss), plus phase=first_week reason no_daily for a matched row whose Daily cell is blank, which the existing album_total panel already splits by surface. The Songstats fill (#3896) adds album_total phase=songstats: ok=True, source=songstats for an album summed from its tracks, else reason no_album (Spotify has no matching release within 2 days) | no_tracks | rate_limited (#3920: a Spotify long-429 block made the albums or tracklist read return [] with no request; before, a blocked albums read logged no_album, 20 such rows after the #3915 deploy; a blocked read is not kept as a miss) | pending (the pass ran out of calls; the next pass continues from the cache) | incomplete (a track had no history or lagged a day). Its Spotify reads emit spotify_fetch with action=artist_albums | album_tracks. Its staged card rides music_releases_posted delivered=staged category="first week". No new field names. No monitor: a miss leaves one row off one card, and the panel’s phase=songstats split shows the hit rate; Songstats’ own budget is already on the quota panel. Reissues off the card (#3929): the first-week card leaves a reissue off its roster and emits album_total phase=first_week reason reissue for it. Since #3445 a reissue is a title _REISSUE_RE matches, OR a title carrying Deluxe / Edition / Version / Live / Expanded that the cached Haiku check claude.release_is_reissue (purpose=release_reissue, labels REISSUE / NEW) calls a reissue or gives no verdict on (the stat is omitted on a model error). The same check gates the board’s first-night stat and the Songstats fill. The calls show in claude_api with purpose release_reissue; a rise in ok=false there means more stats are omitted. The first-week roster also reads the release-version verdicts now (#4230 item 3), as the board does. The release_board_songstats experiment defaults to PRODUCTION since #3929. No new field names. No new panel: the album_total panel splits by phase and reason. No monitor: a skipped reissue is the intended outcome, not a failure. House genres on the board (#3276): the board, the Friday top-drops card, the first-week card and the per-album boards keep rap, R&B and pop only (release_board.house_rows, Apple genre ids through music_policy.in_house_genre_ids). Each in-window album cut emits album_total phase=albums (the board) or phase=first_week, reason off_genre, query artist - title. A Kalshi forecast album takes the genre of its Apple row; with no Apple row it has no genre data and stays only when the act is on the kworb watch list, else album_total phase=first_week reason=no_genre (a kworb outage counts as not known here: the list stands in for the missing genre). One event per cut row per slot, so a row in the window emits a few times a day. No new field names, no new panel (the album_total panel splits by phase and reason), no monitor: a cut is the intended outcome. A rising no_genre count with kworb healthy means the forecast albums stopped matching Apple’s titles. First-week total + per-album boards (#3935): each fill pass emits album_total phase=week: ok when the album has a clean first-week baseline, else reason no_baseline (the row stays on the card without its wk figure); and phase=daily: ok when every track has a clean one-day figure, else reason not_clean (the row stays with no daily). The card emits album_total phase=first_week reason=outside_window for a drop whose Spotify release date is more than 7 days old or in the future. The per-album boards emit album_card with surface=music_releases (ok + result_count = tracks on a build; reason not_ready when a due album’s Songstats tracks are not complete or fewer than half have a clean one-day figure) and ride music_releases_posted category="album board" on delivery. No new field names. The album_card panel now splits by surface. No monitor: a miss costs one figure or one staged board, and the phase=week and album_card ok rates on the panels show a sustained drop. | | watch_new_entry | cogs/music_desk.py | ok (the row was datable), reason (debut | reentry | charted | no_date | deep | radio_entry | side_version), + folded detail: chart, pos, age_days, last_seen_days, title, judge — the watch lane’s new-entry gate (#2045). side_version (#3441, 2026-10-16) is a live / remix / sped-up re-cut. The regex (release_type.is_side_version) screens first. A title it flags goes to the cached Haiku release-version check (claude.release_version_kind, purpose=release_version, labels new / recut / edition) before it drops; judge says which one decided (model or regex, the regex when the model gave no verdict). Since #4230 every side_version drop carries judge, including the model’s recut call on a regex-passed title (judge: model), so judge == 'model' counts all of the model’s drops. Since #4708 a title whose version suffix names a rendition (release_type.rendition_marker: instrumental, a cappella, karaoke, sped up, slowed, reverb, live, acoustic, edit) logs judge: rule, because release_version_kind returns recut for it with no model call. Before #4708 those drops logged model, so judge == 'model' counted calls that did not happen. A title the regex passes is asked only when the lane is about to KEEP it (debut, reentry, radio_entry), so the charted / deep / undated rows cost no call. A model-dropped kept row emits side_version with age_days set. The calls show in claude_api with purpose release_version; the release board asks the same check about its first 20 in-window rows (cogs/music_releases.py:_version_verdicts). The album-milestone count asks claude.album_variant_relation (purpose=album_variant) about every related title pair since CB-17 (about 3-5 calls a crossing, cached 14 days); the per-track card’s album join asks claude.track_version_join (purpose=track_version_join) about a row that joins a track only by the loose key (CB-24, under 1 a day). kworb marks a catalog RE-ENTRY NEW exactly like a fresh drop on the rank-only charts, so chart_stories emits an unresolved new_entry and this dates the release before anything is claimed. The third lane to carry this gate, after apple_debut (#1887) and called_shot (#2038) — the same one bug in three places. charted is the CHART-PRESENCE rejection (utils/chart_presence.py, first-sighting semantics per #2139): the title is past its 24h first-sighting window on this chart (last_seen_days = days absent before its latest return; 0 = continuously charting), so kworb’s NEW flag is a tracked-range bounce or a re-surfacing recent release, and the date gate alone would have re-claimed a debut — the duplicate-debut fix (a week-old release’s age passes the window every time it bounces back). A charted row makes no post. Unlike the other two lanes, a reentry here is KEPT and posts as a re-entry: a catalog track returning to a platform chart after a real absence is real news (it is what the wires post as “‘ICEMAN’ has returned to being the #1 Hip-Hop album”), it just is not a debut. deep is a settled re-entry the DEPTH then cut: it landed below MUSIC_DESK_WATCH_REENTRY_MAX_POS (10). A new title is news wherever it lands, but an older one returning deep in a chart is churn — measured over the 14 days to 2026-08-08, all 14 re-entries the lane kept sat at #56–#99 and none reached the top 10, and the #97 one (“Where We Go”, Marshmello & Thomas Rhett, US iTunes) shipped to X. music_news.deep_platform_cut already cut this exact shape at the same depth for the wire lane; the desk lane had no floor. Debuts are exempt. radio_entry (2026-08-17) is a new_entry on a LEADING chart — radio airplay (_LEADING_CHARTS) — KEPT as a position rather than a debut. Radio builds a song’s spins over WEEKS after release, so a first appearance on the airplay chart is decoupled from the release date, and the release-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 45-day 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 states only WHERE the song sits (“now on the US radio-airplay chart at #N”, kind radio_entry), and only when the entry lands inside MUSIC_DESK_WATCH_RADIO_ENTRY_MAX_POS (20, anchored to the per-format lane’s top-15 breakout cap). A deeper entry is deep-tail airplay churn and drops as deep (petal #72); a tracked-range bounce drops as charted; no release date is ever read for radio. The crown / top-10 / jump kinds carry no release claim and are untouched, and utils/radio.py still tells the “climbing on radio” story off the spins. So reentry + charted + deep + radio_entry streams are the gate working. Watch instead for a sustained no_date stream: that is Deezer failing, and the lane’s new entries are dark. The PRE-ORDER rule (#3483, 2026-09-19): an established title RELEASED within MUSIC_DESK_PREORDER_GRACE_DAYS (1) resolves debut, not charted — a title cannot be established before it exists, so the earlier sightings were its pre-order listing (“Bass Persuades” at #15 on Apple Music albums on release day, first seen 12 days before, rejected every slot). A row that states its own release date (the iTunes albums reader) is dated from the row and pays no catalog lookup; for an ALBUM the shared resolver now takes the LATER of Deezer and iTunes, because Deezer answers a pre-order listing’s date (2026-09-03 against the real 2026-09-18). A debut with age_days 0-1 and last_seen_days set is that rule firing. | | called_shot | cogs/music_desk.py | ok (the row is callable), reason (fresh | stale | no_date), + folded detail: rank, age_days, max_age_days (the limit the lane applied, _CALLED_SHOT_MAX_AGE_DAYS; since #4445 the ops monitor and the desk_debut_ship_rate panel read it from here), album — the HITS called-shot lane’s DEBUT gate (#2038). The projection document has 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; the album’s release date is what separates them, and the gate fails CLOSED (no_date refuses the claim). The HITS twin of apple_debut. Measured live on the building chart dated 2026-07-30: 4 of 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 — and three sat inside the watch band (#2007) that reaches rank 100 for exactly those artists, so the lane could have called a ten-year-old album as a debut. Watch a sustained no_date stream with zero fresh: that is Deezer lookups failing and the lane going dark, which is otherwise indistinguishable from a quiet chart week. | | proj_debut_gate | cogs/music_desk.py | ok (at least one row confirmed), count (rows kept), + folded detail: considered, fresh, stale, no_date, dropped — the DEBUT gate in front of the two PROJECTION BOARDS (_fresh_projection_entries, #3170). Same bug as called_shot, one lane later: projected_debut_rank and projected_debut_sales drew new_entries() raw, and that column reads the same for a catalog re-entry, so the card for the chart dated 2026-09-19 shipped nine rows 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. Both boards now take the caller’s confirmed rows and are ABSENT without them (fail closed), under a floor of 2 confirmed rows. ONE event per evaluation, not one per row, and deliberately NOT in ops_monitor._DEBUT_GATE_SHIPS: the rows overlap the called-shot lane’s, so a per-row shape would double that lane’s counts, and a light release week legitimately confirms nothing — the gate_dark finding would fire on the gate working. The board’s own silence is already covered by lane_dark on phase=chart_proj, which the three RANKING boards keep alive (they make no claim about what is new and do not read this gate). Read the tally, not the count: a stale majority is the gate working (8 of 9 on the document dated 2026-09-10), a no_date majority means Deezer and iTunes stopped answering and both boards are dark for that reason. | | apple_debut | cogs/music_alert.py | reason (fired | deep | slipped | stale | charted | no_date; slipped since 2026-10-23 = a real debut that FELL below its entry rank inside its first day, which no longer posts, owner steer “lets just do rises”; detail.slipped counts them), count (NEW chart entries evaluated), + folded detail: pos, age_days + tier (watched/known/unknown) on fired, charted (# rejected as already-charted), deep (# cut by the depth floor, on EVERY disposition), tiers (live/off — was the recognition list real?), song, chart (songs/albums) — the Apple Music DEBUT lanes’ gate decision, for the songs chart and the Top ALBUMS chart (#1887). The lane runs FOUR gates and each rejection has its own reason. kworb’s NEW marker also fires on a RE-ENTRY, so stale is the release-date rejection (every NEW entry resolved a date, all outside the fresh window) and no_date is Deezer resolving nothing — the fail-CLOSED direction, a lookup problem rather than a real “all old”. charted is the chart-presence rejection (utils/chart_presence.py, first-sighting semantics per #2139): the title is past its 24h first-sighting window on this chart, so the NEW flag is a tracked-range bounce. deep is the tiered DEPTH floor (#2207): the debut is real, and the lane cut it because it landed below the floor for its artist’s recognition tier — MUSIC_DEBUT_KNOWN_MAX_POS (50) or 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” — the third-to-last rung of a top-100 chart by an artist on neither kworb ranking page. deep is reported ahead of stale because a depth cut always passes the date gate first, so without the ordering the cut would read as “the dates were old” when the lane made a policy choice. deep rides the fired event too, not only the terminal one: a row cut above a row that then shipped would otherwise leave no trace, and the depth panel would under-report. tiers is the health of the depth floor’s other half — off means the recognition lists came back empty, so every artist read as known and the floor silently relaxed to the looser bar (the tier_gate_off finding). A no-NEW day emits NOTHING (the quiet norm; a kworb fetch crater is caught by the chart_fetch health watch). So deep + charted + stale streams are the gates working. Watch instead for a sustained no_date stream with zero fired: that is Deezer failing and the lanes dark, which a quiet chart day is otherwise indistinguishable from — and that whole class is now a named finding (gate_dark), because no judge-score check can see it. entry + framing say WHICH WORDING SHIPPED (2026-08-09). The lane picks one of four phrasings for a fired debut and the choice turns on the ENTRY rank, which the event did not record — so a misfiring rule looked identical to an ordinary debut. framing is debut (still at its entry rank), ramp (released within MUSIC_DEBUT_RAMP_MAX_AGE_DAYS and risen since first sighting — takes the DEBUT wording, because on a realtime chart the first-sighting rank records when the bot polled, not where the title entered), climb (risen, but the release is older, so it is a real climb) or position (fallen, which is never a ramp; the blob states the current rank only and names no move – the label was slip before 2026-09-23). entry rides along so a query can re-derive the branch without trusting the label: ['tootsies'] | where event == 'apple_debut' | extend d=parse_json(detail) | summarize count() by tostring(d['framing']). Watch for ramp on a chart where releases are rare, or climb never appearing at all — either says the age threshold is mis-set. Panels: desk_debut_gates (reason mix), desk_debut_ship_rate (per lane+chart, the one that separates songs from albums) and desk_debut_depth_cuts (what the floor cut, by tier and rank). The PRE-ORDER rule (#3483, 2026-09-19): the same exception as watch_new_entry — an established title released within the grace FIRES with framing=debut and entry=0 (the pre-order sighting’s rank is not an entry), instead of counting toward charted; an iTunes albums row is dated from its own releaseDate. | | chart_peak | cogs/music_alert.py (_peak_core) | reason (fired / lapsed / fell_unposted; since 2026-10-23 a peak the title fell from before it held does not post (owner steer “lets just do rises”), so an ended stay reports fell_unposted and lapsed no longer fires), count (candidates at a new peak this tick; for lapsed, the stays this read timed out), + folded detail: pos, chart (ap_songs / ap_albums / it_songs / it_albums), age_days, baseline (the rank it beat), told (true = the baseline is a rank this guild was already posted; false = the entry rank), entry, fell, now_pos, held_min (minutes the title held the peak), song — the NEW-PEAK lanes (2026-10-02, the FOMO miss: “HABIBTI (FOMO)” went #6 -> #2 on iTunes Top Albums on release day and nothing posted, because every climb lane reads the chart’s day-over-day move column, which is 0 on release day). A title inside the top ten (not #1, the crown’s story) fires when it stands at its best rank so far and beats the told rank. Memory: utils/chart_peak.py (kv_cache namespace chart_peak). Since 2026-10-17 (#4455, owner: “limit peak lane, stop at highest rank achieved”) a peak fires only when its climb has stopped: the title held it, unbroken and without a better rank, for _PEAK_SETTLE_MIN (270), or it fell from it (detail fell true, now_pos = the rank it stands at now; pos stays the peak). So a climb posts once at its top. lapsed is an untold top-ten peak whose fall timed out (FALL_KEEP_HOURS, 24) without a post: that top never posted. fired and lapsed are the only emits: a tick with no new peak is the norm. The post lands as music_alert_posted with phase = apple_peak / apple_album_peak / itunes_peak / itunes_album_peak. Panel chart_peak_fired; no monitor (a quiet day is normal). | chart_crown | cogs/music_alert.py | reason (fired | unknown_act — RETIRED 2026-09-28 (owner: “top 10 should be top 10s, they’re not limited”): no crown is limited by the recognition list any more, so this reason no longer fires. Historically: a STORE-chart #1 (iTunes songs / albums) held by an act outside the recognition list, which the lane declines to card: the fanbase-push filter artist_watch.recognized_for_store_chart, 2026-09-19; the desk’s _TOP_KNOWN_CHARTS applies the same rule to its cards, so a quiet iTunes crown with this reason streaming is the filter working), + folded detail: chart (global_daily/apple_songs/apple_albums, and since #3799 the eight iTunes GENRE chart keys itunes_{hiphop,rnb,pop,kpop}_{albums,songs}), pos, prev (the rank it came from; null when there is none), age_days (None = Deezer could not date the row), framing (ramp/climb/entry/took_top/standing — standing = the change is unproven, so the blob states only that the title IS #1: on the iTunes albums chart the clean-chart seed revealed this #1; on an iTunes GENRE chart (#3799) it is the chart’s FIRST read, whose launch seed posts the current #1 on day one), song — the shared #1 TAKEOVER lane’s wording decision (#2235). Since 2026-09-28 (owner: “we can’t miss number one changes”) the lane fires on THREE signals, not one: a climb (pos_change > 0, framing climb or ramp), a title that ENTERS at #1 (is_new, framing entry; a new entry carries pos_change == 0, so the old gate dropped it), and, on the iTunes albums chart, the reader’s own #1 record (took_top, framing took_top, no prior rank stated). The event fires at DETECTION, every tick, before the per-lane gate; a crown lane now may post every DISTINCT #1 in a day (_daily_gate keeps today’s delivered keys), not only the first, and a crown that did not deliver (a take the 0.6 self-gate blocked) is tried again next tick, up to MUSIC_CROWN_MAX_TRIES (3) times per #1 per day. So repeated fired rows for one song are normal while it retries or while a held key waits; a crown that never reaches music_alert_scored shipped=true after its tries is the miss to look for. The lane fires only on pos_change > 0, so by default the title held a real prior rank and the blob says “climbs to #1, up from #N” and forbids “debut” — the #2140 fence, and this lane is where that bad post came from. The one exception is a RELEASE-DAY RAMP, the crown half of #2226’s debut-lane rule: kworb’s pages are near-realtime snapshots, so a title released today enters low and rises through the day as plays accumulate, and its earlier ranks are that ramp rather than a chart history. A #1 reached within MUSIC_DEBUT_RAMP_MAX_AGE_DAYS of release is a real #1 debut. framing says which wording shipped, because the branch turns on a release date the event would not otherwise carry — without it a misfiring ramp reads as an ordinary crown. Fail direction is CLIMB: an undatable row (age_days null) keeps the climb wording, because pos_change already proves a climb while “debut” is the claim needing evidence. Note the ramp blob never names prev — the number rides this event for diagnosis but is kept out of the compose’s context, so the model cannot cite a rank it was never given. Watch for a sustained all-ramp mix, which says the age threshold is mis-set. refreshed_at + refresh_age_s (#3191) are the DETECTION half of the lead-time pair: refreshed_at is the chart PAGE’s own refresh time (kworb’s last-modified), and refresh_age_s is how stale that was when the lane DETECTED the new #1. This event fires while the lane BUILDS the signal, before the daily gate, the self-gate and the repeat check — so a crown counted here may never have posted, and this is NOT the “how fast did users see it” number (Codex on #3193). That one rides music_alert_posted.refresh_age_s, which only a delivered post emits, and crown_lead_time reads it. Keep both: comparing them splits a slow READ (the cache) from a slow COMPOSE (the gates and the model). Both are empty/None when the client cannot report one: absent over invented, because stamping our fetch time there would read as the chart’s refresh time and be wrong by however long the cache held. The RADIO crown lane emits this event now too. It composes its own blob (an audience figure, not a rank) so it never rode _chart_crown, which left it the one crown lane with no event at all; framing is always climb there (radio adds take weeks, so a release-day ramp is impossible on that chart). Watch refresh_age_s for a creep back toward the old 3h: that means revalidation stopped working and the lanes are late again. Panels: desk_crown_framing (this event), crown_lead_time (the delivered twin on music_alert_posted). | | chart_jump | cogs/music_alert.py | reason (standing / fired), + folded detail: chart, pos, jump (spots climbed), top_ten (the act’s top-10 count), posted (did another act’s climber take the slot), song — #2672. fired (#4460, 2026-10-17) is the lane’s PICK: one row per jump the lane offers to post (detail chart, pos, jump, song); the post itself lands as music_alert_posted with phase = the jump lane. Since #4460 every jump lane passes over a climber whose rank, or a better one, was told for that title in the last _JUMP_TOLD_DAYS (1) by any rank lane on the chart (_TOLD_LANES; the Spotify jumps record told ranks too). That refusal emits nothing. Panel chart_jump_fired; no monitor (a quiet day is normal). standing: Emitted only when a jump lane (spotify_jump, spotify_us_jump, apple_jump, itunes_jump) STEPS ASIDE because the climber’s act holds chart_boards.MIN_TOP10_STANDING (2) or more of the chart’s top 10, read with chart_boards.artist_top_ten_count, the desk’s own count. The desk’s watch_artist_board owns that story. A step-aside raises no error, so this event is its only trace. posted=false is a slot the lane left empty; a run of those for an act the desk does not board (the desk boards only recognized acts on the store and radio charts) is the fall-through the issue warned about. The crown lanes do not step aside (owner steers of 2026-09-28: every #1 change posts). | | music_alert_posted / music_desk_posted / music_news_posted source_age_seconds (music lag) | cogs/music_alert.py, cogs/music_desk.py, cogs/music_news.py (#3829, epic #3821, 2026-10-05) | Folded detail.source_age_seconds: the seconds from the UPSTREAM change to the post. It reuses the wire desks’ field, so it adds no field name. The upstream time per lane: an alert settle and a desk reveal read Kalshi’s settlement_ts (reading.settled_ts); the alert’s crown, jump, debut, peak, radio and release lanes read the chart page’s refresh time (_LAG_LANE_PAGE in cogs/music_alert.py, one page per lane, the same reader as the lane’s crown); a desk chart_proj board reads the HITS document’s _updatedAt (the unit’s upstream_ts); the desk watch_chart cards, platform_board / board_presence boards and the kworb milestone cards (career streams, monthly listeners, a track’s or an album’s streams) read the refresh time of the kworb or iTunes page they were built from (upstream_ts, #3842, through the shared utils.break_latency.page_refreshed_at, read right after the fetch; _CHART_PAGE in cogs/music_desk.py maps a chart key to its page); the newsroom lane=x_watch reads the authority tweet’s created_at, and lane=feed_watch (#3834) reads the RSS item’s published time. A page clock is the time the page last changed, so on a page that refreshes often (Apple, iTunes) a story first seen on an earlier refresh reads younger than it is: the age is a lower bound there. Every other lane has no upstream time, so the field is absent, never 0: an alert line_move / last_call (Kalshi gives no time for a move; a settle’s close_ts fallback is unix seconds, which the age helper reads), the artist-market lanes, and the desk lanes whose source gives no honest time: chart and artist_catalog (Billboard; the client exposes no publish time), projection / tracking (a live Kalshi price has no change time), a HITS year-to-date album milestone (the client does not expose that document’s _updatedAt), and a Songstats subject milestone. Panel music_lag (Desks dashboard, p50 / p90 by event + lane). Gate: the ops-monitor music_lag_slow finding (p90 over 600s per lane at n>=5). Query: ['tootsies'] \| where event in ('music_alert_posted','music_desk_posted') \| extend age=toint(parse_json(detail)['source_age_seconds']) \| where isnotnull(age) \| summarize percentile(age,90) by event, phase | | music_news_posted age_read | cogs/music_news.py, utils/news_age.py (age_read_text) (#4684, 2026-10-23) | Folded detail.age_read: the Grok news-age read’s prose (up to 200 chars from its start, citation links cut), which states when the EVENT happened. is_stale_recap(record=...) fills it on a FRESH or a STALE verdict; the newsroom carries it in Composed.meta to the post. Absent when the gate made no read (no Grok client, an error, no verdict). source_age_seconds dates the source TWEET; this dates the STORY, which is the gap #4671 shipped through (a fresh tweet about a June award). Read by the ops-monitor story_age_stale finding. No new top-level column. | | competitor_match | scripts/competitor_scoreboard.py (#3862, epic #3821, 2026-10-05), run from the ops-monitor job | The competitor scoreboard: the same music story from @chartdata, @talkofthecharts, @chartmastersorg or @hitsdd against ours. Not emitted by the bot: the script ingests the rows straight into the dataset, with _time at the POST time (ours for a match, theirs for a miss), so a week groups by when the story posted. kind = match (one row per our post and competitor that tell the same story) | miss (a story of theirs that none of our posts within 12h tells) | run (one health row per scoreboard run). On match: surface (music_news | music_desk | music_alert), phase (our lane), source (the competitor handle), matchup (our subject, 80 chars), delivered (x = our clock is the X crosspost time, room = the Discord post time), + detail (a JSON string): lead_seconds (their post time minus ours; positive = we were first, negative = they were), chart (the charts_named keys, cut family or source the story names), tweet_id (theirs), via=relay (the newsroom took this story FROM that account, so it was first by construction). On miss: source, post_preview (their text, 120 chars), detail{chart, tweet_id}. On run: ok, count (competitor posts read), duration_ms, error (on a failure), detail{pages, judge (CB-28: model = the Sonnet pair read and the Haiku story read ran, rules = no key or no client, so the rule ladder decided), judge_reads, judge_errors (reads that fell back to the rules)}. The reads are claude_api events with purpose competitor_same_story and competitor_story, but they reach only the ops-monitor job log: the job never calls axiom.configure, so emit() does not send them to Axiom. The Axiom signal is this run row’s detail.judge and detail.judge_errors, which the script ingests itself. Matching reuses utils.music_news (artist_grounded_in_wire, title_grounded_in_wire, charts_named, chart_family, claim_platform, ungrounded_quoted_titles) plus a count / cert / position agreement check; the module docstring lists the rules. Field cost: none. The top-level names are registered shared ones, and the detail keys live inside one JSON string (not _FOLD_FIELDS, which polices emit() calls; this script does not call emit()). Panels: competitor_lanes + competitor_accounts (Content Desks). Gated by the ops-monitor competitor_slow finding. | | spotify_debut | cogs/music_alert.py | reason (fired | deep | slipped | stale | no_date | side_version; slipped since 2026-10-27 = a debut below its first-sighting entry rank, cut like apple_debut’s, counted in detail.slipped), count (fresh chart entries evaluated), + folded detail: pos, age_days (on fired), entry (the first-sighting rank, 0 = unknown), days_on_chart, song, chart (global_daily), tier (on fired), tiers (live | off), deep (rows the depth floor cut). The depth floor is #2213: the shared aw.debut_depth_floor table scaled x2 for the top-200 Global Daily (WATCHED no floor, KNOWN to #100, UNKNOWN to #50), run after the date gate and skipping rather than ending the scan, as on apple_debut. deep is reported before stale because a floor cut always resolved a date. This row is the Spotify RELEASES lane’s DEBUT gate, the Spotify twin of apple_debut under the same fail-CLOSED contract. Real case: the lane selected ROA’s “VAMO A VEL” at #191 on 2026-09-22. kworb’s days-on-chart column counts days on THAT chart, never days since release: a song released weeks earlier that only now crosses into the Global Daily top 200 reads days=1, identical to a same-day drop. Shipped in prod on 2026-08-04 as “Steve Lacy’s ‘oh yeah?’ opens with 8.4M Spotify streams day one” — the song came out 2026-07-17 and had charted in the US every day since; it entered the GLOBAL chart at #126 that morning, and the 8.4M was a running total, not a first day. The lane now confirms the Deezer release date (_debut_release_age, the gate the Apple debut lanes already ran) before it calls anything a new release. Watch a sustained stale / no_date stream with zero fired: that is the lane dark, which a quiet chart day is otherwise indistinguishable from. The gate runs BEFORE the Songstats lookup, so a rejected entry costs no metered slot. entry + days_on_chart are the #2224 fix. chart_debuts accepts a row up to MUSIC_DAILY_DEBUT_MAX_DAYS (2) days on the chart and the row carries its CURRENT rank, so the blob’s “entered the chart at #N” was stating a day-2 rank as an entry rank — #2140 on the lane that never got it. Measured live: Stray Kids’ “This & That” entered at #31 on 2026-08-08 and still counted as a debut at #82 on 08-09, so a post one day later would have been wrong by 51 places; it did not ship only because _daily_gate’s same-key rule blocks a REPEAT, which is dedup and not correctness. The lane now reads chart_presence under the shared sp_global key for the entry rank, falls back to the current rank only on a day-ONE row (where the two are the same by definition), and otherwise claims no entry rank at all. The post leads with the LATEST rank — owner steer 2026-08-09, and chart_presence’s own #2139 rule that a consumer inside the window reports the CURRENT rank. A CLIMB carries the entry as context (“is at #20, up from a #31 entry inside a day”); a FALL carries only the current rank and “not its debut position”, with no entry rank and no direction (owner steer 2026-09-23). Either way the current rank is never read as a debut, which is the #2140 half. The “inside a day” phrase is gated on info.new, so it is dropped once the row is past its 24h first-sighting window. Presence is read for the RANK ONLY here, never for suppression: the Spotify days-on-chart count is cumulative so a bounce never reads as day 1, and info.new is a 24h window while this lane allows 48h, so suppressing on it would drop every legitimate day-2 debut. days_on_chart also fixes the other number in the same sentence — the plays were narrated as “its first day ON THE CHART” on a day-2 row | | radio_debut | cogs/music_alert.py | reason (fired | crossover | no_date), count (radio entries that matched a big Spotify hit), + folded detail: pos, age_days, days (radio chart days; 1 = the entry day, #3683), song — the RADIO ENTRY lane’s age decision (#2077), the fifth #1887-class fix. Radio’s days column counts weeks on the RADIO chart, never days since release, so the lane composed “a BIG new song just DEBUTED” about whatever crossed to radio — live it called an 847-day-old Malcolm Todd track new. Unlike its siblings this is a FRAMING split, not a gate: the lane’s real picks measured BIMODAL (fresh releases reaching radio at 5–21 days, a summer single crossing at ~44–48, the 2.3-year outlier), and a months-old streaming hit finally reaching radio is a real story — arguably the better one. fired composes the NEW framing (the release age is stated in the blob); crossover composes the crossover framing with “new”/”debut” banned; no_date takes a NEUTRAL framing — both “new” AND “established hit” are banned, because an undatable song may be a drop-day release the catalogs have not indexed yet (#2125), so asserting either age is invented. Neither non-fired reason blanks the lane. The window is MUSIC_RADIO_ENTRY_NEW_MAX_AGE_DAYS (60), sized to radio’s measured lag — a streaming-lane 14d window would misframe most legitimate radio arrivals. | | radio_standing | utils/radio.py (standing, #4172) | ok (a format row matched the song), reason (hit | no_title | no_artist), count (format lines returned) — ONE row per per-song radio lookup over a non-empty Mediabase chart (the music desk’s context join, cogs/music_desk.py). Most songs are not on radio, so a single no_title is normal; read the ok-rate and the no_title share over a day. Why it exists: #3454 made the title match strict, and Mediabase glues the feature into the track (“Jaded f/Ella Langley”), so 0 of the 45 “f/” rows on the live building chart matched and the radio clause dropped out of the desk context with no signal. Both titles now pass through title_forms.bare_title first. A sharp fall in the ok-rate, or no_title rising while the chart is full, means a new source title form the stripper does not read. no_artist = a title matched and the credit did not (a same-titled song by another act, the refusal working). Graded (#4180): the ops monitor files radio_title_blind (medium) when the no_title share is 85% or more over at least 10 lookups in the window. The baseline is the first chart lane after #4178 (2026-10-20): 33 of 66 lookups no_title (50%), 30 hit, 3 no_artist. The lane runs once a chart week, so most windows hold no rows at all. Panel radio_standing on the desks board. | | stream_lookup | cogs/music_desk.py, cogs/music_news.py, cogs/music_alert.py | query (the release or act, <=120 chars), surface (music_desk | music_news | music_alert), source (songstats | kworb | none), ok (source is songstats or kworb) — did a scheduled music surface get USABLE streaming numbers. songstats_fetch is the HTTP layer under it and grades only the call, so a hollow payload (HTTP 200, the release’s counts not ingested yet) is songstats_fetch ok=true and stream_lookup ok=false. Graded per surface (#2687): the ops monitor files stream_lookup_hollow (medium) for a surface whose ok rate is 10% or less over at least 5 lookups and 3 distinct releases. Measured over 30 days to 2026-10-25: the newsroom 96% ok, the alert 0-100% per 12h window, the desk 40-90% on a normal day. The desk read 0% on 750 lookups over 2026-10-02/03 and 0-9% over 2026-10-23/24, while songstats_fetch stayed at 99%: both would have fired. Panel stream_lookup_by_surface on the desks board. | | music_news_retired | cogs/music_news.py | guild_id, channel_id, reason (the dedup signal: text_similarity | content_overlap | topic_restated | shared_run), phase (story kind), source (the @account), post_preview — the newsroom RETIRED a source tweet because the repeat check dropped its composed unit (#2037). The desk marked a post handled when it posted, when the self-gate failed it, and when the classifier called it a non-story — but not when the repeat check dropped it, so the post stayed a candidate for its full 30h freshness window and re-composed every slot, paying one Sonnet write plus one judge call each time. Measured over 7 days: 892 composes covering only 95 distinct subjects — 9.4 writes per story (petal 136, AH HA 102, Music/Fashion/Film 75) against 104 posts, so any read of the funnel off the compose count was ~9x inflated. Pair with post_dedup on the same reason to confirm every mechanical/topic drop retires its source; a post_dedup stream with no matching retire means the unit carried no external_id | | dedup_overturned | utils/dedup.py (arbitrated_match), emitted by cogs/scheduled_poster.py + cogs/discourse.py + cogs/chimein.py | surface (music_news/music_desk/… plus discourse and chimein), guild_id, channel_id, signal (the mechanical signal overturned), reason (the judge’s words), post_preview — the topic judge OVERTURNED a mechanical dedup hit, so the post shipped (#2037). The mechanical gate compares WORDING, which on a chart desk is mostly boilerplate, and its hit used to be final: live against a real 20-post history it called a different artist’s identical-shape line a repeat, and the same song at a NEW chart position a repeat. The judge now arbitrates instead of only backstopping. A healthy rate is small and non-zero. Zero means the arbitration is not running, or the judge is unreachable — which fails CLOSED and leaves the mechanical verdict standing, so an outage looks like silence here rather than a flood of repeats. A rate approaching post_dedup on the same surface means the mechanical gate is firing on shape far more than on stories, and its thresholds want a look. Always read the two together. The arbitration is shared, but the CATCH direction is not (#2083). The decision lives once in utils.dedup.arbitrated_match, which the wire desks, discourse and chime-in all call. The desks pass catch=True and pay one judge call per post, because they write from a house style and restate each other constantly. Discourse and chime-in pass catch=False, so the judge runs only when the mechanical gate has already found a hit: their gates fired 9 times and 0 times in 30 days, against 705 mechanical drops across the desks in 7, so catching there would buy a handful of suppressions for thousands of calls. For those two surfaces the DROP side is not post_dedup — read discourse_dedup and chimein_evaluated decision=dedup_gate against this event instead. | | post_dedup | cogs/scheduled_poster.py | surface, guild_id, channel_id, reason, phase, post_preview — the MECHANICAL dedup gate (utils.dedup.duplicate_reason) discarded a unit that was already composed AND already through its self-gate. reason is the signal that tripped (same_link / text_similarity / shared_run / content_overlap), or source_seen (#3824: the double-post guard in ScheduledPoster._process_unit, answered by the surface’s _sched_source_delivered; music_news answers from its seen-set, and the music desk from its posted keys and, for a milestone, its stored rung (wave 15: a milestone’s ms: key was never stamped, so the slot and the fast pass both posted Rodeo’s 4B at 15:02:42 and 15:02:57 on 2026-10-17) – the slot and a watcher composed the same source post, and the second to deliver found it already delivered; a steady trickle is normal, a large count means the slot and the X watcher keep composing the same posts), or fact_claimed (wave 15, utils/chart_fact.py; music_desk, music_alert and, since wave 16, music_news): another surface already holds the claim on the same (chart, song, rank, ET date), or – desk and newsroom only – on the same streaming milestone (platform, track or album, song, rung; surface milestone_fact, no date), or on the same CATALOG count (platform, act, streams rung, song count; same surface, no date; desk catalog_stat and newsroom catalog milestones, since #4683), so this card did not send. Since #4683 the desk’s PLATFORM top-10 and streams boards (phase=platform_board) also claim the (chart, #1 song, 1, ET date) fact their headline states, so a board and an alert crown of one #1 no longer both post. Since #4505 the music alert also READS the claim (chart_fact.claimed) after its daily gate and BEFORE it composes, and spends one of the story’s 3 daily tries; that skip emits (at most 3 times per story per day) fact_claimed with detail.step = pre_compose (parse_json(detail)['step']) and costs no model call. Before it, the lane composed a claimed fact, the topic judge dropped it, and the next tick did the same: three Apple album debuts the desk had claimed were composed 19 times on 2026-10-23/24. Each row is one cross-surface repeat stopped. On music_news the phase is the story kind (chart / milestone), and the tweet is retired (music_news_retired reason=fact_claimed). Panel: desk_exact_guards; phase is the lane, so the loss is attributable. Why it exists: this gate ran with no event at all, so a composed-but-discarded unit cost a write call plus a judge call and left no trace. On 2026-08-04 the music desk composed 45 units and posted 5 in 140 minutes, and finding out why meant replaying real production lines through duplicate_reason by hand — 38 of 40 died here. Drop-rate = post_dedup events / units composed; a lane whose drop-rate approaches 1 is paying full price for posts nobody sees. Fires for EVERY ScheduledPoster surface, not only the topic-dedup opt-ins. A drop rate is not a material rate (#2037). A surface that does not CLOSE OUT the SOURCE of a deduped unit re-composes it every slot until the source ages out, so this event counts the loop, not the stories: the newsroom’s 892 composes over 7 days covered 95 distinct subjects, one written 136 times. Read post_dedup alongside dcount(matchup) on the surface’s *_scored event before concluding anything about volume — composes-per-distinct-subject is the number that says whether a surface is busy or stuck. _sched_on_dedup is where a surface closes the source out. The PUBLICATION lanes run a REDUCED signal set (#2098). chart_board / debut_sales / chart_proj / market_board are deduped on wording and links alone (_sched_dedup_wording_only), which drops shared_run and content_overlap (utils.dedup.FORMAT_SIGNALS) and the topic judge’s CATCH direction. Those two signals measure a post’s SHAPE, which on a recurring chart graphic is the FORMAT: a card names its chart, its week and its source every time, and “the Aug 15 Billboard 200, with HITS Daily Double” is a 40-char verbatim run before the post says anything of its own. Measured 2026-08-07 on the live Aug 15 week, the four projection boards all cleared the 0.6 gate and only ONE reached the room; after the opt-outs plus per-board compose steers, 4/4 did. So a post_dedup on one of those four phases should only ever read same_link or text_similarity — a shared_run or content_overlap there means the opt-out regressed, and is worth a look.| | history_gate | utils/history_gate.py, taken by cogs/scheduled_poster.py + cogs/event_poster.py | surface, guild_id, ok, reason, duration_ms — the per-guild POST-HISTORY GATE (#2758), which serializes the history READ -> judge -> history WRITE walk so two wire desks cannot both read a history that holds neither’s post yet. Emitted on TWO branches only, so the steady state is silent: reason=waited (ok=true) is a real collision the gate serialized instead of double-posting, duration_ms being how long the second desk queued (since #3828 the gate is held per unit, so a wait is bounded by one unit, about 38s for a delivered card; a waited row near 150s again means a lane went back to holding it across a walk); reason=wait_timeout (ok=false) is the gate giving up after GATE_WAIT_SECONDS and posting on a possibly-stale read. THE GATE FAILS OPEN: a timeout is never worse than no gate, but it is the old race, so any wait_timeout row means a deliver is stuck for minutes and wants a look. An uncontended take emits nothing (~120 desk posts a day would otherwise be ~120 events of no signal). WHY IT EXISTS: on 2026-08-31 sports_desk and pop_desk both composed Messi’s international retirement on one tick, judged at 15:31:43 and 15:31:46, and tweeted at 15:31:48.389 and 15:31:52.396 — pop_desk read the history ~2s before sports_desk’s row existed. The dedup gate itself was healthy: it blocked the same story twice later that hour (15:37:29, 15:47:25), once the row was there. Panel: desk_history_gate. NO MONITOR: a waited row is the gate working (an alert on it would page on success), and wait_timeout is bounded by the deliver path, which post_deliver_failed and the latency ceilings already watch — revisit if wait_timeout is ever non-empty. | | post_deliver_failed | cogs/scheduled_poster.py | surface, guild_id, channel_id, phase (the lane), post_preview — a gate-passed, non-deduped unit’s _sched_deliver returned False (#2063): a missing/unsendable channel or a failed send. This was the LAST silent branch of the ScheduledPoster spine — a dedup drop emits post_dedup, a delivered unit emits its surface’s *_posted event, and a failed deliver emitted nothing, so a gate-passed unit could vanish with no telemetry. With it the funnel closes as an identity: gate-passed = posted + post_dedup + this. That identity is how #2063’s ~890 “missing” newsroom units/month were diagnosed — they were dedup drops from before #2037 added the post_dedup emit, and the funnel has closed day-by-day since (measured 08-06..08-08: scored exactly = posted + deduped). Expected rate ~0. A sustained rate on one surface means its room config or send path is broken — the slot then re-fires next tick (nothing-delivered slots are not recorded), so a persistent failure also shows as repeated composes of the same subjects. | | slot_muted | utils/schedule_calendar.py (note_muted), called by cogs/scheduled_poster.py, cogs/discourse.py and cogs/x_game.py | surface, guild_id, mood, reason=calendar_empty — the guild’s /menu calendar is MANAGED for this mood and holds no slot for this surface, so schedule_calendar.calendar_hours returns [] and _maybe_scheduled_post exits before any other event. Why this row exists: that exit used to emit nothing, so a surface muted ON PURPOSE and a surface that BROKE produced the same evidence — no events, no errors, no panel movement. It cost two diagnoses and a reverted PR. #3357 was filed as a p1 bug against sports_desk, pop_desk and cinema_news, dark 6 to 8 days, when the live post_schedule was {"yaps": {}, "chill": {}} — the owner’s deliberate mute of every surface not on a firehose set (steer 2026-09-19). #3491 read that same empty map as unmanaged, switched the desks back on, and was reverted the same night. How to read it: a surface with no events and NO slot_muted row today is one to investigate; a surface with a row is one a mod turned off. Rate-limited to a 6h HEARTBEAT per (surface, guild_id), not one row a day. The first version keyed the memo on the ET date, and a review caught why that fails: _dashboard_payload defaults every board to a qr-now-12h window, so the day’s single row ages out around midday and a continuously muted surface reads as broken again for half of every day — the exact ambiguity this row exists to remove. Six hours always leaves a row inside a 12h window and still costs 4 rows a day per muted surface against the ~1440 an unrated emit would write. ONE definition, three callers: ScheduledPoster, discourse and x_game all read this calendar and all had the same silent exit, so they call the shared helper rather than carry three copies. Two boundaries make it trustworthy: it fires on an empty resolved SCHEDULE, never on an empty due list, and only on [], never on None (no calendar for this mood, where the caller’s own default applies). Early in the day a real plan has nothing due yet, and firing there would emit for every configured surface every morning. Expected rate: one row a day for each surface a guild has muted — three a day at the time of writing. It does NOT change what the calendar does. Since 2026-10-15 surface=discourse stops: the retired discourse loop no longer runs, so it has no slot to mute (#1871). | | slot_replan | cogs/scheduled_poster.py (plan_shrank) | surface, guild_id, channel_id, reason=plan_shrank, count (the stored posts_today), expected (slots due under the plan running NOW; folded into detail) — the daily-cap gate found today’s slot counter ABOVE the current plan’s elapsed-slot count and let the slot post anyway (#2921/#2926). Why the state is diagnostic on its own: the cap only lets a channel post while posts_today < expected, and expected only grows while the plan holds still, so posts_today > expected cannot happen under a stable plan. It means the plan SHRANK mid-day — a mod moved the surface to a lighter firehose cadence (scheduler_firehose_surfaces → scheduler_firehose_hourly_surfaces → scheduler_firehose_two_hourly_surfaces), changed the guild mood, or edited the /menu calendar. Before this escape the counter simply sat above the new elapsed count and the cap suppressed every remaining slot until the ET date rolled, returning BEFORE any log line and raising nothing: on 2026-09-03 music_desk posted 18 of its 30-min slots by 10:56 ET, a mod switched it to hourly 14 seconds after its last post, and the desk was dark for nine hours with zero error rows. surface_dark cannot see this class — that finding reads self-gate SCORES, and a surface stranded before compose produces none, so it drops out of the aggregation entirely rather than reading as a blackout. The escape is keyed on the most recent slot of the CURRENT plan, so it re-opens the gate once per new slot and the surface resumes the new cadence instead of draining the difference in a burst. Expected rate: a short burst on the day of a cadence change, then zero. A surface emitting it every day means its plan is being rewritten daily, or something other than the slot walk is bumping the counter. | | topic_dedup | cogs/scheduled_poster.py | surface, guild_id, channel_id, reason, post_preview — the semantic TOPIC-dedup gate (a cheap Haiku judge, claude_client.topic_duplicate) SUPPRESSED a post as a RESTATEMENT of something already shipped. Opt-in via ScheduledPoster.TOPIC_DEDUP on the proactive news/market surfaces (sports_desk/pop_desk/cinema_desk/cinema_news/music_desk/music_news/market_drop); runs AFTER the mechanical duplicate_reason and catches the same STORY re-angled that order-sensitive token-overlap misses (a signing posted 3 ways scored 0.33-0.46 overlap; “72 Hours” across sibling Netflix boards 0.47 — both real prod cases). Policy (owner steer “allow real updates only”): a restatement carrying no materially-new info is suppressed; a genuine DEVELOPMENT (a new number/party/resolution) or a different subject is ALLOWED (the judgment tokens can’t make). It now ARBITRATES a mechanical hit as well as catching what the mechanical gate missed (#2037). It used to run only when duplicate_reason found nothing, so a mechanical hit was final — and that gate compares WORDING, which on a chart desk is mostly boilerplate: live it called a different artist’s identical-shape line a repeat, and the same song at a NEW chart position a repeat. An overturn emits dedup_overturned. The verdict is TRI-STATE because the two jobs want opposite failure directions — catching fails OPEN (never invent a duplicate), overturning fails CLOSED (never ship a repeat because Haiku was down) — so None means could not judge and both leave the mechanical verdict alone. The judge is handed the MATCHED post first, so an arbitration never depends on where in the list the match sits. Measured with the real judge on real history: 12/12 repeats confirmed, 3/3 cross-subject collisions overturned. HOW DEEP IT SEES (#2189). The judge keeps the newest claude_client.TOPIC_DEDUP_MAX_RECENT posts of whatever list its caller hands it. That ceiling was 12 and the wire desks read 80 rows over 36h, so the judge silently discarded 68 of them — and a truncated prompt still returns a confident verdict, so a clean “not a duplicate” and a verdict on a list that never contained the duplicate looked identical from telemetry. Measured on 2026-08-09: six desks post ~112/day into one shared history, so the newest 12 rows spanned 1h18m against the caller’s 15h19m. What it cost: three surfaces posted the same fact (“Dai Dai” by Shakira is #1 on Spotify’s Global Daily chart) at 17:19, 21:27 and 01:02 UTC on 2026-08-08/09, and in the 80-row list the third one read, the first two sat at depth 24 and depth 40. The ceiling now MATCHES wire_lanes.WIRE_DEDUP_READ_LIMIT (a test pins the two together), and a caller that exceeds it gets a WARNING log naming the drop. Raised 80 -> 180 (#2493) as the desk volume kept climbing: measured over the 14 days to 2026-08-23 the pool posts a median of 119/day (busiest 174), so 80 rows had shrunk to ~11-16h of span – under the same-day window this read exists for – and a 12-24h-apart cross-lane restatement fell past the cap (the newsroom posted the SAME Weeknd UK-leg wrap twice ~12h apart, the first at row 78 by the time the second composed). 180 rows restores a full 24h of span even on the busiest day and reaches 36h at the median; recall holds with no precision cost at the larger size (the buried repeat caught 5/5 at a 120/160/180-row read, live Haiku on the real pool). Re-measure and move it if the surface set grows again. A story that settles PERMANENTLY (a cert, a streaming milestone) still gets an exact key instead (music_news.cert_story_key / milestone_story_key) rather than a bigger window; the window is for same-day evolving numbers. Dilution was measured rather than assumed (scripts/ablate_dedup_depth.py, 5 runs a cell on that production history): the repeat is caught 0/5 at 12 and 5/5 at 24/40/80, while a candidate on a subject absent from the list ships 5/5 at every cap including 80. music_alert runs this judge too, off the same shared read (cogs/music_alert.py:_topic_repeat) — #2163 gave it the WRITE half of WIRE_DEDUP_SURFACES only, so it logged its posts for the desks and never read theirs; it emits topic_dedup (the semantic kind, not the mechanical post_dedup) with phase=the lane (on phase=settle also matchup and detail.ticker, #3889; a settle is judged only against the posts that state its settled figure, #3846), so it lands in the same desk_repeat_gate bucket as the desks’ judge verdicts and the suppress-rate math stays right. market_alert joined the same read on 2026-09-22 (#3638), for the same reason one surface later: its two pacing gates (_blocked, _forecast_paced) read only its own per-SERIES market_alert_state table, so music_alert posted the “Bass Persuades” pure-sales forecast at 02:48 UTC and market_alert posted the same Kalshi event at 04:34 UTC with neither able to see the other. It emits topic_dedup with surface=market_alert and phase=the trigger kind (swing / forecast_move / ending_soon / lead_change), so watch the desk_repeat_gate panel for a new slice rather than a new kind — no new event kind and no new field names, so the field budget is unchanged. Its REVEAL lanes (the RT scorecard, the ladder + entity outcome cards) write the shared history but do NOT run this judge, on the crown-lane reasoning: a result is new information by construction, and the judge would read it as a restatement of the forecast it settles. Emitted ONLY on suppression (its existence IS the suppression); the per-check count + latency ride claude_api purpose=topic_dedup, so suppress-rate = topic_dedup events / topic_dedup calls. FAIL-OPEN (a Haiku miss/error lets the post through, never swallows a real update). The mechanical content_overlap (#1681) stays the free deterministic first pass; this is the semantic backstop for the low-overlap re-angled cases it misses. | | wire_collision | utils/wire_lanes.py collision_allowances (pop_desk, sports_desk, cinema_news; CB-29, 2026-10-18) | surface, ok, decision, reason, count — a wire post whose 5-token signature key was already posted, in OTHER words than every headline stored with that key (*_events.headline), went to claude_client.topic_duplicate. decision=allow (reason=allowed) = both of two calls said “not a duplicate”, so the story ranks although its key was posted; decision=drop with reason=signature_dup = a repeat, or reason=judge_error = a call returned None or raised (the old drop). count = the calls made (1 when the first said duplicate). ok=false = a call failed. One event per judged (key, headline) pair per process. The same words, or a key stored before CB-29 (no headline), drop with no call and no event. The calls ride claude_api purpose=topic_dedup, so they also count in that purpose’s call total; read the topic_dedup suppress rate with that in mind. Measured on 6229 real wire posts: 226 such pairs, 24 allowed (all distinct moments by hand), 196 dropped as repeats, 6 run-to-run flips. Panel wire_key_collisions (Desks board). NO monitor: every failure keeps the old drop. | | discourse_skipped | cogs/discourse.py | guild_id, channel_id, reason (rate_limited/compose_error/empty) Retired 2026-10-15 (#1871): the discourse loop is dormant, so no new rows after that date. | | discourse_icebreaker | cogs/discourse.py | guild_id, channel_id, category (ranking|hot_take|this_or_that|confession), score, reason, shipped, manual, trigger (fallback|quiet_room|manual), has_memory, has_topical, has_market, post_preview (a linkless opener; material-only, so a slot with no source material skips with reason=no_material; reason=room_unanswered = the human-activity floor held it back, fewer than MIN_HUMAN_MSGS_BETWEEN_ICEBREAKERS (3) human messages have landed since Toots’ own last post in the channel, so a second unanswered opener can’t stack on the first (enforced once in _icebreaker_fallback via a live channel-history read, shared by the scheduled + quiet-room paths, restart-proof; manual bypasses it); market line reused from compose or sports-room-gated. trigger names the source: fallback = an otherwise-empty scheduled discourse slot fell back to one; quiet_room = cogs.chimein broke a silent room (the inverse of a chime-in: fired off the chime-in tick when a listen channel went QUIET_AFTER with no human message, riding the chime-in mood/hours/cooldown/cap gates, one opener per lull); manual = on-demand /discourse category:icebreaker (also manual=True). icebreaker is a permanent surface (no per-guild experiment): a ship-worthy opener (clears the quality floor) posts straight to the room — shipped=True; a gated/no-material slot is recorded by this event with shipped=False.) | | chimein_evaluated | cogs/chimein.py | guild_id, channel_id, decision (mood_off_gate/hours_gate/vibe_gate/cooldown_gate/daily_cap_gate/threshold_gate/reacted/empty_generation/dedup_gate/quality_gate), vibe, score, mood; situational: signal (same_link/text_similarity/shared_run/content_overlap, on dedup_gate), local_hour_et, quality_score, quality_reason, post_preview, voice_present (a voice note in the buffer lowered the worth-posting threshold — likelihood only, the quality gate is unchanged) | | chimein_posted | cogs/chimein.py | guild_id, channel_id, score, vibe, hook, mood, quality_score, quality_reason, voiced (shipped as a native voice note), sing (sung delivery), post_preview (the shipped take, POST_PREVIEW_CHARS — graded by the live-log eval pass), voice_present (a voice note in the buffer lowered the post threshold) | | quiet_room_decided | cogs/chimein.py | guild_id, channel_id, decision (break|hold), score, reason (the verdict on cold-opening a quiet room — the inverse of a chime-in. After the cheap silence/mood/hours/cooldown gates, an early deterministic human-activity pre-gate runs first — reason=room_unanswered (score=0.0, no model call) holds when she posted recently and fewer than MIN_HUMAN_MSGS_BETWEEN_ICEBREAKERS humans have spoken since, so the path skips the paid quiet_room_score/produce_icebreaker work; otherwise a Haiku quiet_room_score call (gated on the same mood-tuned threshold as chime-in, 0.7 chill / 0.5 yaps) decides whether the room is genuinely dead and worth reviving, weighing whether Toots was just mentioned/engaged. break = a quiet-room icebreaker is being produced (→ discourse_icebreaker with trigger=quiet_room, called with skip_activity_floor=True so the shared floor isn’t re-read), hold = left alone. Bias is toward hold; emitted once per lull) | | link_enrich | utils/link_enrich.py | platform, url_host, ok, duration_ms, cache_hit | | link_check | utils/link_enrich.py | platform, ok, alive (False only on confirmed 404/410), status, duration_ms — the dead-link HEAD guardrail’s per-URL liveness probe (distinct from link_enrich’s metadata fetch) | | github_api | utils/github.py | op (create_issue|get_issue|list_issues|close_issue|comment), ok, status, duration_ms — one GitHub REST call (the /order path + the feedback log) | | feedback | cogs/order.py | guild_id, user_id (the regular), logged; on logged=True: kind (request|suggestion|complaint|praise), issue_number; on logged=False: reason (daily_cap|github_error) — Toots logged SOFT feedback via the /ask note_feedback tool, recorded as a comment on one rolling feedback-log issue (label feedback, plain body so claude-code-action ignores it) for mods to triage. The lighter lane beside file_fix: no preflight/kitchen/pipeline gates, just a per-guild FEEDBACK_DAILY_CAP | | fix_update | cogs/order.py | guild_id, user_id (the regular), logged; on logged=True: issue_number, order_id; on logged=False: reason (unknown_order|terminal|daily_cap|github_error) — Toots appended NEW INFO from a regular’s follow-up reply onto an existing in-flight fix-order’s issue via the /ask update_fix tool (add_fix_update), as a plain attributed comment (no @claude, so it records context without spawning a duplicate PR run). The counterpart to file_fix (file opens the order, update adds detail); scoped to this guild’s non-terminal orders, runaway-guarded by the per-guild AUTO_ORDER_DAILY_CAP (shared with file_fix, separate counter) | | railway_api | utils/railway.py | op (list_deployments|redeploy|deployment_logs|build_logs|gql), ok, duration_ms — one Railway GraphQL call (the /undo rollback path AND the read-only check_deploys /ask diagnostics tool) | | tool_call | cogs/ask.py + cogs/message_search.py + cogs/discord_info.py + cogs/playercard.py | tool (the /ask tool that ran: break_down_board, lookup_player_props, scoreboard, api_sports, standings, matchup_preview, box_score, game_plays (#3404: a named game’s scoring plays + last plays; matched = resolved on the slate, has_events = a scoring log rendered, count = last plays rendered), game_markets, match_highlights, list_sports, game_lookup, engagement, discord_lookup, look_at_image, find_image, find_media, listen_to_audio, read_media, search_messages, self_serve, check_deploys, music_forecast — the market’s tracking-week projection for one release off the desk’s Luminate ladders, #3392: matched = readings returned, ok=false with matched=0 = no live ladder or a thin book; chart_history — the stored past for an act or one release off the chart-history store, #3562 E: matched = store rows read (0 = nothing stored for the act, still ok=true), ok=false = the DB read raised; source=x_mentions when the X mention reply lane made the same read for its facts block, with count = the history lines it handed the draft; detail.outlook (#3562 B) says whether the block carried the store’s own next-week projection, which is the one signal that catches the silent case – blocks still rendering while every projection is gated off by a stalled walk, a store with no printed units, or a curve cell that fell under its pair floor. The dashboard’s tool_outlook panel graphs it), ok, + the promoted shared dims where they apply (query, matched, count, reason, phase, sport, aspect, action, source, guild_id, user_id, host, url_host, error) + a demap’d detail dict for tool-specific fields — the UNIFIED on-demand /ask tool-run event (#1296: folded ~20 near-identical per-tool *_lookup kinds into one parameterized event keyed by tool, via utils.events.emit_tool; the tool-volume/hit-rate dashboards group by tool, tool-specific signals ride detail off the field budget). memory_search (recall dashboard), reference_lookup + catalog_lookup (integration-health), and social_search (timed latency) deliberately stay their OWN kinds | | auto_order | cogs/order.py | guild_id, user_id (the reporting regular), filed; on filed=True: issue_number, order_id; on filed=False: reason (gated = kitchen-closed/pipeline-red/in-flight cap/AUTO_ORDER_DAILY_CAP daily budget; plumbing/refused = preflight rejected; preflight_error/github_error = downstream failure) — Toots filed (or declined to file) a fix-order ON HER OWN via the file_fix /ask tool off a regular’s bug report. The autonomous counterpart to a mod’s /order; does NOT consume the server order budget (its own tighter daily cap) and still honors kitchen/pipeline/in-flight gates + the order preflight (protected paths) | | health | cogs/health.py | integration (the watched source: apple_music, the market/odds feeds api_sports/sgo/the_odds_api/polymarket/kalshi, plus the #968 app-backbone set embeddings/elevenlabs_tts/elevenlabs_stt/perplexity/twitterio/odds_divergence), action (filed|dedup|alert|usage_alert); crater actions also carry failure_rate + attempts (the numbers it judged on) + recent (#725: True = a sharp RECENT-tail crater the full-window average still hid — assess checks the last _RECENT_TAIL_EVENTS calls too, so a fresh throttle self-announces while it’s fresh instead of waiting ~15 min to dominate the window; this is why she stayed quiet on the SGO blip before). action=filed: a fix-order filing was attempted — ok=True with guild_id + issue_number + order_id on success, ok=False with reason (gated = every guild blocked by kitchen/pipeline/cap; plumbing|refused|preflight_error|github_error = the order text itself rejected) on a miss (the filing also emits auto_order). action=dedup: a crater was found but already filed within the 24h window, held back. action=alert: an owner-facing crater alert posted to #bot-logs (guild_id) with an @master ping, delivered=bot_logs (#968: #bot-logs ONLY, never a content room), gated by master switch + a per-(guild, integration) durable cooldown — deliberately NOT mood-gated (an ops alert isn’t proactive content). Fires for every crater, incl. the ops-only (notice="") backend watches that used to file a fix-order silently — the self-healing watch (#443) now also reaching the owner, closing the gap the drained twitterapi.io wallet exposed. delivered=suppressed (reason=backstop_healthy): the crater has a backstop integration (a Watch.backstop) that’s provisioned + healthy and carrying the load, so the owner alert is held (the fix-order still files for ops) — it only fires when the backstop is ALSO down. The SGO betting-lines alert rides this (#748): once the Odds API SGO-down backstop shipped, SGO going flaky no longer drops betting lines, so she stays quiet unless the_odds_api is degraded too (“everything flaky and down”). action=usage_alert: a metered-wallet early warning posted to #bot-logs (guild_id, integration=the source) with an @master ping — a prepaid wallet under its floor (twitterio/scrapecreators) or a capped source ≥85% of its limit, caught before the 402/429 wall (deduped per guild: a prepaid wallet on usage:<source> with a 12h window; a capped source on usage:<source>:<period>:<level>, level low|full, with a 7-day window for a month cap and 12h otherwise, #4186) | | db_query | db.py (_run) | op (coarse sql_op() label, never params), method (execute|fetch|fetchrow|fetchval), ok, duration_ms — one DB query through the central choke point (singleton queries; transactional paths bypass _run). SAMPLED: only emits for queries ≥ _DB_QUERY_EMIT_FLOOR_MS (50ms) or any failure, so the hot-path sub-ms majority doesn’t flood logs (method=transaction / op="TXN <table>" rows emit every call). So these events are a SLOW-CALL SAMPLE with no denominator (#2257): the fast calls are not counted, and a percentile over the events is a percentile of slow calls only – one 1s call among thousands of 2ms calls read as a 1s “p99”. The sample gives two exact values, and every consumer uses only those: the COUNT of calls at or over a threshold of 50ms or more, and the MAX. The ops monitor keeps db_query OUT of the p50/p95/p99 latency map; it keys a separate db_slow table by op (sampled, fails, calls ≥ DB_SLOW_CALL_MS 1s, max) and files a db_slow finding (an optimization candidate) when a shape has ≥ DB_SLOW_CALLS_MIN (5) calls at or over 1s in the window (measured 2026-10-25 over 12h: 8,770 sampled calls; SELECT FROM kv_cache 161 over 1s, four shapes 10-20, seven shapes 1-4). The Axiom Database board shows the same counts + max, and its two monitors count 2s+ calls (> 10 in 30 min) and 5s+ calls per shape (≥ 3 in 1h) instead of a p99 | | span | utils/instrument.py (@timed / timed_span) | name (function / flow-step label), ok, duration_ms — the catch-all latency primitive for heavy functions (feeds.recent_messages, markets.get_context, the memory write pipeline) and discrete flow steps (ask.parallel_fetch, ask.memory_context, ask.vector_recall, recap.produce_recap, …). Ops monitor keys these span:<name>; report-only (no per-span ceiling), but the trend gate catches a climbing tail | | pplx_<purpose> (pplx_ask/pplx_discourse/pplx_recap/pplx_chimein/pplx_music/pplx_icebreaker/pplx_trending/pplx_cinema_desk) | utils/perplexity.py | ok, duration_ms; on success: input_tokens, output_tokens, response_chars, hedged, source_count, context_size, recency; on failure: error. pplx_icebreaker is the idea-tuned conversation-starter hook fetched per quiet-room opener (surface="icebreaker": current debates/divisive/nostalgic angles to springboard off, never cited, always fresh, fail-open). pplx_trending (#1272) discovers the SPECIFIC current viral moments that fit a channel’s vibe (surface="trending", this-week), distilled by parse_trending_phrases into short searchable phrases the trending-clip gather searches — the fix for the raw-topic-string clip query | | grok_search | utils/grok_search.py (#1390) | purpose, model, ok, duration_ms; on success: input_tokens, output_tokens, count (x_search calls), + folded detail: response_chars, source_count, cost_usd, x_posts_fetched, x_users_fetched, cached_tokens, reasoning_tokens; on failure: error, http_status. detail.cost_usd is xAI’s own billed cost of the call (#4720), read from usage.cost_in_usd_ticks. It includes the per-post ($5 / 1,000) and per-user ($10 / 1,000) fetch fees, which were 90% of the 2026-10 bill and which no token count shows. It is absent (not 0) when xAI does not return it. Read spend with summarize sum(todouble(parse_json(detail)['cost_usd'])) by purpose. The ops monitor’s X-search (xai) line sums it by purpose, the LLM board’s spend panels include it as vendor xai, and the monitor Grok X-search spend over $75 in 24h fires at about twice a normal day. Spend from a script run outside Railway does not reach Axiom, so the xAI console stays the full total | | hedge_check | utils/perplexity.py hedged_checked (CB-22, 2026-10-18); callers music_desk._perplexity_context, music_news._pull_artist_news, music_news._perplexity_research | purpose (music_desk | music_news | music_news_verify), ok, decision, reason. A Perplexity answer that hit the _HEDGE_MARKERS phrase list went to Haiku perplexity_found_answer (claude_api purpose=pplx_found_answer). decision=keep (reason=found) = the answer states facts, so the lane keeps it (the list alone dropped it before); decision=drop with reason=none (a real non-answer) or reason=no_verdict (an error, ok=false; the list’s drop stands). An answer with no list hit makes no call and no event. Measured on 35 real desk reads: the list hit 5, all 5 stated facts, and Haiku kept all 5 in 2 of 2 runs; 3 non-answer goldens dropped. About 5 a day (119 desk + 37 newsroom list hits in the 30 days to 2026-10-18). Panel hedge_check. NO monitor: a failed call keeps the old drop. | | link_stripped | claude_client.py (discourse, ask, recap, music_post, chimein_post, discourse_icebreaker, commentate) | purpose, reason (hallucinated | redundant | dead_link), count, urls (openers + live commentary are linkless, so any URL is stripped as hallucinated) | | discourse_relabel | claude_client.py (react_to_trending) | purpose (discourse_trending), reason (scaffolding_leak | unresolved_pick), ok — a trending clip react echoed its internal candidate label into the shipped text (“clip B out here selling…”, #clip3-leak; the [A]/[B] tag that binds a vision frame to a list entry, meaningless to a reader). The PICK was fine, only the wording leaked, so instead of discarding a good clip it re-reacts to JUST the picked clip (resolved by the link the reaction ended on) — a single clip carries no letter scaffolding, so the retry structurally can’t leak. ok=True = the clean single-clip retry shipped; ok=False = dropped (the slot falls through to a normal discourse take): reason=scaffolding_leak when the retry itself came back empty, reason=unresolved_pick when the leaked reaction carried no clip link to pin the pick to. Bounded to one extra call (_allow_retry), never a loop | | market_fetch | utils/markets.py + utils/api_sports.py + utils/the_odds_api.py + utils/espn.py | source (sgo/polymarket/kalshi/api_sports/the_odds_api/espn), query (volume_batch:N = the move lanes’ trade read, #4714 KalshiClient.get_recent_volume, which fails OPEN, so the ops monitor gives it its own kalshi_trade_read health rate apart from the per-source line), ok, duration_ms, cache_hit, result_count, error (for the_odds_api a call skipped by the client’s own gate is no_key or, since #3552, quota_dead – a 401 put the client to sleep for 6h), http_status (every sgo AND kalshi fetch miss classifies error off the HTTP code via _classify_fetch_error — rate_limited 429, forbidden 403, server_error 5xx, not_found 404, unreachable no response/a breaker short-circuit — instead of a bare fetch_failed, order #722/#73 for sgo, order #77 for kalshi: a 429 quota throttle, a 403 datacenter-IP block, and a plain timeout used to be indistinguishable in the same bucket). source=kalshi query=open_events is the hourly catalog walk; its error is pagination_failed (a page gave up after retries) or, since #3553, truncated (the walk hit _KALSHI_OPEN_EVENTS_MAX_PAGES with a cursor still outstanding, so the catalog has outgrown the cap). Either way the walk returns nothing and the prior FTS table is KEPT; a truncated row means discovery is going stale and the cap needs raising. Before #3553 a cap hit was silent and the partial set was rebuilt over the good one, so events past the cap simply vanished from discovery. source=espn carries http_status on a failure since #3584 (a 429 throttle vs an outage vs a bad path — previously every miss was a bare fetch_failed), and its breaker cooldown escalates per failed probe, so a run of circuit_breaker integration=espn transitions should now SPREAD OUT rather than repeat on a fixed 60s beat; a flat 60s cadence means the escalation is not being reached. source=espn (#1113): the FREE, keyless ESPN scoreboard, the last-resort score/finals backstop for the SGO-only individual sports — tennis (only when SGO degraded) + MMA/UFC (only when API-Sports degraded); query=sport (tennis|mma), result_count=matches parsed, error=all_feeds_failed. Reuses market_fetch so the ops-monitor’s per-source integration health + the ops-only espn health Watch cover it with no new wiring. Team-sport fallback (#full-espn-fallback): EspnScoreProvider now ALSO backstops the TEAM sports (soccer / NBA / WNBA / MLB / NHL / NFL) — live scores + just-finished FINALS off ESPN’s day scoreboard (source=espn_results, one market_fetch per league/day) — gated on api_sports.degraded, so an API-Sports outage (a suspended account, a crater) no longer darkens live commentary or bet settlement for those sports. The finals carry REAL scores, so the Bookie settles off them through the shared idempotent _settle_game (a level score PUSHES, never a wrong payout). Bookie pregame-odds fallback (#espn-odds-fallback): the SAME espn_results fetch now ALSO feeds the Bookie’s LAST-resort price fill (_supplement_with_espn_odds) — for an UPCOMING game still unpriced after SGO + the Odds API + the prediction markets, ESPN’s FREE scoreboard moneyline is folded on (odds_source='espn', American values), so the game is bettable instead of hidden. Fill-only-when-absent (a fresher SGO/Odds line always wins), pregame-only (ESPN has no in-play line, so the stale-line router reprices it off predictions if it goes live), and fold-only (it never INTRODUCES a game no settleable source surfaced, so it can never strand a bet). No new event — the fold’s ESPN reads are the existing espn_results market_fetch, so the ops-monitor integration health already covers it; the odds_source='espn' stamp on the bet is the per-bet audit trail. Verified vs the Odds API consensus on 15/15 live MLB games: favorite agreement 100%, implied-probability delta within ±1.5%. source=espn_summary (#espn-depth-parity): one game’s summary?event= payload – the boxscore + key events + rosters + season series that backstop the commentator’s per-game DEPTH (goal/card EVENTS, team STATS like possession/shots, and PREGAME formations + lineups + head-to-head) when API-Sports is degraded. EspnScoreProvider.game_events/game_team_stats/game_pregame parse it (one fetch shared per tick, cached per event); game_standings reads ESPN’s standings endpoint, and game_leaders reads the summary’s pre-computed leaders (basketball’s “who’s cooking”; soccer/baseball have no top-level leaders and stay empty rather than index-guess a stat off the raw box score). All display-only – the commentator degrades to the scoreline on a miss, never blocks a post. source=sgo reason=budget_shed (#786): the proactive budget governor (SportsGameOddsClient._shed_heavy_call) DROPPED a heavy league-wide read — the full game-board or league-wide player-props (the priciest calls on the tight rookie-tier monthly entity cap) — because the entity budget is near the cap; NO network call (cache_hit=True → excluded from integration-health like a no-op guard), result_count=0, ok=True, caller degrades fail-open (no board/props). The proactive complement to #1177’s scoreboard-cadence throttle: #1177 slows the CORE fetch, this drops the OPTIONAL depth first, reserving budget for the cheapest critical reads. source=sgo reason=quota_paced (#3229): the month-pace governor (SportsGameOddsClient.over_month_pace / _paced_out) SKIPPED an SGO read at ANY entry point (event odds, props, board, the in-season probe, the leagues catalog) because the month’s month_entities usage is ahead of an even spread through the UTC calendar month, or the cap is spent. Same shape as budget_shed: NO network call, cache_hit=True (excluded from integration-health), ok=True, result_count=0; a live-score read still serves a slate younger than the stale bound. While paced, sgo.degraded reads True, so every consumer takes its existing ESPN / The Odds API fallback. Why it exists: SGO moved from the paid rookie tier (100,000 entities a month) to the free amateur tier (2,500) on 2026-09-11, and the fan-out then spent each month’s 2,500 in about one day and 429’d for the rest (about 144 real failed requests and 700 failed market_fetch rows a day in October). The quota itself stays visible on the quota event (metric=month_entities) and the ops-monitor near-cap finding. source=api_sports errors (#still-no-art): API-Sports answers an ACCOUNT / PLAN / RATE problem with HTTP 200 + a populated errors object (a suspended account → {"access":"…suspended…"}, a plan gate → {"plan":…}, a throttle → {"rateLimit":…}) and an EMPTY response. _fetch used to hand that back as a healthy [], so a suspended account read as ok=True “nothing live” across every api_sports call (games, live scores, team logos) for days while sports GAME cards shipped art-less — the ops-monitor never saw it. _response_error now turns a populated errors into ok=False (the breaker opens + fallbacks take over), so the outage shows in the api_sports integration-health rate. source=espn_logos (#still-no-art): the KEYLESS ESPN team-crest backstop for the market card’s sports art – when api_sports.team_logo returns nothing (a down / suspended account), EspnClient.team_logos reads the crest off ESPN’s /teams endpoint (query={home} vs {away}, sport, result_count=2 on a matched pair else 0). Both sides must resolve inside ONE of the sport’s ESPN leagues or it returns None (the card keeps the branded floor, never a wrong crest); the team list is cached per league path (static), so this costs a fetch only on the first miss per league. Single-team crest (#full-espn-fallback): EspnClient.team_logo(name, sport) is the one-team analog — the deterministic floor-beater for a card whose subject is ONE team (a futures winner like “MLS Cup Winner” -> its leading club, a standings/award market), and the ESPN fallback inside the wire-desk team card (entity_image._team_fallback_card). It sits BELOW the vision photo search in the market ladder, so a real team PHOTO wins first and the crest only fills a card otherwise heading for the branded floor; exact-name-matched inside the sport’s leagues, so never a guessed crest | | billboard_fetch | utils/billboard.py | source (billboard), query (<chart>:<week\|current>), ok, duration_ms (the READ and its retries only: the limiter token is taken BEFORE the event opens, the reference_fetch rule. Before #4368 the queue was inside it: the music desk’s source watcher reads 8 charts at once behind ONE 6/min limiter, so the reads landed on exact 10s steps and the last one reported ~60s. Measured 2026-10-17 on the real 8-chart read: max 60,084 ms before, 664 ms after, same 60s wall time. The same applies to billboard_artist_history and billboard_story), count (rows parsed), cache_hit (two-speed durable cache: ~12h when current, ~20min while a flip is due); on a miss: reason + error + http_status — one FIRST-PARTY Billboard chart read (Hot 100 / Billboard 200 / Global 200 / Artist 100 / …, one parser for all). reason: blocked (403/451 — billboard.com REFUSING us; its robots.txt names anthropic-ai, an EXPECTED end-state — docs/BILLBOARD_SOURCES.md §5), shape_change (a 200 that parsed to near-nothing = a redesign, the SILENT failure), partial_credits (a 200 whose song/album rows carry an EMPTY artist — the page read MID-PUBLISH: Billboard lands the rows first and attaches the artist links on new entries minutes later; measured 2026-09-09, the Sep 12 Hot 100 one minute after its flip carried 15 of 16 debuts with no credit, was cached 12h, and posted as “Take Care leads 16 new entries … from 2 different acts”. Refused like a shape change: nothing served or cached, a cached week with a credit-less row is re-read too, the next poll re-reads, the block clears itself on the full page; dropped in detail = rows with no credit. Artist-unit charts are exempt — their artist cell is empty by design), http_error, breaker_open, unpublished (#4769: a DATED read of a week Billboard has not printed yet. A week after expected_chart_date is not read at all, and the due week read before its Tuesday flip comes back as a 200 with no rows and no week line. Billboard used to answer that date with the current chart; since 2026-10 it answers with that empty page, and it read as shape_change: 1,905 reads of billboard200:2026-10-24 over 2026-10-18..20. Not a block, no error, and the ops monitor leaves it out of the rate and the per-chart run). Panel bb_fetch splits the reads current vs dated by reason. Each dated page keeps its own failure streak (keyed by the query, <chart>:<week>, in BlockState.streaks; follow-up on #4769): with one key per chart a success on any other page of that chart reset the streak, so latin:2026-10-17, empty on every poll on 2026-10-25, logged consecutive=1 and an error 20 times that day. billboard.com serves the empty page for PUBLISHED dated pages too, for hours, through its CDN (measured 2026-10-25 08:00Z: 16 of 24 dated pages across 8 charts empty, a cache-busting request empty too, every current page full). So a dated week that read cleanly is cached BILLBOARD_CACHE_TTL_DATED_DAYS (14) days, not 12 hours: a published week cannot change. A page whose weeks caption was not read (2026-10-27: Billboard renamed “WEEKS” to “WEEKS ON CHART” and every row read weeks_on=1) is served, not refused, and emits error with source=billboard_weeks, recoverable=true on every such read (billboard.weeks_unread: every row with a last-week position reads 1 week). There is deliberately NO stand-in source (owner call 2026-08-04: the GitHub-mirror fallback was removed — it was itself a once-daily crawler of the same site, so it added a hop and staleness, never data): a block yields nothing, loudly. blocked + shape_change + partial_credits also emit error on the first failure of a streak and every 10th after (recoverable=False for the CURRENT page; recoverable=True for a DATED page since #4769, because the current page still serves the live lanes and only the week-over-week comparison drops out), and the ops monitor raises a source_blocked finding naming the action (high for a refusal / a sustained or still-dark run; medium for a partial or empty page that completed within the window) | | billboard_artist_history | utils/billboard.py | source (billboard), query (<slug>:<code>), ok, duration_ms, count (entries parsed = the artist’s entry count on that chart), cache_hit (~6h durable, its own namespace); on a miss: reason + error + http_status — one per-artist CHART-HISTORY read (/artist/<slug>/chart-history/<code>/), the CAREER-ENTRY count a weekly chart page cannot carry (#2154 follow-up). Only the two flagship charts are mapped (billboard200→tlp, hot100→hsi). reason: blocked (403/451, the same §5 refusal as billboard_fetch), unknown_slug (404 from a GUESSED slug — an ORDINARY fail-open miss, NOT a Billboard block), no_entries (a 200 that parsed to nothing — a soft-404 or a redesign), http_error, breaker_open. detail.slug_source (2026-09-15, #3331) says WHICH page was asked for: chart_row (the slug Billboard printed in the act’s own chart row, read out of the cached week) or guess (artist_slug, the fold of the credit string). The guess does not only 404 — it lands on ANOTHER ACT: BLACKPINK’s LISA is filed as lisa-of-blackpink, so “lisa” answered 200 with a 1980s act’s three Hot 100 entries from 1982-84, and the debut guard read that page as hers. Watch the guess share: it is the fraction of these reads that can still be another artist’s, and it rises when acts reach the read without a row on any chart week we hold (the slug is only remembered for the current week). Deliberately QUIET, unlike billboard_fetch: a miss suppresses one optional “their Nth entry” clause, never a post, so it does NOT touch the weekly-chart block_state() and does NOT emit error — a wrong-slug 404 is not Billboard refusing us. Watch the ok-rate as the source-health signal; a sustained blocked here (not unknown_slug) is the AI-agent block reaching the artist pages too | | billboard_story | utils/billboard.py | source (billboard), query (story_posts:<week> = the WordPress post-API discovery read, story:<week> = the story page), ok, duration_ms, count (posts in the window | 1 for a page); on a miss: reason + error + http_status — one Billboard chart-STORY read, the chart-history store’s source for the printed units of the Billboard 200 top 10 (research round 2, 2026-09-20; utils/billboard_stories.py parses the prose, cogs/chart_history.py’s story walk verifies every figure against the stored page rows before writing it). Two reads per chart week, ever: the Chart Beat posts in the week’s publish window (/wp-json/wp/v2/posts, category 8546590, the Sunday..Thursday before the chart Saturday), then the one story page. reason: blocked (401/403/451, the same §5 refusal as billboard_fetch), shape_change (the post API answered something that is not a post list), http_error, breaker_open (its OWN breaker: a failing story endpoint never opens the chart breaker). Deliberately QUIET, like billboard_artist_history: a miss leaves one week’s units unfilled and the walk retries it next tick, no error and no block_state(); the ok-rate (billboard_story in the misc integrations) is the health signal, and a sustained blocked here is the AI-agent block reaching the story pages. Panel bb_story | | music_news_debut_framing | cogs/music_news.py | ok (the debut was CONFIRMED), reason (presence | history | unconfirmed), guild_id, phase (chart), matchup (the subject), source (the wire account), via (how the wire’s debut call was read: classifier | bounded | regex, #3418), + folded detail: chart (the chart the history was read on), entries (rows that page returned), event (the resolved chart event: debut / reentry / move / hold / “”) — did anything CONFIRM that a chart story is a NEW ENTRY (owner steer 2026-09-01). The gap it closes: chart_presence settles debut-vs-established for the live PLATFORM charts only, so a weekly Billboard chart came back is_debut=null and the wire’s word rode through as Toots’ own claim. On 2026-09-01 both “Painted You Pretty debuts at No. 93” and “All My Exes … debuts at No. 92” shipped off @billboardcharts while our own Hot 100 read was waiting_for_flip — the site still served the 08-29 chart, so music_news_chart_lookup said absent and nothing had checked either claim. reason=history is the new confirmation: Billboard’s own artist chart-history page (billboard_artist_history) carries a debut DATE, and it updates EARLIER than the chart page — measured 2026-09-01, it already listed “All My Exes … 9/05/26 … 92”. The date is compared against the CURRENT chart week (billboard.expected_chart_date, the one definition of that), never a rolling window of days: Billboard dates a chart to a Saturday and reveals it the Tuesday four days before, so on reveal day LAST week’s chart date is only three days past and any window wide enough for this week would confirm a second-week title as a debut (Codex review). detail.rewrote says the story RECORD was rewritten too — news_compose_blob builds it from the claim and the self-gate grades the take against it, so a record still reading “debuts at No. 93” is an answer key that marks the debut correct; the claim becomes the bare position through the same settled_claim formatter a live reading uses. reason=unconfirmed hands the compose unconfirmed_debut_note and the take states the bare POSITION in the owner’s wording (“is at No. 93 on the Billboard Hot 100” – the note SHOWS that sentence, naming the position the story reports, because describing the wording got “sits at” instead): the wire’s number still ships, only the unverified claim about the title’s chart HISTORY is dropped — prefer absent over invented. It also vetoes the career-ENTRY ordinal (music_news_entry_ordinal claim=entry), so a post that may not say “debuts” cannot say “their fourth entry” either. Filed only when the wire framed the story as a debut or we confirmed one, so an ordinary climb story never files a row. WATCH the history share of the confirmed rows: it is the ONLY way a Hot 100 or Billboard 200 debut keeps its wording, and it rides a GUESSED artist slug — an act Billboard slugs differently 404s (Hudson Westbrook did) and reports flat. A history rate falling toward zero means the slug guess or the page parse broke, and every real weekly debut is being under-reported | | music_news_entry_ordinal | cogs/music_news.py | ok, phase (chart), matchup (subject), count (the artist’s entry count), + folded detail: chart (the CHART_SOURCES key), claim (peak | streak | weeks | entry) — the newsroom stamped a chart take with a grounded CAREER fact read off the chart’s own pages (billboard_artist_history for peak / entry, the weekly Billboard row for weeks, the kworb daily row’s (x?) days-at-peak column for streak), instead of a guess. detail.claim=entry (#2154) is the debut ORDINAL (“JUNGLE’s fourth Billboard 200 entry” instead of the guessed “first”); it fires only for a debut music_news_debut_framing CONFIRMED (the count is the title’s ordinal only when it is the newest entry, and a new-entry claim she may not state in words must not ship as an ordinal either) and its grounding converts a career_ordinal DROP into a ship. detail.claim=peak (#2587) is a career-BEST milestone (“their first #1”, “their highest-charting entry yet”) off the peak column + the CURRENT position, so it is fresh news and can fire on a CLIMB, not only a debut; it takes precedence over the entry ordinal (one clause ships). It fires only the week the position is REACHED (owner steer 2026-09-08): the settled row’s last_week / weeks_on ride ChartPosition, and a held position, a fall, or a re-entry yields no peak claim — “Choosin’ Texas … her first #1” had shipped off a row reading last week #1, 45 weeks on. detail.claim=weeks is the rung a HELD position gets instead (music_news.chart_run_fact, “in its 45th week on the Billboard Hot 100”, off the row’s own weeks column, a digit ordinal so the digit gate grounds it). detail.claim=streak is the daily twin (owner example 2026-09-08): “has 11 days at #1 on the global Spotify daily chart in total” for a held Spotify #1 that is its own peak, off music_news.chart_streak_fact (the day’s plays ride the settled reading itself: “#1 on the global Spotify daily chart with 5.4M streams on the day”). A held daily #1 reaches the compose only when its streak CROSSED a milestone bucket since the last report (music_news_filtered reason=chart_repost now folds detail.streak, the days-at-#1 count the gate saw; MUSIC_NEWS_STREAK_MILESTONE_DAYS, default 7), so on spotify_global / spotify_us expect a streak row about once a week per long-running #1 and chart_repost rows in between. music_news_filtered reason=streak_unstated is the streak-triggered repost whose composed line did NOT state the streak: dropped and marked seen rather than shipped as a bare repeat of the held #1, and the milestone is not consumed (detail.chart, detail.streak). Measured 0/25 in the dry runs (the compose stated the clause every time), so a rising rate here means the compose stopped folding the CHART HISTORY clause in. Watch the peak : weeks split on hot100: a peak row with the same subject in consecutive weeks is the stale-milestone bug back; weeks should be the common kind for a #1 story, peak the rare one. Both history kinds require the reported title on the page (the wrong-artist guard, music_news.entry_ordinal_fact / peak_history_fact), emit at SHIP time (after the gates + self-score), and only when the composed line ACTUALLY states the fact (music_news.career_clause_in_line) — so the rate is how often a grounded career fact shipped. Watch claim=entry against music_news_scored reason=career_ordinal (the drop): a rising entry rate with a falling drop rate is the fix working. Watch claim=peak on its own — a #1 / career-best is rarer and higher-value | | feed_fetch | utils/industry_feeds.py | source (the feed key: pollstar|iq|mbw|billboard_news), ok, duration_ms, count (items parsed), cache_hit (~15min durable); on a miss: reason (unreachable = no 200 | unparseable = a 200 with zero items, i.e. a feed shape change) + error — one trade-press RSS read (#reporting-oversight). The FIRST-PARTY news source, read directly so a number is in hand rather than chased through a web-confirm that lags a fast story (the R&B Tour gross case: the wire said $153.4M/20 shows, the web-confirm returned the stale $101.9M/13 shows, and Toots posted the stale one). One parser for all four feeds, namespace-driven. Each source has its OWN breaker (integration=industry_feeds, source=<key>) + limiter so one host refusing us does not blind the others. Fail-open (a miss → the desk keeps its X wire) with emit_error(recoverable=True); DARK until a desk consumes it. Per-source ok-rate is the health signal a single feed went quiet | | feed_article_image | utils/industry_feeds.py | source (the feed key), ok, duration_ms; on a miss: reason (unreachable | no_og_image = the page no longer names an og:image, a shape change) — one read of a feed story’s ARTICLE page for its lead photo (2026-09-30, the Rauw Alejandro “Macacoa 2000” card). Billboard’s feed items carry no image of their own, so the newsroom reads the page at card time, only for a story that becomes a card. It rides the source’s own breaker + limiter. Fail-open: a miss falls to the catalog/entity resolver, and desk_art.source still says which rung shipped. Health: the per-source ok rate on the Content data sources panel. No monitor: a miss costs one card its article photo, and the resolver still gives it art, so desk_art reason=no_art is the signal that matters and it is already watched. | | music_ranking (RIAA boards) | cogs/music_desk.py, utils/credit_act.py | reason=credit_act, ok=True (CB-23, #4557): the Haiku act check (claude_api purpose=credit_act) overrode the old placeholder list on count credits a board can rank, RIAA cells and Billboard census parties alike; detail.not_act / detail.act name them (up to 10 each), detail.asked is the pass size. Expected near zero: on 30 days of real credits the model changed 1 of 1,138. A rise means a prompt or model change is dropping real acts; read the names. The existing music_ranking panel groups by reason, so it shows there. No monitor: a rare correction with no user-facing silence. reason=riaa_roster_partial (the Diamond boards wait: count acts of the roster read so far) | reason=riaa_timelines_partial (the fastest-to-Diamond board waits: count roads timed so far of expected roster Diamond singles, parse_json(detail)['expected'], #4794) | reason=riaa_diamond_list_partial (the Diamond singles and Diamond albums boards wait: the read of RIAA’s own Diamond list stopped short), ok=False — the RIAA record boards’ “absent over invented” rows; both trend to zero as the seven-day caches fill, and a count that stops rising is a finding. Measured 2026-09-03: #2862 put the genre loop between if complete: and the roster-partial else, so the row fired every slot (count=40 of 40) as a for/else; it is an if not complete now, with a test that records the emits. | | riaa_fetch | utils/riaa.py | source (riaa), query (recent | timeline | tallies | artist | awards | genre | diamond), ok, duration_ms, count (rows parsed | history points | tally rows | act rows | awards naming the act), cache_hit (recent ~2h, timeline ~7d, tallies + artist + awards ~7d, durable); on a miss: reason (unreachable = no 200 | unparseable = a 200 with zero rows, i.e. the table changed; the site’s empty end-of-list payload on the page after the last one is the list’s END, not a miss (list_ended, a one-page act read as unparseable before 2026-09-02) | capped = an awards walk hit AWARDS_MAX_PAGES with pages left, so the list is not the whole catalog and is not served) + error. A timeline read of a BLANK record is ok=true with reason=no_history and count=0 (#4788): the site’s normal layout with an empty timeline, every date January 1, 1970, title/label/format empty (award 441124, a Diamond single on Zach Bryan’s list, measured 2026-10-25). It is cached for the timeline window and raises no error; before #4788 it was re-read every music-desk slot and raised 85 riaa unparseable (timeline 441124) errors over 2026-10-24..25. A 200 with no timeline container is still unparseable. query=awards reason=superseded (ok=true, #4794) = the music desk dropped one act’s cached award list because a roster Diamond single’s timeline was blank: RIAA replaced that award with a new record (re-certification), and the 7-day list still held the old id. Once per (award id, act) per process; the next slot re-reads the list. A steady stream of it for the SAME act means the replacement record is not in the act search either. A timeline read of a LATIN-program award is ok=true with reason=latin_program and count=0 (#2902), not a miss: RIAA’s Latin program has its own ladder (Oro / Platino / Diamante, 600,000 units a level against the main program’s Gold 500,000 / Platinum 1 million / Diamond 10 million), so parse_timeline deliberately maps none of it and returns an empty history – but the page itself is perfectly readable. That empty result used to fall through to unparseable, which meant a Latin award was never cached, cost a cold read out of the slot’s three-read budget on every attempt, and filed a recoverable riaa error each time, for a page that would never parse (measured 2026-09-03: award 453588, “DE LA NADA - REMIX (FT. DE LA ROSE)”, a Latin single certified that day). timeline_ladder tells the two apart, so unparseable goes back to meaning the one thing worth paging about: the site changed shape. The newsroom’s cert lane now also skips non-main-program awards outright, so the read is not requested at all — one read of RIAA’s own Gold & Platinum database (#2839, line B of #2800). The certification AUTHORITY read directly: 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 at all. recent walks the newest-first feed until a page’s oldest award is older than the caller’s window (the newsroom’s 3 days), capped at 8 pages of 30 (~15 awards land a day, 58 in one burst); timeline reads one award’s history so the take can state the previous level (count = history points, on a cache hit too). Rows for WATCHED/KNOWN acts become first-party SourcePosts (platform=riaa, handle riaa, relay_number=True) into the newsroom’s existing cert lane, so they classify, key (cert_story_key, <n>xplatinum|riaa) and dedup against the same certification from any wire. Gated on music_riaa_certs – graduated 2026-09-07 (master-only), so the gate is graduated_stage and resolves PRODUCTION in the master guild, OFF elsewhere (while it was a trial, anything but PRODUCTION routed its cards to #bot-logs whatever the parent newsroom’s stage, and such an audition never booked the settled cert key; audition=True can no longer occur). A recent read that stops short (a later page unreachable or unparseable) returns the rows it has, emits ok=false with that reason plus an error row naming the page, and is never cached; a timeline miss emits an error row too. One breaker (integration=riaa) + one limiter for the host. Fail-open (a miss → the cert lane keeps its wires) with emit_error(recoverable=True). Cloudflare refuses a bare Mozilla/5.0 (403) and accepts a browser-shaped user-agent; the client and the riaa probe send the same string, and the probe requires award rows in the body (a 200 challenge page without a table is a 502 on the sweep), so a future challenge page reads as a failure here and on the /debug/integrations sweep, never as a quiet cert lane. An RIAA audition’s #bot-logs header named music_riaa_certs as the flip that ships it (no longer reachable since the graduation). On the ops monitor as riaa in the misc integration health (_MISC_INTEGRATIONS, cache hits excluded) and on the data_sources dashboard panel. Expect ~12 recent reads a day per process (a 2h cache under the ~15-min desk tick) plus a timeline read per new watched-act award. diamond (#2915) is the Diamond boards’ own read and is NOT a roster read: it searches RIAA’s award database for every Diamond award of one format (awards[]=D), up to DIAMOND_MAX_PAGES (26) pages, held ~7d. Measured 2026-09-03 the singles list is 516 awards over 55 acts. Before this the two Diamond boards ranked only the 40 names of the streaming watch list, so they MISSED any Diamond holder outside it – the posted card omitted Post Malone (9, which is #2), Lil Wayne (6), Morgan Wallen (6) and Cardi B (4), and two X replies called the board wrong. A short read emits ok=false with capped, unreachable or unparseable and draws NO board (music_ranking ok=false reason=riaa_diamond_list_partial). The RIAA record boards (music_riaa_boards, graduated 2026-09-07 master-only alongside music_riaa_certs; utils/riaa_boards.py) add tallies (the top-albums list, 4 pages, ~7d cache) and awards (one act’s award SEARCH, every award naming the act, up to 8 pages, ~7d cache, at most _RIAA_TALLY_FETCH_PER_SLOT cold walks a desk slot – the diamond boards, the Platinum-singles board and the certified-units board all count features and collaborations off it, one read per act for every board); artist (one act’s awards-by-artist page) is parsed and cached but no board reads it today; a music_ranking ok=false reason=riaa_roster_partial row marks a slot where the diamond board waited for the roster (count = acts read so far), which is normal while a cold cache fills and a finding if it never clears. year_feed (#2871) is the YEAR board’s own read and is NOT a roster read: RIAA’s dated feed walked back to Jan 1, so the board ranks every act RIAA certified rather than the first 40 names of a streaming watch list. It is budgeted – one call takes at most YEAR_FEED_PAGES_PER_CALL (25) pages, resumes on the page it stopped on, and emits ok=false reason=filling until the walk reaches Jan 1, with music_ranking ok=false reason=riaa_year_feed_partial on the lane side and NO board drawn meanwhile (prefer absent: a partial walk under-counts every act). Measured end to end 2026-09-03 the cold walk is 61 pages / 1830 rows / ~9 minutes; a covered walk then tops up from page 1 every ~6h and costs one or two pages a day. It REPLACES nothing – the roster’s awards searches still run for the Diamond, Platinum-singles and certified-units boards, so the year board used to ride them for free and now pays its own way. Held a week (_YEAR_FEED_TTL), that is ~13 reads a day against the roster’s ~16, i.e. about a 45% ADDITION to the RIAA read budget. A 1-day TTL was the first choice and cost ~64 a day, because the row expiring forces the whole cold walk again. Rows merge by award id with the later certification date winning, so a re-certification upgrades its row instead of counting the title twice. Caller attribution (#2937): every riaa_fetch row a scoped caller causes carries surface + path (<surface>.riaa_fetch.riaa). The timed reads stamp through timed_event; the cache-hit and per-alias rows are plain emits and stamp through instrument.surface_fields (riaa._emit_fetch). Before this, 371,701 rows in 7 days had surface=None, so a failed read could not be traced to its lane (#2902 went to the wrong lane). A row with no surface now means the CALLER has no @scoped_surface, not the client. Both callers are scoped on main (#4098): 0 of about 432,000 rows carried surface=None from 2026-10-16 to 2026-10-25. Panel riaa_by_surface on the media board. reason=omitted_credit_lead_short (ok=false, no duration) is a FETCHING awards_for_act read whose search for an omitted-credit LEAD came back short; the act’s catalog still serves without that lead’s rows. A cold lead under a cache-only (fetch=False) read emits nothing and returns None, so the caller makes the fetching read (#4796). Before that fix, a cold “young money” lead emitted this reason 742 times in 36 hours with no request made, and it was the whole of the ops monitor’s riaa fail rate. | | music_desk_career | cogs/music_desk.py (#3363) | chart (hot100|billboard200), kind (entry — a settled debut’s ordinal | projected_entry — the standing a HITS-projected debut WOULD set, #3363), matchup (title - artist), count (entries on the act’s Billboard chart-history page) — the chart lane attached a grounded CAREER STANDING (“her seventh entry on the Billboard Hot 100”) to a row whose own story is thin. Why it exists: measured over 1,034 of our posts (2026-09-08..15) a bare chart position is the worst-performing type on all 45 wire accounts we read (0.69x their own median) and 37% of our output, while a career standing is the best (1.30x) and 1% of ours. Why it is narrow: the A/B improved 4 of 4 debut/re-entry rows but REGRESSED a row that already had a hook (Luke Combs’ “a new peak 24 weeks into its run” 0.92 → “his 43rd career entry” 0.88), so it fires only on debut/reentry, and only when music_news.is_latest_entry confirms the title is the act’s newest (an ordinal on an older title implies a recency it lacks — Ariana Grande’s 99th of 110). Emitted only when a fact was produced, so the rate is how often the clause is available; fail-open, every miss ships the card unchanged. projected_entry is the forward twin on the projection lane: it swaps that framing’s flat career ban for the one grounded fact. The ban was not producing silence about a career — measured 2026-09-19 it produced a WRONG one, “a solid new entry with no chart history to lean on yet” about Dolly Parton, who has 51 Billboard 200 entries. Its guards refuse a title already on the history page (a catalog re-entry, not a debut) and an empty page (a guessed slug that 404s reads the same as a genuine first-timer) | | pollstar_fetch | utils/pollstar_charts.py | source (pollstar_charts), query (the chart key: global_concert_pulse|live75|new_tours|artist_power_index|mediabase_<format>, plus a <key>:issue row per ISSUE RESOLUTION — #3357, the config read that finds the current week’s chart id, kept separable from the body read so one failing does not hide the other; it carries folded detail.issue_id), ok, duration_ms, count (rows parsed), cache_hit (~6h durable, keyed by ISSUE so a new week busts the body cache at once); on a miss: reason (unreachable|unparseable|no_issue — the config did not parse, so the read falls back to the seed issue and the freshness fence takes it from there) + error — one Pollstar free-tier chart read (#reporting-oversight). The live-music numbers Billboard does NOT publish (worldwide active-tour ticket averages, avg boxoffice gross/city, new-tour on-sales, 24 Mediabase radio formats), off the public chart JSON at data.pollstar.com, anon-only — no auth is ever sent, and only the free-preview rows the endpoint returns are taken. ToS one-way-door (owner-accepted, epic issue): Pollstar’s terms forbid redistributing chart content and forbid stripping attribution, so every posted number MUST be attributed to Pollstar (PollstarChart.attribution + the chart link). Breaker integration=pollstar_charts. Fail-open with emit_error(recoverable=True); DARK until a desk consumes it | | artist_news | cogs/music_news.py | guild_id, count (synthetic candidates produced this slot), + folded detail{checked, hits, stale, top, tail} — one PROACTIVE watched-artist news pull ran (owner steer 2026-08-20). The newsroom’s reactive feed only reports what a followed account posts; this lane inverts it: each slot it rotates a bounded set of the top watched artists (top ~25 fast, the ~75 tail slow — the split is detail.top/detail.tail), asks Perplexity for each one’s biggest recent RELEASE / CERTIFICATION / TOUR update, and feeds any answer through the SAME classify → tier → verify → compose → gate pipeline as a synthetic SourcePost (platform=artistpull, source tag artist_watch, which may_relay_number reads as untrusted so the pull story ships ONLY when independently verified). checked is the artists swept, hits the ones whose pull returned FRESH real news, stale the ones dropped because the model’s stated event date was old or missing (#2488 — the pull’s recency gate; the pull has no source-tweet time, so a now-stamped synthetic post would otherwise defeat the _RECENCY_HOURS window, which is how a nine-month-old $1B tour record shipped as “breaking”). (count then trails hits after the seen-set dedup and the _ARTIST_PULL_MAX_STORIES slate cap.) Gated on the music_news_artistpull experiment (STAGING default; rides the parent newsroom’s staging-vs-production routing like the other overlays). count sitting near 0 while pplx_music_news stays green is the COMMON case — most swept artists have no news that sweep, which is correct; checked collapsing toward 0 while kworb’s chart_fetch stays green = the watchlist build broke. STREAMING milestones AND chart positions are dropped here (the desk owns both) — see music_news_filtered reason=artistpull_desk_owns. A SALES-unit milestone is a known gap (no ladder, no discovery, no dedup key), tracked as #2793. The pull’s shipped stories are normal music_news_scored/music_news_posted rows with the artist_pull lane tag. detail.mode distinguishes the two proactive lanes that emit this event: pull (Perplexity, certifications/tours) and newrelease (Apple catalog, album drops, owner steer 2026-08-21). The deterministic new-release lane (_gather_new_releases) reads each rotated watched artist’s Apple Music discography (keyless, free — a deterministic read the metered pull can miss; the top ~25 swept every slot are caught for sure, the tail best-effort), turns an ALBUM (singles/remixes filtered) dated within _NEW_RELEASE_WINDOW_DAYS into a release candidate with a STABLE newrelease:<collection_id> id (a drop posts once), and rides the SAME pipeline; gated on the music_news_newrelease overlay (OFF default — dark until a mod opts in, since it rides the parent stage for routing). Apple OWNS releases (the pull no longer asks for them). checked/hits read the same for both modes; count near 0 is the common case (a watched artist rarely drops an album in a given week) | | artist_watch | cogs/music_desk.py | guild_id, count, detail{checked, tracks_windowed} — one TOP-ARTIST WATCH milestone sweep ran (owner steer 2026-08-04): the watched artists’ free kworb Spotify metrics (career streams, monthly listeners, cumulative totals for their tracks on the Global Daily chart) through the SAME songstats_milestone_state rungs as the opportunistic Songstats checks, so the two detectors share one high-water baseline and can never double-fire. count is crossings composed this sweep (usually 0 — rungs are sparse by design), checked the rung checks run, tracks_windowed the watched charting tracks eligible for the bounded, hour-rotated track-page window. The watch’s other halves (lower chart/radio floors, the wider callable band for watched artists) ride the existing lane events — a watched chart move is a normal music_desk_scored phase=chart row. The sweep going silent = the watch is dark (kworb miss / empty watchlist); checked collapsing toward 0 while kworb’s chart_fetch stays green = the watchlist build broke detail.mode=tenure (#2800 line C) marks the CHART-TENURE pass on the same event: checked = watched rows on the Spotify daily charts that carry days, hits = rows at or past the first rung (100 days), count = tenure cards composed. hits near 0 while the chart sweep is healthy = the watchlist has no long-running songs, which is normal for a young roster; checked at 0 while watch_chart cards flow = the days column stopped parsing. | | milestone_source | cogs/music_desk.py | surface=”music_desk”, matchup (the artist), source=”kworb”, ok (do kworb and Songstats land on the SAME career rung? null when Songstats returned nothing), reason (resolved | no_row | no_total | feed_out), detail{metric, kworb, songstats, gap_pct, matched} — one CAREER-STREAMS rung check resolved its source (_kworb_career_total, 2026-09-14). Why it exists: the career rung is the ONE milestone metric on which this repo’s two Spotify sources disagree. Measured live over 14 top acts: monthly listeners agreed to 0.00% on 8/8 and track totals to ~0.1%, while career totals ran 0.0%–4.3% apart with Songstats always higher, and 2 of the 14 sat in the gap band where Songstats clears a rung the kworb board does not. That gap shipped the Weeknd “crosses 100 billion career Spotify streams” card on 2026-09-14 — Songstats read 102.1B, kworb 97.9B, and the card’s own standing strip (kworb) printed #4 with 97.9B beside the 100B hero. The Songstats artist total also RESTATES DOWNWARD, which a high-water rung cannot represent (Bad Bunny posted “at 133.3B” on 09-13, the same field read 130.7B a day later). kworb now drives the rung alone and this event keeps the divergence visible after it stops driving the break. ok=false is the useful series — it counts the checks where the old path would have fired early, so a rate near zero means the sources have converged and a rate climbing means the gap is widening. A gap_pct turning NEGATIVE (Songstats BELOW kworb) or widening well past ~5% means kworb has started lagging and the source choice needs a re-look. reason is the funnel. no_row means no EXACT name match on the board. That covers an act the board does not carry AND a COLLAB credit: kworb’s strict match is a CREDIT test — right for a rank, wrong for a career total — and live it hands back Drake’s row for “Drake & 21 Savage” (his solo 141.0B) and Future’s for “Future & Metro Boomin”, so this path does not use it. detail.matched carries the board’s CANONICAL name, which is also the rung’s entity key: it is shared with _watch_milestone_units, and folded matching makes the spellings differ often (“Beyonce” → “Beyoncé”, “JAY Z” → “JAŸ-Z”, already in the live table as artist:jaÿ-z), so keying by the news subject would be a second state row over one figure. feed_out is the one to alarm on: the all-artists board came back EMPTY, which stops EVERY career milestone, so a run of it is an outage rather than a quiet day. no_row is an act off the board, normal below the ladder’s 50B floor. Panels: “Career-streams rung: how the kworb figure resolved” and “Career-streams source gap” on tootsies-desks. The event absent entirely = kworb is unwired, and NO career milestone can fire | | album_milestone_sweep | cogs/music_desk.py | guild_id, count, detail{pages_read, ytd_rows, crossings, stale, undated, joint} — one ALBUM milestone sweep ran (_album_milestone_units, owner ask 2026-09-30 “both sales and streams”). Two halves: a rotated window of watched acts’ kworb ALBUMS pages feeds the album_streams ladder (lifetime Spotify, global; aged at the album’s daily by the same freshness gate as the per-track sweep), and the HITS year-to-date albums chart feeds album_units + album_sales (US) for albums released THIS year only. count is cards composed and is usually 0. Read pages_read and ytd_rows for health: pages_read 0 for a day means the kworb half is dark; ytd_rows 0 for a week means the HITS read failed (see hits_fetch), or it is early January. Graduated at ship (owner steer 2026-09-30, no trial): graduated_stage, live in the master guild; any other guild emits nothing. Since 2026-09-30 detail.joint counts crossings credited to more than one act (the iTunes credit, each act confirmed on its own kworb page). Panel: album_milestones. | | catalog_sweep | cogs/music_desk.py | guild_id, count, detail{read, readings, roster, mode} — one CATALOG-COUNT sweep ran (detail.mode = total or daily, #2800: the same page counted twice, once per column; one event each) (#2772, owner question 2026-08-31 “why didn’t we proactively find different stats about artists and genres?”). Every other music lane fires on a printed chart week or on one scalar crossing a round number; this one asks the CORPUS a question. Each slot it rotates a bounded window of watched artists (MUSIC_DESK_CATALOG_FETCHES, default 6), reads each act’s kworb SONGS page, counts the rows at or above each rung in catalog_stats.CATALOG_RUNGS (1B and 2B), and upserts the counts into artist_catalog_counts — the GLOBAL, cross-guild field a rank is taken over. A count that TICKS UP breaks through the shared songstats_milestone_state rungs as metric catalog_count, so seed-on-first-sight holds and a rung breaks once. read is pages fetched this sweep, readings the BOUNDED counts stored, roster the watched acts carrying a kworb artist id. count near 0 is the NORMAL case — a song crossing 1B or 2B is rare, so the sweep’s job most slots is to fill the corpus, not to post. That makes count useless as a health signal: read read (0 = the sweep is dark, a kworb miss or an empty watchlist) and readings (collapsing while read holds = the songs pages truncated above the rung, so count_catalog marked the counts unbounded and none may be stated). The corpus ageing out is the quiet failure: catalog_counts_at_rung drops rows older than 30 days, so a sweep that stalls silently shrinks the ranking field until coverage_ok (90%) gates every rank — and because the lane refuses to compose without a ranking clause, the lane then goes SILENT rather than shipping bare counts. That is deliberate (a bare count lets the compose invent a record) but it means a stalled sweep looks exactly like a quiet one on count alone; read and readings are what separate them. The ROUND-COUNT path (owner ask 2026-09-10): a TOTAL count landing on a multiple of five with no rank clause goes to the candidate with the web qualifier REQUIRED, so its outcome is NOT in this event – read qualifier with phase == 'catalog_stat': verified is a card in flight, required_missing is the lane finding no reported ranking (detail.settled true = the rung advanced, no retry; false = a transient miss, retries next slot). Measured before the path shipped (2026-09-10): 224 total-mode sweeps in 14 days, 0 cards. Gated on the music_catalog_stats experiment (STAGING default) Since 2026-09-18 the TOTAL-mode event also reports the PER-TRACK half (owner steer, off the Hip Hop All Day “Plot Twist” post): detail.crossings is the number of lead rows on the pages read whose lifetime total newly crossed a per-track streams rung (100M, 250M, 500M, 1B…) since the last read, and detail.track_cards the plain milestone cards composed off them (capped by MUSIC_DESK_MAX_CATALOG_TRACK_MILESTONES, default 2). The state is the SAME songstats_milestone_state track: row per song the Global Daily sweep uses, read and seeded in bulk (db.milestone_rungs_bulk / seed_milestone_rungs_bulk), so the FIRST read of every page seeds ~500 rows and shows crossings 0 by design; after that, measured on the top 30 acts, about 30 crossings a week are due (18 at 100M) against the 2 the Global Daily chart could see. A delivered per-track card also writes the global stream_crossings ledger (_record_ship), the stream REPORT lane’s input (row below). Since #3467 the TOP TIER is read every slot: the first MUSIC_DESK_CATALOG_TOP_TIER (10) acts of the kworb ranking are read on every sweep, outside the rotation, and the rotation covers the rest; detail.read is every page read this sweep (top tier plus the rotated window) and detail.top_tier the top-tier count, so a crossing on a top act’s page lands within the slot kworb shows it rather than within the day. Since 2026-09-19 a crossing must be FRESH to be a card: milestones.crossing_age_days measures how long ago the total crossed the rung at the row’s own daily pace ((total - rung) / daily); older than MUSIC_DESK_FRESH_CROSSING_DAYS (7) advances the rung with no card (db.advance_milestone_rung), emits music_desk_scored with reason=stale_crossing (phase milestone, detail.age_days / detail.rung), and counts in detail.stale. Why: the rung is measured between READS, and a track: row the chart sweep seeded can go unread for months, so the songs-page sweep’s first read shows a REAL crossing that is old news – live 2026-09-19, “Shape of You” 5B (66 days old) and “Wonderwall” 3B (29 days) both posted as “just passed”. The same gate runs in the Global Daily sweep (_watch_milestone_units), on an actual crossing only. Since the second fix (2026-09-19, the “That happened 40 days ago” reply) a row with NO daily figure is not taken as fresh: it is DEFERRED – no card, no rung advance, a music_desk_scored drop with reason=undated_crossing (detail.rung / detail.entity_key), counted in detail.undated; the next read that shows a daily decides. kworb’s daily column is blank for whole pages at a time (Arctic Monkeys 0 of 143 rows that day), and “Circles” at 3.28B shipped as “reaches 2 billion” through that hole. The backlog itself came from the chart sweep seeding track: rungs off the track page’s chart-history Total (500M for a 5.1B song); it reads the songs page now. stale high on a page’s first re-read is the expected drain of that backlog; stale steady at zero afterwards is normal. undated steady above zero for an act for days = kworb is not publishing dailies for that page, and its crossings wait; undated_crossing drops name the songs. Both markers (stale_crossing, undated_crossing) are in the ops monitor’s NON_SCORE_REASONS: score=0.0 stamps from a lane that composed nothing, never a judge verdict (unregistered, the 29 settles of one 2026-09-19 sweep read as 29 milestone posts scored 0.0). | | desk_art | cogs/music_desk.py + cogs/music_news.py + cogs/cinema_desk.py + the three WIRE desks (cogs/pop_desk.py, cogs/sports_desk.py, cogs/cinema_news.py) | surface, ok (art bytes returned), source (the rung that answered: artist_photo | discography | album_search | song_search | release | release_discography | catalog_cover | artist_portrait | tmdb_poster | deezer_artist | deezer_artist_exact | wiki_logo | wiki_portrait | steam_art | …). wiki_portrait is new (#deezer-403) — the Wikidata/Commons artist photo, the SECOND provider under Deezer’s artist picture. Until it existed every artist photo in the repo came from Deezer alone, and on 2026-09-14 Deezer began answering 403 to Railway’s IP: a music card whose title the Apple catalog does not carry (a new radio single is the normal case) then had no rung left at all and shipped bare, which is the David Banner “+8” most-added card. Read its SHARE the way artist_portrait’s is read, and with the opposite sign: wiki_portrait is a FLOOR, so a low steady share is health and a share that climbs says Deezer has gone quiet again. steam_art is new (#2741) — the Steam catalog rung for a VIDEO GAME subject, which the wiki_image rung structurally could not serve (pageimages excludes the non-free box art every game article leads with, so a game resolved NOTHING and the card fell to the branded floor). Watch it the way artist_portrait is watched: steam_art appearing where a game subject used to produce none is the fix working, and a game subject falling back to none again is the signal that Steam’s storesearch leg has gone quiet — a silent, fail-open degradation that raises no exception (the steam integration probe is the other half of that watch). music_desk’s single catalog label split into catalog_cover vs artist_portrait (#2274) when resolve_art_url gained a portrait floor: a cover is the record, a portrait is a stand-in for a record the catalog could not supply, and one label for both would let a rise in portraits read as healthy art coverage. Watch the portrait SHARE — it is the signal for a cover-lookup regression, which is otherwise silent because the card still ships a picture. It was HIGH on the song lanes until #2278 fixed the subject-kind title guard; live A/B moved 24 of 45 song rows from artist_portrait to catalog_cover, so the expected steady state is now mostly covers, with portraits only for a release the catalog genuinely lacks. A fourth cover rung reads the album’s TRACKLIST, and a split-credit hit reports catalog_cover_split (#3508 / #3511): resolve_art_url reads the lead act’s newest discography tracklists (apple_music.artist_tracks) for a SONG subject after the keyword and album-discography rungs miss, so an album CUT on a record the search does not index yet resolves its album’s cover instead of a portrait. A hit on the WHOLE credit (an exact iTunes artist id for the full credit string) reports catalog_cover as before. A hit reached by SPLITTING the credit (“Yeat” for “Yeat & Drake”, the lead of “Lead feat. Guest”) reports catalog_cover_split, which the delivery media gate judges like any unverified image (it is absent from _IDENTITY_VERIFIED_ART). Read the two shares together: catalog_cover_split rising while media_coherence mismatches on music_desk stay flat is the rung finding real cuts on joint credits; catalog_cover_split rows that the gate DROPS (media_coherence reason=mismatch with source=catalog_cover_split) are the wrong-cover cases the label exists to catch, and a steady stream of them says the split is finding the wrong act. A music_desk or x_mentions media_coherence row can now come from the ROOM path as well as the crosspost (gate_split_art, #3515): a split cover is judged once before the room copy ships and, if it passes, again by the crosspost, so one split card can leave two rows. A portrait share climbing back toward the covers is the regression signal. A FIFTH cover value, catalog_cover_fuzzy (2026-09-20, the Miguel Newberry cover): the rung is now EARNED per row off its credit (catalog_art.cover_rung). A cover whose row matches the request EXACTLY – the credit deterministically (credit_verified: whole billed acts, no act the request never named) and, when a title was requested, the core title equal (title_verified: no fuzzy containment, no ratio, no remix or live tag the request lacks) – reports catalog_cover as before; one that matched only by the spelling ratio or the fuzzy title match, a billing with an extra act (a remix or joint single), a row with no credit, or the artist-level no-credit last resort (pick_any_art_url), reports catalog_cover_fuzzy, which the delivery media gate judges by face like any unverified image (absent from _IDENTITY_VERIFIED_ART). The card that forced it shipped Miguel Newberry’s same-title single cover on a Miguel Billboard 200 card: the matcher’s substring hole let the credit pass, the rung said catalog_cover, and the exemption overruled the judge’s correct flag (media_coherence reason=verified_source verdict=subject). Read the two together: catalog_cover_fuzzy should be a SMALL share (a stylized spelling on one side, “Rick Ro$$” for “Rick Ross”), and a media_coherence DROP on it (reason=mismatch, source=catalog_cover_fuzzy) is the gate catching a wrong cover the credit rule let through — the exact event this rung exists to make visible. A fuzzy share that climbs says a source has started spelling acts differently from the catalog, which was silent before because every cover wore the exempt label. entity_image’s music cover now also reports deezer_cover when Deezer answered (it said catalog_cover for both providers before). The desk_art panel groups by source dynamically, so it picked the new values up with no axiom_setup.py change, reason (on a miss: no_url = the resolver found no URL | download_miss = a URL resolved but the image GET failed | no_art | no_subject | error). music_desk’s _art_bytes now names which of the two it was (#deezer-403); it collapsed every miss to no_art, so the David Banner card could not be told apart from a CDN blip without replaying the whole ladder by hand. Its sibling _fetch_art always split them, and the two now match, phase (take_retry on the wire desks only) — a desk’s ART resolution resolved or missed. The art path used to be silent, so an art-blank card (“why didn’t it have art”, owner) could only be spotted by eye: a *_posted carded flag says a card was BUILT, never that it had art. The music newsroom now emits it for EVERY story, including the ones with no number (#wrong-missing-images): it used to return before resolving art whenever the story had no figure, so the whole RELEASE lane resolved no art and emitted no event — the lane’s art misses were invisible, and its posts shipped imageless in the room and on the timeline. Expect a small rise in surface == 'music_news' volume from that lane alone; a release story whose art misses is now a countable ok=false instead of silence. The three WIRE desks emit it only for their LAST rung (phase=take_retry): they resolve art BEFORE compose on the wire HEADLINE, and a headline that names no entity (a chart/roundup title) classifies to an empty subject list, so that pass has nothing to resolve — the composed TAKE is then the first text that names a subject with art, and the retry runs on it. Query phase == 'take_retry': ok=true counts posts that would have shipped bare, ok=false counts the ones that still do (the residual, also visible as the *_posted source=none rate). cinema_desk’s bare-text lanes now carry count (#individual-art) — the number of posters resolved for the take, one per show it names (the release “what’s out” roundup resolves several; a single-title news/awards take resolves one). count > 1 is the multi-poster path; count == 0 with ok=false is a lane that resolved no art at all. music_news also carries tour (#tour-announcement, folded into detail) — whether the card took the TOUR path (_claim_is_tour or _is_tour_subject). It is the queryable half of that fix: tour == true with source == 'deezer_artist' is the intended photo-first result, while tour == true with source == 'catalog_cover' is the BACKDROP net (no artist photo resolved, so the act’s own cover keeps the card). A backdrop share that climbs says the artist-photo rungs have gone quiet, which is silent otherwise because the card still ships a picture. music_news also carries bare (folded into detail, 2026-09-14) — a TRI-STATE: true shipped the picture with NO card drawn over it, false was a candidate for that rung and kept its card, and ABSENT was never a candidate. One rung takes it: a figureless TOUR announcement whose art is feed_photo, the image the publisher filed WITH the story, AND whose image the image_is_tour_admat judge says lists shows. It records what SHIPPED, never what was attempted (Codex review on #3291): the judge can say no, and download_image_bytes validates only the HTTP status and the body length, so a non-image body reaches build_bare_art_card and comes back None — in both cases the branded card ships, and a bare=true row there would be a lie told by the one signal this rung has. So read false as the judge’s refusals: a run of them is how a judge that has started refusing everything becomes visible, which is otherwise silent because the card still ships. Read it against delivered == 'room' (Codex review on #3291): the card is built before _sched_deliver branches on the stage, so a STAGING audition runs the same route and would otherwise inflate the shipped-bare share with posts that only reached #bot-logs — the same dimension desk_link carries, for the same reason. That image is the tour admat, so it already prints the tour name, the act and every date; the card repeated all three in its headline and its bottom scrim covered the date list (owner steer, the Malcolm Todd “Do That Again Tour” post — “this probably looks better without our markup on it”). Read bare == true as a SHARE of tour == true, never as a count: the rung is rare because feed_photo itself is rare. A bare share that climbs past the tour lane says the feed rung started answering stories it did not answer before, and those posts now ship unbranded — no wordmark, no tag, no source stamp — which nothing else in the ledger reports. music_news also carries dropped + reject_detail (both folded into detail, 2026-09-16) — a feed image the GEOMETRY guard refused before it could become the card. dropped names the rung (feed_photo) and reject_detail the band and the size (undersized 728x90, banner 970x250). The case they come from: Billboard’s College Campus Tabby Tour story opened its body HTML with a 728x90 house ad, industry_feeds._first_image took it as the article’s photo, and it shipped to @tootsiesbar as the art. Nothing downstream could catch it — download_image_bytes checks only the HTTP status and the body length, and the delivery coherence gate (x_crosspost._media_gate) passes a branded graphic BY DESIGN so our own chart and number cards clear it, so an ad, which names no person and no work, contradicts no take and its wrong-SUBJECT question cannot see it. catalog_art.ad_banner_reject answers it on GEOMETRY with no vision call: display advertising is sold in fixed IAB units whose shapes no editorial photo has (the leaderboard past 8:1, the skyscraper 1:3.75), and the same 200px floor catches a feed THUMBNAIL (Billboard’s own lead images are 237x158 crops). These two fields exist because the guard degrades SILENTLY otherwise: a refused image falls through to the catalog/entity resolver, so the story still carries art and ok stays true — a guard that had started refusing every real photo would look exactly like a healthy lane. Read the share against a flat feed_photo count, and read reject_detail’s band: a run of banner is the ad class it was built for, while a run of undersized on a feed that used to answer says the publisher moved to thumbnails. What geometry CANNOT catch is the 300x250 medium rectangle, which sits inside both bands; that residual is deliberately left open rather than answered with a vision judge, since the rung builds about 4 cards a week and the moment-framed photo gate rejects a REAL Billboard lead photo as readily as the ad (5/5 each, measured on the Tinashe story) tour + event_shape (#3413): tour is the bare tour flag the card build used to derive from phrase lists; event_shape is the classifier’s announcement lane on a release (record / tour / tour_update / live / nonaudio), a fold field (parse_json(detail)['event_shape']). Before it a LIVE moment was stamped tour=false while taking the tour art path, so the dashboard could not tell live from release; group music_news art by event_shape to read the four lanes apart. | | release_art_stale | cogs/music_news.py | surface (always music_news), source (the rung that answered INSTEAD — a portrait rung, or none when no art resolved at all), reason (the REFUSED row’s release date, or undated), detail.topic (the story subject) — a RELEASE card refused a matched catalog row because the row is from a different era than the story, so its cover would have claimed the wrong record (_row_is_a_different_record). The case it comes from (#3171): Linkin Park’s “Faint [Live in São Paulo]” dropped 2026-09-11 off the UNSHATTER film soundtrack, the wire named only “Faint”, and resolve_music_row answered with the 2003 Meteora recording — catalog_art._rank_rows prefers the clean original over a live edition when the requested title does not name one, which is right for a cert or chart story and wrong for a drop. The card then stamped NEW RELEASE over a 23-year-old cover. The same shape shipped the 2006 “IRREPLACEABLE” cover on five B’Day 20th-anniversary cards and the 2009 “Fear” cover on “Fear of Missing Out”. Read source, not just the rate. The guard trades a wrong cover for the act’s PHOTO, so a healthy steady state is mostly portrait rungs; a rising source == 'none' share means it is trading covers for BARE cards instead, which is a worse post than the one it replaced and the signal to revisit the window (_RELEASE_ROW_MAX_AGE_DAYS, 365 days). Measured before shipping: over 30 days, 19 of 71 catalog-matched release cards would fire this, about 13 of them plainly wrong art. A rate far above that says the window or the throwback carve-out (retrospective.states_past_interval) is mis-calibrated, not that the bug got worse A desk-flagged THROWBACK never emits it (2026-09-17): the guard reads the CLAIM for a stated backward interval, and the claim is the classifier’s extraction, which strips the time frame – “15 years ago today, Adele’s ‘Someone Like You’ hit No. 1” classified as “No. 1 on the Hot 100, first #1 featuring only piano and vocals”, so the guard refused the correct 2011 cover, the card fell to a Deezer namesake’s portrait, and the media gate dropped that (a bare number card on X). _artwork_and_link now takes the desk’s own retro flag (composed.meta["retro"], the same flag the link route reads) and skips the guard for a throwback. A daily stream count never emits it either (#3296, 2026-10-15): a claim mn.is_daily_stream_count matches (“6,906,506 streams on Spotify yesterday”, popstats02’s album sweeps, kind chart) is about the existing record, and 34 of the 60 firings in the week of 10-08 were this shape, each trading the right album cover for the artist’s photo. A chart story about a title already on the chart never emits it either (#4131, 2026-10-16): on the chart kind, a classifier chart_event of hold / move / none / exit with no edition word keeps the row (mn.chart_story_is_same_record). Replayed on 30 days of firings, it removed 16 chart-position firings. A debut, a re-entry and an edition word still emit it. | | tmdb_calendar_filter | utils/tmdb.py (releases_between) | source (tmdb), op (discover_upcoming), count (rows KEPT), query (the window, <start>..<end>), + folded detail{dropped, undated, stale} — the coming-soon calendar DROPPED rows TMDB dated outside the window it was asked for (#3285). dropped is the stale-dated rows; undated is rows TMDB gave no date at all, counted apart because the two faults are different and one number for both reads as noise. TMDB matches a film on any US theatrical date in the window but returns the film’s PRIMARY date, so a re-release comes back in-window carrying its original date: Avengers: Endgame dated 2019-04-26 led the live 2026-09-15..2026-09-28 query, reached the card under the hero “Opening Next”, and the take called a 2019 film the most anticipated of the fortnight. The correct date is not in the response, so the row cannot be dated and is dropped — absent beats invented. This is a SILENT, fail-open drop: it raises nothing and only shrinks the board, so this event is the one place it shows. Emitted on EVERY build, not only on a drop: count is the starve signal, and a starve does not need a drop — TMDB returning six in-window rows is the same blackout as TMDB returning twenty of which fifteen are stale, and a drop-only event is missing exactly then. Steady state measured over five consecutive 14-day windows is 0–3 dropped against 17–20 kept, for a card that draws 10 and needs 5. WATCH dropped climbing while count falls: that is the calendar starving, and below 5 kept the board goes absent with no other signal. READ IT PER BUILD, never summed across a bin: the 5-row floor is a per-build threshold, so two starved builds that kept 3 rows each sum to 6 and read as healthy. The panel graphs min(count) (the bin’s worst build) against max(dropped), with count() alongside as the cadence | | tmdb_poster_verify | utils/catalog_art.py (_verified_poster_url, #2759) | source (tmdb), op (search_movie | search_tv), query (the requested title, truncated to 120), reason (verified | same_work | title_mismatch | year_mismatch | judge_failed | no_match), + folded detail{reject_detail (TMDB’s title on a refusal; <title> (<year>) on a year_mismatch), answer (TMDB’s title on a same_work accept), via_model (true when the Haiku same-work judge decided, #4174)} — ONE film/TV poster title check. TMDB’s search answers any query with its top hit, so a wrong name upstream got a real poster of a different film: the classifier’s “Looney Tunes” for “Coyote vs. Acme” fetched the 2003 “Looney Tunes: Back in Action” poster, under the catalog label the delivery media gate trusts. The rung now requires the titles to be EQUAL after case, accents, &/and, punctuation and a leading article are folded (a show request may end in “Season N”, which is dropped). Since #4174 a title mismatch is judged, not refused outright: claude.same_screen_work (Haiku, purpose=screen_same_work on claude_api, cached 30 days) answers whether TMDB’s hit is the same work under another name -> same_work (ships) or title_mismatch with via_model (refused). judge_failed is an error or an unreadable answer: refused, fail closed, not cached. title_mismatch WITHOUT via_model means no Claude client was attached. year_mismatch is a bracketed request year more than one year from TMDB’s (a same-title remake), refused with no model call. A refusal returns no poster and the card drops to its next rung, so it is otherwise silent. Panel tmdb_poster_verify (Media board). No monitor: the rung builds about 10 cards a month (desk_art source=tmdb_poster plus market_subject, 30 days to 2026-10-15), too few for a rate to mean anything, and a refusal degrades one card to its next rung rather than going dark. Read a rising title_mismatch share on the panel instead. | | catalog_title_confirm | utils/apple_music.py (confirm_band_rows, #3440); call sites in resolve_apple_music_url, _resolve_via_artist, song_release_date and deezer album_release_date, track_release_date, album_genres, album_top_track. Also utils/catalog_art.py (_judge_release_names, #4115) | source (itunes | deezer | itunes_release_name: the #4115 bracket-name check in resolve_music_row, one event per UNSURE candidate name, Haiku via classify() purpose catalog_release_name; there different drops the name, same_work keeps it, and judge_failed KEEPS it (the old rule), so a judge outage shows as a rising judge_failed share and never as missing covers | itunes_credit | deezer_credit: the CB-07 credit check apple_music.judge_credits, one event per ratio-only iTunes credit or overlap-only Deezer credit, Haiku via classify() purpose catalog_credit_match SAME / DIFFERENT, cached 30 days per (request, credit); different and judge_failed drop the row), query (the request, truncated to 120), reason (sure_present | same_work | different | judge_failed), count (distinct band titles), + folded detail{via_model, cached, answer (the kept title)} — ONE ambiguous-band decision. The fuzzy title matchers (_title_matches, album_title_matches) accept a title by ratio 0.6 or by containment. A pass that is not equal (CB-07: at any ratio; #3440 cut it at 0.85) or, for an album, adds a new core word is the BAND. sure_present = an equal row in the same result set beat the band rows, no model call. Otherwise claude.match_catalog_title (Haiku, purpose=catalog_title_match on claude_api, cached 30 days per request+candidate pair) picks the same title from the real rows or none -> same_work (kept) or different (dropped). judge_failed = an error or an unreadable answer: band rows dropped, fail closed, not cached. No event when no row is in the band. Panel catalog_title_confirm (Media board). No monitor: the dry run on 90 real chart rows reached the band 25 times and the model 3 times, so a rate is too thin to gate; a refusal drops one link or date to absent rather than going dark. Read a rising judge_failed share on the panel. kind=scope (#2784): a fuzzy “alive” / “living” phrase the exact ranking regex does not read (output_checks.universal_scope_candidates, “the best drummer alive”) goes to Haiku claude.universal_scope_claim (purpose=universal_scope, CLAIM or MOOD). claim and judge_failed hand the phrase to the ranking arbiter above as an ungrounded claim (the drop direction); mood ships it. 0 of 10,725 real texts produced a candidate (2026-10-17), so expect about 0 rows; a scope row is a line worth reading. | | wiki_namesake_check | utils/music_markets.py (_pick_namesake, #3319), called by _wiki_portrait, the Wikidata rung of resolve_art_url | source (wikidata), query (the lead act name, truncated to 120), reason (picked | unclear | judge_failed | no_judge), count (same-name music acts with a photo), + folded detail{answer (the picked QID, or “”)} — ONE namesake decision. wikidata.artist_portraits returns every music act with a P18 photo under the lead’s name; one act needs no call and emits nothing. Two or more is the Steve Lacy case (the jazz musician Q504641 ranks ahead of the R&B singer). Haiku reads the card’s credit, title and each act’s Wikidata description (classify, purpose=wiki_namesake on claude_api, cached 30 days per lead+credit+title+QIDs): a number picks that act (picked); UNCLEAR gives no photo (unclear, the card drops a rung). judge_failed (an error or an unreadable answer) and no_judge (scripts, before boot) keep the old first-match rule. Panel wiki_namesake_check (Media board: picked / unclear / failed, plus failed_pct). No monitor: the Wikidata rung answered 0 cards in the 14 days to 2026-10-17, because it runs only when Deezer misses. Read a rising failed_pct on the panel. | | kworb_act_check | utils/kworb.py (confirm_act, #4170); call sites KworbClient.artist_standing, youtube_artist_standing, standings, resolve_chart_row | source (streams | listeners | youtube | chart), query (the request credit, truncated to 120), reason (picked | none | judge_failed | all_parts_ranked), count (matched names), + folded detail{answer (the kept name, or “”)} — ONE act check (a classify cache hit emits too; the real model calls are the claude_api rows with purpose=kworb_act_check). artist_standing asks once for both its halves. The strict credit match splits an UNRANKED multi-act credit into parts, so a part can equal a different ranked act (“Zezé Di Camargo & Luciano”, a Brazilian duo, took the German rapper Luciano’s streams row). The known ranked set (#2615) cannot keep an unranked duo whole. When a row matched only through a part (fragment_match), Haiku labels the credit (claude.classify, purpose=kworb_act_check on claude_api, cached 14 days per credit): COLLAB keeps the best-ranked part (the old rule, picked); ONE gives no row (none). judge_failed = an error or an unreadable answer: no row (absent), not cached. all_parts_ranked (#4277) = every part of the request is a ranked act on its own (“George Michael & Wham!”), so the answer is COLLAB with no model call; the panel counts it as no_call. No event when no fragment match happens, or when no judge is attached (scripts, tests). Panel kworb_act_check (Media board: picked / none / judge_failed by source, plus failed_pct). No monitor: the dry run on the live pages reached the check 12 times over 577 requests, so a rate is too thin to gate; a refusal drops one rank to absent rather than going dark. Read a rising failed_pct on the panel. | | credit_party_check | utils/artist_watch.py (judged_first_party, #3450); call site utils/deezer.py artist_picture_match_for_credit | ok (the judge gave a verdict), decision (yes|no|none) — ONE miss-side first-party check. A co-billed credit (“Drake & 21 Savage”) that missed the whole-credit search and the “+” retry asks Haiku whether the name before the first comma, “&” or “with” is the full name of a music act (claude.classify, purpose=credit_first_party on claude_api, cached 7 days per credit in memory; a cache hit emits too). yes = the portrait search runs on that name and keeps only an EXACT row (whole name, _EXACT_MIN_FANS), reported NON-exact so the media gate still judges it; no / none = absent, as before. No event when no candidate exists or no judge is attached (scripts, tests). Panel credit_party_check (Media board). No monitor: a failed check leaves the card as it was before (no art), and the dry run reached the check 72 times on 707 real credits; read a rising none_pct on the panel. | | market_label_check | utils/markets.py (confirm_runner_up_cut, confirm_event_platform, KalshiClient._apply_sport_checks, #3425 / #3434 / #3428); call sites KalshiClient.get_events_for_series, get_event_markets, refresh_series_index | source (kalshi), purpose (market_runner_up_check | market_platform_check | market_sport_check), reason (runner-up: cut | kept | judge_failed; platform: picked | none | judge_failed; sport: same | changed | judge_failed), category (runner-up only), count (platform only: platforms the rules name), + folded detail: ticker, topic (the competition, on the sport check), answer (sport check: <ladder tag> -> <model tag>, - = no tag). A runner-up judge_failed KEEPS the event (fail open); a platform judge_failed leaves the title as it is; a sport judge_failed keeps the ladder tag. | Panel market_label_check (Sports board). No monitor: about five title checks a week, and a failure keeps today’s behavior for that one event. The sport check is about 560 rows after each deploy, then none; a burst of judge_failed there means the ladder tags stand, which is the behavior before #3428, so it earns no monitor either. | | claude_api purpose=riaa_recase / purpose=riaa_same_release | claude_client.py (recase_titles, riaa_same_release), called from utils/riaa.py (recase_texts, resolve_title_collisions) | the claude_api fields only; NO new event and no new field | #3438 / #3959 (2026-10-16). riaa_recase is one Haiku call per RIAA board that is about to compose (only its uncached rows; 30-day riaa_recase cache), and fixes letter case only (“Sza” -> “SZA”). riaa_same_release runs only on a title collision on the plaques window (14-day dry run: one group, one call). Both fail OPEN to the old rule: a model error leaves the rule casing or the merged title, so the card still ships. The failure shows as claude_api ok=false for the purpose. A row the recase answers wrongly is dropped silently (only a log line), because the rule output for that row ships instead. Read: ['tootsies'] \| where event=='claude_api' and purpose in ('riaa_recase','riaa_same_release') \| summarize calls=count(), failed=countif(ok==false) by purpose. No panel and no monitor added: about 2 to 5 calls a day, and every failure falls back to the old card text, which is the state before this change. The existing per-purpose LLM panels already list both purposes. | | <surface>_scored reason=retro_unframed / reason=retro_interval | cogs/music_news.py, cogs/pop_desk.py, cogs/cinema_news.py, cogs/sports_desk.py | guild_id, score (always 0.0), shipped=false, matchup (subject), post_preview, + folded detail: frame (the wire_retrospective frame the source post carried) — a THROWBACK take was composed and then DROPPED because it stated no look-back frame of its own (retrospective.frames_as_past). The case it comes from (#3270): @Genius posted “eight years ago today, 6LACK dropped ‘east atlanta love letter’”, the desk detected the throwback and told the compose to write a look-back, and the take that shipped to X read “East Atlanta Love Letter debuted at No. 3 on the Billboard 200 with 77,000 equivalent album units in its first week” — past tense, no time frame, so it reads as this week’s debut for a 2018 album. Nothing else catches the class: the self-gate grades whether the claims are GROUNDED and every word of that line is true, so it scored high and shipped. Read this rate against wire_retrospective on the same surface — that is the denominator (how many throwbacks the desk framed) and this is how many lost the frame on the way out. Expect it NEAR ZERO: the note that names the frame was measured on the production compose model with the real inputs at 12/12 framed, against 4/12 for the note it replaced. A sustained rate means the note stopped landing (check whether a block below it in the context turned present-tense — the X CROWD READ block, which says “what people on X are saying RIGHT NOW”, is what pulled the old note off in the dry run), and a rate at or near 100% on one surface means its compose never sees the note at all. reason=retro_interval is its twin on music_news: the take DID frame itself, but with an era the wire never gave it (“back in 1999” off an eight-year-old album), so the numbers inside its look-back clause are not the ones the source fixes (retrospective_values); detail.numbers carries the invented ones. Read it as a fabrication rate, not a framing rate — the self-gate cannot catch it, because the claim is true and only the date is wrong. Its FIRST live hit was a false positive, and the rate is how it was found (2026-09-15). One drop, on a @Genius “today in 2007” wire about “Crank That (Soulja Boy)”: the take quoted the source’s own year back, and the gate called 2007 an invented era because retrospective_values derived the interval 19 from a year frame and never listed the year itself. Read a retro_interval drop against the wire_retrospective FRAME on the same surface — a drop whose detail.numbers is a four-digit year on a today_in / this_day_in source is the take agreeing with its wire, not inventing an era. On music_news the drop marks the post SEEN, so a hit costs that anniversary; that is the trade the WAP re-roll bought (a re-roll pays verify + compose + score again for a phrasing lottery), and the lever that keeps anniversaries in the lane is the note, not a retry. Panel: desk_guard_drops (tootsies-desks), added with this reason — it graphs every deterministic refusal that rides the LANE’s own phase rather than shape_reject, so ungrounded_number and career_ordinal land on it too; before it, those reasons were documented here and graphed nowhere. NO monitor, the same call desk_shape_reject makes: a guard firing is the guard WORKING, and there is no calibrated rate yet that separates “the note is landing” from “a prompt regressed” — the panel builds that baseline first | | music_news_scored reason=album_unmarked | cogs/music_news.py | guild_id, phase (milestone), score (always 0.0), shipped=false, matchup (subject), post_preview | #album-streams (2026-10-03). An ALBUM stream total (stream_shape=album from the classifier, accepted by album_stream_story) must say “album” in its take, else it reads as a song (the live Jay-Z “Blueprint” 600M post). mark_album inserts the word after the title; this reason fires only when the take names no title to attach it to. Marked seen. A steady low rate is normal; a spike means the compose stopped naming the title. | | music_news_scored reason=unsupported_rank | cogs/music_news.py | guild_id, phase (story kind), score (always 0.0), shipped=false, matchup (subject), post_preview, + folded detail: claims (the flagged phrases) | #3838 (2026-10-05). On a story whose post text is ground truth (a number authority on X, a feed item, a catalog post), the take states an ordinal, a superlative’s rank or an all-time scope that the post and the desk’s own readings do not support (output_checks.unsupported_rank_claims). The web background and the classifier’s claim are NOT its source. Live cases: “her second album” on Adele’s “25”, “the most-streamed metal album ever” off “the top two”. Marked seen. Panel desk_guard_drops. A rate above a few a day means the compose is adding clauses again; check the SOURCE POST block still reaches it. | | music_news_scored reason=unsupported_title | cogs/music_news.py | guild_id, phase, score (always 0.0), shipped=false, matchup, post_preview, + folded detail: claims (the titles) | #3838 (2026-10-05). The twin of unsupported_rank: on a source-post story the take quotes a title the post and the desk’s own readings never name (mn.ungrounded_quoted_titles). Live case: Wiz Khalifa’s catalog total gained “built mostly on the back of "See You Again"”. Marked seen. Panel desk_guard_drops. Since #4583 (#4553) the word match is the fast path: each title it misses goes to the title judge (purpose title_in_post), and a title the judge finds in the source emits music_news_recover detail.via=title_judge_take and does not drop the take. A judge error drops, as before. Measured: 1 drop in the 30 days to 2026-10-24. | | music_news_scored reason=wrong_move | cogs/music_news.py | guild_id, phase, score (always 0.0), shipped=false, matchup, post_preview, + folded detail: numbers (the move sizes) | #3853 (2026-10-05). The take states a move size (“up 21 spots”) that equals no move the post states (mn.wrong_move_sizes: “#1(+20)”, “vaults 21-1”, “up N spots”). A post with no move checks nothing. The classifier wrote “(up 21 spots)” off “vaults 21-1” 10 of 10 runs before its example was reworded, and mn.strip_wrong_moves now cuts that clause from the claim too. Marked seen. Panel desk_guard_drops. A non-zero rate means the compose computed a move; read the preview. Since #3864 a size written in words (“twenty-one spots”) is read too, and with no move in the post a WORD size is flagged, because the digit gate cannot hold it; wire_move_sizes also reads “from #9 to #8”. | | music_news_scored reason=chart_frame | cogs/music_news.py | guild_id, phase, score (always 0.0), shipped=false, matchup, post_preview, + folded detail: claims (the words) | #3954 (2026-10-09). The take names a chart period its ground does not (“daily” over a “Weekly” claim), or adds a word that says the position did not hold (“briefly”, “temporarily”) that the ground does not state (mn.misstated_chart_frame). The ground is the source post and the desk’s own evidence (the classifier claim only when there is no post text), never the web research: on the live case the research proposed the wrong sentence. Only a period word that names a chart counts. A ground with no period checks no period. Marked seen. Panel desk_guard_drops. A non-zero rate means the compose took a frame from the background; read the preview. | | music_news_scored reason=confidence_hedge / reason=wrong_credit | cogs/music_news.py | guild_id, phase, score (always 0.0), shipped=false, matchup, post_preview | #3856 (2026-10-05). The take wrote a confidence disclaimer (“can’t verify”, “remains unverified”, “reportedly”; output_checks.confidence_hedge_hits) or named a platform or a market as the counter (“per YouTube’s own count”, “per the market”; self_count_credit_hits). The confidence signal is the tag and the card’s source pill (owner steer). The prompt causes were cut first (0/15 hedged takes on the ablation set), so this is a backstop. Marked seen. Panel desk_guard_drops. Since #3864 the adjective use on another fact (“an unconfirmed release date”, “the reportedly shelved album”: a determiner before the word, an allowlisted non-figure noun in the next two words, and no digit, number word or figure noun in the next three) is not a hit. Any other wording still counts, so the exemption fails closed. Since 2026-10-06 reason=wrong_credit also covers a credit to an UNNAMED source (anonymous_credit_hits: “third-party”, “a/one/some tracker”, “fan-run count”, “unofficial tally”). The shipped line was “A third-party tracker puts Drake at 1.7 billion Spotify streams across all credits in September.” A named tracker (“per ChartMasters”) and the verb “tracking for” still pass. | | music_news_scored reason=later_week | cogs/music_news.py | guild_id, phase, score (always 0.0), shipped=false, matchup, post_preview | #3860 (2026-10-05). The post is about a later week of a title already out (mn.post_states_later_week: re-peak, return to #N, re-entry, a numbered week past the first), and the take calls a figure “first-week” that the post does not attach to a first week (mn.later_week_first_week_claim). The classify parse already moves such a story off first_week, so this is a backstop. talkofthecharts’ HABIBTI re-peak shipped “65K first-week units” 3 of 3 runs before the fix. Marked seen. Panel desk_guard_drops. Expect near zero. | | music_news_scored + detail.unconfirmed_penalty | cogs/music_news.py | guild_id, phase, score (the JUDGE’s own score, unchanged), shipped (read off the gated score), matchup, post_preview, + folded detail: unconfirmed_penalty (0.25), gated_score (score minus the penalty) | Owner steer 2026-10-09, after the Madonna “260 million equivalent album sales” post: @chartmastersorg was the only source, the verify judge found no other, the take scored 0.82 and shipped, and the owner deleted it. A number is UNCONFIRMED when the judge did not confirm it and no live reading settles it (settled is False). The ship floor then reads score - UNCONFIRMED_SCORE_PENALTY, so a take under 0.85 does not ship. A release story (no number) takes no penalty. A penalty-only drop (raw score at or over 0.6) is not marked seen, so a later slot can ship it once confirmed. score keeps the judge’s value so the ops monitor’s quality MEAN still measures the writing; its below-floor count (surface_dark / lane_dark / low_quality) reads detail.gated_score when present, so a lane the penalty silences still reads dark. Panel outcomes: shipped, penalty_drop, below_floor. Measured over the 14 days before: 79 of 372 posts were unconfirmed, about 16 of them under 0.85. Panel newsroom_unconfirmed. No monitor: a policy cost, read weekly. | | music_news_recover via=live_leg / music_news_filtered reason=market_unread | cogs/music_news.py | guild_id, ok=true, phase (market), matchup, + folded detail: via, figure (the live percent), was (the wire figure), title (the Kalshi/Polymarket event) / guild_id, phase, reason, matchup, source, + folded detail: claim, venue, matched | 2026-10-06, the Drake / Coachella post. A wire said Drake had “a better than 50% chance” to headline Coachella 2027 and the newsroom relayed it as “50%+” while Kalshi had the leg at 62.6%. A single-outcome market story that names an exchange now has its odds replaced by the LIVE leg (_live_market_odds: Kalshi via our open-event index + kalshi_price.market_yes_price, Polymarket via its public search; mn.pick_market_leg picks the event and leg). No leg, or a claim with a second number the price cannot restate, drops as market_unread. A live leg under mn.MARKET_STORY_MIN_PROB (25%) drops as market_longshot (detail: claim, venue, figure) and marks the post seen (2026-10-10, the Tate McRae “7% chance to debut at No. 1” post the owner did not need). Dashboard: newsroom_market_reads. No monitor: a few market stories a week is too few for a 15-minute window; read the panel ratio instead. | | music_news_recover via=title_judge / music_news_filtered reason=title_ungrounded detail.rescue | cogs/music_news.py | guild_id, ok=true, phase (story kind), matchup, + folded detail: via / the drop row gains folded detail.rescue = none | unquoted | error | #3410 wave 19 (2026-10-18). The title gate’s word match (mn.title_grounded_in_wire) is the fast path. A miss asks Haiku for the post’s own words that name the title (claude_api purpose title_in_post), and mn.quote_grounds_title keeps the story only when the quote is in the post and shares a content word with the title. Measured on 47 real drops (2026-09-27..10-18, 3 runs): 11 kept that the post does name, 0 memory titles kept (GUTS, L.A.M.B., DtMF), 1 event description kept on 2 of 3 runs (“GOLF” for Tyler’s El Clásico jersey). rescue=error is the silent degradation: the judge is down and the gate drops as it did before #3410 wave 19, which is the safe direction. Dashboard: newsroom_title_judge. No monitor: about 6 checks a day, and every failure falls back to the old drop. | | music_news_recover via=rank_position | cogs/music_news.py | guild_id, ok (a position heroes), phase (story kind), matchup, source, + folded detail: via, was (the band, “top 10” / “Top 2”), figure (“#4”, the best position the subject holds, or “”), reason = story | search | unstated | ungrounded | none | 2026-10-18, the Taylor Swift “Top 2” card. Owner steer: “stop doing Top N, and just always name the position like #N”. A card figure that is only a band, after the live chart read, goes to Haiku (ClaudeClient.name_chart_positions, claude_api purpose music_news_rank_position) with the post and the verify research. When neither names a position, one claim-scoped Perplexity search runs (pplx_music_news_rank) and Haiku reads it. The answer must sit inside the band and be written as a position in that text (mn.position_grounded), else the figure goes empty and the card falls to headline mode. story and search are the check working. unstated is a band with no position anywhere, the expected miss. none is the silent degradation: Haiku gave no verdict, and the card dropped its figure. ungrounded means the model named a number the text does not carry. Dashboard: newsroom_rank_position. No monitor: about 1 to 2 a day (30 days to 2026-10-18 had 45 band figures), and every failure drops the figure, which is the safe direction. | | music_news_recover via=ranked_stat | cogs/music_news.py | guild_id, ok (the stat was found, or none was missing), phase (story kind), matchup, source, + folded detail: via, figure (“1,113 days” or “”), reason = story | search | not_ranked | unstated | ungrounded | none | 2026-10-24, the JENNIE “2nd” card (“second longest-running active streak among female artists on the Global Spotify chart”, no day count; the verify note held “1,113 days”). Owner steer: “Always say the actual stat. Never top N, first, second, third.” A claim with a ranking word (mn.claim_may_rank) goes to Haiku (ClaudeClient.name_ranked_stat, claude_api purpose music_news_ranked_stat) with the post, the verify research, the X context and the verify note. When none states the stat, one claim-scoped Perplexity search runs (pplx_music_news_ranked_stat, last week only) and Haiku reads it. The stat’s number must be written in the text it came from, with its unit word right after it (mn.stat_grounded; the unit keeps “Hot 100” from matching “101 weeks”, Codex review on #4755). A found stat is added to the claim (mn.claim_with_stat) and heroes the card. unstated and ungrounded drop the story (music_news_filtered reason=ranked_no_stat); a take that leaves the stat out drops too (music_news_scored reason=ranked_stat_missing). none is the silent fault: the claim ships as written. Dashboard: newsroom_ranked_stat. No monitor: a few a day (16 of 400 real lines in the 7 days to 2026-10-24 were a ranking with no number), and every failure but none drops the post, the safe side. | | music_news_recover via=artist_rescue / music_news_filtered reason=unknown_artist detail.rescue | cogs/music_news.py | guild_id, ok=true, phase (story kind), matchup, + folded detail: via, artist (the tracked act), was (the cut credit) / the cut row gains folded detail.rescue = none | no_candidates | error | #3439 (2026-10-15). A story the tier gate is about to cut as an unknown artist asks a cached Haiku closed-set check (aw.rescue_unknown, claude_api purpose watched_act_match) against the nearest tracked names. rescued keeps the story. rescue=error is the silent degradation (no client, a model error, an answer outside the list): a run of it means the judge is down and the gate is back to its old cuts, which is the safe direction. Dashboard: newsroom_artist_rescue. No monitor: about 5 checks a day is too few for a window, and every failure falls back to the old cut. | | luminate_subject | utils/music_markets.py | ok, reason (changed / agreed / error), ticker (the Kalshi event), topic (the subject after the check), + folded detail: was, metric, was_metric | #3436 (2026-10-16). The Kalshi music sweep reads each event’s act, release and metric with the cue rules (luminate.metric_cues + _SUBJECT_RE). An event they cannot settle (no metric cue or two different ones, no leg that parses, or two acts in one comparison) asks one cached Haiku call (claude_api purpose luminate_subject). Every value is checked against the text; a comparison of two acts credits no act. error = no client or a model error; the cue result stands, which is the pre-#3436 behaviour. One row per real model call (answers cached 7 days, errors 10 minutes). Dry run 2026-10-16 on 121 live events: 50 reached the model, 14 changed. Panel luminate_subject_check. No monitor: an error is never worse than before. | | music_news_recover via=chart_scope_model / music_news_filtered reason=chart_cut detail.via / music_news_recover via=forecast_wording detail.read | cogs/music_news.py | guild_id, ok=true, phase (chart / no_chart), matchup, + folded detail: via, family / the cut row gains folded detail.via = model | rule / the forecast row gains folded detail.read = model | rule | CB-01 (sub-epic #4557, 2026-10-18). One cached Haiku read per story (claude_api purpose chart_story_read) names the chart family, forecast, later week, edition and what the title is (song / album / tour); a second label (take_marks_album) says whether an album take already says album. A chart NAME in either scope list still decides with no read; the model decides only the residue that names neither. chart_scope_model = a story the model kept that the rule cuts as unnamed (measured: 10 of the 11 real kept charts in 30 days of unnamed cuts, 2 runs). detail.via=rule on a residue cut, or read=rule on a forecast, is the silent degradation: the read gave no answer and the old word lists decided, which is the pre-CB-01 behavior. Dashboard: newsroom_story_read. No monitor: about 60 reads a day, and every failure falls back to the old rule, not to silence. | | music_news_filtered reason=ladder_rung detail.via | cogs/music_news.py | the drop row gains folded detail.via = model | rule | CB-08 (#4557, 2026-10-18). Haiku (claude_api purpose market_rung, ClaudeClient.market_claim_is_rung) decides whether a market claim quotes one rung of a threshold ladder; the regex quotes_ladder_rung is the fallback. Measured: the regex read a year as a threshold (“the top US artist in 2026”, 7 drops in 30 days). via=rule is the silent degradation: the label gave no answer. Dashboard: newsroom_market_reads (ladder_rung:model / ladder_rung:rule). No monitor: about 1 market story a day. | | music_news_recover via=tracker_lag | cogs/music_news.py | guild_id, ok=true, phase (milestone), matchup, source (the authority handle), + folded detail: via, note (the judge’s note) | #3838 (2026-10-05). The verify judge refuted a number authority’s milestone total, and the note’s figure is under the post’s rung by less than 2% or is dated before the post (mn.refute_is_tracker_lag). The verdict becomes UNCONFIRMED, the story is relayed on source trust, and the lagging research is cleared before compose. Live case: Fall Out Boy’s “passed 2 billion” refuted by kworb 1,975,713,319 while kworb’s own page read 2,002,211,599. Panel newsroom_refutes (beside stale_refute). | | music_news_recover via=over_rung / via=wrong_match | cogs/music_news.py | guild_id, ok=true, phase (milestone), matchup, source (the authority handle), + folded detail: via, note (the judge’s note) | #3855 (2026-10-15). The verify judge refuted a number authority’s milestone total with a figure that is not evidence against it. over_rung: a note total sits above the post’s rung by at most 10% and no note total sits under it (mn.refute_is_over_rung); live case: Nelly Furtado’s “8 billion” refuted by “Songstats measures 8.6B”. wrong_match: every note total is under a tenth of the rung, so the research read another item (mn.refute_is_wrong_match); live case: Sean Paul’s “Temperature” at 1.5B refuted by Songstats’ 963.1K for another recording. A claim that also states a small count (“3 songs over 3B”) is read by neither rule. Same outcome as tracker_lag, and the Songstats block is also withheld from the compose and the gate source. On the 30-day replay these recover 17 of the 60 authority milestone refutes. Panel newsroom_refutes. | | music_news_recover via=research_dispute | cogs/music_news.py | guild_id, ok=true, phase (the story kind), matchup, source (the wire handle), + folded detail: via | #3957 (2026-10-15). A culture-wire story the verify judge CONFIRMED (from X posts) whose Perplexity research DISPUTES the claim. Since #3437 the verify judge answers this in the same call (web_disputes in its JSON, no extra call); perplexity.research_disputes is the fallback when the judge gives no flag or did not see the research (an artist pull). The regex reads the answer’s opening verdict (“partly accurate but not fully verifiable”, “not currently verifiable”, “conflates …”), never the body, which nearly always says “exact current figure: not verifiable”. The research then leaves the compose AND the digit/ordinal gate source; the judge still read it. The case: @rap’s “Drake rises to #1 on Spotify’s Weekly Top Artists Global chart” (2026-10-09) shipped as “Drake briefly rose to No. 1 on Spotify’s global daily artist chart” off research that mixed it up with a daily #1. Measured on the production model, n=6 per arm: with the research 6/6 takes carried a research-only fact and 0/6 shipped; withheld, 0/6 and 6/6; a softer header instead, 5/6 and 1/6. On 12 real confirmed stories (2026-10-15) the test flagged 3, and all 3 openings did dispute the claim. No monitor: it is a cut toward absent, and a burst means wires the web cannot confirm, not a fault. Not on a dashboard panel yet (the newsroom_refutes panel lists its via values by name; follow-up #4130). | | music_news_recover via=forecast_wording | cogs/music_news.py | guild_id, ok=true, phase (chart), matchup, + folded detail: via, flag (the classifier’s is_projection read, False) | #3874 (2026-10-05). The classifier left is_projection empty on a post that states a forecast (“‘The Life of a Showgirl’ to return to #1 … with 172K units”, 3 of 4 runs), and last week’s page (#21) settled the claim. mn.post_states_forecast reads strict forecast wording (“projected”, “set to”, “on pace”, “will” + a chart verb, the headline infinitive “X to return to #1”) in a sentence that states a position and names the subject’s title, and makes the story a projection. Each row is one classifier miss the wording caught. A steady low rate is normal; a rising rate means the classifier’s projection call is drifting. | | music_news_recover via=riaa_level | cogs/music_news.py | guild_id, ok (a level was found), phase (cert), matchup, reason on a miss (feed_incomplete = the newest-awards walk was short or capped; not_in_feed = the 14-day feed does not hold the title; feed_level = it holds the title only below 2x, or at two levels), + folded detail: via, level (RIAA’s badge level, null on a miss), format (ALBUM / SINGLE / "", the format the claim names) | 2026-10-10, owner report on the Ella Langley “Dandelion” card. The wire wrote “multi-Platinum” with no count, and the card heroed that word. RIAA has no award by that name. _riaa_level_for reads the level RIAA holds for the title off a complete, fresh (uncached) walk of RIAA’s newest-awards feed, 14 days back (no cache is read) and the card heroes “2x Platinum”. It also sets cert_id (2xplatinum|riaa), so the story dedups against the RIAA lane’s own post of the same award. ok=false keeps the wire’s word: no row, rows that disagree on the level, or another authority. No new panel: it rides the existing music_news_recover panel. | | music_news_filtered reason=chart_exit | cogs/music_news.py | guild_id, phase (chart), matchup (subject), source (wire handle), + folded detail: via (classifier / regex) | #3565 (2026-10-05). A chart story reports a title LEAVING the chart (mn.states_chart_exit: “exits”, “falls off the Hot 100”; not a top-N tier, a negation, a return or a claim with a position; the post is read only for a sentence that names the subject’s title). Cut before verify and compose and marked seen, the newsroom twin of the desk’s MUSIC_DESK_EXITS cut (owner steer 2026-09-21). MUSIC_NEWS_EXITS=1 restores the relay. Since #4201 the classifier’s chart_event decides first: exit cuts inside the same bound (exit wording or “departs”, in the claim or a sentence that names the title), where a “from #N” or a peak position does not block; detail.via=classifier. Any other event is no exit. via=regex is a reply with no event. Since #4583 (#4553) detail.title says what tied the exit clause to the subject’s title: word (the word match) or judge (the title judge, purpose title_in_post, asked only on an exit clause the word match misses; a judge error is no exit). Measured: 0 cuts in the 30 days to 2026-10-24. | | music_news_filtered reason=position_only | cogs/music_news.py | guild_id, phase (chart), matchup (subject), source (wire handle), + folded detail: via (the classifier’s chart_event, hold / none) | 2026-10-23 (owner steer “only rise”, the newsroom twin of the desk’s music_desk_trim reason=fall). A chart story that says only where a title IS, below #1 (mn.position_only_claim): “Dandy Hats’ ‘El Rabbanito’ is #9 on Billboard’s Hot Latin Songs chart”. The classifier’s chart_event decides whether the wire names a move: only hold (same position as last week, which includes “an Nth week at #3”) and none (a position with no week-on-week verb) are cut; debut / reentry / move / exit and an EMPTY event are never cut. The position is a closed-format regex (“No. N”, “#N”); an earlier draft also used open move-word and record-word lists, which broke the owner rule that an open set of words goes to a model (tests/test_classifier_registry.py), so the model’s event replaced them. Since the same day a HELD #1 is cut too (“Housewife is No. 1 … its third day on top”; owner: level posts “aren’t relevant and often redundant noise”, and the alert cog posts every NEW #1 itself). A none #1 is kept, because the wire may be the first report of a new #1 or a record (“hits No. 1 … for the first time ever”). A “top N” presence stat and a projection (is_projection) are kept. Cut before verify and compose and marked seen. Measured on the 7 days before the cut: 20 of 208 shipped newsroom chart posts (about 3 a day), mostly Mediabase radio positions and Spotify daily positions. Since 2026-10-27 a FORECAST hold is cut here too, read off the classifier’s chart_direction (level + is_projection, mn.direction_cut; detail.via=direction): “Housewife is aiming to spend a second consecutive week at #2, per early forecasts”. A settled hold stays with position_only_claim, because on two runs over 100 real wire posts the model read some career records (“ties … as her highest, both #4”) as level on one run in two. No monitor: a zero count is a quiet wire, not a failure, and the newsroom going dark is covered by surface_dark. | | music_news_filtered reason=fall | cogs/music_news.py | guild_id, phase (chart), matchup (subject), source (wire handle), + folded detail: via (direction), event (the classifier’s chart_event) | 2026-10-27 (owner steer “only rise”). A chart story the classifier’s chart_direction (Haiku, classify_music_news) reads as a FALL, settled or forecast (mn.direction_cut). Before it, position_only_claim read only chart_event, and a move is a climb or a fall, so a fall (“Billboard Hot 100: #7(-3) Been By Now”) reached verify and compose and only the wording checker stopped it after compose; a forecast was never cut, and “drop dead is expected to fall below the top 25 and go recurrent” shipped on 2026-10-26. Measured on 100 real wire posts (2026-10-27, two runs): 7 of 7 falls read down on both runs, and no other post read down. A debut, a re-entry or an exit is never cut here (an exit is the exit gate’s, MUSIC_NEWS_EXITS); an EMPTY direction cuts nothing. Cut before verify and compose and marked seen. No monitor, same reason as position_only: a zero count is a quiet wire. | | music_news_recover detail.via=settled_move | cogs/music_news.py | guild_id, phase, matchup, ok (a move kept), + folded detail: move (climb / debut / none) | 2026-10-27. A live chart read rewrote an UP story’s claim (mn.settled_claim), which dropped the wire’s move: “WTF GOIN enters the Top 5 on today’s US Rhythmic Radio rolling chart” shipped as “sits at #4”, and a rise read as a hold. Haiku (purpose settled_chart_move, ClaudeClient.settled_chart_move) reads whether the live position still fits the wire’s move; a kept move rides back as a word (“climbs to #4 on …”), never as the wire’s number. The row facts overrule it: a weekly row whose last week was no lower is not a climb, and a title the chart memory has as established is not a debut. Measured on 92 real wire/live pairs from the 7 days before (three runs): 35 of 37 real moves kept, and 0 moves claimed on the 24 pairs where the live position contradicts the wire (“rises to No. 6” vs live #17, “enters the Top 20” vs live #21). The Haiku call shows on claude_api purpose=settled_chart_move. No monitor: ok=false is the safe direction (the position alone, the old behavior). | | music_news_filtered reason=itunes_depth | cogs/music_news.py | guild_id, phase (chart), matchup (subject), source (wire handle), + folded detail: pos | 2026-10-23 (owner steer “lets cut itunes to top 5”, “for both”; Codex on #4679). An iTunes chart story (chart_key in mn.ITUNES_CHART_KEYS) whose claim states a position below x_gate.ITUNES_TOP_N (#5), debut and move alike, the newsroom twin of music_desk_trim reason=itunes_depth. A projection is not cut. Cut before verify and compose and marked seen. | | music_news_recover via=chart_key_scope | cogs/music_news.py | guild_id, ok=true, phase (chart / no_chart), matchup, + folded detail: via, family (the kept platform family), key (the classifier’s chart_key) | #3448 (2026-10-16). A chart story that neither KEPT_PATTERNS nor CUT_PATTERNS could name (chart_cut family=unnamed) is kept when the classifier’s chart_key names a Spotify daily chart and “spotify” is in the text. Live misses it fixes: “opens at No. 46 on the Spotify Global chart”. A Billboard key on an unnamed story stays cut: the regex reads every Billboard name the post states, so that key is the model’s memory. Shown as kept:<family> on the desk_chart_scope panel. | | music_news_recover via=chart_event_not_exit | cogs/music_news.py | guild_id, ok=true, phase (chart), matchup, + folded detail: via, flag (the event) | #4201 (2026-10-16). The exit regex reads an exit and the classifier’s chart_event says another event, so the story is NOT cut. Live case: “Rosé and Bruno Mars Lead ARIA Charts Shake-Up After Festive Exit”, a #1 story the regex cut. A steady count of real exits here means the field is keeping exits; read the matchups. | | music_news_recover via=event_pick / via=count_collision | cogs/music_news.py | ok (event_pick: a leg was picked; count_collision: the model gave a verdict), phase (market / chart), matchup, + folded detail: via, count (candidate events / colliding listings), title (the picked event, event_pick), verdict (event_pick, CB-05: event / none / floor = the year-role floor vetoed the pick / error = no verdict, the code rule decided), agree (event_pick: the model’s pick equals the code rule’s), chart + reason (same / different, count_collision) | #4202 (2026-10-16). event_pick: since CB-05 (#4557) on every live read, not only a tie: Haiku (claude_api purpose market_event_pick) picks one of the events that list the subject (mn.subject_market_legs) or NONE, and mn.market_role_conflict (year / role) vetoes a pick as a safety floor. Regression query: ['tootsies'] | where event == 'music_news_recover' and tostring(parse_json(detail)['via']) == 'event_pick' | summarize count() by v=tostring(parse_json(detail)['verdict']), a=tostring(parse_json(detail)['agree']); agree=false rows are where the model changed the outcome: read their titles. count_collision: two rows on one chart share a title but not a lead credit (#3664), and Haiku (purpose count_same_record) says whether they are one record; only a different verdict splits the count. Both are rare: 0 of each in the dry run. | | wire_retrospective reason=cut | utils/retrospective.py (cut_throwback; called first in _compose_one of cogs/pop_desk.py, cogs/cinema_news.py, cogs/sports_desk.py) | surface, guild_id, matchup (subject), source (wire handle), reason (cut), + folded detail: frame | Owner steer 2026-10-20: a throwback ships on no wire desk. The regex frames decide first; Haiku (prime_throwback, wire_throwback_check) reads a post the regex misses. The desk drops the story before the Grok news-age read, and its caller tries the next story. Dry run 2026-10-20, live Haiku, 250 real posts from 14 wire accounts: 12 cut (7 regex, 5 Haiku only), 0 current posts cut. The old <surface>_scored reason=retro_unframed guard on these three desks was removed with the reframe path. Panel: wire_throwback_outcome. No monitor: a miss ships one post. | | music_news_filtered reason=milestone_watch | cogs/music_news.py (_milestone_watch_cut) | guild_id, phase (milestone), matchup (subject), source (wire handle), + folded detail: tier (old / no_date / not_watched) | Owner steer 2026-10-21: a milestone posts for a WATCHED act (the kworb top 100), or for another act only when its title is from the last two years (recency.age_verdict, the shared release-date resolver). A bare-artist subject by an unwatched act (a career total) has no title to date and is cut as not_watched. Cut before the verify; no_date is not stamped seen. Fail-open when the watch holds fewer than 50 names; off with MUSIC_DESK_RECENT_CUT=0. Dry run 2026-10-21 on the 419 milestones posted in the 30 days before: 266 watched, 17 recent, 136 cut (97 old, 37 career totals, 2 undated). Panel: release_age_cut (lane milestone). No monitor: a miss ships one post. | | music_news_filtered reason=past_cohort | cogs/music_news.py (_compose_one, utils.music_news.past_cohort_rank) | guild_id, phase (the story kind), matchup (subject), source (wire handle), + folded detail: cohort (1972 / 1980s) | Owner steer 2026-10-21: a story whose hero is a rank on a list limited to a past release year or decade (“Harvest is now No. 6 among 1972’s most-streamed albums”) is cut first in _compose_one, before the CB-01 read, the verify and the compose, and marked seen. Lists for this year and last year pass. A milestone that only carries such a rank (its hero is the stream count) passes. Panel: release_age_cut (lane rank_list). No monitor: a miss ships one post. | | music_news_filtered reason=throwback | cogs/music_news.py (cuts_throwback) | guild_id, phase (the story kind), matchup (subject), source (wire handle), + folded detail: frame (the retrospective_frame slug, model when Haiku read it) | The music newsroom cuts every throwback. A throwback is dropped before the verify and the compose, and marked seen. History: 2026-10-19 cut only the chart / first_week kinds plus a Haiku chart-position read on the other kinds (reason=chart_throwback, detail.via, claude_api purpose=throwback_chart). On 2026-10-20 a cert anniversary shipped to X (“23 years ago today, Amy Winehouse’s ‘Frank’ was certified 4x Platinum”) and the owner asked for all throwbacks to go, so the kind list and the Haiku read were removed. No wire desk ships a throwback any more: pop_desk, cinema_news and sports_desk cut theirs as wire_retrospective reason=cut (row above). A wire_retrospective row with no reason is the older reframe path. Panel: wire_throwback_outcome. No monitor: a miss ships one post framed as past. | | music_news_filtered reason=album_shape_conflict | cogs/music_news.py | guild_id, phase (milestone), matchup (subject), source (wire handle), + folded detail: claim | #album-streams (2026-10-03, #3811). The classifier said stream_shape=album but the story is not one album: the post calls the title itself a song (“Lover, a track from the album Lover”), or the subject is a bare artist. The classifier writes “album” into an album claim, so the claim is not trusted either; the story is dropped and marked seen. A steady low rate is normal; a spike means the classifier’s album/track call drifted. | | music_news_recover via=qualifier_cut / via=qualifier_unchecked, music_news_filtered reason=qualifier_wrong, music_news_scored reason=cut_qualifier | cogs/music_news.py | guild_id, ok, phase (story kind), matchup (subject), source (wire handle), + folded detail: via, cut (the removed phrases), note (the judge’s reason); the scored row carries detail.cut (the qualifier words) | The QUALIFIER CHECK (2026-10-17, the Tate McRae “first time ever” post). A claim with a qualifier gets its own research search (qualifier_check.qualifier_research, event pplx_music_news_qualifier, no recency window; #4486) and is asked of check_claim_qualifier (Sonnet, claude_api purpose=music_news_qualifier). A Spotify Global “first” claim also gets the kworb Global history (chart_fetch query=global_daily_totals; #4491). qualifier_cut: a wrong qualifier was cut and the rest shipped. qualifier_wrong: the qualifier is wrong and the cut was not clean, so the story is dropped and marked seen. cut_qualifier: the take stated a cut qualifier word again, so it is dropped and marked seen; a pre-judge score=0.0 stamp in NON_SCORE_REASONS. qualifier_unchecked (ok=false): the judge gave no verdict and the claim shipped as written. A run of qualifier_unchecked is the fault to look for; the other outcomes are the check working. Panel newsroom_qualifier; no monitor (a few a day). | | owner_ban | utils/ship_guard.py (owner_ban_label, CB-03 #4557) | reason (the fence: attribution_tag / confidence_hedge / cross_source_comparison / stream_unit_comparison / row_arithmetic / prior_position), ok, shipped, post_preview, + folded detail: label | One row per owner-ban fence hit. The regex in utils/output_checks.py finds the words and a cached Haiku read (claude_api purpose=owner_ban) says what they do in the line. shipped=true is a CLEAR: the line passes that fence and no drop event follows, so this is the one place a clear shows. ok=false means no model answer; the regex verdict stands and the line drops (these are owner bans, so they fail CLOSED). The drops themselves keep their <surface>_scored reason=<fence> rows, now with detail.via (model / rule). Callers: the music desk (_bans_cleared before _unshippable, and detag_credit), music_alert (settled-alert credit), music_news (hedge / credit, prior position through ship_guard.drop_prior_position), market_drop (cross source), sports_boards and cinema_desk (row arithmetic). Expect about 1 row a day (19 fence drops in the 30 days to 2026-10-18). Panel owner_ban (Desks board). No monitor: a model failure keeps every drop, so it cannot ship a banned line; the risk is a wrong CLEAR, and at about one read a day no rate is calibrated. Read the panel’s shipped=true rows. | | qualifier_shape | claude_client.py (qualifier_shape, #4522) | ok, reason (model / error / unreadable), count (time phrases listed), duration_ms | One row per claim that reaches check_claim_qualifier. A cached Haiku read (claude_api purpose=qualifier_shape) says which #4512 shape rules the judge gets: the time phrase that limits a qualifier, “joins X and Y as the only”, a maximum “most”. ok=false: no model answer, so the regex gates decided (the rule before #4522). reason=error covers every raise in the read (_call, the cache, .strip(), json.loads; #4537), so a fault in the read gives the regex gates, never no rule. The fallback share is the fault to look for. Panel qualifier_shape; monitor: the ops-monitor health line qualifier_shape. | | qualifier_present | claude_client.py (claim_has_qualifier, #4522) | ok, reason (yes / no / no_verdict), count (candidate words), duration_ms | One row per claim the qualifier word list (qualifier_trim.qualifier_candidates) fires on; a claim with no candidate makes no read. A cached Haiku read (claude_api purpose=qualifier_present) says whether a candidate is a real qualifier. no skips the qualifier research and judge (a false hit such as “58-63k first week”). ok=false: no verdict, so the word list’s answer stood and the judge ran (the rule before #4522). It raises nothing, so the fallback share is the fault to look for. Panel qualifier_present; monitor: the ops-monitor health line qualifier_present. | | qualifier_check | utils/qualifier_check.py (any lane that repeats a source; epic #4412) | surface (the lane), guild_id, ok, reason (kept / cut / no_clean_cut / unchecked), matchup (subject), + folded detail: cut (the removed phrases), note (the judge’s reason, or no_research) | One row per source claim with a qualifier that a lane checked with check_source_qualifier (2026-10-17). cut: a wrong qualifier was cut and the rest ships. no_clean_cut: the lane drops the story. unchecked (ok=false): no research or no verdict, the claim ships as written; a run of these is the fault to look for. A claim with no qualifier emits nothing. The take-drop side is each lane’s <surface>_scored reason=cut_qualifier. The newsroom keeps its own music_news_recover via=qualifier_* events. Surfaces (2026-10-18): sports_desk, pop_desk, cinema_news, cinema_desk, music (the music drop), music_desk, market_alert, music_alert, x_reply_draft (the X reply and banger lanes, trigger=reply|banger; #4464, #4467), x_mentions (the mention reply lane’s parent post, research purpose x_mentions_qualifier; #4467). Each lane’s judge call is purpose=<surface>_qualifier, and its research is the Perplexity purpose <surface>_qualifier (the music drop uses music_drop_qualifier). market_alert has no *_scored event: its take drop is market_filtered reason=cut_qualifier. The X reply lane has none either: its drops are x_reply_draft reason=qualifier_no_clean_cut and x_reply_draft reason=cut_qualifier. On both alert lanes a news line with no clean cut is market_news_read reason=qualifier_no_clean_cut, and the alert ships price-only. Panel lane_qualifier (rows by lane and outcome, the take drops included, market_filtered as lane market_alert, x_reply_draft reason=cut_qualifier as lane x_reply_draft). Monitor: the ops-monitor health line qualifier_check_<surface> (the share of checks with a verdict), because unchecked fails open and raises nothing. | | deezer_lookup / deezer_track | utils/deezer.py (_get_json, via every read’s timed_event block) | query (the search term or a artistpic: / albumcover: / chart: / track_ prefixed key, truncated), hit, ok, error, duration_ms — ONE Deezer read. ok told the truth only from 2026-09-15 (#deezer-403). _get_json is fail-open: it catches its own error and returns None, so a dead Deezer left the surrounding block through a clean exit and timed_event stamped ok=true. Measured over the 2026-09-14/15 outage, while Deezer answered 403 to every call, deezer_lookup reported ok=true on 402 of 402 reads. The only trace was an error event marked recoverable, which error triage discounts — the exact fail-open blind spot the integration-health rates exist to cover. _get_json now calls event().fail(...) on a failed read, and on an {"error": ...}-with-HTTP-200 body whose code is NOT 800. Code 800 (DataException, “no data”) is deliberately excluded: Deezer answers both outcomes through the same envelope, and 800 is a HEALTHY request for a row that does not exist (a delisted track id, an artist with no album), verified live against /track/999999999999. Counting it would report an outage every time a pooled clip id went stale — the same ok=False contamination catalog_search had to correct. A read that SUCCEEDS also clears an earlier failure in the same block, because fail() latches and several blocks read more than once (album_cover tries two search terms then the discography rung), so without it a block that missed once and then found the cover reported ok=false while the card shipped correct art. Both kinds are registered in ops_monitor._MISC_INTEGRATIONS under ONE deezer bucket, not split per query: the query field is a raw search string, so a per-query split would make one throwaway bucket per artist, and this failure is total anyway — the host refuses every call or none. Deezer is the repo’s ONLY source of an artist PHOTO and it is fail-open twice over (the client returns None, each caller reads None as “no match”), so a dark Deezer silently empties the portrait rung and the genre boards rather than raising. This rate is the only thing that says so. | | portrait_sharpness | utils/catalog_art.py (portrait_blurred, #2716; one shared check since #4134), called by utils/entity_image.py (_deezer_photo_bytes), utils/music_markets.py (_portrait_rungs, the music-card ladder, surface music_markets) and cogs/music_desk.py (_fetch_art’s artist photo, surface music_desk) | source (deezer), surface (absent for entity_image), reason (sharp | blurry), score (Laplacian variance over intensity variance at 256px, catalog_art.portrait_sharpness; absent when the bytes do not decode, which keeps the photo) — ONE Deezer artist portrait checked for a BLURRED face after the placeholder and blank checks. Deezer stores some acts’ portrait as a defocused art shot (Drake’s, 2026-10-15), and full-bleeding a 1000px source into an 1800x2400 card makes an obviously blurry card; every XL portrait is 1000x1000, so no size check can see it. blurry = under MIN_PORTRAIT_SHARPNESS (0.010): the photo is refused and the card drops to the next candidate (a featured act) or the next rung, otherwise silently. Calibration (219 real Deezer portraits, 2026-10-15): the one blurred face scored 0.003; the lowest sharp portrait 0.019. Raw Laplacian variance (the first idea in #2716) does not work on this source: it tracks contrast, and sharp portraits of Lady Gaga (38) and Shaboozey (4) scored under the blurred Drake (79). Panel portrait_sharpness (Media board): sharp and blurry counts plus the 10th-percentile score. No monitor: about 20 checks a day, and the expected blurry share is about one in 200, so a rate monitor would fire on noise. The failure it would catch (the floor refusing sharp faces) shows on the panel as a blurry jump and a falling p10, and costs a fall to the next rung rather than a dark surface. | | catalog_search | utils/apple_music.py (search_catalog, songs_by_upc) | source (itunes_search; itunes_upc for songs_by_upc, the Lookup-API read by release BARCODE that resolve_music_row makes only after every search term misses – the STORM II card, 2026-09-29; the same iTunes host, so it shares the one health bucket), entity (song | album | artist, FOLDED into detail so it costs no column — query it as parse_json(detail)['entity'], which is what the panel does), ok, count (rows returned), query (the term as sent, truncated to 120) + limit (the clamped row cap) — with entity these are the TRIPLE that reaches iTunes, and therefore the measured cache KEY. limit is in it because callers pass 1, 5, 10, 50 and 200 and a cached 5-row answer cannot serve a 50-row request, so keying on the term alone counts incompatible calls as interchangeable and overstates the reachable hit rate (Codex review on #3207). query and limit keep their own columns while entity folds into detail, and the panel reads each from its own place — a live run is how that was learned. (calls - dcount(key)) / calls is the share a cache could have served, the panel’s reuse_pct; it is NOT calls-per-key, which reads 100 where the avoidable share is 99% and 1 where it is 0%. Truncation caveat: two terms sharing a 120-character prefix measure as one, which chart subjects and catalog titles never approach), duration_ms — ONE iTunes catalog search. The repo’s busiest music lookup, and until #3200 the only outbound step in the family with no event at all. resolve_music_row is built on it, so every desk card and every release link (#3188) passes through it — and song_release_date reads the same Search endpoint directly, matching on raw iTunes fields the normalizer drops, so it emits the same kind from its own call site rather than being left out of the count (Codex review on #3207); its siblings catalog_lookup and cover_art_fetch were already instrumented and it was not, so its latency, its fail rate and even its VOLUME were unknowable. That is what made “should these reads be cached” unanswerable from telemetry: the only numbers available were proxies (desk_art counts, apple_debut counts). ok is the TRANSPORT outcome and nothing else — False only when the read genuinely failed (retries exhausted, timeout, bad body), which _itunes_get reports by returning None rather than [] since #3207. A rowless answer from a working call is ok=true, count=0, reason=no_rows. Getting this split right took three passes and both wrong versions broke a shared panel: semantic-miss-as-failure told an operator to check the API key over a title iTunes does not carry, and then unconditional success would have made int_ok and media_ok read 100% healthy straight through an iTunes outage. Every success-rate consumer — ops_monitor._MISC_INTEGRATIONS, int_ok, media_ok, the last two off the shared _OUTBOUND_KINDS/_MEDIA_KINDS inventories — means one thing by ok=False, so this event means that thing too. Because that is what ok=False means, the kind IS registered in ops_monitor._MISC_INTEGRATIONS (as itunes_search), so a real outage on the repo’s busiest music lookup raises integration_unhealthy; it was left out while ok=False still meant a semantic miss, and leaving it out after the flip would have meant a total outage raised nothing there (Codex review on #3207). The rowless SHARE is the separate question and stays on the dedicated panel as empty_pct, over ANSWERED calls only (ok==true and count==0) — a raw empty COUNT is not comparable across entities or dashboard windows, since 100 of 10,000 and 100 of 100 render the same, and counting bare count==0 read an OUTAGE as “the catalog holds nothing” because a failed read carries count=0 too, so the panel also carries fail_pct in its own column (Codex review on #3207). Transport failures also still reach error triage via _itunes_get’s emit_error(source=apple_music), a 200 with no results list included. A dedicated ops-monitor finding on the ROWLESS share is the follow-on, once a baseline exists to set a floor against — the same reason #3199’s floor is deliberately loose. Watch count too: a drift toward thin answers shows before it becomes zero. Registered in the unified outbound accounting — axiom_setup._OUTBOUND_KINDS and events._VENDOR_BY_KIND (vendor apple_music) — so the overview’s outbound-calls table and the health board’s vendor rollup both count it; a kind missing from those is silently dropped from both (Codex review on #3207). An empty QUERY emits nothing — that is a caller bug, not a search. | | desk_link | cogs/music_desk.py + cogs/music_news.py | surface (music_desk | music_news), ok (a url resolved), phase (the lane — ktt2_chatter | release), reason (no_match = the catalog held no EXACT-name artist row; link_only | no_link | no_card = which way a newsroom RELEASE went), delivered (newsroom release only: room | staged) — a desk card’s outbound LINK resolved or missed. The link half of the desk_art watch, added with the forum-chatter card (#3035). It exists because the miss is SILENT in every other signal: the link is optional, so a miss raises nothing, drops no post, and changes no *_posted field — the card simply ships without its button, which nobody sees unless they remember there used to be one. Read the ok=False SHARE per phase, never the raw count: the chatter lane posts once or twice a day BY DESIGN, so one miss is noise and a run of them says the exact-match resolver (apple_music.resolve_artist_link) has gone quiet or the lane’s subjects have moved to acts iTunes does not carry under that spelling. It is deliberately NOT music_link_missing, which is /music’s HARD degradation (a drop with no link is recomposed and counts against the music surface in the ops monitor’s DEGRADATION_KINDS); a missing chatter link costs a button, not a post. music_news’s release phase is a DECISION, not only a health signal (2026-09-12). The newsroom now posts a new release as EITHER the music link OR the card, never both (owner steer: “new releases shouldn’t have a card and music link. only a music link, probably, if it exists. it’s one or the other”), so ok=true/reason=link_only counts the drops that shipped as the take plus the Apple Music link with no card, and ok=false/reason=no_link counts the ones whose catalog row held no link, so the card shipped instead. A THIRD reason, no_card, is the double miss: no link AND no art, so the take shipped as bare text and there is no card either — it is broken out because a no_link that silently meant “nothing shipped but words” would hide catalog-and-art degradation inside the healthy-looking side of the split. Read the three together as the lane’s split. Read the SHIPPED split off delivered == 'room' (2026-09-12). The card is built before _sched_deliver branches on the stage, so a STAGING audition — a post that only ever reaches #bot-logs — runs the same route and emits the same row; the decision is also emitted before the send, so a failed send counts too. Without that filter an auditioning guild’s decisions read as shipped posts. Read them ALSO against music_news_posted carded: a release that ships its link is carded=false and is NOT a degraded post, so the carded rate on phase=release no longer means what it means on the stat lanes. A no_link share that climbs says the catalog lookup (_artwork_and_link) has gone quiet, which is silent otherwise — the post still ships, just as a card | | music_ranking | 2026-09-29 (later the same day): the card renderer no longer draws ANY standing line, so song_attached / daily_attached now count a lookup whose result nobody sees. Read this event as lookup health only, until the follow-up removes the lookups. 2026-09-29: the ARTIST standing strip was REMOVED from every card (owner steer, after an Omar Courtz 100M card read “#437 most-streamed artist”). Only the SONG rungs still emit (song_attached / daily_attached / no_match / kworb_off / error, surfaces music_desk + music_news); a song with no song rank now ships with NO strip and emits no_match. The reasons attached / attached_youtube / feed_down / not_artist and the surfaces music_alert / market_card are retired. The text below is the history. utils/ranking_strip.py (the ONE lookup home since 2026-09-08), gated per surface by cogs/music_desk.py, cogs/music_news.py, cogs/music_alert.py | surface (music_desk | music_alert | music_news – the newsroom’s streaming-milestone card, added 2026-09-08 so a Spotify milestone keeps its rank | market_card), ok (did a standing strip attach), reason (attached = the artist strip | song_attached = a per-track milestone showed the SONG’s all-time most-streamed rank | daily_attached = a per-track milestone showed the song’s DAILY chart position | units_attached = RETIRED 2026-09-28 (a UNITS/SALES card showed its projected Billboard 200 rank; the owner removed that strip, so no lane emits it now) | no_match = no ranking found (artist off kworb’s board, or the release not in the HITS document) | not_artist = an ALBUM streams reading, whose subject is not an artist | kworb_off | error), guild_id — a card TRIED to attach a ranking STANDING strip under the figure, so a reader can gauge WHERE the number ranks. A per-TRACK streams milestone runs a LADDER (owner ask 2026-08-27): the song’s all-time most-streamed rank (song_attached), else its daily Spotify chart position (daily_attached), else the artist strip; a UNITS/SALES card carries NO strip (its projected Billboard 200 rank line was removed on 2026-09-28, owner steer); every other metric keeps the artist’s Spotify standing (their most-streamed rank + monthly listeners, per kworb). (A radio-riser card also carries a rank strip — its position is on the kworb row, so it is deterministic and rides no event.) Attaches on BOTH the Luminate streams card AND the Spotify streaming-milestone big-number card (the one home is _artist_ranking_strip). The music ALERT lane attaches the SAME strips on its Luminate cards (surface music_alert) — the artist Spotify standing on a streams card (music_alert._streams_standing) — it was missing it before, so a ‘cleared 12B streams on the year’ alert shipped a bare number card (owner report: missing ranks). The strip now also renders on the LIVE/last-call PROJECTION card, not just the settled number card: _projection_number_figure carried no standing slot, so #2636’s units rank never showed on the projection card (the common units card) on EITHER lane; it does now. Gated on the music_stream_ranking experiment, which is LIVE (default PRODUCTION) since 2026-08-25; 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 and a mod can still dial it OFF/STAGING on /menu (both leave the strip absent). The strip is FAIL-OPEN — a miss just drops the context and the card still ships the number — which is exactly why this event exists: an ok=False rate is the ONLY way a silent “the strip never attaches” surfaces (it raises no error, and the card looks fine). The MARKET-CARD lane attaches the SAME artist strip on a Kalshi artist COUNT market (surface market_card): a “: Highest daily YouTube view count in August 2026" card heroes a raw count for one act and said nothing about the act's scale, so `market_cards.build_market_card` now stamps `number_figure['standing']` = "#75 most-streamed artist · 94.2M monthly listeners" via `market_subject_image.artist_chart_standing` (owner ask 2026-08-30: add artist ranking). That lane is UNGATED -- it rides the card's own standing ladder (the video read first, the artist read second), not the `music_stream_ranking` experiment -- and it emits `attached` / `attached_youtube` / `no_match` / `feed_down` / `kworb_off` / `error`. `attached_youtube` is the rung that answered: on a YOUTUBE market the strip reads the act's YouTube ranking rather than its Spotify one, because the two platforms rank acts very differently (live 2026-08-30: Drake #1 Spotify / #58 YouTube, Morgan Wallen #52 / #263) and a Spotify rank under a YouTube count is the wrong scale. A YouTube market for an act that page does not carry falls back to Spotify and reports plain `attached`, so a persistent all-`attached` rate on YouTube markets means the YouTube page stopped answering. The emit lives INSIDE `artist_chart_standing`, not at its call site: every failure reaches the caller as the same None, so a caller that classified the outcome itself reported an outage as `no_match`, which reads here as a name-matching regression (Codex, #2702). A market outside the artist count family emits nothing -- it is not a miss. **`feed_down` is the reason to watch on ALL THREE lanes** (added #2702, Codex): kworb's `_fetch` NEVER raises -- a timeout, an IP block, an open breaker and a page-shape change all fail open to `[]` -- so an outage used to arrive as the same empty standing an unranked act gives and was counted `no_match`, i.e. an outage read here as a name-matching regression. `ArtistStanding.feed_ok` is False when BOTH ranking pages come back with no rows at all (a healthy kworb carries ~1000 on each), and all three lanes now report that as `feed_down`. So: `feed_down` = go look at `chart_fetch`; `no_match` = the name match really did break. Rank derived from kworb's `artist_standing`, which rides the shared `chart_fetch` health, so a `no_match`/`error` streak with `chart_fetch` green means the name match broke, not the feed. That match is now STRICT by default (`_find_by_artist(strict=True)`, the #2520 credit test): folded CONTAINMENT read 267 of kworb's live top 1000 acts as a DIFFERENT act (measured 2026-08-30 -- 'Enrique Iglesias' took Sia's #51, 'T.I.' took Justin Bieber's #5), so a `no_match` rise right after that change is a refused near-name, which is the intended trade (absent over a wrong public rank) | | `claim_arbiter` | cogs/music_desk.py (`_claim_cleared`, #3446; `_universal_scope_claims`, #2784) | surface (`music_desk`), kind (`ranking` \| `career` \| `scope`), reason (`cleared` \| `confirmed` \| `no_source` \| `number_veto` \| `judge_failed`; for `scope`: `claim` \| `mood` \| `judge_failed`), guild_id, phase (the lane), post_preview, + folded detail{claims} — ONE arbiter step on a caption a regex flagged. The regex stays the trigger: `ungrounded_ranking_claims` for the milestone / reveal / catalog qualifier guard (`_ranking_drop`), and `has_ungrounded_career_claim` for the bare-row career check on the chart and called-shot lanes when they carry a career block (`_drop_shaped`). An empty source keeps the drop with no call (`no_source`), and so does a career line with an all-time claim or a possessive superlative (`output_checks.career_claim_ungroundable`). Then `output_checks.ungrounded_flagged_ordinals` compares the ordinal VALUES with the source; a mismatch is `number_veto` and no model call is made. Otherwise Haiku reads the whole caption against the same source (`claude.claim_grounded`, purpose=claim_grounded on `claude_api`, `classify`, cached one day in memory). `cleared` skips that one regex drop; every other guard still runs. Every other reason keeps the drop, so a model error has the same result as before. Panel `claim_arbiter` (Desks board). **No monitor:** about 50 flagged captions a month reach it, too few for a rate to gate, and no failure can ship a post the old rule dropped. Read a rising `judge_failed` share on the panel. | | `qualifier` | cogs/music_desk.py | surface (`music_desk`), ok (did a qualifier verify, or did the guard pass), reason, guild_id, matchup (subject), phase (the lane), post_preview (guard hits), detail{kind (`ordinal`/`superlative`/`rank`), stage, claims, enforced, lead, join} — the VERIFIED RANKING QUALIFIER step for a milestone caption (owner ask 2026-08-24; `_qualifier_block` + `_drop_for_ungrounded_ranking`, pure core `utils/milestone_qualifier.py`). The qualifier is the RANK a milestone gives an artist ("the first female rapper to hit the mark this year", "her highest debut", "their tenth release past 100M") — the engagement driver. Two sources, tried in order: a DETERMINISTIC catalog count off kworb ("Frank Ocean's 9th song to pass 1 billion", exact + free, no judge), else a WEB path — a Perplexity LOOKUP of the web and, when Grok is provisioned, a Grok LOOKUP of X (`grok_search.qualifier_lookup_pulse`, owner ask 2026-09-10: chart accounts post a ranking within hours, the web carries it a day or more later; it replaced the Grok `claim_verify_pulse` corroboration read), a second Perplexity read only when the web alone found a claim, then the `claude.qualifier_verify` judge. A NONE from both reads is the settled `no_qualifier`; a NONE beside a read that did not run is `error` (transient, retries). The judge 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). On the CATALOG path (#3533, 2026-09-20) `detail.lead` names which counted fact the clause states (`first` / `album` / `catalog`, the `utils/stream_report.card_qualifier` ranking) and `detail.join` how the iTunes album lookup went (`album` = a tracklist the song is on; `single` = the collection is a single, so no album fact by rule; `none` = no row or no tracklist; `error` = the lookup raised). The catalog fallback is correct (prefer absent) and reads the same as the album form otherwise, so `join` is the only signal a dead iTunes join leaves: the ops monitor rates it as `itunes_album_join` (`none`/`error` = a miss; the generic `integration_unhealthy` finding at the 60% floor), and the `qualifier_lead` dashboard panel splits both. Lookup `reason`: `verified` (attached at PRODUCTION) \| `unprovisioned` \| `no_qualifier` (the read said NONE) \| `unverified` (judge said no) \| `unofficial_rank` (the verified qualifier cannot be attributed: it names a third-party aggregator such as Chartmetric or kworb, or it is a judge kind `rank` that names no official source -- Billboard, Spotify, Apple Music, YouTube, the Official Charts, ARIA -- or its kind is missing; dropped and settled. Owner steer 2026-10-04 after "ranked 847th on Chartmetric's global artist rank" shipped on 2026-10-03) \| `error` \| the cached `cached`/`cached_none`. Guard `reason`: `ungrounded_ranking` (a caption stated a ranking claim the source did not carry; `enforced` says whether it was dropped) \| `off_chart` (2026-09-06: a VERIFIED qualifier about ANOTHER chart than the chart-lane milestone's -- "his second top 20 Hot 100 hit" on a Global 200 peak card -- dropped before the compose; `post_preview` the qualifier, `detail.scope` the chart it had to name). Required-path `reason`: `required_missing` (owner ask 2026-09-10, the catalog lane's ROUND-COUNT path: a lane that composes ONLY with a reported ranking found none -- `phase` the lane, `detail.lookup` the lookup's own reason, `detail.settled` whether the rung was advanced without a card (a settled negative) or left open to retry (a transient miss), `detail.rung` the count). A `required_missing` run of weeks with `settled=true` means no outlet reports the count stats we detect -- alive, no story; `settled=false` for days means the lookup itself is failing (read `error`). The NEGATIVE case (nothing verified) leaves the compose UNCHANGED — no "make no ranking claim" prompt (it primed the ranking words and suppressed the artist's OWN grounded standing rank; `_drop_for_ungrounded_ranking` is the deterministic backstop). The guard also does NOT police the grounded standing rank ("the world's 71st most-streamed artist"), whose number the number backstop grounds. Gated on the `milestone_qualifiers` experiment (default **PRODUCTION**, live): OFF changes nothing, STAGING looks up + emits WITHOUT attaching or enforcing, PRODUCTION attaches + enforces. The lookup is FAIL-OPEN, so an `ok=False` lookup rate is the ONLY signal the qualifier step went quiet; `summarize count() by reason` reads whether verification is converging | | `called_shot_scored` | cogs/music_desk.py | guild_id, phase (always `reconciliation`), matchup (the release, 80 chars), detail{chart, week, called, printed, verdict, projected_units, market_units, settled_units} — a first-week chart CALL was settled against the chart that printed (Lane D, `docs/BILLBOARD_ALERTS.md`). `verdict` is `exact` \| `close` (within 2 rungs either way) \| `miss` \| `uncharted`, and `printed` is 0 when the release did not chart at all. Emitted ONCE per call, at the moment it resolves, so **`verdict` summarized over time IS the lane's public track record** — `summarize count() by verdict` answers "how good are our calls" without trusting any individual post. It is also the tripwire for the failure mode this lane cannot self-report: a verdict distribution with no `miss` or `uncharted` in it means we have started quietly dropping our losses, which destroys the only thing the lane is for. A call whose chart week could not be read emits NOTHING and stays unresolved — never a fabricated verdict. **The units record rides the same event** (owner ask 2026-09-16): `projected_units` (HITS), `market_units` (Kalshi at call time) and `settled_units` (Luminate, off the settled Kalshi market; null = no market covered the release) fold into `detail`, and the same three land on the `called_shots` row. The scorecard (`scripts/called_shot_scorecard.py`) grades both forecasters against Billboard's PUBLISHED count, not against `settled_units` — Kalshi settles on Luminate's total, which ran up to 8% above Billboard's printed figure (Rod Wave 106,778 against 99,000), so the settlement is the reconciliation post's number and never the score. **A verdict recorded before 2026-09-17 with `printed=0` on an EP is wrong**: the scorer compared the raw title against Billboard's "WILD (EP)" row (`names.strip_ep_marker` fixes the join), and the two rows so recorded (KATSEYE 2026-08-29, ENHYPEN 2026-09-05, both real #1s) were re-opened for re-scoring by a one-shot in `db.py`; their re-score emits a second event with the corrected verdict, so a count by verdict over the whole window carries one `uncharted` per case that never happened | | `hits_fetch` | utils/hits.py | source (`hits`), query (chart slug \| doc type \| `type:` \| `genres`), ok, duration_ms, count, cache_hit; on a miss: reason (`unavailable`) + error + http_status; `reason=midweek_lapsed` (ok=true, cache_hit=true) when `latest_projection` refuses a midweek past its tracking week because the building chart has not arrived -- expect it only on a week HITS holds its chart, and read a run of them across a whole weekend as HITS skipping a publish; `reason=newest_doc_empty` (ok=true, `count` = documents skipped) when `projection` skips newer documents that parse to no rows and serves an older one (#3825) -- a run of them on a publish day means HITS published a document in a shape the parser cannot read; `reason=units_label_swapped` (ok=true, `count` = rows read back) when the served document put the units in each entry's `label` field and the record label in the `Activity` column, and the parser read the two back (#3931, #3932) -- before #3932 that shape parsed to rows with no units and raised nothing (the midweek dated 2026-10-08), so this event is the only record that HITS changed the cell order; on the YEAR-TO-DATE read the raw document read logs as query `ytd-albums:doc` and the parse OUTCOME logs as query `ytd-albums`: `ok=true` with `count` = rows parsed, or `reason=parse_empty` (**ok=false**) when the document has data but parses to no rows -- HITS changed that row shape on 2026-09-24 and the board was dark for ten days with only `ok=true` reads, so any row of it is a parser to fix. The two labels are separate on purpose: the ops monitor rates health per query, and one shared bucket made a parse blackout read as a 50% fail rate, under the 60% flag (Codex review on #3976) — one HITS Daily Double read off their **public, unauthenticated Sanity GROQ API** (no key, no quota; `docs/BILLBOARD_SOURCES.md` §2.2). This is the FORECAST half of the chart frontier: `hits-top-50` (building chart — units + the pure-albums/TEA/SEA split + label marketshare) and `midweek-20` (the earliest callable projection) land **~5 days before billboard.com flips**, and the Mediabase `add_chart`/`building_chart` radio reads refresh multiple times daily. Fail-open (a miss → None/[]), so this ok-rate is the ONLY signal that the projection lane went quiet — nothing raises. Guarded by limiter + breaker (`integration=hits`) + retry + a durable cache keyed to each source's REAL cadence (projections 5 min, 2 min while a publish is due; radio 5 min). `/debug/integrations` carries a `hits` probe so a dataset that stops answering anonymously reads red before a lane silently empties | | `billboard_block_cleared` | utils/billboard.py | integration (`billboard`), reason (what the streak was), **query** (the chart that recovered), count (how many consecutive failures THAT CHART ran for) — one chart's block streak RECOVERED. Emitted because a block that silently heals is as confusing as one that silently starts. **Per chart since #2824**: the client used to keep ONE streak counter, so a success on any chart reset it and this event reported `count=1`/`2` all through a 40-minute outage of `global200` on 2026-09-01 — the streak is a property of the chart, and the event now names it | | `sports_board_scored` | cogs/sports_boards.py (#2268) | guild_id, phase (the board kind: `league_table` \| `win_streaks` \| `loss_streaks` \| `leaders_` \| `title_odds` \| `upcoming` \| `players` \| `game_props`), league, score, reason, shipped, post_preview — one sports STATS BOARD composed and put through the 0.6 self-gate. The lane's funnel: `summarize count() by phase, shipped` says which boards actually reach a room and which are dying at the gate. Watch for a board kind that is built often and NEVER ships: that is a wrong-RUBRIC failure, not a quality one. Every board left in the lane grades on `drop_score`'s LIVE rubric, because every one of them reads a state that is still moving. The lane used to pick between two rubrics off `SportsBoard.settled`, and the `results` board was the only board that ever set it — both went out together (#3237, the board got no engagement), so `phase` never reads `results` again and historical rows keep it. The `upset` board went the same way (owner, 2026-09-15: "no more upsets of the week") — it was the BUSIEST kind in the lane when it went (Axiom, 30 days to 2026-09-15: 33 of 161 posts), so `phase` never reads `upset` again either and those slots go to the boards that remain. The measurement behind that old split is worth keeping if a settled board ever returns: on real lines 2026-08-10 a weekend-scores take scored 0.20 under the live rubric and 0.92 under the settled one, and a standings take scored the reverse. Since compose self-gates fail CLOSED, the wrong rubric silently kills a good board every time | | `sports_board_posted` | cogs/sports_boards.py (#2268) | guild_id, channel_id, phase (board kind), league, + folded detail: notability — a sports board card SHIPPED to a room. Paired with `sports_board_scored` it gives the lane's full funnel; on its own it answers which leagues and which board kinds a guild actually sees | | `sports_board_lookup` | cogs/sports_boards.py (#2268) | source (`espn_scoreboard` \| `sgo_game_props`), league, result_count (how many of the pool have a usable line — for `sgo_game_props`, the number of paired game+props cards built), count (the pool: tonight's slate for `espn_scoreboard`, tonight's props for `sgo_game_props`), ok — the odds boards' per-build coverage read, one row per source. A board under its coverage floor posts NOTHING, raising no exception and leaving no trace in error triage. `ok` IS that gate, which makes a sustained False rate the only sign that the book stopped attaching odds. **source=espn_scoreboard:** the UPCOMING board's read — how many of tonight's games the book has priced. ESPN attaches odds to a day scoreboard only while a game is upcoming, so this costs no fetch of its own; `ok` is whether it priced anything at all, and a sustained False means ESPN stopped attaching odds and the board has gone quiet without raising. A third source, `espn_pregame`, went out with the upset board (2026-09-15); historical rows keep it. | | `chart_lookup` | utils/chart_credits.py + utils/chart_ages.py (owner ask 2026-08-10) | source (`genius` \| `musicbrainz`), query (the chart key, e.g. `hot100`), count (rows RESOLVED for the week, cache hits + live + verified misses), ok (resolution reached the module's COVERAGE FLOOR — 0.8 for credits, 0.85 for ages; False = the board is still cache-filling, the source degraded, or the token is unset), + folded detail: rows (chart length), fetched (live lookups this build), dated (ages only: rows carrying a real year), refused (ages only, #3444: picks the cached Haiku check, `claude_api` purpose=`chart_age_check`, said were a DIFFERENT work; each caches as a miss) — one PER-BUILD coverage read for the lookup boards (producers/writers off Genius credit arrays; oldest-songs off MusicBrainz first-release dates). The per-CALL latency/errors ride `reference_fetch` (source=`genius`/`musicbrainz`); this event is what the boards actually gate on, so a sustained `ok=False` stream on one source is the ONLY sign that lane went quiet (the boards fail SILENT below the floor by design — a partial-chart producer count would be a whole-chart claim). Live lookups are budgeted (20/build Genius, 15/build MusicBrainz, ~1.1s paced) over durable per-song caches (`kv_cache` namespaces `chart_credits`/`chart_ages`), so steady state is a handful of `fetched` per week **`source=tmdb query=weekend_budget`** (#3452, cogs/cinema_desk.py): one per budget-crossing build, `count` = chart titles TMDB named with a VERIFIED match (same normalized title, primary release in the chart year or the year before), ok=False when every title looked at was refused, detail `looked_at` / `refused`. A refused title gets no budget card; a rising `refused` share means the match rule is dropping real films. No monitor: the budget card is an occasional bonus story, and a refused title is the intended outcome, not a failure. | | `chart_fetch` | utils/kworb.py (#daily-releases, order #79) | source (`kworb`), query (`global_daily`\|`us_daily`\|`global_weekly`\|`artists`\|`listeners`\|`radio_us`\|`apple_us`\|`itunes_us`\|`apple_albums_us`\|`yt_videos_global`\|`yt_videos_anglo`\|`yt_us_weekly`\|`yt_us_trending`\|`track_`\|`artist_songs_`\|`global_daily_totals` = the Spotify Global all-time totals page, read only by the qualifier check for a Spotify Global "first" claim (#4491); `result_count` is about 12,000 when healthy, and a parse under 1,000 rows fails with `reason=shape_change` — one event per page, each revalidated per path), ok, duration_ms, result_count; on a miss: **`reason`** + error + http_status — one kworb scrape (a Spotify/Apple/radio/YouTube chart or ranking page, a per-track streams lookup, or the per-artist songs page that backs the album-streams card). **`yt_us_weekly`** (owner ask 2026-08-31) is the US WEEKLY YouTube chart behind the `us_video_top` + `us_video_new` boards — a DIFFERENT page from the `yt_videos_*` pair and a different metric: those are worldwide daily VIEWS, this is US weekly STREAMS, and it is the only YouTube page we read that says whether a title is NEW. It backs both boards off one fetch, so a miss darkens the pair together. **An INCOMPLETE read reports here, on this event** (Codex #2753): both boards need the WHOLE chart — the top 10 draws positions in order and the debuts board counts every `NEW` row in it — so `us_video_chart` requires positions exactly 1..N for one of the sizes in `kworb._US_VIDEO_CHART_SIZES` and emits a SECOND `chart_fetch` row with `ok=false`, `reason=shape_change` and the first missing position when they are not. `_fetch` has already logged the HTTP read as ok=true by then, so routing the rejection to an unmonitored display-filter event would have left kworb's integration health green while both boards went dark — the silent-degradation trap. **THE SIZE SET IS WHY THAT CHECK HOLDS BOTH 20 AND 100** (2026-09-14): kworb flips this page between the two, and a single-value constant darkened the boards on each flip — 100→20 on 2026-09-06 (#3042), then 20→100, which failed 263 of 364 reads over Sep 7-14 with "expected 1..20, got 100 rows" while these were the only YouTube boards left. Both sizes are measured complete pages; a size we have never seen still darkens the boards, which is the right direction to fail. A `yt_us_weekly` `shape_change` rate climbing means kworb moved the page to a THIRD size — add it to the set. A `yt_us_weekly` ok-rate dipping is the signal that the page's shape moved for the music new-release lane's CHART ranks ("#2 US, #9 global, #3 this week") + the `/ask` `spotify_chart` tool. **`reason`** (order #79, the same idiom as `billboard_fetch`): `blocked` (401/403/451 — kworb refusing us), `shape_change` (a 200 that parsed BELOW the chart/ranking floor — `min_rows=1` on every chart/ranking page, since each carries hundreds of rows when healthy; a per-track lookup keeps `min_rows=0`, a genuine per-track miss being normal), `http_error` (any other non-200), `breaker_open` (the breaker itself short-circuited — no network call), `unreachable` (a raw connection failure/timeout survived every retry). Before order #79 a `shape_change` (kworb's markup moving under the parser, or a soft-block/challenge page) recorded `ok=True` — silently reading as healthy — and an `unreachable` case escaped `_fetch` as a RAISED exception instead of degrading to `[]`, breaking the fail-open contract for any caller with no try/except of its own. **`not_modified` (#3191) is a SUCCESS, not a miss**: the page was revalidated with a conditional GET and kworb answered 304, so the cached parse stands. It carries `ok=true`, `http_status=304`, `cached=true` and the cached `result_count`. Counting it as a failure would trip the breaker on a perfectly healthy page, so read kworb's ok-rate with `not_modified` included as ok. **`superseded=true` on a `not_modified` row** means this 304 lost a race: a concurrent caller landed a fresh 200 for the same page while this conditional request was in flight, so the newer rows were kept and this read returned them unchanged (the generation guard, Codex on #3193). It is rare and healthy; a steady stream of it means two surfaces are revalidating the same page in lockstep and one of them could read from the other's result instead. A HEALTHY tick is mostly `not_modified` rows with an occasional full 200; all-200 means the validators stopped being sent and we are re-scraping full bodies, while a sudden all-`not_modified` stretch on an intraday page (Apple, iTunes, the Spotify rankings, YouTube trending) means the page stopped refreshing upstream. Guarded by a gentle rate limiter + breaker (`integration=kworb`) + retry + a REVALIDATING cache (`_REVALIDATE_AFTER_SECONDS`, 2 min). Only whole-CHART and RANKING pages revalidate: a page serving no validator, and any PER-ENTITY read (`track_*`, `artist_songs_*`, `artist_albums_*` — 337 of 540 events over 24h, so the bulk of this event by volume), keep the old flat 3h hold. Fail-open (a miss → no chart line, the streams still lead), NEVER raises | | `itunes_chart_fetch` | utils/itunes_charts.py (`itunes_albums`, #3469; `genre_chart`, #3799) | source (`itunes_rss`), query (`itunes_albums_us`, or a GENRE chart since #3799: `itunes_{hiphop,rnb,pop,kpop}_{albums,songs}_us` — the same feed with `/genre=`, each chart with its own durable snapshot row and its own first `warming` day; the ops monitor buckets this event's health PER QUERY, so one dead genre feed flags as `itunes_charts:`; the genre feeds have their OWN breaker, whose transitions emit `circuit_breaker` with `integration=itunes_genre_charts`, so failing genre feeds never open the overall albums chart's breaker), ok, duration_ms, result_count; on a miss: **`reason`** (`blocked` \| `shape_change` \| `http_error` \| `breaker_open` \| `unreachable`) + error + http_status — one read of the US iTunes STORE Top Albums chart off Apple's own iTunes RSS, the ONE platform chart kworb does not mirror (kworb's iTunes page is songs only; the albums URLs 404, probed 2026-08-10). It is a SALES ranking, distinct from Apple Music's streaming albums chart (`apple_albums_us`): on 2026-09-18 Miley Cyrus' "Bass Persuades" was #1 here while Apple Music's albums feed led elsewhere, and no lane could say so — the miss this event's source closes. The feed prints ranks only, so the reader derives `pos_change` + `is_new` against the PREVIOUS UTC DAY's final snapshot, held in one durable `kv_cache` row (namespace `itunes_charts`); a day-over-day baseline rather than read-over-read, because the alert tick (10 min) and the desk slot (hourly) share one client and a read-over-read diff would hand a takeover to whichever caller fetched first. **`reason=warming` on an `ok=true` row** = the read succeeded but NO baseline exists yet (the first day after a deploy, or the row expired after 14 days): every consumer reads the chart quiet by design (no crown, no debut, no move) rather than every row as NEW. A `warming` stream lasting past one UTC day means the durable row is not being written — read the `itunes_charts_state` error source. `shape_change` is a 200 under 10 parsed rows (the feed carries dozens when healthy). The 56-65 row reads seen through 2026-09-28 were the CLEAN chart: the URL lacked `explicit=true`, so Apple removed every explicit album. The full chart returns 100 rows, so a `result_count` well under 100 after that fix is worth a look. The fix also moved the snapshot to a new `kv_cache` key, so expect ONE UTC day of `reason=warming` after that deploy. Guarded by a breaker (`integration=itunes_charts`) + retry + a 5-minute flat cache (the feed serves no `ETag`/`Last-Modified`, probed 2026-09-18). Fail-open, NEVER raises: the `itunes_album_crown` / `itunes_album_debut` lanes (cogs/music_alert.py), the desk's `it_albums` watch cards and platform boards, the newsroom's `itunes_albums` chart-claim row, and the X mentions lane's `itunes_albums` unit (2026-09-20) all go quiet on a miss, so this ok-rate is the only place a dead feed shows. Panel: `data_sources` (with the other content feeds); the `apple_music`-family vendor row reads it as vendor `itunes` | | `yt_oembed` | utils/youtube_oembed.py (#us-yt-trending, 2026-09-30) | ok, duration_ms, result_count (1 = a channel name came back); `reason=no_data` + http_status on a 401/404 (one video with no oEmbed data — ok=true, it is an answer about that video, not an outage); on a miss **`reason`** (`unreachable` \| `bad_response` \| `shape_change` — a 200 with no `author_name`, not cached) + error / http_status — one YouTube oEmbed read of the channel behind a video id. It names the artist on a bare-titled row of the `us_trending` board: an artist's auto-generated audio is uploaded under the song name alone ("Patient Zero"), and only a ` - Topic` channel is read as an artist. A 403 counts as a failure, because YouTube answers a blocked client with 403. Cache hits emit nothing (a video's channel never changes; failed reads are not cached). The lookups run 5 at a time under one 15s budget per page read, each attempt capped at 5s; a lookup past the budget is cancelled and its row keeps no artist. Fail-open, so the ops monitor rolls it into the `youtube_oembed` ok rate: a dead endpoint otherwise shows only as cards with fewer artist names. Panel: `data_sources`. **The US trending page read itself** is `chart_fetch` query `yt_us_trending` (`kworb.us_trending_music`), which also reports a non-contiguous music list as `ok=false reason=shape_change`. | | `album_card` | cogs/music_alert.py (#daily-releases, #2376); cogs/music_releases.py (#3935) | surface (`music_alert` \| `music_releases`, the per-album song board on an album's first day with data and on day 7: ok + result_count = tracks, or reason `not_ready` while a due album's Songstats tracks are incomplete or fewer than half have a clean one-day figure), ok (the card built), query (the album title, ≤80), result_count (rows on the card, on a build), recovered (tracks the Haiku fallback matched past the deterministic fold, #2582 follow-up) — the fresh-drop ALBUM-STREAMS card outcome: the NEW_RELEASE lane trying to build the album's tracklist ranked by daily Spotify streams (the "album charts sorted by streams" surface, owner ask 2026-08-16). It rides the NEW_RELEASE post as that post's CARD (via `build_board_card`), replacing the streams-hero number card, but ONLY in the release WEEK (`_ALBUM_CARD_MAX_AGE_DAYS`, default 7): kworb reports a per-track daily while a song is charting, which for an album is its first week — after that the deep cuts drop off the daily column and the card would silently omit half the tracklist. Data is free: the artist's Spotify id rides the chart standing (`ChartRow.artist_id`), the tracklist comes from iTunes (`apple_music.album_tracklist`), and one kworb songs-page fetch (`chart_fetch` query `artist_songs_`) gives every track's daily. **Fail-OPEN at every step** — a skip is NOT an error, the lane falls back to the number card — so the **`ok=False` RATE is the only signal the card quietly stopped building**. `reason` on a skip: `no_kworb` (no client) \| `no_artist_id` (the release isn't charting yet, so no id to reach the songs page) \| `no_tracklist` (iTunes had no album) \| `no_kworb_songs` (the songs page missed) \| `thin_coverage` (fewer than the row floor carry a live daily — the album is past its charting week). **Track recovery (#2582 follow-up):** before the board is built, the same Haiku closed-set matcher the lifetime board uses (`_recover_album_aliases` → `chart_boards.recovered_aliases`) recovers tracks the two sources spell differently (Apple `Rusty Intro` vs kworb `Intro`), so a spelling variant no longer drops a row — the `recovered` count says how many it added, and its latency/outcome ride the `claude_api` event (`purpose=album_track_match`). No monitor added: the lane is rare (one Kalshi-tracked debut at a time) and kworb reachability already rolls up under `chart_fetch`; a sustained `no_tracklist`/`no_kworb_songs` rate points at iTunes/kworb, which their own health covers | | `album_total` | utils/stream_totals.py (#stream-discovery) | surface names the lane — `music_desk` (the individual-artist board), `x_mentions` (the per-chart mention board), `artist_sweep` (the manual sweep) —, ok (a lifetime total resolved), query (the album title, ≤80), source (which of the two sources answered: `kworb_albums` \| `tracklist_sum`), result_count (matched tracks, on a `tracklist_sum` hit; 1 on a `kworb_albums` hit), track_count (tracklist length) + recovered (tracks the Haiku fallback matched past the deterministic fold, #2582) — one album's LIFETIME Spotify total on the individual-artist (catalog) board. A Billboard 200 row is an ALBUM, and kworb's weekly page is songs-only, so the board used to show a sales rank alone (blank for a catalog album outside the sales top 50). This resolves each album's total FREE, from TWO sources in order. **First the artist's kworb ALBUMS page** (`chart_fetch` query `artist_albums_`, one fetch per act), which publishes Spotify's OWN cumulative total per release — `chart_boards.album_page_total` matches the Billboard title to it accent- and case-insensitively (`Debi Tirar Mas Fotos` → `DeBÍ TiRAR MáS FOToS`). **Then the tracklist sum**, the older path: the tracklist is one iTunes lookup (`apple_music.album_tracklist`), and one kworb songs-page fetch (`chart_fetch` query `artist_songs_`, cached ~3h and shared across the act's albums) gives every track's cumulative total; `chart_boards.album_total_streams` sums the matched tracklist. The page total leads because it is the PUBLISHED figure while 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 because iTunes does not return every act's albums at all (measured 2026-08-28: an iTunes album search for Bad Bunny returns his singles and features, none of his albums, so his Billboard 200 rows resolved only once the albums page was added). `source` on each event says which one answered. It is a LIFETIME total, not a weekly one — the compose framing names it as such so the take never recasts it as "this week". **Fail-OPEN at every step** — a skip leaves the album row on its sales rank alone — so the **`ok=False` RATE is the only signal streams quietly stopped resolving for album boards**. `reason` on a skip: `no_kworb` (no client) \| `no_artist_id` (neither id source knows the act — see below) \| `no_tracklist` (iTunes had no album) \| `no_kworb_songs` (the songs page missed) \| `thin_coverage` (the match covers less than the 0.6 tracklist floor, so the sum would UNDERCOUNT — prefer absent over invented). **Track recovery (#2582):** before the floor is read, a Haiku closed-set matcher (`claude.match_album_tracks`) recovers tracks the two sources spell differently (Apple `Rusty Intro` vs kworb `Intro`), under a plausibility bound (a recovered row cannot out-stream the album's biggest matched track). The `recovered` count says how many it added; a HABIBTI-shape album that read 8/11 = 267M now reads 10/11 = ~328M. The matcher fails CLOSED (a Haiku outage recovers nothing, leaving the deterministic sum), and its own latency/outcome ride the `claude_api` event (`purpose=album_track_match`). **The artist id has two sources (2026-09-02):** the FREE kworb weekly rows (an act with a song on the Spotify Global Weekly top 200), then the all-artists leaderboard by exact name (`StreamTotals.resolve_artist_id`, the route the mentions lane always used). The weekly page alone left every CATALOG act blank — Future held three Billboard 200 albums on the 2026-09-05 chart with no global top-200 song, so the desk board shipped with an empty streams column while his kworb albums page carried all three totals — and that branch emitted NOTHING, so the 14-day ledger read 100% ok. Now an act neither source knows emits `ok=False, reason=no_artist_id` per album, from the desk lane (`MusicDesk._catalog_artist_id`) and the mentions lane (`board_stream_totals`) alike. No monitor added, re-judged when the X-mentions lane joined (2026-08-28): the volume is higher now, but a miss still degrades ONE COLUMN and never the post -- a board with no totals falls back to its move markers -- while iTunes + kworb reachability already roll up under their own health (`chart_fetch`, `catalog_lookup`) and a sustained `thin_coverage` rate points at kworb catalog coverage, not our code. The panel is the read. **A second CALLER since 2026-09-22 (#3613), stamped `phase=page`:** the music desk's market-card compose reads `StreamTotals.album_page_streams` for the release's CURRENT album-wide state -- its cumulative total AND its latest tracked DAY -- off the same albums page, to hand the compose a figure that is true on the day it posts. That call is PAGE-ONLY: it does not fall back to the tracklist sum, because a caller handing a compose the album's current state wants the published per-release figure or nothing. It takes BOTH figures off the ONE row the published total belongs to (`chart_boards.album_page_row`), never a maximum per figure: the fold drops parenthetical qualifiers, so an edition collides with its standard, and two independent maxima can pair one release's lifetime total with another's daily and read as one album's own numbers (Codex on #3615; four titles collide on the live Miley Cyrus page). Its reasons are the page ones alone -- `no_kworb_albums` \| `no_match` \| `no_artist_id` -- and `phase` keeps its hit rate separable from the boards', including the no-id miss, which `emit_miss` now stamps too (without it the panel's `coalesce(phase, 'board')` hid every card miss caused by an unresolved artist among the board rows). WHICH readings reach it is NOT decided here: the desk gates the block on a LIVE ALBUM-EQUIVALENT UNITS metric, because an albums-page hit is no proof the subject is an album -- a title track matches the album row. Fail-open like the boards: a miss omits the context block and the take runs on the market number, so the `ok=False` rate stamped `phase=page` is the signal the card's album block went quiet. No monitor, same reasoning as the boards: a miss costs ONE optional clause, never the post, and kworb reachability already rolls up under `chart_fetch` | | `song_total` | utils/stream_totals.py (#2430) | surface names the lane (`music_desk` \| `x_mentions` \| `artist_sweep`), ok (a lifetime total resolved), query (the song title, ≤80), result_count (1 on a hit) — one SONG's LIFETIME Spotify total on a per-artist board, the sibling of `album_total`. Three music-desk boards draw the column now: the individual-artist board, the debut class (2026-09-09) and the GENRE board (2026-09-12, owner report on the Karol G Hot Latin Songs card: "where did our streams go"). The genre board costs one songs-page read per board, because every row belongs to one act. It REPLACED the old song streams source (the kworb Spotify **Global Weekly top 200**), which left every US Hot 100 song ranking lower globally blank — the Karol G card showed a number on only 1 of 4 songs. Now each song matches its title against the artist's kworb songs page (`chart_boards.song_total_streams`, the same free `artist_songs` fetch the album lane shares) and shows its cumulative total, so a currently-charting act fills every row. **Fail-OPEN** — a skip leaves the song row on its sales rank alone — so the **`ok=False` RATE is the signal streams quietly stopped resolving**. `reason` on a skip: `no_kworb_songs` (the songs page missed) \| `no_match` (the song is not on the artist's songs page) \| `no_artist_id` (neither the weekly rows nor the leaderboard knows the act; emitted per song since 2026-09-02, the same two-source id lookup + miss `album_total` describes). **Since 2026-10-02 the resolver tries more pages before it emits a miss:** a `no_artist_id` act is looked up by exact Spotify artist search too (a Spotify id is a kworb id), and a `no_match` title is tried on the page of each act the row's credit names, leads then features (Yeat's "MISS MY DAWG (feat. Drake)" was on Yeat's page only, so Drake's 2026-10-01 Hot 100 card on X shipped it blank). **Monitor: the ops-monitor `stream_cell_blank` finding (low)** lists EVERY `ok=false` title on `x_mentions` and `music_desk`, with no rate floor: one blank row in fifty is a 2% rate, which a floor hides, and each one is a blank cell on a card that shipped. A title that is on no kworb page yet (a brand-new song) is expected; a title on a page the resolver did not read is a bug. **The finding caught a third cause within minutes of the 2026-10-02 deploy:** kworb prints `I Knew It, I Knew You - From "Toy Story 5"` where Billboard prints the bare title, so the title match missed. `chart_boards.song_total_streams` now retries a miss with Spotify's ` - ` suffix dropped from both titles. kworb reachability still rolls up under `chart_fetch`. | | `release_date_resolve` | utils/release_age.py (`resolve_release_date`, #3176) | source (the calling lane, e.g. `debut_release_age`, `called_shot_age`, `x_gate`), mode (`song` \| `album`), ok (a date resolved), provider (`musicbrainz` \| `deezer` \| `itunes` \| `deezer+itunes` \| `spotify` (the last rung since 2026-10-20: a fielded Spotify search, only after the other three missed) \| empty when nothing dated; `musicbrainz` since #2958, the first rung, which dates a catalog title to its original release instead of a reissue), reason (`itunes_title_mismatch` \| `musicbrainz_timeout` \| `no_date` \| empty; since CB-04 (2026-10-18) `itunes_title_mismatch` also counts an iTunes row whose title passed the overlap rule in the ambiguous band and was refused by the catalog title judge, which then also emits `catalog_title_confirm` with `source=itunes`; `musicbrainz_timeout` = the MusicBrainz rung ran past `RELEASE_AGE_MB_BUDGET_SECONDS` and was skipped, so the catalogs dated the row, #2958) `detail.core_retry` (2026-10-20) is set on the round that retried a title without its bracket or dash notes after the full title missed on every rung; the full title's miss emits nothing in that case, so the event still counts one outcome per title. `source=recency_gate` is the desk's release-age cut; the ops monitor reads its ok rate as `release_age_cut_dating`, because that cut fails CLOSED and a dead resolver would silently empty the No. 6-10 cards — ONE catalog round of the shared release-date resolver. A memo hit emits nothing, so the volume is one event per title per lane per day (a resolved date expires after `RELEASE_AGE_HIT_TTL_SECONDS`, 24h by default; it used to last for the life of the process). **Why it exists:** every debut gate logs a wrong date as `stale`, the same as a correct catalog rejection (measured on `called_shot`, 30 days: 1022 `stale`, 366 `fresh`, 24 `no_date`). Two wrong-record shapes hid inside `stale`: ADELA's PRIMA dated from another track on the album, and Ashe's "The Girl Of Your Dreams" dated from its pre-release single "Stop The Wedding!" (#3998). The resolver now refuses an iTunes row whose own title fails `deezer.album_title_matches` and reports it as `reason=itunes_title_mismatch`. Panel: `release_date_resolve` on the desks board. **No ops-monitor rate:** `ok=false` includes the normal `no_date` population (a drop-day title the catalogs have not indexed), and the transport health of both catalogs is already on the monitor through `deezer_lookup` and `catalog_search`. | | `plaque_facts` | cogs/music_desk.py (`_plaque_facts`, owner ask 2026-09-04) | surface (`music_desk`), query (the act, ≤80), count (rows the board draws, at most 10), result_count (rows that resolved a RELEASE DATE), hit (kworb knew the act, so a daily figure was read off its songs/albums page), ok (at least one row dated), + folded detail: daily_rows (rows that carry a daily streams figure) — one event per NEW PLAQUES board built (`riaa_boards.plaques_board`, one board per act AND format since 2026-09-04, so an act with new singles and new albums emits two). The board's row sub used to name the format ("single" / "album"), which the owner read as noise on the Disney card of 2026-09-04 and then ruled out outright ("no plain 'single' string"); it now carries the title's release date and, where Spotify tracks the title day to day, its daily streams ("Jun 14, 2015 · 1.2M/day"), and is EMPTY when neither resolved -- the format lives in the chart key and caption. The date is MusicBrainz's `first-release-date` (`chart_ages.first_release_date`, one paced read per title behind the desk's per-process memo, the reads on `reference_fetch source=musicbrainz`), looked up under the row's own credit first and the act second, so a soundtrack cut RIAA files under a cast cell still gets a try under the act; it is NOT the Deezer/iTunes `release_age` resolver, which dated catalog titles to reissues on the live feed (see `docs/ARCHITECTURE.md`, `riaa_boards.py`). The daily comes off the act's kworb page through `StreamTotals.song_daily` / `album_daily` (one fetch per act, the same cached page the lifetime totals read); an act the kworb leaderboard does not know (`hit=false`, a catalog act like Disney) draws dates alone. **Fail-OPEN**: no exception anywhere, so the `ok=false` RATE is the only sign the MusicBrainz path died — on the ops monitor as `riaa_plaque_dates` in the misc integration health (`_MISC_INTEGRATIONS`). A single `ok=false` on a soundtrack or kids'-channel batch MusicBrainz does not hold is normal (measured 2026-09-04: it dated 12 of 26 rows across seven batches, and none of the Disney rows); a sustained one is not. The board's fire-on-change `signature` reads label + value only, so a daily figure moving never re-posts a board. No monitor beyond the health line: the board fires only on a batch, a few times a week. | | `cert_batch` | cogs/music_news.py (`_cert_batch_card`, owner report 2026-09-12, #3211) | surface (`music_news`), matchup (the act, ≤80), count (the awards RIAA's own sweep holds), ok, guild_id; on a DRAW: kind (`single`\|`album`, the format the board drew) + awards (rows on the card); on a SKIP: ok=false + reason (`cold_cache`\|`no_sweep`\|`count_mismatch`\|`other_authority`\|`under_floor`), + folded detail: batch (the count the WIRE claimed). One event per certification-BATCH story the newsroom cards. **Why it exists:** RIAA certifies an act's whole catalogue on one day and the wires report the sweep as a TOTAL, so the classifier heroes a count and the room got "53 certifications / Future" with no titles under it. The desk's own plaques lane could not supply the board — it reads awards certified in the last THREE days, and RIAA had dated that sweep 2026-07-16, 58 days before the wire noticed it (measured live 2026-09-12: 50 awards on 07-16, 4 on 07-15, 0 Future awards anywhere in the recent feed). So the card is drawn off the act's OWN award search instead — the read the record boards already hold a week (`riaa.awards_for_act`), never a fresh fetch: one search is up to 16 paced pages (~100s measured) and a post cannot wait. **Fail-OPEN**: every skip ships the story's number card, so no card is lost and no exception escapes -- and for a LONG batch figure the cog restores the count in its short form first (`53 new RIAA certifications` is 26 characters and the card's phrase guard clears anything over 24, which would hero nothing and, with no art, ship bare text; Codex review). The `cold_cache` RATE is therefore the silent-degradation signal — it means the roster walk (`music_desk._riaa_candidates`, RIAA's own artist ranking at 3 acts a slot, cached 7 days) has stopped keeping the big acts warm, and a climb there turns every sweep back into a bare number with nobody seeing an error. `count_mismatch` is the prefer-absent guard, and it runs BOTH ways (Codex review): a take saying "53 certifications" over a three-row board tells a bigger story than the card shows, and a story about 3 new certifications over a 50-award sweep from two months back draws the wrong event entirely -- the busiest date in the window is still the old one. The counts must agree within a factor of two in each direction. `other_authority` is the second fence: the board reads RIAA and wears its pill, so a BPI story is refused here and `cert_batch_count` matches no BPI figure either. Sweeps are MAIN-program only -- the Latin ladder is different and `award_name` reads every row on the main one, so a Latin sweep would print "6x Platinum" over 360,000 units. No monitor: a sweep is a few cards a month and every failure degrades to a card that is merely less good, so the panel is the right weight. **ZERO events is itself the signal, and it went unread for a month:** `_cert_batch_card` returns before any emit when `cert_batch_count` misses, so a figure the matcher never sees leaves NO row at all. Until 2026-10-14 that was every sweep (the unit split ran before the read, and "53 certs"/"53 singles" did not match), and the Future sweep shipped three bare number cards with an empty panel. A cert-kind `music_news_posted` with a count figure and no `cert_batch` row at the same minute is that failure. | | `count_board` | cogs/music_news.py (`_chart_count_card`, owner report 2026-09-13, #3224) | surface (`music_news`), matchup (the act, ≤80), count (the settled count), ok, guild_id, + folded detail: chart (the chart key the count settled on), top_n (the window the claim named); on a DRAW: detail.rows (rows drawn on the card) + detail.units (rows that carry a figure; the name predates the streams column) + detail.value_kind (`lifetime_streams` = lifetime Spotify totals, any chart, 2026-10-29; `units` = the HITS units fallback, Billboard 200 only, 2026-10-18; empty = no figure). The per-row lookups emit `album_total` / `song_total` with surface `music_news`; on a SKIP: ok=false + reason (`other_figure`\|`under_floor`). One event per artist-level COUNT story the newsroom cards. **Why it exists:** a wire states a count about an ACT ("Lil Durk has 6 titles inside the top 100 on the US Apple Music albums chart"), the newsroom settles it against the live chart, and the card heroed the settled count -- so the room and the timeline got a "6" over an artist photo with no titles under it (owner report 2026-09-13, the same shape as the certification sweep one day earlier). The board lists the titles instead. **The rows cost no read**: `resolve_artist_count` already keeps the `(position, title)` pairs it counted, `_sched_compose` stamps that reading on the unit, and the board draws those rows -- so the board and the take come from ONE count and cannot disagree. That is why there is no counts-agree gate here, unlike `cert_batch`. **Fail-OPEN**: both skips ship the story's number card, and no exception escapes. `other_figure` means the card's settled hero is no longer that count (a market story heroes its own leg), so a chart list would sit under a take about something else; `under_floor` is the board's `MIN_COUNT_ROWS` cut -- one title is a position, and the number card states it better. The one fetch is the Billboard 200 units column (the printed week + the HITS document for it); it never decides ok, so a HITS miss shows as detail.units=0 on a draw, not as ok=false. So a SUSTAINED ok=false rate means the count stopped reaching the card (a compose-side change), never an upstream fault. No monitor: every failure degrades to a card that is merely less good, so the panel is the right weight. | | `catalog_board` | cogs/music_news.py (`_catalog_board_card`, #3225; replaces the number card since 2026-10-04) | surface (`music_news`), matchup (the act, ≤80), count (the titles at or above the rung), ok, guild_id, + folded detail: rung (the rung in streams), rows (rows drawn, on a draw); on a SKIP: ok=false + reason (`under_floor`). One event per catalog-milestone story the newsroom cards. **Why it exists:** "Drake has 30 songs past 1 billion Spotify streams" heroed a bare "30". #3131 made that card say WHAT it counts but it still never said WHICH thirty songs. **The board REPLACES the number card** (owner, 2026-10-04, on the Billie Eilish "13 songs past 1.3 billion" tweet: "should've gone with right"). It first shipped as a second image beside the card (2026-09-13); the tweet then showed the count three times. Same shape as `count_board` now. Its block names the metric only ("SPOTIFY STREAMS"), never the rung. **It costs no read** — `resolve_stream_claim` already selects the titles and keeps them on the reading, so the count in the take and the rows on the card come from one selection. **Fail-OPEN**: a skip or an exception ships the number card. A sustained ok=false is not an outage — `under_floor` means the act has one title past the rung, which is a crossing rather than a list. No monitor: every failure degrades to the number card. **A figureless catalog story reaches it too** (#3185, 2026-10-15): when the hero is cleared (a rank word, an empty judge figure) the settled reading's count restores it, so the story no longer takes the bare-art route before the board is tried (Britney Spears' 14, Maroon 5's 13 and Doja Cat's 10 had shipped with no number). | | `genre_debut_tag` | cogs/music_desk.py (`_genre_debut_groups`, owner question 2026-09-05) | source (`itunes`), query (` - `), ok, duration_ms, count (1 = the Apple result matched the row on the `album\|artist` key), **`reason`** (`tagged` \| `other_genre` \| `genre_disagree` \| `genre_unconfirmed` \| `no_match` \| `breaker_open`), genre (Apple's `primaryGenreName`) and genre_tags (Deezer's album genre names), both folded into `detail`, via (folded; `track` when the album search had no row for the album and the iTunes SONG search found it off its tracks' `collectionName`, #4734) — ONE iTunes album search that tags a TRUE DEBUT on the HITS Top 50 with the genre Apple publishes on the album, so a first-week album reaches the hip-hop / R&B genre board. The genre board's membership rule is the PRINTED Billboard genre chart, and the HITS document projects the week AFTER it, so a debut is on no printed chart at all 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). Only a row on NO row of the printed Billboard 200 is a candidate (`chart_boards.genre_debut_candidates`), capped at `MUSIC_DESK_GENRE_DEBUT_TAG_CAP` (8) per read. **Apple's tag alone is not enough** (owner steer 2026-09-11, "the release isn't the problem, it's the genre"): Apple reads `Hip-Hop/Rap` on Beyonce's 2006 "B'Day" record while reading `Pop` on that same album's other editions, so Beyonce led the hip-hop sales board for the week of 2026-09-19 off its 20th-anniversary reissue. DEEZER's album genre (`deezer.album_genres`) is the second, independent source, and the album joins a board only when both sources name the same group (`chart_boards.confirmed_genre_group`). Deezer is read only when Apple already names a kept group, so a pop or country debut costs no call. `genre_disagree` (Deezer names another group, or none — the B'Day case) and `genre_unconfirmed` (Deezer holds no genre for the album yet) both leave the album absent. A settled answer (`tagged`, `other_genre`, `genre_disagree`) is cached per album for the process, while `genre_unconfirmed` and `breaker_open` are not cached, so the next read retries. A `no_match` (a release-day album iTunes has not indexed yet, or a catalog row whose iTunes title never agrees with Billboard) is HELD for 6 hours per album (`_GENRE_DEBUT_MISS_TTL_S`, #4117), then retried; a held album makes no call and emits no event. Only a miss where every search returned rows starts the hold (the album search, plus the song search that runs when no album row matches, #4734): `search_catalog` returns [] for a transport failure too, so an empty read is retried on the next slot (Codex review on #4146 and #4759). A 21-day replay that clears the hold at each boot: 6,404 lookups would have been 1,539 (−76%), with no settled answer delayed. ok=False ONLY for `breaker_open` (the iTunes breaker is open, the read never happened, and the cache is left alone so the next read retries); `no_match`, `other_genre`, `genre_disagree` and `genre_unconfirmed` are healthy outcomes. Fail-open: a miss drops one row, never the board. Ops monitor: misc health `itunes_genre_tag` — a sustained fail rate is the only sign the week's debuts are silently missing. Dashboard: the `data_sources` table | | `entries_board` | cogs/music_news.py (`_entries_board_card`, owner report 2026-09-15, #3331) | surface (`music_news`), matchup (the act, ≤80), count (the act's entries on the chart), ok, guild_id, + folded detail: chart (the chart key); on a DRAW: detail.rows (rows drawn); on a SKIP: ok=false + reason (`under_floor`). One event per career-entry milestone the newsroom cards. **Why it exists:** "LISA ties JENNIE with 7 Billboard Hot 100 entries" heroed a bare "7" over her photo with none of the seven titles on it — the third appearance of one bug, after `count_board` (#3224) and `catalog_board` (#3225). **The board REPLACES the number card** (owner, 2026-10-20). Until then it was a second image beside the card. On "Gunna becomes the 25th artist to chart 100 Billboard Hot 100 hits" the post carried a "25th artist" card and the board, and the owner reposted to X with the board alone: the take already states the milestone. `catalog_board` took the same step on 2026-10-04, so no newsroom post carries two images now. **It costs no read** — `_live_entry_history` settles the claim against Billboard's own chart-history page and `_sched_compose` stamps the reading on the unit, so the count in the take and the rows on the card come from ONE reading. **Fail-OPEN twice over**: a skip ships the number card unchanged, and the build is wrapped so a board error falls back to the number card and never costs the post its image. **The wrong-artist guard lives UPSTREAM and is silent here**: a career count names no title, so the check is the count itself (the page must hold exactly as many entries as the claim states) and a refusal lands in `music_news_chart_lookup` with `phase=milestone` + `detail.reason` (`count_mismatch` \| `unreadable_row`), not in this event. So read the two together: this event going quiet for a whole act, with `count_mismatch` rows beside it, means the read is landing on the wrong artist page — check `billboard_artist_history` `detail.slug_source`. No monitor: every failure degrades to the card that shipped before this existed. | | `artist_filter` | utils/kworb.py (`is_functional_audio`; `daily_under_read`, #3881); cogs/music_desk.py (`daily_implausible`, #3797) | source (`kworb`), kind (`streams`\|`listeners`\|`watch`\|`board`), count (rows dropped), reason (`functional_audio`\|`functional_audio_model`\|`daily_implausible`\|`daily_under_read`), + folded detail: dropped (the dropped names, ≤10) — functional-audio accounts (white noise / sleep sounds / ASMR / lofi study loops) removed from the Spotify all-artists ranking (kind `streams`, name + stream-shape signals) AND the monthly-listeners ranking (kind `listeners`, NAME signal only — that page carries no stream-shape numbers, and a functional account self-labels by name) before any consumer sees it. Rides `_fetch`'s one-shot `transform`, so it fires on a FRESH page fetch only — a 3h cache hit returns the already-cleaned rows and never re-emits. The board `artist_streams_board` re-ranks the whole page by the DAILY column, and a white-noise account collects huge daily streams from overnight sleep-playlist autoplay, so one floated to #1 over Drake and shipped to X: "White Noise Radiance sits atop Spotify's most-streamed artists ... 211.3M clear of Drake" (the account is real on kworb — #658 by total, 7.1B total, 269.5M/day — it is just not a music artist). Two signals catch it, either one enough: a NAME marker phrase, or a STREAM-SHAPE anomaly (daily ≥ 1% of a total over 5B — no real catalog artist turns over that share of their whole history in a day; the live top sits near 0.04–0.10%). Emitted only when a row is dropped, so a non-zero count IS the signal. **The watch is OVER-filtering**: a genuinely huge, tiny-catalog real act wrongly held off a board is the silent degradation (the numeric floor is set 10x over the real max + a 5B total floor to make that near-impossible, but the event is where it would show). No monitor added — this is a display filter, not an integration; kworb reachability is already covered by `chart_fetch`, and an over-filter would surface as a named artist missing from the ranking rather than a rate crater **`reason=daily_implausible` (#3797)** names rows whose kworb "Daily" figure is over 0.3% of the act's all-time total (`artist_watch.daily_share_ok`), which means that read of the page holds a multi-day delta. `kind=watch` fires from the desk's hourly known-list build and names the rows held out of the daily ranking (not on the monthly-listeners page either); `kind=board` fires from the artist-board lane and names the top-10 rows that made `artist_streams_board` yield. Each kind fires ONCE per bad page generation (`MusicDesk._report_doubted_daily`, keyed on the kworb page's refresh time plus the doubted names), not once per call: the page is cached for hours, and the board lane runs per guild per slot. A clean read resets it, so the next bad read reports again. One event is one bad read. **`reason=daily_under_read` (#3881)** names rows whose "Daily" figure is under 30% of the act's LAST TRUSTED figure (`artist_watch.check_daily_drops`), which means kworb's figure is too low: on 2026-10-02 Drake read 11.1M against 64.0M and Bad Bunny 5.5M. `kind=streams`. `KworbClient.spotify_artists` checks each FRESH artists page once against a per-act store (`kv_cache` namespace `kworb_daily_baseline`, one JSON map), so one event is one fresh page with at least one under-read; a cache hit or a 304 never re-emits. Every published daily figure then reads `artist_watch.daily_trusted`, so a count here means a figure was held back, not printed. A refused figure is held as pending; a LATER read with a different total that is still low is a real decline and is accepted, so a decline costs one kworb refresh and does not repeat here. A steady stream of the SAME names across many pages would mean real declines are not confirming (the pending rule is broken). No monitor: it is a display filter, and the count rides kworb's own refresh batches rather than a rate this repo can set a floor on. **`reason=functional_audio_model` (#3432):** `kworb.screen_functional_top` asks a cached Haiku yes/no (`ClaudeClient.functional_audio_check`, `claude_api purpose=functional_audio`) about ONLY the rows a board draws (the daily artist top 10, the listeners top 10 + movers top 10, the songs top 10), after the regex passed them. It emits this event when the model drops a row (kind `streams`\|`listeners`\|`songs`; `dropped` names "artist - title"). A judge error keeps the row (the regex verdict). Verdicts cache 30 days per folded (artist, title) in `kv_cache` namespace `functional_audio`. No monitor: a judge outage is visible as `claude_api purpose=functional_audio ok=false`, and its effect is the pre-#3432 behavior. | | `rt_fetch` | utils/rotten_tomatoes.py (#2266 follow-up) | op (`scores_for`), ok (a Tomatometer was resolved **for the right film**), duration_ms, count (search candidates returned), + folded detail: checked (film pages actually opened), matched_year (whether any candidate's own release year matched the chart's) — one lookup for the critics scoreboard's ROTTEN TOMATOES column. **Rotten Tomatoes publishes no API**, so this reads their public pages (owner decision 2026-08-11): two GETs per film, a `/search` for candidate `/m/` slugs then the film page for `media-scorecard-json` + the JSON-LD `Movie` block. It exists because OMDb carries an RT score for roughly a quarter of current releases (2 of 8 on a real weekend top 8, against 8 of 8 direct), so the board's dash claimed “unreviewed” and meant “our source lacks it”. **This is the fail-open case error triage cannot see**: when RT changes a page nothing raises, the client simply stops matching and the column silently reverts to dashes — so the `ok` RATE is the only signal, rolled into the ops monitor's integration health as `rotten_tomatoes` and split by failure step on the `rt_scores` dashboard panel (`count`=0 means the SEARCH page moved or RT blocked us; `count`>0 with matched_year false means the FILM page moved). Never ranks, only fills — the board still orders on Metacritic, so a bad day at RT changes which numbers appear, never which films. Gated per guild on the `rt_scores` experiment (default STAGING); rate limiter + breaker (`integration=rotten_tomatoes`) + retry + a 12h TTL cache keyed on title AND year | | `market_filtered` | cogs/market_alert.py (alert pool) + `utils/markets.py:get_events_for_series` (Kalshi discovery, `reason=non_leader_chart`\|`netflix_views`) | guild_id, reason (the filter that cut it: `horizon`\|`unwatched_sport`\|`trailing_leg`\|`same_answer`\|`many_winners` (a `lead_change` board Kalshi marks not `mutually_exclusive` whose prices sum past 1.5, so it has no leader to change; once per board per day, 2026-10-23)\|`no_trades` (#4714: a MOVE post -- market_alert `forecast_move`, `swing` or a conviction break, or music_alert `line_move`, named by `surface` + `kind` -- with fewer than the `market_move_min_contracts` tunable (default 1) traded on the market that set the reading (a ladder's crossing rungs, or the leg) within the `market_move_window_min` window (default 60); one check, `utils/markets.py:traded_behind_move`, rule `kalshi_ladder.move_is_traded`; `volume` = the contracts traded. A FAILED trade read fails OPEN (the move posts), and the read's own `market_fetch query=volume_batch:N ok=false` row is the telemetry: the ops monitor's `kalshi_trade_read` rate flags a dead read. Measured over 30 days: 25 of 58 forecast moves and 1 of 23 swings had zero such volume. The same rule on every READING would have cut 152 of 239 forecast posts (64%), so readings are NOT gated. Watched on the `market_no_trade_moves` panel; no monitor, because the cut is the intended outcome)\|`forecast_cooldown`\|`forecast_series`\|`forecast_decided`\|`non_leader_chart`\|`netflix_views`\|`soft_price_tier`\|`self_correction`\|`decline_narration` (the composed line narrated a DOWNWARD move in a market number, `has_market_decline`, owner steer 2026-09-18; the drop's copy rides `market_drop_scored`)\|`sibling_repeat`\|`sibling_contradiction` -- the cross-event claim check (#2016, `utils/market_sibling_claims.py`), on BOTH market surfaces (market_alert with `kind`, market_drop with `source`): the post names a title that a market post on ANOTHER Kalshi event named in the last 6h, and Haiku (`claude_api` purpose=market_sibling_claim, cached) read it as the same claim or the opposite one; `ticker` is the post's own event, `count` the earlier posts judged, `post_preview` the dropped text. Fail-open: no verdict ships. Watched on the `market_sibling_drops` panel by text; measured before shipping at about 0.4 checks a day and 0 wrong drops on 30 days of real posts\|`young_ladder_cap` -- the young-board lane's per-room cap (#3113): a scalar board with NO previous-day price (so no `price_change`, so never a mover) that the `_YOUNG_LADDER_MAX_EVENTS` volume cut dropped before its ladder was resolved + seeded; `ticker` is the EVENT, `kind=forecast_move`; rendered, never flagged, a steady count says the cap is too tight for that room -- \|`pure_over_units` -- the music sweep's sibling sanity check, #3069: a release's pure-sales forecast read above its own album-equivalent-units forecast, impossible since pure sales are a component of units, so `fetch_desk_readings` drops the pure reading for every consumer; guild-less, `ticker` + `topic` in detail), kind (the shape it WOULD have fired as: `swing`\|`near_lock_crack`\|`favorite_collapse`\|`forecast_move`), + the folded `detail` map: ticker, topic (≤80 chars), move (`|price_change|` in points), resolves_in_days (days to resolution per the best-available date) — a market that WOULD have alerted (a qualifying swing or conviction break, `_would_alert`) was **cut by a market_alert pool filter before its move was ever examined** — the observability for a **silent candidate-exclusion filter** (#world-cup-spike: the Men's World Cup winner Spain leg spiked 20→55% but was dropped by the "within a week" horizon because Kalshi stamps a placeholder ~2yr `close_time` on open tournament legs, so no alert ever fired). The generalizable lesson: a filter that removes candidates from a pool is as load-bearing as the surface it feeds, so it needs the same instrumentation — verify the property that matters (does this date reflect reality?), not the convenient proxy (does the field exist?). Emitted per dropped would-alert market (bounded to the top few by move size so a wide pool can't flood; a persistent market re-fires ~every 20min tick and the ops-monitor **dedups by ticker**). The ops-monitor renders a **"Filtered markets"** line every run + flags **`market_movers_dropped`** when ≥ `MARKET_FILTERED_MIN` (2) distinct hot markets are cut (`horizon` medium, `unwatched_sport` low); a dropped market always resolves >a week out (that's why it was cut), so the sample's `resolves Nd` is the tell — a human confirms whether that far date is REAL (a legit future) or another placeholder-date bug hiding a near-term market. The **market_drop's** horizon exclusion is already observable via `market_drop_skipped(reason=all_far_off)` (#1092), so only the alert's silent pool filters needed this. **`reason=trailing_leg` (#netflix-race, made BLANKET + fail-closed in #1878)** is a DELIBERATE routing choice, not a silent bug: a NON-leader leg of a multi-outcome Kalshi race (each movie in "Top US Netflix Movie" is its own yes/no leg) is dropped from the swing/conviction pool so a collapsing LOSER is never headlined on its own ("72 Hours sits at 18%, closes in 2 days" — owner: "why do we care who's not leading… no narrative just a dry report"). Instead the moved leg's whole EVENT is resolved into a leaderboard race (`_resolve_races` → `get_event_markets` → `_race_snapshot`: the full legs, since a stable longshot is filtered out of the movers pool, folded into an `{outcome: prob}` map + per-leg `chart_specs` so the card draws the real crossover) and routed through the existing lead-change path (an OVERTAKE narrated as "A Toxic Love Story overtook 72 Hours for the top spot, now most likely at 74%"); only the leader's own moves or a real switch make the feed. So the ops-monitor RENDERS `trailing_leg` in the "Filtered markets" line but NEVER flags it as `market_movers_dropped` (it's intentional, not the #world-cup-spike class). **`reason=ladder_rung` (#ladder-forecast)** is the same shape of deliberate routing: a Kalshi SCALAR market is a LADDER of "Above N" rungs over ONE underlying number, and 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, the Spider-Man `KXRT-SPI-90` rung swung 66%→48% while the figure Kalshi publishes for that market held flat at 90, and this surface posted "a favorite falling to a coin flip" about a market that hadn't moved in two weeks, three times in three days. So rungs are dropped from the swing/conviction pool entirely (`utils.kalshi_ladder.is_ladder_rung`, the same test `market_drop` has used since #1011), leaving a scalar market a shape this surface simply doesn't narrate. A RISING `ladder_rung` rate is HEALTHY — it counts the false alerts no longer posted — so the ops-monitor renders it and never flags it. What a ladder DOES forecast (its E[X], the figure Kalshi prints) alerts via the `forecast_move` kind (a material move in the forecast vs a durable per-event baseline, debounced) — and **a ladder CLOSING soon posts through the ending-soon lane reframed onto its forecast** (#forecast-card-copy, owner steer "the forecast is the story, the odds is a subtitle qualifier"): the take reads "X is forecasted to hit ~17M" (the close time gates the alert but stays OUT of the take — owner steer 2026-08-26, the settling clock is plumbing, not the story), the card heroes the forecast VALUE, and the rung's odds ride only as the card's subtitle qualifier ("75% to clear 16.5M", `_rung_qualifier`) — the Bieber views board had shipped the inversion (75% heroed, the ~17M figure unmentioned) because the ending-soon lane never saw the swing pool's rung filter; a closing rung of an UNREADABLE ladder (bucket/thin, no forecast) is dropped fail-closed with this same `ladder_rung` reason. (The same change fixed the forecast trigger having shipped DORMANT: `ladder_forecast` reads the raw book fields (`yes_bid_dollars`/`yes_ask_dollars`/`last_price_dollars`), which `_kalshi_market_to_snapshot` never threaded onto snapshot meta — so `_resolve_ladders` always saw None-book rungs and no forecast ever resolved; the mapper now carries them.) **`reason=unconfirmed_leg` (#1878)** is the ONLY leader-rule drop that is neither editorial nor a correctness guard, and the fail-CLOSED half of the leader rule: a named-outcome leg may be headlined only when its event's own leg set CONFIRMS it either leads its race or isn't in one (`_event_leader` over any event with ≥2 named priced legs — a lower bar than the ≥3-leg leaderboard fold, since a two-horse race has a loser too), and anything unconfirmed is SUPPRESSED rather than posted blind. The rule existed before but fail-OPEN, so every gap in resolution came out as a trailing leg headlined with the leader unmentioned: a Charli xcx album sank to 15% on the 26-album Billboard 200 board and got its own alert while Olivia Rodrigo led it at 71% — the room told who was LOSING a race it was never told anyone was winning. What left it unresolved was the per-tick resolve cap (3) being spent on scalar LADDERS, whose rungs carry named-looking labels and so passed the race candidate test (that tick, three Rotten Tomatoes / YouTube-views rungs moved 47-52 points and took every slot). So rungs are excluded from the race candidates (they have their own lane, making the two candidate lists disjoint and each event fetched once), candidates are ordered would-alert-FIRST (a conviction break fires BELOW the swing bar, so move size alone buries it), and the budget is split: every event that can FIRE (`_RACE_MAX_EVENTS` 50, sized to a live sweep — Sports 39, Entertainment 13, ≤20 elsewhere) plus a handful resolved only for lead-change reach (`_RACE_CONTEXT_EVENTS` 5), so a quiet day's long tail of small movers costs nothing. Live-swept: 107 trailing legs suppressed vs 29 leaders still alertable, 5 lone binaries untouched. **`reason=same_answer` (#1983)** is the ANSWER gate, and it REPLACED the 10-point same-ticker move threshold that carried `reason=repeat`. That threshold asked how far the PRICE had travelled as a proxy for whether the next post would say anything new, and the proxy broke on the case the owner reported: Ransom Canyon Season 2 held the #2 Netflix spot from 31 July to 3 August while its price swung 44 -> 92 -> 90, so every repeat cleared 10 points honestly and the room read the same sentence four times. The price is volatile; the answer is not. So the gate now compares the CLAIM: for a race leg WHO leads (the leader rule already drops every non-leader, so the leader's name IS the claim), for a lone binary the subject plus its side of even money. Stored per EVENT in `market_alert_answers`; a candidate asserting what we already published does not post, however far the price moved. Strictly better in BOTH directions -- a real change ships at once with no cooldown to wait out, and a non-change never ships. Measured over 30 days of real shipped alerts it removes 47 of 240 posts, and removes ZERO from the per-game sports families (MLB game/spread/total, tennis): a new fixture each night is a different event with a different answer, so per-game markets are protected by construction rather than by a taxonomy. The `answer` field (folded into `detail`) carries the claim that repeated. A RISING rate is HEALTHY -- it counts posts no longer made -- so the ops-monitor renders it and never flags it. NOT applied to the scalar-ladder `forecast_move` lane: there the claim is a number that drifts, so "the answer changed" is true nearly every time and only the per-event time limit from #1977 bites. Because nearly EVERY Kalshi market is a named leg (299/299 would-alert markets on a live sweep), the event fetch is now load-bearing for the whole surface — so a sustained `unconfirmed_leg` rate means it's going DARK, not quiet, and this is the one filter reason the ops-monitor FLAGS (medium, pointing at `get_event_markets`) rather than only rendering. **That fetch must therefore be COMPLETE, and #1879-follow-up is why (`_legs_complete`):** `get_event_markets` read ONE `/markets` page (100) and dropped the cursor, so any bigger field came back silently truncated — and a partial leg set doesn't weaken this rule, it INVERTS it, crowning the best leg on page 1 while suppressing the real favourite as trailing. Live: the 146-runner Rocket Classic came back cut at 100 with Cameron Young (the true 26% favourite) missing entirely, so page 1 read Patrick Cantlay at 14% as the leader; 1 of 279 live events exceeded a page, but large fields (golf, a 32-team futures, a big candidate slate) recur weekly. So the fetch now follows the cursor (page size raised to Kalshi's 1000 cap, so the extra round trip is only ever paid past 1000 markets — the golf field drains in one) and STAMPS `event_legs_complete` on every leg; an undrained walk (page budget spent, or a later page failing) marks the set partial and `_resolve_races` treats it exactly like a failed fetch — left out of the map, every leg `unconfirmed_leg`. Suppressing the true leader alongside the pretender is the accepted cost of not knowing, and is the same direction every other gap in this rule takes. **`reason=forecast_cooldown` / `reason=forecast_series` (#1977)** are the scalar-ladder forecast lane finally being PACED like every other trigger, and they carry `kind=forecast_move`. That lane is keyed on an EVENT ticker rather than a snapshot, so it never went through `_blocked` and asked neither pacing question — its whole bar was "did the forecast move ≥15% and hold two ticks", which on a CONVERGING board is not the same question as "should we say it again": the per-event baseline RESETS to each post's value, so a ladder drifting one direction re-clears the bar indefinitely. Live, `KXRT-ICE` posted three times in fifteen hours (45.4 → 26.5 → 16.5, each hop an honest ~40% move), against the same KXRT family sitting at exactly one post a day for the ten days before the lane went live — the series gate was working, a new trigger had simply been routed around it. `forecast_cooldown` is the EVENT's own 24h clock (`market_forecast_alerts.last_alert_at`, stored since the table shipped and read by nothing until now); `forecast_series` is the shared #1007 window, so a forecast can't ride in behind a swing on a sibling of its family. Both are DELIBERATE pacing like `repeat`/`ladder_rung` — a RISING rate counts posts no longer made — so the ops-monitor renders them and never flags them, and both fail OPEN on a DB error (a pacing lookup must never silence a surface). The pair is also the tell for the residual asymmetry: the lane reads `market_alert_state` but does not WRITE to it, so `forecast_series` can fire while the reverse (a swing blocked by a forecast post) cannot — a sustained `forecast_series` rate alongside same-family swings is what that gap would look like in the data. **`reason=forecast_decided` `kind=forecast_move` (#outcome-timing, 2026-08)** is the STALE-forecast guard: (a BUCKET board collapsed onto one bucket is decided too since #2657, `utils.kalshi_ladder.bucket_is_decided`; it has no survival forecast, so it never reaches the move lane and never emits `forecast_decided`. Its rungs leave the ending-soon pool as `reason=ladder_rung`, the same as a decided survival ladder's rungs) a scalar ladder is a forecast only while its underlying number is UNKNOWN, and once the event happens (a video airs, a game ends) the survival curve collapses onto the now-known value — a near-PERFECT step, the whole drop from ~1 to ~0 packed into the single strike gap bracketing the value (`utils.kalshi_ladder.ladder_is_decided`, a shape test because there is no field for the real event date: Kalshi's `expected_expiration_time` is a padded settle-by ceiling, so `ends_within` still reads a decided-but-open market as near-term). The move that fired the alert is then the reveal catching up, not news: the GTA VI "An Extended Look" runtime card shipped "the runtime forecast pulled back to ~28.3 minutes" after the video had already aired. The move lane emits this reason and suppresses (the ending-soon lane drops the same ladder's rung fail-closed under `ladder_rung`). The band is tight (survival ≥0.92 → ≤0.05) to separate a decided board from a merely TIGHT live one — the Tyla album board (Above 7K ~0.11) stays a forecast because its "no" rung sits above the LO; validated against 634 live open exchange ladders, zero false positives. A RISING rate is HEALTHY (stale posts no longer made), so the ops-monitor renders it and never flags it. Posting the OUTCOME instead of suppressing is the reconcile lane's job (today KXRT-only) — the #outcome-timing follow-up. **`reason=same_answer` `kind=bet_value` (#2162)** is the betting VALUE alert's edge baseline, emitted from cogs/betting_value.py: the per-game cooldown is pacing, not a content gate, so 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 (4 times on one measured day). The edge signature (team|book%|market%) is stored on each fire (`bet_value_alerts.last_edge`) and an unchanged signature never re-fires; a moved number is a new signature and still posts under the cooldown. A rising rate is HEALTHY (posts no longer made) **`reason=non_leader_chart` (2026-08)** is a SEPARATE emit site — the Kalshi DISCOVERY boundary `markets.get_events_for_series`, not the alert pool — for the owner's leader-only cut: a non-leader chart board that is Entertainment, and names a `#N`/runner-up in its EVENT title (`_is_cut_runner_up_event`; music boards are cut too since 2026-10-27, owner steer "it's pretty underwhelming to report on number two") is dropped from every pool the discovery feeds (trending / movers / ask). Sports is a separate Kalshi category (kept), Elections/ballot measures are a separate category (kept), and a per-leg identifier `#N` (a video-game "Remake #3") is kept because the event title carries no rank. Carries `source=kalshi` + `category` (the event category) + `ticker`/`topic` in `detail`. **This is the WATCH for a BAD cut** — the failure is silent (a dropped board raises nothing), so the cut is instrumented instead: the **`runner_up_cut`** dashboard panel lists the cut TITLES by name (steady state is about 13 distinct boards per 12h — Netflix Movie/Show #2, Google #2, the Billboard Hot 100 / 200 #2 boards, the daily YouTube music-video runner-ups — so a new/unexpected title is the eyeball alarm), and the **"runner-up cut over-cutting"** monitor fires when the distinct-ticker count over 12h jumps past 20, i.e. a catalog/title change or a bug is over-cutting boards that should stay. Verified against the live Kalshi catalog (6,000 open events, 2026-08): cuts exactly 5 boards, keeps every sports rank board + ballot measure. The music runner-ups it kept then are cut since 2026-10-27. **`reason=netflix_views` (2026-08-22)** shares that same discovery emit site for a SURGICAL owner cut: only the Netflix VIEW-COUNT boards are dropped (`_is_cut_netflix_views_event`) — the "how many views will the #1 show/movie have this week" boards (`KXNETFLIXTOPVIEWSTV`, `KXNETFLIXTOPVIEWSMOVIE`), a dry number with no narrative. 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. Keyed on the `NETFLIXTOPVIEWS` ticker cue, 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`) is never swept in. The runner-up cut above only catches a `#N` board, and a view-count board names no rank, so it slipped past both; this cut removes it. Gated on the `CUT_NETFLIX_VIEW_MARKETS` env flag (default on) — set it falsey to let the view-count boards post again. Over-cut risk is near zero (the ticker cue is exact), so no dedicated over-cut monitor is earned — the generic `market_filtered` panel (grouped by `reason`) surfaces the `netflix_views` count. Carries the same `source=kalshi` + `category` + `ticker`/`topic` fields. **`reason=blocked_series` (2026-08-26)** shares the same discovery emit site for a whole-show BLACKLIST by ticker PREFIX (`_BLOCKED_TICKER_PREFIXES` + `_is_blocked_ticker`) — the Netflix cut generalized from one board type to whole series. A subcategory tag cut cannot do this job: Kalshi tags a show's series INCONSISTENTLY, so a room whitelisting the broad `Television` tag re-admitted Big Brother no matter which tags it picked, and the S27 winner series (`KXBIGBROTHER27`) carries ONLY `Television` — no tag catches it. The STABLE key is the series ticker: every Big Brother series starts with `KXBIGBROTHER`, so one prefix drops eviction / winner / rank / S27 / Brazil alike. **Big Brother has since MOVED into the per-guild menu (owner steer 2026-08-26), so `_BLOCKED_TICKER_PREFIXES` now ships EMPTY and `blocked_series` fires for NO show by default — Big Brother's cut now emits `muted_series` (below) instead.** The mechanism stays for a future all-surface block. **`reason=other_day` (#3560, 2026-09-21)** is the DATE scoping of game-line discovery: with the game's kickoff known, `kalshi_search` (and the series arm of `_kalshi_snapshots`) drops every candidate whose ticker encodes another Eastern date, or a start more than 4h away (a doubleheader), before the picker, and emits ONE row per search with `count` = the candidates dropped (not one per candidate: a series matchup drops ~20 per search, cached per game for 10 min). A high `count` is normal for MLB; the number to watch is the `bookie_live_reprice` `matched=false` rate for baseball rising after the change, which would mean the filter is dropping the right day too (a ticker-format change at Kalshi). When a global prefix IS set it cuts at FOUR sites so no discovery path bypasses it, each emitting this event: `list_series_by_category` (the PRIMARY drops/alerts/trending category pull — cut here so a blocked series is never ranked, which matters for the board path; the series never reaches `get_events_for_series`, so this is the emit site for that path), `get_events_for_series` (the series-arm fetch, before the /events call), and the EVENT-first arm the /ask + compare/combined tools use — `kalshi_search` (blocked events dropped from the FTS candidates before the last-mile picker) + `get_event_markets` (the direct event fetch, before the /markets call). All the skips return `[]` (clean skip, not a failure). Carries `source=kalshi` + `ticker`. **`reason=muted_series` (2026-08-26)** is the PER-GUILD sibling of `blocked_series`: a show a guild opted to mute on the /menu "mute shows" picker (`markets.SHOW_CATALOG` -> ticker prefixes, stored in `muted_series`). Unlike the global blacklist it is applied at the GUILD-AWARE layer -- `market_drop._drop_muted_shows` filters the pick pool, and `market_alert` filters its candidate pool via `_emit_filtered` -- so it carries `guild_id` too, and (in the alert path) only a WOULD-alert muted market is logged, like the other alert pool filters. Applies ONLY to the automated surfaces (drops + alerts), never to an explicit /ask. A RISING rate is HEALTHY (posts a guild chose not to see); the generic `market_filtered` panel grouped by `reason` surfaces it. Over-cut risk is near zero (an exact prefix), so no dedicated monitor is earned — the generic `market_filtered` panel grouped by `reason` surfaces the `blocked_series` count. **`reason=soft_price_tier` (#ella-dandelion, 2026-08-29)** is the market_DROP's twin of the alert's `soft_price`: a board (or a lone binary) STEPPED ASIDE because its top-priced leg carries no price the book's own best bid stands behind (`kalshi_price.bid_supported`). The picker then takes the next candidate, so the slot still posts. Emitted once per skipped board/binary with the event ticker; without it the skip is invisible and reads exactly like a board that was simply never hot enough to pick. It is a SKIP and not a tier downgrade for a measured reason: gating only the drop's conviction tiers left `market_cards.leader_figure` re-heroing the identical leg one layer down, which is the bad card minus two words. The shipped case it exists for: DANDELION's 40% was an 18-hour-old print over a 6c bid, so "DANDELION at 40% is the market's top pick for #2 on the Billboard 200" went out two minutes before the music desk posted the HITS projection with Dandelion at #3. Like `trailing_leg` and `ladder_rung`, the ops-monitor RENDERS it in the "Filtered markets" line and NEVER flags it -- a rising rate counts claims we no longer make. No monitor is earned yet: the live rate is 7% of frontrunner boards and 15% of locked ones, so the shape to watch is the OPPOSITE direction (a rate falling to zero would mean the stamp stopped reaching the tiers), which the generic `market_filtered` panel grouped by `reason` already shows. **`reason=settled_rung` (2026-09-21, the Fuerza Regida miss post)** is the alert pool cutting a NUMERIC rung -- a threshold or a bucket (`kalshi_ladder.is_numeric_rung`) -- that fell to zero (`_settled_out`) **on an event that holds at least two rungs** (`_LadderInfo.rung_count`, the neighbours test; a LONE numeric binary keeps its settled-out report, since there the leg is the whole market and no ladder lane holds an alternative -- Codex review on #3585): a rung that settles NO says the number landed outside its strike, and the story is that number (the ladder's forecast / its top settled rung -- the lead), never the strike a rung missed, so the leg posts neither as a swing nor as a settled-out "did not clear" report. Deliberate, rendered, never flagged; expected to be RARE, because a confirmed ladder's rungs already leave the pool as `ladder_rung` -- a steady `settled_rung` count means rungs are reaching the pool with their ladder UNCONFIRMED, which is the fetch (`get_event_markets(include_settled=True)`) going wrong. The incident behind it: Kalshi settles the rungs of an "at any point during the week" views ladder as the value passes them, the open-only event read returned two of fifteen legs, under the ladder floor, so the event was read as a two-horse race, the 24M rung was crowned its leader and posted as a miss. `decided_entity` (2026-09-06) is the entity outcome lane CLAIMING a named field that has collapsed onto one outcome: the lock's legs leave the swing pool so the result is confirmed and posted as a fact, never narrated as "tracking toward" (the Emmy incident); rendered, never flagged, and the count is the lane's liveness tell -- it fires whether or not the hunt confirmed. **`reason=soft_baseline` (the Emmy "Tie" incident, 2026-09-07)** is the lead-change lane RESETTING a stored leader baseline that names a leg the market never stood behind (a lone print over a zero bid, stamped `soft_priced` by the race fold) to the current leader instead of measuring a flip from it -- the guard against a second wrong post ("Kate O'Flynn overtook Tie") born from the first. Carries `source=kalshi` + `ticker` (the event) + `guild_id`. Expected to fire ONCE per poisoned row and then never; a sustained rate means a board whose leader keeps flipping into a soft print, worth a look. Rendered in the "Filtered markets" line, never flagged. **`reason=cracked_lock` `kind=artist_market_move` (2026-09-26, the Ed Sheeran / Gillette card)** is a SECOND emit site, `cogs/music_alert.py:_artist_market_move_signal`: the music artist-market move lane withheld a FALL on a market whose daily mids touched 95% or more inside the week (`music_markets.artist_market_move`). A lock that breaks means news decided the question, and the move blob carries only the new price for a fall, so the take would read the decided outcome as a live forecast ("Ed Sheeran performing at Gillette Stadium in 2026 is priced at 8%", posted after a nor'easter cancelled both shows). Carries `guild_id`, `source=kalshi`, `ticker` (the MARKET), `topic` (the event title), `move` (peak minus live price, in points). Fires once per 10-minute tick while the market stays in the lane's window; rendered, never flagged -- a rising rate counts posts no longer made. **`reason=unbacked_book` `kind=artist_market_move` (#3870, the Ye / Allegiant "9%" card)** is the same emit site: the move lane dropped a market because the live book does not stand behind the price the card would state (`kalshi_price.book_backs_move`: the price must sit inside the live bid/ask, and the YES bid backs a rise, the NO bid `1 - ask` backs a fall). It is checked AFTER `cracked_lock`, so a cracked lock on a wide book still logs as `cracked_lock`. Carries `guild_id`, `source=kalshi`, `ticker` (the MARKET), `topic` (the event title); no `move`. Same cadence, rendered, never flagged. **`reason=not_the_act` / `reason=act_check_failed` (#3447)** come from `music_markets.judged_title_act`, the act check on the artist-market lanes (`kind` = `artist_market_listing` \| `artist_market_settle` \| `artist_market_move` \| `artist_market_leader`). A title that names a watched act as a whole-token run goes to one cached Haiku YES/NO (`claude_api` purpose `market_act_check`). `not_the_act` is a NO: the title names somebody or something else (Derik Queen, the esports team VP.Future). `act_check_failed` is no verdict, and the lane skips the market. After a no verdict, no check is asked for 10 minutes (`_TITLE_ACT_COOLDOWN_SECONDS`), and every hit in that time also logs `act_check_failed` with no call, so an outage does not make one failing call per hit per tick. Carries `guild_id` (None when the cog has no artist-market guild bound), `source=kalshi`, `ticker` (the EVENT), `topic` (`: `). The ops monitor renders `not_the_act` and never flags it; it flags `act_check_failed` at low severity, because a steady rate means every artist market is skipped. A cached NO re-emits each time a lane re-reads the catalog; the panel counts distinct tickers. No new panel: the `markets_filtered` table groups by `reason` and `kind`. **`reason=cracked_lock` `kind=market_drop` (#4065)** is the THIRD emit site, `cogs/market_drop.py:_cracked_lock`: the scheduled drop withheld its hottest pick because a leg the card states FELL from a near-lock inside the week (the same rule as #3705, `_fell_from_lock`: now under 0.95, at least 15 points under the week's first daily mid, and some daily mid at or above 0.95). One batch `get_daily_mids` read per Kalshi pick; a failed read keeps the pick (fail-open). The drop then picks again with that topic avoided, at most twice. Carries `guild_id`, `source=kalshi`, `ticker` (the MARKET), `topic` (the board title), `move` (peak minus the stated price, in points). **`reason=unbacked_price` `kind=ending_soon` (#3878)** is the alert's ending-soon lane skipping a candidate because the live book does not back the price its blob would state (`market_alert._ending_soon_unbacked` -> `kalshi_price.book_backs_move`; the head-to-head "X the clear favorite at N%" read is tested on the NO side, `rising=False`). Emitted from `_pick_ending_soon` AFTER the pacing gates, so it counts only a market that would have posted; the next candidate still gets the slot. Carries `guild_id`, `ticker` (the market), `topic`, `resolves_in_days`; no `move`. Live sweep 2026-10-05: 161 of 3,785 ending-soon candidates (e.g. Knights vs Tuskers "sitting at 75%" on a 31c / 74c book). Logged at most ONCE per market per guild per UTC day (`MarketAlert._emit_once`), so the count is a count of markets, not of polls. **`reason=read_only_flip` `kind=lead_change` (#3878, coordinator review on #3887)** is the lead-change lane refusing a flip that exists only because the race read changed: since #3878 a runner-up's print under its bid reads at the bid, which can lift it past the leader with no market move. The race fold stamps the leader under the old read (`meta["print_leader"]`, `min(race read, trusted price)` per leg), and a flip posts only when the new leader leads on both reads. Carries `guild_id`, `ticker` (the EVENT); once per event per guild per day. Live 2026-10-05: 5 of 9,095 races changed leader between the two reads (e.g. `KXNFLFFLEADERTOP-27RBT10`, Jahmyr Gibbs printing 67c under a 72c bid). Expected to fade as the prints catch up. **`reason=unbacked_leg` (#3878)** is the market DROP's board read: after the soft-leader skip, every leg the board states reads at `kalshi_price.bid_backed_price`, and a leg priced over 10c that nobody bids on leaves the board. One row per board, with `source=kalshi`, `ticker` (the EVENT) and `count` (legs cut); a board left under its minimum outcome count is skipped and logs the same reason. Both are correctness guards: a rising rate counts prices we no longer state. **`reason=stale_print`** is the market DROP's liveness check on the one leg it states (a board's leader after the re-read, or a lone binary): the leg has a WIDE book (`kalshi_price.firm_book` is False) and traded under `KALSHI_ACTIVE_VOL_24H` (50) contracts in 24h, so its price is an old trade (`kalshi_price.stale_wide_print`), and the board steps aside. The drop pulls its pool with that floor OFF to see every leg of a board, and no other guard asked whether the stated leg traded. The case: the 2026-10-19 06:02 UTC drop "For the Billboard 200's Oct. 31 No. 1, HABIBTI sits first at 11%", posted to the room and to X, read one 10-contract trade from four days earlier on a 1c / 28c book. One row per skip, `source=kalshi`, `ticker` (the EVENT) and `topic`; rendered by the ops monitor, never flagged; it shows on the `markets_filtered` panel by reason. **`reason=leader_fabrication` (#2527, #4239)** is the alert's take gate refusing a line that names the wrong title. A LEADER take (a lead change, a confirmed ending-soon leader, a swing on the leading leg) on any chart board (YouTube daily charts plus `is_chart_slot_market`: Netflix, Billboard, Spotify) ships on the `take_names_leader_only` regex, else asks the Haiku `market_take_names_leader` judge (`claude_api purpose=market_take_check`); a False verdict is this reason. **`reason=leader_check_unavailable` (#4239)** is that judge erroring: the gate fails CLOSED, so a Haiku outage reads as a run of this reason. Any other take on a YouTube daily-chart board keeps the old check (it must name its leg, `take_names_a_leg`). Carries `guild_id`, `kind`, `ticker`, `topic`, and `post_preview` (the refused take) on both leader reasons. The ops monitor flags it at low severity like `self_correction`: a steady rate means the compose is still inventing acts despite the kworb grounding, or the matcher refuses a correct paraphrase. They render in the generic `markets_filtered` panel (grouped by `reason`, `kind`) with no new panel. No monitor is earned: the failure that matters is a WRONG price shipping, which these remove, not a cut. NOTE: `scripts/ops_monitor.py` does not yet list them as render-only, so its `market_movers_dropped` finding will flag them at low severity until that list is updated (#3884). `repeat_board` / `x_weak_board` (market_drop, `source`=the exchange, #4682): `repeat_board` is the fixed repeat skip -- the board's newest shipped take named the same leader at the same rounded % (the first figure after the leader's name), so the drop skips before the history judge ("Kneecap at 14%" shipped three times in a week while that judge said `history_unchanged` about 60 times). `x_weak_board` is a leaderboard-angle drop whose multi-leg board is led under 20% (`_X_MIN_BOARD_LEADER_PRICE`): the room post ships and the X mirror is skipped. Measured over the 7 days to 2026-10-23: `repeat_board` would have cut 4 of 62 drops (all 4 had reached X), and the X floor 9 of the 32 drops that reached X. Both land on the existing `markets_filtered` panel (it groups by reason). No monitor: each is a deliberate skip of a post with nothing new, and the panel shows its count; a sudden rise in `repeat_board` is worth a read (the history window is 7 days). | | `chart_take_gate` | utils/market_subject_image.py (`judge_chart_take`, CB-02), called by cogs/market_drop.py and cogs/market_alert.py | surface (`market_drop`\|`market_alert`), kind (`leader` = a take on the slot; `leg` = the alert's YouTube non-leader take with no leg phrase), ok (= the take ships), reason (`shipped`\|`leader_fabrication`\|`leader_check_unavailable`), matched (the old phrase test `take_names_leader_only` said the take names only the leader; it decides nothing since CB-02, so `matched=true, ok=false` counts takes the old fast path would have shipped and the judge dropped), decision (the board-shape label from `market_chart_family`: always `SLOT_TITLES` here), source (`model`\|`rule`: `rule` = the board-shape read failed and the title rules decided), + folded detail: ticker. One row per judged take. Panel: `chart_take_gate` ("Chart take gate") in scripts/axiom_setup.py. No monitor: the volume is a few takes a day, and a judge outage already shows as a run of `leader_check_unavailable` on the same panel. | | `market_corrected` | utils/music_markets.py (`apply_printed_units`, the shared desk sweep) | reason (`printed_units` is the only one today), source (`billboard`), + the folded `detail` map: ticker (the Kalshi event), topic (the release, <=80 chars), rank (the printed chart rank the figure was read at: 1 from the story headline before the Tuesday page flip, any top-10 rank once the page can pin it), printed (Billboard's figure, which the reading now carries), settled (the Kalshi figure it replaced). Every one of those five is FOLDED, so a panel reads them with `parse_json(detail)['<name>']`; read as top-level columns they come back null on every row — a SETTLED music reading was **restated on a more authoritative number** before any lane read it. This is the sweep's sibling of `market_filtered`: that one DROPS a bad reading, this one REPAIRS one. **Why it has to exist (#3627):** Kalshi settles an album-equivalent-units event on Luminate's RAW consumption count, and the Billboard 200 figure Billboard publishes is filtered DOWN from that same count, so the two are different numbers for one release and one tracking week — and every desk lane was stating Kalshi's while naming the Billboard 200. Measured over the six settled debuts we hold both figures for (2026-09-22): +7.9% on Rod Wave's `Don't Look Down` (106,778 against 99,000), +6.6% on `Westside Whimsy` (78,919 against 74,000), +5.4% on KATSEYE's `WILD` (179,122 against 170,000), then +1.3%, +0.8%, -0.2%. The shipped post that found it went to X on 2026-09-22: "Jhené Aiko's 'Westside Whimsy' closed its debut week with 78.9K album-equivalent units, her first No. 1 Billboard 200 album", a day after @chartdata printed 74,000. Emitted once per restated reading at the ONE shared sweep, so the desk, the alert, the newsroom, the mentions lane and `@Toots` all state one figure. **The direction to watch is ZERO, not high:** the two sources nearly never agree, so a zero rate while settled units readings keep posting means the story lookup has gone dark (a billboard.com shape change, the breaker open, a headline that stopped naming the release) and the Kalshi figures are shipping again — the `billboard_story` event is the upstream tell for a blocked or dead FETCH, and `chart_history:story` (which measures the PARSE since #3631) the tell for a page that still loads but stopped parsing. Neither catches the third way it goes dark: the sweep's HEADLINE matcher (`billboard_stories.leader_row_for`) no longer naming the release while the parse works, which still needs the denominator #3631 describes. A rising rate is simply the desk restating more debuts. | | `printed_units_sweep` | utils/music_markets.py (`apply_printed_units`, the shared desk sweep) | source (`billboard`), count (settled album-equivalent-units readings in this pass whose Billboard chart page is due: from the Wednesday, UTC, before the chart date), + folded `detail.restated` (units readings this pass moved onto the printed figure) — the DENOMINATOR `market_corrected` asked for (#3631). One row per sweep pass, and only when `count` > 0. The `chart_history` reveal reads (`story_leader`, `story_figures`) cannot be the denominator: the chart-history store's settled walk emits the same rows for old weeks, and a read whose title matched no page row emits nothing. **Graded:** the ops monitor files `printed_units_dark` (medium) when at least 3 such sweeps in the window restated nothing. The finding names the `billboard_story` ok rate, so the reader knows whether to look at the fetch or at the matcher. A release outside the printed top 10 has no printed figure and also reads as a miss, so check the chart page first. Panel `printed_units_sweep` on the desks board. | | `market_image` | utils/market_cards.py | guild_id, surface (drop\|alert), market_source (kalshi\|polymarket), image_source (the ladder rung that answered: `number`\|`bare_art`\|`chart`\|`banner`; `number` is the DEFAULT since bars went sports-only on 2026-08-08 -- a non-sports card heroes its leading leg, so `chart` belongs to the `bet_*` surfaces and a Sports-category drop/alert), ok; on `image_source=chart` also: outcomes (# lines drawn), with_series (# with REAL price history vs a flat line) — a market post's CARD IMAGE was resolved (the link-unfurl fix). kalshi.com links don't unfurl (Vercel bot wall serves Discord's crawler a "security checkpoint" instead of OG tags), so `cogs/market_drop` + `cogs/market_alert` build their OWN embed (clickable market link + her take as the embed description + image) instead of a bare link. The image is the self-rendered **chart card** (`utils/market_chart.py` — market name + each outcome's price line + big %, co-branded "Tootsies on {source}") on ~every post; the polymarket native image → committed floor banner are fallbacks only if the chart can't render. This records which RUNG answered so the ops monitor watches the mix + catches the chart silently failing (a spiking `banner` rate) or the candle fetch degrading (a `with_series`=0 rate). `ok` is True (a card image always resolves — the banner is the floor); `image_source` + `with_series` are the real signals. `bare_art` (2026-09-15) is the rung for a WORD hero over real art on a NON-MUSIC lane: the picture ships with no card drawn on it, because the post's heading and Toots' take already say what the card would stamp (the Emmy frontrunner card). A number hero keeps `number`, and so does every music card whatever its hero. Measured at 92 of 912 heroed cards a month, all on `alert` / `predictions` / `drop`. Watch the two as a MIX: ANY `bare_art` on a music surface is a bug in `is_music_lane`, and a rising share on the market surfaces means a figure builder started handing words where it used to hand a rank | | `rt_reconcile` | cogs/market_alert.py (the "final numbers" lane, #final-numbers) | guild_id, shipped (cleared the 0.6 reconcile gate + posted), score/reason (the `drop_score(reconcile=True)` self-gate), + folded `detail`: event_ticker (the settled KXRT event scored), verdict (`exact`\|`close`\|`miss`), reason=`void` (settled with no usable `expiration_value` -> resolved + nothing posted) \| `ungrounded_number`, post_preview — the RECONCILIATION half of the prediction surface: `market_alert` posts a Rotten Tomatoes score FORECAST card off a Kalshi `KXRT-*` scalar ladder (`~38.9 . Rotten Tomatoes score`), and once the film's reviews land and the market SETTLES this lane scores the forecast against the real Tomatometer (read off the settled market's `expiration_value`, `utils/rt_reconcile.parse_settled_rt_score`). The forecast we posted is logged at fire time in `rt_forecast_calls`; the hourly sweep (`rt_reconcile_tick`) reads the KXRT settled set once and scores each guild's matching forecast, delivering a `build_number_card` "— Final" card through the shared staging/room/X spine. Honest by construction — the `drop_score` reconcile rubric HARD-FAILS a spun miss or an inverted direction, and the pure verdict is arithmetic (`utils/rt_reconcile`). Gated by the `market_reconcile` experiment (default STAGING, separate from `market_alert` so a new scorecard auditions on its own) + mood + master kill switch. Dashboard: a `rt_reconcile` panel (ship + verdict split); no monitor earned yet — films settle rarely, so the shared desk ship-rate + the tick liveness cover a regression | | `rt_reconcile_tick` | cogs/market_alert.py | duration_ms, ok, + folded `detail`: settled_events (KXRT settled-set size, read once per sweep) and posted (Final cards shipped this sweep) — the hourly reconciliation sweep's liveness + latency (twin of `market_alert_tick`). A sweep that finds a settled forecast but never posts, or a settled_events that drops to 0 while KXRT is active, is the silent-degradation tell | | `card_take_conflict` | cogs/music_news.py (`_build_card`, reason `window`), utils/market_cards.py (`build_market_card`, reasons `rank` and `figure`) | surface, reason (`window` \| `rank` \| `figure`), guild_id — a card element that the take CONTRADICTED was dropped before the card shipped (#4383). `window`: a first-week block names its release window off the CLAIM (`_first_week_window`); when the claim names two windows, or the take names a different one, the block names none and this fires. `rank`: a `#N` hero whose subject the take gives another position (`market_cards.take_rank_conflict`, the "#2 Dai Dai" under "first at 32%" case), so the card heroes the subject's NAME instead. `figure`: the take states the number figure's `alt` and not its hero (`market_cards.swap_to_stated_alt`), so the card heroes the alt; when a coarse take number states both, the figure nearer the printed number wins (`output_checks.stated_gap`, the K/DA "2M plays" case). The Spotify-debut lane of `music_alert` sets the alt to the chart-day plays under a Songstats running total: "143.4K" under "1.9M first-day plays" (TRALALA), 5 of 8 such cards in 30 days. `via` (a fold field, `parse_json(detail)['via']`, `window` and `rank` only): `model` when Haiku read the window or the place (`ClaudeClient.release_window` / `take_rank_place`, owner rule 2026-10-18), `rule` when the model gave no answer and the old phrase rule decided. A rise in `rule` is the model failing; its calls are `claude_api purpose=release_window` / `take_rank_place`. Every field is shared or folded, so the event costs nothing against the field budget. Measured before the change: about one of each per month on X. **Panel:** `card_take_conflict` on the desks dashboard (daily count by reason). **No monitor:** the volume is a few a month and each row is a post that already shipped without the contradiction; a rising line is a compose bug to read off the panel, not an incident. | | `market_card_hero` | utils/market_cards.py (`emit_card_hero`, called from `build_market_card`'s number rung and from `build_number_card`) | guild_id, surface, kind (`figure`\|`headline`), source, + folded `detail`: figure, titled (`outcome`\|`question`), chart (the accent-block chart tag, absent when the category ladder tagged the card), block (the accent block that was DRAWN, always), rung (which rung of `card_block` drew it: `figure_tag`\|`chart`\|`event`\|`category`\|`sport`\|`market_category`\|`post_type`\|`caller`\|`bare_art`\|`none`) — WHAT A MARKET CARD ACTUALLY HEROED, one event per card, on every surface that draws one (the drop, the alert, the music newsroom, the cinema desk). The standing rule is that a card heroes the OUTCOME the market prices, never the market's confidence in it (owner steer #1918): a percent answers "how sure is the market", which is not the question the reader asked. `kind` names the MODE, and both healthy modes are here — `headline` is the outcome set big with the figure cleared, `figure` is a number the subject owns (`#1`, `~147K`, `105M`, `80+`, `NO`). `rung` is `bare_art` (2026-09-15) when the card drew NOTHING: a WORD hero over real art, on a non-music lane, ships the picture bare, so the event records the hero that was decided and the rung that replaced the render. A `bare_art` row on any music surface is a bug in `is_music_lane`, not a mix shift. **A bare percent in `figure` is the regression** (`where kind == 'figure' and figure matches regex @'^[0-9]{1,3}(\\.[0-9]+)?%$'`). WHY IT EXISTS (the 2026-09-03 sweep): the hero had NO telemetry outside the music newsroom. Measured over 14 days, 261 events carried a card hero and every one came from `music_news_verified` — the drop and the alert, the two highest-volume market surfaces, logged nothing about theirs. So a hero rung could stop firing and ship percent heroes indefinitely with the ops monitor blind to it; the only detector was the owner seeing the post, which is exactly how the Olivia Dean "41% odds" card was found. Every field is already in the schema vocabulary, so the event costs nothing against the Axiom field budget. Dashboard: the `card_hero` panel (shape mix per surface), the rank-filtered `card_chart`, plus `card_block` (which RUNG drew the accent block, across every card, per surface). The two block panels answer different questions and neither subsumes the other. The BLOCK TEXT itself has no panel on purpose: `detail.block` carries it on every row, so a rung mix that moves is one Axiom query away from the words, and a third panel would have said little the other two do not. Monitor: the ops-monitor `percent_hero` finding (medium) — flagged per SURFACE by RATE, over `PCT_HERO_RATE_CEILING` (34%) with at least `PCT_HERO_MIN_CARDS` (6) cards, because ONE rank-less binary per window is the known remaining shape (#2812) and a third of a surface's cards is a rung that regressed. Fail-open — the telemetry never breaks a card. **`block` + `rung` are the FOURTH rule (#3263), and they are what makes the third one diagnosable.** `chart` records what the chart/event rungs read and is absent otherwise, so a card tagged by the category ladder logged NOTHING about its block: the Miley Cyrus companion-projection card shipped `ENTERTAINMENT` over a first-week-units figure and no query could see it (#3261 — the owner found it by reading the post on X). `block` is the tag that was drawn, always; `rung` is which rung of `market_cards.card_block` answered. READ IT AS A MIX PER SURFACE, NEVER A LEVEL: `category` is the CORRECT block on most of the Entertainment board, so a level threshold would fire forever; what says something is a surface whose `category` / `post_type` share CLIMBS, which means a market family whose own words stopped being read. **No ops-monitor finding, deliberately** — the monitor's deterministic core compares a window against a fixed ceiling, and there is no honest ceiling here for the reason just given; the panel is the right instrument until someone wants window-over-window comparison, which the monitor does not do today. **`titled` is the SECOND rule on the same card (owner steer, 2026-09-06): the card titles itself with the OUTCOME, never with the market's QUESTION.** The rule reads the question's subject off `market_cards.outcome_split` — a verb registry over Kalshi's own prose, measured at 95% of the live board (`scripts/dryrun_market_outcome_title.py`) — and a question it cannot split KEEPS the title it always had, rather than ship a subject cut out of the middle of a name. That fallback is fail-open by design, which is exactly why it needs a rate: nothing raises, nothing looks broken, and the card simply goes back to asking instead of reporting. `titled` is stamped off the rendered title (a trailing `?` is the whole test, so it cannot disagree with what shipped). Dashboard: the `card_titled` panel (outcome vs question per surface). Monitor: the ops-monitor `question_title` finding (LOW) — per SURFACE by RATE, over `QUESTION_TITLE_RATE_CEILING` (50%) with at least `QUESTION_TITLE_MIN_CARDS` (10) cards. **The rate divides by cards CARRYING `titled`, never by every card the surface drew:** the `build_number_card` path (music desk, newsroom, cinema desk) draws cards with no `titled` at all — correctly, they are not market cards — and dividing by all heroes padded the denominator enough that a surface could be entirely question-titled on its MARKET cards and never reach the ceiling (measured the day the field shipped: `music_desk` drew 9 cards in 6h, not one a market card). Both thresholds sit looser than the `percent_hero` pair on purpose: a question title is a safe card, not a broken one, so this flags a market FAMILY the registry has stopped reading (new exchange phrasing) and not an ordinary tail. The fix it names is to re-run the sweep and extend `market_cards._OUTCOME_VERBS`. **`chart` is the THIRD rule on the same card (owner steer, 2026-09-08): the accent block names the CHART the market settles on** (`market_cards.chart_card_fields`, the registry in `market_chart_tag`; measured 146 of 8,000 open Kalshi events tag, all Entertainment, plus the artist YouTube view-count family that the alert posts most). `chart` is the tag drawn; it is absent when the category ladder tagged the card, which is the safe fallback working (the take under the card carries the chart, so nothing is lost; the card has had no caption since 2026-09-10). Dashboard: the `card_chart` panel, which stays RANK-FILTERED on purpose (Codex review on #3268) — a chart-registry gap shows up only among cards that hero a `#N`, and in the all-cards `card_block` mix below those few are buried under the large, legitimate population of non-rank cards that use `category` correctly. It now reads `detail.rung` rather than inferring the fallback from an empty `chart`. No monitor: the fallback is deterministic and legible, so a rising no-chart share on a surface is a registry gap to read off the panel, not an incident. | | `wire_throwback_check` | utils/retrospective.py (`prime_throwback`, B3 wave 19; called from cogs/music_news.py, cogs/pop_desk.py, cogs/cinema_news.py, cogs/sports_desk.py before `retrospective_note`) | surface (the desk), reason (`model` \| `current` \| `cached` \| `cached_current` \| `ungrounded` \| `error` \| `no_client`), ok (false on `ungrounded` / `error` / `no_client`), cache_hit — ONE wire post that no anchored `_FRAMES` regex matched, sent to Haiku (`ClaudeClient.throwback_frame`, `purpose=throwback_frame` on `claude_api`). A regex match costs no call and emits nothing. `model` means Haiku named the post's own time-frame words and `ground_frame` found them in the post; the desk then treats the post exactly like a regex throwback, and its `wire_retrospective` row carries `detail.frame=model`. `current` is a settled no (cached 30 days). `ungrounded` means the words were not in the post, or named this year or a later one; the answer is refused and not cached. Every non-`ok` reason leaves the regex verdict (no throwback), which is the behaviour before B3. **Why it exists:** the regex is anchored to the start of the post, and @Kalshi_Music writes the frame at the end ("... released 'Sunflower' 8 years ago today."). Measured over 21 days of real wire posts (2026-09-27 to 2026-10-18, 3,526 posts): the regex matched 63 throwbacks; Haiku agreed on all 63 and found 55 more, 53 of them real. **Panel:** `wire_throwback_check` on the desks dashboard (reason by surface). **No monitor:** every failure falls back to the old regex verdict, so a failure costs no more than the state before B3; a rising `error` share shows on the panel and on `claude_api purpose=throwback_frame ok=false`. A regression after merge reads as `['tootsies'] | where event == 'wire_retrospective' | summarize count() by tostring(parse_json(detail)['frame']), bin(_time, 1d)` (the `model` series) against the `music_news_scored reason=retro_unframed` drops. | | `market_card_send` | utils/market_cards.py (`send_market_card`, #2630) | ok (always false), reason (`text_fallback` = Discord rejected the card with HTTP 400 and the plain-text take shipped instead \| `failed` = nothing shipped), http_status (absent on a non-HTTP Discord error), surface (from the caller's `surface_scope`; absent when none is active) — A MARKET CARD THAT DID NOT LAND AS A CARD. `send_market_card` returns True on the text fallback, so every caller's `carded` flag and the betting board's card-gated state read it as a card. The 13-caller return-type change was declined (scope note on #2630): this event is the measurement instead. Before it the fallback logged nothing, and the `failed` branch's log line had 0 matches in the 30 days to 2026-10-25. A rich send emits nothing. Panel: `market_card_send` on the desks dashboard. **No monitor:** the rate is unknown and was 0 visible cases, so a ceiling would be a guess; a `text_fallback` row is one post that shipped without its card, and the remedy is the bad image URL behind it. Re-check after 30 days of data. | | `market_hero_parse` | utils/market_cards.py (`prime_outcome_splits`, #3424; called from `market_drop._compose`, `market_alert._fire`, and since #4552 `build_market_card` and the music_alert settle card) | surface (`market_drop` \| `market_alert` \| `music_alert` \| `music_desk` \| ...), reason (`model` \| `cached` \| `cached_none` \| `unsplit` \| `ungrounded` \| `error` \| `no_client`), ok (true when a split is now in use: `model` or `cached`), cache_hit, verdict (folded, on `ok` rows only, #4552: `plural` / `singular` = the model's agreeing verb ("now") grounded and decides the decided caption's verb form, "is" / "are" for "be"; `none` = it did not ground and the trailing-s rule `_outcome_plural` decides) — ONE market question sent to the Haiku split (`ClaudeClient.market_outcome_split`, `purpose=market_outcome_split` on `claude_api`), with the `_OUTCOME_VERBS` list in the prompt as examples. Until 2026-10-18 only the questions the regex did not split reached the model (73 of 2,901 "Will ..." questions, 2026-10-16); since then every question the drop and the alert card does, and the card reads the model split before the regex (classifier audit B1). Volume: the drop and the alert built 74 cards in the 7 days to 2026-10-18, so at most about 22 calls a day before the 30-day cache, about $0.0004 each. `ungrounded` means the model's subject + verb + rest was not the question's own text, in order, so the answer was refused and the card kept the question as its title; it is not cached. `unsplit` is the model saying the question has no main verb (cached 7 days). `error` is an API error or an unreadable reply. Every non-`ok` reason keeps today's title, so the event is the only place a degraded split shows. Read it with `market_card_hero`'s `titled` mix: a climbing `question` share with no `market_hero_parse` rows means the priming stopped running. Since #4209 a FIELD question that `_field_clause` does not read is primed too, in its "Will ..." form (`market_cards._field_candidate`), so its rows share this event. **Panel:** `market_hero_parse` on the desks dashboard (`scripts/axiom_setup.py`, reason by surface, #4209), and `market_hero_agree` (verdict by surface, #4552; a `none` share over about 5% means the agreement read broke, measured 4-5 of 260). Since #4552 the music lanes prime too, so add their volume (about 70 cards a day, most of them repeat questions inside the 30-day cache). **No monitor:** the volume is about 20 calls a day at most, too few for a rate monitor, and every failure falls back to the regex split, which is the pre-2026-10-18 title. | | `market_outcome` | cogs/market_alert.py (the decided-ladder outcome lane, #2655) | guild_id, ok, reason, shipped, score, post_preview, + folded `detail`: event_ticker, verdict (`exact`\|`close`\|`miss`), question, web_value, x_value, market_now — a DECIDED ladder (`ladder_is_decided`: the event happened, the board collapsed, Kalshi not yet settled — the GTA VI runtime window) triggered a hunt for the real result. TRUST MODEL (owner steer 2026-08-28 "kill grok if you have to" — the first dry run showed X doesn't carry niche numbers, 3 of 5 real outcomes declined on the X leg while the web read was exact every time): the WEB leg (Perplexity) must fully land — a non-X publisher citation + a number extracted by its own Haiku call (`outcome_extract`) — AND the confirmed value must match the collapsed board (the market's own traders are the second independent authority; the tolerance is GRADED — ~1.5 rungs when X corroborated, the strict half-rung sources must meet when it didn't, the live URBN catch: an uncorroborated web read confidently cited the COMPANY comp 8.4 for the BRAND board settled 6.2 and the loose bar would have posted it); GROK x_search is the BONUS corroborator — silent/absent proceeds (recorded in detail as `x_miss`: `no_x_client`\|`x_no_result`\|`x_no_value`, with `corroborated` true/false), but an X read that SPEAKS a different number still vetoes (`disagree`) (`market_consistent` — the wrong-subject guard: a decided board is near-certain, so a confirmed far number means the search answered a different question, never "the market was wrong"). The card grades the market's PRE-collapse forecast (`market_forecast_refs`), not the collapsed E[X] (which copied the answer). `ok=False` + `reason` names the decline (`no_web_client`\|`web_no_result`\|`web_uncited`\|`web_no_publisher` — the web leg must cite a real press outlet: an X mirror can be the very post Grok reads, and the prediction market's own page (kalshi.com/polymarket.com) is the board grading itself\|`web_no_value`\|`disagree` — X spoke and contradicts the press\|`market_contradicted`\|`no_forecast_ref` — a stored PRE-collapse baseline is REQUIRED: without one the only quotable "forecast" is the collapsed board's copied answer, a false forecast history, so the board gets no card; the move lane also never seeds a decided ladder's baseline for the same reason); a decline keeps #2654's plain suppression (absent over invented) and retries after a 6h deadline ENCODED IN the attempt state's value (`encode_retry`/`attempt_blocks` — restart-proof: a DurableCache L2 hit re-enters L1 under the default TTL, so a TTL-only retry row would pin for 3 days after a redeploy). `ok=True` carries shipped/score (the `drop_score(reconcile=True)` gate on the ladder kind; on `kind=entity` the score is `claude.winner_line_score`, the lane's persona-less faithfulness judge -- `claude_api purpose=winner_line_score`) or `reason=ungrounded_number`. Bounds: the `market_outcome` experiment (default STAGING — #bot-logs audition), a durable per-(guild,event) attempt state (`market_outcome_attempt` kv namespace: posted/rejected terminal under a 30-day `TERMINAL_TTL` so a long-open collapsed event can't re-post after the default row expires; declines retry after the encoded 6h deadline), and 1 paid hunt per guild TICK (a shared budget across every room's movers + trending passes). The card's source chip credits the RESULT's actual publisher — the web read's first cited domain (`cited_host`, e.g. `vice.com`) — never Kalshi (unsettled; it supplies only the sub's forecast); an uncited read ships the neutral unverified 'Reported' label. The lane RIDES the market_alert tick (its pools are the collapse detector), so market_alert OFF or a spent alert daily cap idles the hunts — deliberate; the `market_outcome` experiment narrows within that, never widens past it. Dashboard: a `market_outcome` panel (decline-reason mix + ship rate); no monitor yet — decided ladders are rare (0 of 634 live boards on #2654's sweep), so the panel + the `market_filtered reason=forecast_decided` count (which keeps firing either way) cover liveness. A rising `disagree`/`market_contradicted` rate is the extraction or the question-building drifting — that's the number to eyeball **`kind=entity` is the WHO-WINS twin (the Emmy incident, 2026-09-06; trial key `market_outcome_entity`, default STAGING).** A named field decided by the real event -- `market_outcome.entity_is_decided`: one leg a lock (>= 0.95, `DECIDED_LOCK_PRICE` == `SETTLED_LEADER_PCT`) the book's own bid stands behind, every sibling <= 5%, and the lock NEW (its `previous_price` under the line, so a standing 97% category never hunts and the tell switches itself off a day after a real result) -- hunts the winner's NAME from the market's CLOSED slate (`claude.winner_extract` returns an index or null, never a new name). Same decline vocabulary; `reason=market_contradicted` here means the press named a different candidate than the lock leg (`names_match`; live on the Creative Arts Emmys the Guest Actress in a Comedy field declined this way -- board 0.96 Gilpin, press twice Cherry Jones -- the honest fail-closed for a press/market disagreement); `reason=market_talk` (ok=True, shipped=False) is the composed line quoting a %, naming Kalshi or a market, or using the odds words after the one named re-ask also did -- terminal, and a rising rate means the compose framing lost to its context; `reason=void` is the settle sweep closing a field Kalshi settled with no YES leg. On the sweep's shipped row `detail.winner` is the lock the row recorded, `detail.web_pick` the leg Kalshi settled YES, and `detail.flipped=true` marks the two disagreeing (the settled leg posts as the official winner either way). `detail` carries `winner` / `web_pick` / `x_pick` / `prior` (the lock's day-ago price, the card's sub-line) / `market_now`; `detail.authority=kalshi_settlement` marks the sweep's OFFICIAL final -- the `market_outcome_calls` ledger row written at detection, read against Kalshi's raw markets on `reconcile_tick` (`settled_winner`), posted only when the press hunt never shipped (an attempt state of `posted` closes the row silently: one result, one card; a live `retry:hunting:` / `retry:settling:` HOLD state makes the other delivery path stand down for up to 15 minutes; the sweep reads the whole field in one 1000-row page, least-recently-checked row first; the lane runs only on events Kalshi marks `mutually_exclusive`). A ladder row carries no `kind`. Dashboard: the same `market_outcome` panel, now split by `kind`. No monitor, for the parent's reason (rare by construction); the liveness tell is `market_filtered reason=decided_entity`, which counts every claim whether or not the hunt confirmed. Expect it QUIET outside awards nights and election nights, and expect a burst on them: the 2026-09-06 Creative Arts Emmys would have produced two claims in one hour (Woodley, Harden Jr.). A rising `market_contradicted` rate means the extractor is answering a neighbouring category -- read the `web_pick` beside the `winner` before touching the tell. | | `market_projection` | cogs/market_drop.py (`_project_lone_market`, #2490/#2697) | guild_id, ok (a projection was read), kind (`threshold`\|`outcome`), reason (which rung answered, or why none did: `units`\|`rank`\|`no_projection`), + folded `detail`: ticker (the SOURCE event) — a LONE-leg Kalshi market tried to read a PROJECTION, so the card can report a number instead of the leg's bare percent. The **outcome** kind has TWO rungs (#2700, owner steer "either say the projection or the rank"): the cross-series companion ladder's first-week **units**, then the trade desk's projected chart **rank** (`reason` names which one answered). Emitted on EVERY attempt, both kinds, which is the point: a rung that quietly stopped resolving — Kalshi renaming a ladder subject, HITS moving a field, a series retired — raises nothing and skips nothing, and `reason=no_projection` now DROPS the slot, so without this event the surface would just go quiet. `reason=rank` rising while `units` falls means the companion-ladder match is degrading even though cards still ship — the failure the panel exists to catch, and one no error rate would show. The `market_projection` dashboard panel groups `ok` by `kind`; **no monitor is earned yet** — the outcome family is 4 open markets at a time, so a 12h rate is too sparse to gate on without flapping, and the panel's ok=false count is the eyeball alarm (on the live sweep all 4 open debut markets matched exactly one ladder, so steady state for `outcome` is ~0; a nonzero count is either a subject that stopped matching or the two-ladders-for-one-subject tie the reader refuses). Revisit if Kalshi grows the family. | | `companion_pair` | cogs/market_drop.py (`_companion_forecast`, #3458); reason from `utils/kalshi_ladder.py` `companion_pair_reason` | ok (one partner found), kind (`threshold`\|`outcome`), reason (`paired`\|`tie`\|`no_events`\|`unparsed`\|`no_partner`), count (companion events read), + folded `detail`: ticker (the SOURCE event) — ONE lone-leg companion pairing, emitted on every attempt that read the series (a failed series read emits only its `market_fetch`). Before this, a pairing that found no partner left no trace: the leg fell to the rank rung or dropped, and `market_projection` named only the rung. `unparsed` = the series has events and not one sub_title has the shape the match reads (`companion_sub_title_parses`): a Kalshi title-template change. `no_partner` = the sub_titles parse and none matched (usually a ladder that is not open yet). Panel `companion_pair` (Markets board). **Monitor:** the ops monitor counts only `unparsed` as a fail (`kalshi_companion_template`, `integration_unhealthy` at 60% over 5 or more pairings); `no_partner` (a ladder not open yet) and `no_events` (nothing open in the series between periods) are normal states, so counting them would fire falsely. Live rate before the change: about 24 attempts a day. | | `circuit_breaker` | utils/markets.py + utils/api_sports.py + utils/the_odds_api.py + utils/espn.py (via `utils/circuit_breaker.py`, #615 Kalshi / #725 SGO + API-Sports + Odds API / Polymarket / #1113 ESPN) | integration (`kalshi`/`sgo`/`api_sports`/`the_odds_api`/`polymarket`/`espn`), from / to (`closed`/`open`/`half_open`), failure_rate, samples — an outbound-client circuit-breaker state change. Same mechanism, one instance per client. Kalshi/SGO crater from the datacenter IP (50%+ failure windows); **API-Sports got its own breaker in #725** (it's the live-scores + final-**settlement** backbone), and since **#4669 it is one breaker PER HOST** with `sport` naming the host on each transition: each sport host is a separate plan with its own daily quota, and on 2026-10-21 the soccer and baseball hosts running out tripped the one shared breaker and short-circuited every NBA read while the basketball host had 89% of its quota left. A short-circuited API-Sports read now marks its `market_fetch` `ok=false error=breaker_open` (it used to log `ok=true result_count=0`, so the outage read as an empty slate). `api_sports.degraded` is true while ANY host is open; `degraded_for(sport)` reads one host (the ESPN MMA backstop gate), and **the Odds API got one too** (it's now load-bearing — the `/bet` slate's SGO-down source AND the settlement backstop). **Polymarket got one** once it became load-bearing for the live Bookie reprice — at parity with Kalshi (breaker + bounded retry on every read), so a Polymarket crater short-circuits straight to Kalshi with no wasted round-trips. Once the failure rate over the window crosses 50% with enough samples the breaker trips **OPEN** and **short-circuits every read — no network call** — for a cooldown, so the bot stops hammering the downhost and degrades gracefully (a peer source answers). Two `degraded` signals are load-bearing: the **API-Sports** breaker routes the bookie's settlement to the Odds API `/scores` backstop (payouts survive), and the **Odds API** breaker makes `get_odds` serve its **stale-on-error cache** (`_STALE_FALLBACK_SECONDS`, 10min) so `/bet` keeps showing recent games through a blip instead of blanking. After the cooldown one **HALF_OPEN** probe tests recovery: success → **CLOSED**, failure → re-OPEN. Each breaker is scoped to its client instance (a crater of one never stalls the others). Fail-open, pure + clock-injectable, telemetry-agnostic (the client wires this event as its transition hook) | | `odds_health` | cogs/commentator.py (via `utils/sportsdata/odds_health.py`, #968) | game_key, sport, sources (the source names compared), home_pcts (`{source: home implied %}`), max_divergence (max pairwise gap in home %, 0 if <2 sources), incoherent (sources whose sides don't sum to ~100±vig) — a **STATELESS cross-source odds snapshot** for one LIVE game. With every source for the game already in hand (SGO/enriched line via `game_sides_pct`, The Odds API via `event_sides_pct`, Polymarket + Kalshi via `prediction_sides_pct` — **no extra fetch**), the commentator emits each source's home implied % so a source that's **stale/wrong** (it diverges from the others) or **mis-parsed** (its sides don't sum sanely) is catchable with **no oracle — disagreement IS the signal** (the 37%-on-a-93%-game bug reads as a ~56pp divergence between a frozen book line and the live prediction markets). **#1458: a LIVE single-leg knockout ALSO gets a `match_winner` source** — the full-MATCH (win-by-method) line via `prediction_match_winner_sides_pct` (Kalshi Method of Victory, summed per team; one extra fetch but the Kalshi pick is cached + shared with the Bookie reprice). This is the one comparison the regulation-only sources CAN'T provide: at 0-0 full time EVERY regulation line collapses to the same 50/50 (both win legs → ~0), so they AGREE on the wrong number and the cross-check saw no outlier — the match-winner line (Spain ~71%) is what makes the stale regulation line an outlier, self-flagging the 50%-at-0-0 bug this PR fixes even if the fold ever silently stops engaging. Emitted only with **≥2 sources** (divergence needs a comparison); computed from THIS tick and forgotten (**no storage, no history** — owner steer). Pure helpers in `utils/sportsdata/odds_health.py` (`summarize_odds_health` + the per-source `*_sides_pct` extractors, all unit-tested); fully fail-open (observability never breaks commentary). Ops-monitor keys `odds_divergence` (gap ≥ `ODDS_DIVERGENCE_PP` 15pp → a source stale/wrong) + `odds_incoherent` (off-sum line → parse/mapping drift), and renders an "Odds health" line every run. Reactivity (did-it-move-on-a-goal) was deliberately **dropped** — it's the one signal needing stored per-tick history; divergence already catches a frozen source as an outlier | | `prediction_match` | cogs/commentator.py (via `utils/sportsdata/odds_health.py:diagnose_prediction_match`, measure-to-improve #kalshi) | source (`kalshi`\|`polymarket`), game_key, sport, matchup (away @ home, ≤80 chars), candidates (# raw snapshots the source returned before matching), stage (`no_coverage` = returned nothing / no coverage of this game/league; `no_match` = returned snapshots but none matched this game; `mapped_fail` = a snapshot matched but yielded no usable line; `ok` = produced a two/three-way line), reason (on `mapped_fail`: `unmapped_labels` = an outcome label didn't resolve to home/away/draw, the cross-provider NAME gap; `no_outcomes`; `incoherent_or_ambiguous` = labels mapped but the line was rejected downstream), unmapped (up to 5 offending outcome LABELS on `reason=unmapped_labels` — the actual failing team names to fix, e.g. a nickname/abbreviation that didn't substring-match the bookie's full name) — the per-source game-line FUNNEL for ONE live game, turning "**is Kalshi matching?**" into a queryable number + the exact names that missed. Emitted per prediction source on **every live game every tick** (comprehensive coverage, alongside `odds_health`, from the SAME already-fetched snapshots — no extra fetch). Pure `diagnose_prediction_match` (unit-tested); fully fail-open. Ops-monitor aggregates a render-only "**Prediction line funnel**" per source (the `no_coverage`/`no_match`/`mapped_fail`/`ok` distribution + the top `unmapped` labels) so the Kalshi-vs-Polymarket gap and its cause are watchable every run | | `bet_pricing_pass` | cogs/bookie.py (#3602) | phase (`fill` / `stale_live`), count (games given a slot); FOLDED into `detail`: candidates (games that needed a price), dropped (candidates minus count), sports (a per-sport map of slots taken) — one row per prediction-market pricing pass. The pass is capped at `_MAX_PREDICTION_PRICING` (24) and deals its slots round-robin across sports (#3576), which makes the starvation FAIR but does not stop it: with five sports on a busy night each gets four or five slots, and a game past the cap is simply absent from `/bet`. That absence raises no exception and no other event sees it, which is why `dropped` exists. The ops monitor turns a non-zero window total into a `cap_binding` finding (`bookie:pricing_cap`, medium); `scripts/axiom_setup.py` graphs candidates against dropped plus a per-sport slot table, and a monitor fires when anything is dropped over 3h. `candidates`/`dropped`/`sports` are existing FOLDED names, so the event costs nothing against the field budget | | `bet_settle_backstop` | cogs/bookie.py (#725) | source (`the_odds_api` \| `espn`), bets_settled — **Attributed by the settling ROW's `source` since #3569 (#3561):** the `api_sports` leg reads the hub's MERGED finals, ESPN rows included, so on 2026-09-20 ESPN paid every NFL bet under that leg's name and this event never fired; keying on the leg name was the bug. A row here now means a backstop-source final paid the bet, whichever leg carried it. the Odds API `/scores` PAYOUT BACKSTOP settled open bets because API-Sports (the primary final-score feed) was **down** (`api_sports.degraded`). The symmetric resilience to the `/bet` slate fix: a World Cup/NBA bet still pays out W/L through an API-Sports outage by settling off the Odds API final (matched by the cross-provider `match_key`, names aligned by `canonical_team` #739). Budget-safe: only for sports with open bets (`db.open_bet_sports`), reusing the shared idempotent `_settle_game`. **The `degraded` gate is GONE and this is no longer an outage marker (#2465/#2466).** A row here means the backstop won the settlement on the merits, not that API-Sports was down -- reading it the old way inverts what it says. Since #3552 the `the_odds_api` leg runs only while ESPN cannot settle (`Bookie._espn_settling` False), so `source=the_odds_api` rows should be RARE and `source=espn` the normal shape; a `the_odds_api` row on a day ESPN was healthy is worth a look. It had to change because the failure was invisible to a breaker: the API-Sports **baseball** and **american-football** hosts sit on a free plan that reads only seasons 2022-2024 and answer a current-season query with HTTP 200 plus an empty body, while the healthy soccer/basketball hosts on the SAME shared breaker kept it CLOSED. So `api_sports.degraded` was never true, the backstop never fired, and current-season MLB/NFL bets would have voided at the 96h stale-void. Verified 2026-08-31: 7 bets settled over 45 days and **4 came through this leg**, against zero in the 45 days before #2465. So it is no longer true that it normally never fires -- on the current bet mix it carries the majority. A run of ZERO here while MLB/NFL bets are open is now the thing worth asking about. **ESPN is now a SECOND backstop source on this event (#2466).** The Odds-API leg is metered, so a quota lapse or a key failure would have left MLB and NFL with no settler at all. ESPN is free and keyless and carries the full final scoreline for both, so it is registered as the third layer and its settlements are counted here too. Read `source` to tell the layers apart: `the_odds_api` is the metered first backstop, `espn` the free second one. `backstop_settled` on `bet_settle_tick` now SUMS both legs rather than holding the Odds-API leg alone. The dashboard panel is `Settlement BACKSTOP payouts by layer` on the Bookie board. No dedicated monitor: a window of zero here is the healthy case whenever API-Sports settles everything, so it would alert on nothing, and the real alarm -- settlement has stopped paying out -- is already the `bet_settle_tick` stalled monitor plus the 96h stale-void | | `bet_board_scored` | cogs/betting_board.py | guild_id, score, reason, shipped, sport, matchup, post_preview, + `source` on the pregame lane (#2653): the line's `meta["odds_source"]` stamp (`kalshi` / `polymarket` = a prediction fold, `the_odds_api` / `espn` = a book fold, null = an unstamped line the provider priced itself). Query `where event == 'bet_board_scored' | summarize count() by source` to see how many pregame lines ride a prediction fold. A fold stamped `prediction` (no known venue) never reaches the gate: it emits `phase=declined reason=unknown_odds_source` on this same kind. The take and board credit the venue by name, never "the sportsbook", for a prediction fold. | | `bet_board_posted` / `bet_alert_posted` / `bet_value_posted` `detail.implied_pcts` / `bet_value_scored` `reason=card_mismatch` | cogs/betting_board.py, cogs/betting_alert.py, cogs/betting_value.py (#2676) | One post states one number per side, and it is the market CARD's. When a Kalshi/Polymarket card ships, the take is composed off the percents that card draws (`market_cards.card_outcome_pcts` on the same snapshot, mapped to sides by `betting_board.card_side_pcts`), and the post stamps them as the folded `implied_pcts` (`{side name: pct}`, the same field `bookie_live_reprice` uses for the percents a fold wrote). No stamp means the take quoted the book (no card, or a card that could not carry the take, so a book-line card or plain text shipped). A matched card the take cannot quote emits `market_filtered` `reason=card_cannot_carry_take` with `kind` = the surface, so a rise in dropped cards (an exchange renaming its legs) shows in `market_filtered` counts instead of reading as "no market". `bet_value_posted` stamps `implied_pcts` too ({value side: card pct}). `bet_value` states the book side as odds and declines with `phase=declined reason=card_mismatch` when the card does not draw the value side or the book has no price for it. Before the change, 144 of 156 matched-market posts in 31 days stated a percent their card did not draw. Dashboard panel `bet_take_vs_card` counts each stated percent in `post_preview` that is not in `implied_pcts`; `off_card` must be 0. No monitor: the rule holds by construction and the unit tests guard it, so a non-zero row means a code regression, and the panel shows it. **#4391:** `bet_value` composes on the `value` angle and scores with `drop_score(value=True)`, and it runs the `ungrounded_numbers` backstop before the gate: a take with a figure the blob never gave emits `bet_value_scored` `reason=ungrounded_number` with `detail.numbers`, score 0.0. A backstop drop counts in the existing `bet_surfaces_gate` panel's ship rate. No new monitor: the surface emitted no events in the 30 days to 2026-10-24. | | `bet_slate_narrowed` | cogs/betting_board.py (`bettable_slate`, #mlb-menu-visibility) | guild_id, surface (`bet_board` \| `bet_alert`), + folded `detail`: kept, dropped, sports (the cut sport keys), watched (the guild's coverage picker) — the `/menu` COVERAGE picker cut games out of a betting POST surface's slate. The two `/menu` sports pickers answer different questions: the BETTING picker says what `/bet` takes bets on, and the COVERAGE picker says what Toots covers at all. The post surfaces (the odds board + the line-move alert) now require BOTH, so a guild can keep taking MLB bets while its board never names MLB. The owner asked for this on 2026-09-14 after a morning board came back five-sixths MLB for a guild with no MLB in its coverage picker. **This event exists because the narrowing is otherwise SILENT** — it raises no error and writes no post, so a board that goes quiet because a mod unticked a row looks exactly like a dark feed. It is emitted ONLY when it actually cuts something, so a slate that loses nothing costs no events; the expected steady state on the master guild is one firing per 5-minute tick per surface while the betting picker holds a sport the coverage picker does not. `kept=0` is the interesting value: the surface is dark for that guild, and `detail.sports` names the picker row to check. An unset or empty coverage picker covers NO sports (#2765, owner decision 2026-10-15), so it emits `kept=0` with `detail.watched=[]` on every tick the betting picker has games; before #2765 it narrowed nothing. A coverage READ ERROR keeps the full slate and emits `error source=bet_board_watched` instead — a DB blip must not silence a surface. Since #4218 the coverage picker is read before the Bookie slate: a guild that covers no sports emits `kept=0`, `dropped=0` and `reason=no_coverage` (a shared field) and the slate is NOT fetched, so `dropped` no longer counts games for that case. The panel still counts it under `blackouts`. Graphed on the `bet_slate_narrowed` panel; no monitor, because a nonzero rate is the filter WORKING and the rate the owner would want flagged (`kept=0`) is already the `surface_dark` finding's job. | | `bet_awards_posted` | cogs/bet_awards.py (#4850) | guild_id, period (`week` \| `month`), count (# leagues on the post), cards (# award cards rendered), delivered (`room`), ok, reason (`no_bets` \| `no_channel` \| `send_failed` on ok=false) — the per-league bet winner post ran for one guild. A league with no settled bets in the window has no row, so `count` is the leagues that did. `ok=false reason=no_bets` is a quiet window, not a fault. Panel: `Bet winner posts by period + outcome` on the Bookie board. **No `OkRate` health line, on purpose:** the post fires about once a week per guild, so a 12h ops-monitor window holds zero or one sample, and a rate over that is noise. The failure paths are covered another way: every failed send goes through `emit_error(source=bet_awards, recoverable=True)`, so error triage sees it, and `no_channel` / `send_failed` release the window claim so the next hourly tick retries on the same ET day. | | `bet_awards_tick` | cogs/bet_awards.py (#4850) | period (the periods due now comma-joined, or `none`), count (# configured guilds checked) — the scheduler's HOURLY tick, emitted every hour, due or not. A period is due on its ET day (Tuesday / the 1st) from noon ET to the end of that day, so a restart across noon still posts on the next tick; the window claim (`db.claim_bet_awards`, a `post_dedup_history` row under the `bet_awards` unique index) makes it post once. It is the loop's heartbeat. Monitor: `Tootsies: bet winners scheduler stalled` (no tick in 3h). Why not `surface_dark`: that finding reads self-gate judge scores, and this post has no model text and no judge. Why a tick and not the post: the post is weekly, and the longest monitor range in use here is 24h. | | `bet_leaderboard` | cogs/bookie.py (`show_leaderboard`, #4849) | guild_id, user_id, league (a `utils/bet_leagues.py` key, or `all`), period (`week` \| `month` \| `all`), contributors (# rows on the board; 0 = an empty board) — someone opened `/leaderboard bets`. A filtered board (a league, or week/month) reads `db.bookie_window_standings` and counts bets by `settled_at`; a week runs Tuesday 00:00 ET to Tuesday 00:00 ET. Panel: `/leaderboard bets views by sport + period` on the Bookie board. No monitor: it is an on-demand read with no failure mode of its own (a DB error raises to the command handler and lands in `error`). | | `bookie_key_migration` (league backfill) | cogs/bookie.py (`cog_load`, #4849) | stored (# bets given a league), unmapped (# bets left NULL) — the startup backfill of `bookie_bets.league` from the stored sport prefix + matchup (`utils/bet_leagues.league_for_stored`). Emitted on a boot where any bet still has no league. `set_bet_leagues` writes only NULL rows, so a re-run changes nothing. A bet no single league fits (one NBA and one WNBA team, a team outside the rosters) stays NULL and counts only on the all-sports board. Panel: `Bet league backfill` on the Bookie board. No monitor: `unmapped` is a slow data-quality number, read on the panel. | | `bookie_live_reprice` | cogs/bookie.py | game_key, sport, source (`polymarket`\|`kalshi`\|`none`), repriced (bool), matched (bool), sides (# bettable sides after), line_type (`moneyline`\|`match_winner`, only meaningful when repriced) — a **LIVE** game whose sportsbook line was stale / incoherent / absent was repriced off the **prediction markets'** live implied %s. SGO is our only live sportsbook feed; when it's down (its monthly entity cap recurs exhausted) the Odds API h2h fallback is a **pre-match-style line that doesn't reprice in play** (the frozen Norway −205 / Draw +1000 served on a live 0-0 game — the screenshot bug), while the prediction markets move in real time. `_supplement_with_live_predictions` reprices a live game `_sides` finds no coherent line for, fetching **Polymarket AND Kalshi CONCURRENTLY as a unified resilient enricher** (#prefer-kalshi: KALSHI preferred — CFTC-regulated + US-web-bettable — Polymarket the fallback, and kept for a SOCCER game whose Kalshi line came back a draw-less 2-way, via the `usable_prediction_line` guard so a soccer draw is never dropped; feed list derived from the source-capability matrix `utils/sportsdata/sources.py`, each feed circuit-breaker + retry guarded) and pricing off the first that yields a **coherent** line (a sanity guard, `three_way_coherent`, rejects a single-digit-% draw / near-decided market before it reaches a bettor; an incoherent fold is cleared via `clear_moneyline` so no half-line lingers). **`matched=True, repriced=False`** is the defensive-parsing / silent-thinning signal: a market for the game EXISTED but yielded no coherent line (schema drift, e.g. a Polymarket label change, or a near-decided market) — distinct from `matched=False` (no coverage). **ODDS-ONLY** via `prediction_line_odds`/`fold_prediction_as_line`: maps outcomes to home/away/draw through our internal `canonical_team` mapping (the SAME key `match_key`/settlement use; fail-safe on an ambiguous name) and writes only `game.odds` — never `home`/`away`/`title`/`meta`, so reconciliation is provably unaffected. Fail-open per game + per feed. **#968: also carries `implied_pcts`** (a demap'd `{side: pct}` JSON string of the ACTUAL implied %s the fold wrote — ONE field, not three `home_pct`/`draw_pct`/`away_pct` fields, #1296; before this, `repriced=true` told us a fold happened but never WHAT it priced, so a stale 37%-on-a-93%-game fold was invisible in logs). **MUST-WIN (single-leg knockout) games are priced off the MATCH-WINNER line, not the Regulation-Time moneyline (`line_type=match_winner`):** a knockout is settled on who wins the MATCH (`_winning_team` pays the advancer via ET/penalties, #1120), but the regulation 1X2 market's two win legs both COLLAPSE to ~0 at 0-0 full time and renormalize to a meaningless 50/50, then the near-resolved floor drops the line entirely — the Spain vs Argentina 2026 final, where bettors saw a 50% card then no odds heading to penalties, while Polymarket/Kalshi plainly showed Spain ~71%. So for a game that clears `is_single_leg_knockout` (the same predicate the draw-suppression uses), the reprice fetches Kalshi with `KALSHI_INTENT_MATCH_WINNER` (a prose intent — the Haiku picker lands on the per-game 'Method of Victory' market, NOT the Regulation moneyline; no ticker hardcoding, so it generalizes across competitions) and folds it via `fold_prediction_match_winner`/`prediction_match_winner_odds`, which SUMS each team's win-by-method legs (Reg + Extra Time + Penalty Shootout) into a 2-way — the true full-time line that stays correct through ET/pens. It's gated Kalshi-only-in-practice (Polymarket's per-game event is Regulation-scoped, so its snapshot carries a Draw leg and the summed fold bails to the normal path), fully fail-open (no MoV market → the existing moneyline path runs, nothing regresses), and the summed fold normalizes by the total so an over-round leg set can't crash the American converter. The reprice runs on the bettable-slate refresh (~60s) and `fetch_prediction_snapshots` is deliberately UNCACHED, so each fold uses genuinely fresh prediction odds; the bettor reads the slate cache, so a bet's line is as fresh as that ~60s fold. **The warm loop re-folds live games even when betting is idle (#968):** during an active session it runs the full metered refresh, but when idle AND a game is live it does a CHEAP re-fold of just the live games' odds off the FREE prediction markets (`_supplement_with_live_predictions(force_refresh=True)` — re-folds an already-prediction-sourced line, leaves an SGO self-priced one) — closing the gap where the commentator pulled fresh odds for a live game but the picker sat stale because no `/bet` was driving the slate refresh. There is deliberately no placement-time rejection guard (removed per owner: a stale lock is rare enough with the fresh fold that a re-quote isn't worth the UX) **`reason`** (`resolved`\|`unparsed`, only when matched and NOT repriced, else None): WHY a matched market gave no line -- `resolved` = a priced side sits under the near-0 floor (`_MIN_LIVE_SIDE_PROB`), the market is decided and folds to nothing by rule (a 34-3 blowout); `unparsed` = no two-sided line mapped (an unmapped label, a duplicate side, a soccer draw missing), the defensive-parsing / silent-thinning failure; judged on the MAPPED home/away legs only (`format.prediction_line_reject_reason`), so an unrelated tiny third leg is not read as a decided game. When several sources matched and none repriced, `unparsed` wins over `resolved` whatever the source order. A draw-carrying sport whose draw leg is missing or mislabeled is `unparsed` (the fold's structural rejection comes before the floor). **`live`** (folded into `detail`): was the game live, or an UPCOMING unpriced fill. **`start_ts`** (folded): the game instance's kickoff, since `game_key` is date-less and a doubleheader shares it. **Ops monitor (#3550):** rated PER GAME INSTANCE as `bookie_reprice:<sport>:<game_key>@<start_ts>` in the integration-health table (a doubleheader's priced game 1 cannot carry a failing game 2), LIVE attempts only (`parse_json(detail)['live']`): an upcoming miss is not final, the ESPN pregame fallback runs after this pass and can still price the game. `ok` = repriced, or matched with `reason=resolved`; `matched=False` (no market found) and `reason=unparsed` are the misses. Per game, not per sport, because on 2026-09-20 three of eight live NFL games missed 100% of attempts for hours (under the 60% floor as a sport-wide rate) with no finding anywhere. `aggregate` dedups this kind on `(event, ts, game_key, start_ts)`: a refresh prices its games concurrently and they share a millisecond `ts`. | | `usage_fetch` | utils/markets.py + utils/api_sports.py + utils/the_odds_api.py + utils/tts.py + utils/github.py + utils/twitterio.py (#727, #753, #1255) | source (`sgo`/`api_sports`/`the_odds_api`/`elevenlabs`/`github`/`twitterio`), ok, duration_ms; on failure: error (+ http_status on an HTTP miss) — one metered-source usage read, the cheap authoritative call behind the `quota` poll. SGO `/account/usage` is **quota-EXEMPT** (it answers even while every other SGO read 429s on the entity cap — exactly when you need it); API-Sports `/status` is a free account read; the Odds API refreshes its free header budget via `/sports`; **ElevenLabs `/v1/user/subscription` is a free read of the monthly character cap (#753; may 401 if the key lacks the `user_read` scope → `ok=False`); GitHub `/rate_limit` is free and doesn't count against the limit (#753); twitterapi.io `/oapi/my/info` is the quota-exempt prepaid CREDIT-BALANCE read — NOT breaker/rate-limiter guarded, so it answers even while the paid reads fail, which is exactly when you need the balance (#1255)**. Highlightly, **Giphy, and OpenAI** have no usage endpoint, so they're read passively off captured response headers (no `usage_fetch`) | | `quota` | cogs/health.py (#727, #753, #1255) | source (`sgo`/`api_sports`/`the_odds_api`/`highlightly`/`elevenlabs`/`github`/`giphy`/`openai`/`twitterio`), ok; on ok=True: tier, metric (`month_entities`/`day_requests`/`credits`/`month_characters`/`hour_core_requests`/`day_searches`/`minute_requests`/…), period, used, limit (None = source reports it unlimited), remaining, pct (used/limit, None when unlimited); on ok=False: error — a metered API's usage vs its cap, emitted per (source, metric) by the health-watch usage poll (~30min) so the ops-monitor flags a quota nearing exhaustion (`quota_low`, high) **before** it 429s. **#1255** added **twitterio** (`credits`, the curator's X source): a PREPAID wallet with no fixed cap, so `limit`/`pct` are None and it can't flag via the generic pct-based `quota_low`; instead the ops-monitor fires a dedicated balance-FLOOR finding (`twitterio:budget`, high) when `remaining` drops under `TWITTERIO_CREDITS_LOW` — the same prepaid-wallet model as the Odds API's own budget finding. **#753** extended the poll to **ElevenLabs** (`month_characters`, the voice-budget SGO-entities analog), **GitHub** (`hour_core_requests`, the REST-core hourly cap the `/order` filing path spends against), **Giphy** (`day_searches`, daily cap captured passively off search headers), and **OpenAI** (`minute_requests`, per-minute RPM captured passively off embed headers — `period="minute"` is **INFORMATIONAL**, rendered but never flagged since a rolling rate can't fill ahead of time; the ops-monitor's `_INFORMATIONAL_QUOTA_PERIODS` guard on `QuotaReading.low` enforces this). The early-warning that was missing when SGO's monthly **ENTITY** cap (100k, `rookie` tier) silently filled and took `/bet` dark while API-Sports sat at 90% of its daily request cap. Rendered every run in the ops report's "Metered-API usage quotas" table; on-demand via the token-gated `GET /debug/usage`. `the_odds_api` keeps its own real-time budget finding (off `market_fetch` `credits_remaining`, #734), so it's excluded from the `quota`-driven finding to avoid a double-flag. **`songstats` survives a restart (#3908).** Its `day_budget` (our `SONGSTATS_DAILY_CAP` counter) and `day_requests` (RapidAPI's `x-ratelimit-requests-limit`/`-remaining`, the `remaining` field) read the client's per-UTC-day `kv_cache` row (namespace `songstats_spend`, key = ISO date). The client loads it on the first Songstats read of the day, a cached read included, and writes it after each live attempt. Before, each restart set `used` to 0 (4 restarts in 20 minutes on 2026-10-06) and `day_requests` was absent until the first live call of the boot. A new UTC day clears the stored `remaining`. A failed DB read or write emits `error source=songstats_spend recoverable=True` and the in-process counter stays in use | | `music_fallback` | cogs/music.py | guild_id, channel_id, channel_name, reason (`claude_returned_empty` \| `self_correction` \| `missing_artist` \| `date_hook`: the slot was skipped) | | `music_scored` | cogs/music.py | guild_id, channel_id, channel_name, score, reason, must_post, post_preview; a deterministic guard's drop is `shipped=False phase=shape_reject` with `reason` = `self_correction` \| `cut_qualifier` \| `missing_artist` (the take names none of the artist's words, `detail.artist`, 2026-10-20) \| `date_hook` (the take ties the pick to an anniversary or a birthday, `detail.hook` names the pattern and `detail.words` the matched words, #4688, 2026-10-23; it runs before the artist guard; measured 3 of 33 composes in the 30 days to 2026-10-23, so expect a few a month; it rides the existing `desk_shape_reject` panel, which groups by reason, and no monitor, because a firing is the guard working) and `matchup` = `<lane>: <channel>`; a `cut_qualifier` drop's matchup starts with the check outcome, or `broken_cut` / `lost_artist` (the cut took the artist out) | | `awards_posted` | cogs/awards.py (#1017) | guild_id, period (week\|month), ok; on ok=True also: channel_id (None when staged), awards (# categories handed out), winners (# people tagged), manual (a mod's `/awards` vs the scheduler), delivered (`room` = posted to the awards channel; `staged` = auditioned in #bot-logs -- **awards graduated 2026-09-01 (#2796), so it always resolves PRODUCTION and `staged` can no longer occur**), cards (# themed champion-card images rendered), hypes (# per-award memory HYPE-UPS that generated — `claude.awards_hype` on the per-guild awards model, default Opus; `hypes`=0 with winners is the silent-degradation signal that the memory/model came back empty across every award), rank (FOLDED into `detail` — query with `parse_json(detail)['rank']`; the first RANKED award's real place on the board it was crowned off. **ABSENT when the post has no ranked headline** — a window with a besties pair but no reactions or replies omits MVP and Funniest, and besties is a relationship award with no board (rank 0); emitting that 0 would count the row in the audit's `n` but never in `countif(r > 1)`, making a headline-less post read as board-leading, so the field is omitted instead: 1 = the board leader, higher = the rolling anti-repeat skipped past recent winners to reach them, and `AwardSpec.measure` drops the superlative from the post's wording to match. Added 2026-08-30 after an accuracy audit had to REPLAY 5 weeks of engagement snapshots to answer "was the crown the top of the board" — the answer was no in 3 of 5, once at rank 4, and no event recorded it. `['tootsies'] | where event == 'awards_posted' and ok == true and delivered == 'room' | extend r=toint(parse_json(detail)['rank']) | summarize off_top=countif(r > 1), n=count() by period` is the standing check — `delivered == 'room'` is REQUIRED, since a STAGING audition also emits ok=true and deliberately bypasses idempotency, so repeated mod previews would otherwise swamp the real crowns this measures); on ok=False also: reason (`no_channel` = production but none set on `/menu`; `no_window` = no seed / no snapshot to diff against, run `/stats period:`; `no_winners` = the window was too quiet; `empty` = the voice call came back blank) — the weekly (single-award "Player of the Week", same fanfare as the monthly, Sundays) / monthly (gallery led by the "{month} MVP" e.g. "June MVP" + funniest/besties, the 1st) engagement awards posted (weekly + monthly headline are the SAME engagement metric titled per cadence; NO OVR/"Player of the Month" award). Each award is the top of a WINDOWED board (seed − the period snapshot); pings the winners. Gated by the `awards` experiment (off skips, staging auditions in #bot-logs, production posts to the awards channel) + master kill switch + mood != off; idempotent per ISO-week / per-month with anti-repeat over the prior period's winners | | `curator_fetch` | utils/twitterio.py (#curator) | phase (user\|search\|**trends**), handle, count (# posts / trends parsed), ok, duration_ms; on a `phase=user` timeline read also **`guild_id` + `stop_reason` + `pages`** (#curate truncation audit): the fetch is PER-GUILD (each guild passes its OWN seen-set as `stop_ids`, so the paged walk terminates at a different depth per guild — `guild_id` makes it splittable), and `stop_reason` records WHY the walk stopped — `hit_seen` (reached an already-posted tweet = fully CAUGHT UP, nothing dropped), `page_end` (endpoint `has_next_page=false`), `cursor_end` (has_next_page true but no `next_cursor`), `max_pages` (walked the whole `_MAX_PAGES_PER_FETCH` cap and STILL no seen id = **unambiguous truncation**, more backlog than we can fetch), `limit` (legacy no-`stop_ids` single-window cap), `page_error` (a later page failed, kept page 1). A `page_end`/`cursor_end`/`max_pages` (never `hit_seen`) with `count` at a full-page boundary is the truncation signature; a `hit_seen` means the drop is DOWNSTREAM (best-home routing / fit-judge, see `curate_routing`/`curator_evaluated`), not the fetch. `pages` (# pages walked) folds into `detail`. **`undated`** (also folded) is how many of the returned posts carried NO publish time, and it is the tripwire for a silent fail-open: `utils.curate.is_fresh` reads an absent stamp as fresh, so when a platform's time field goes null upstream the 24h curation window stops filtering that platform and NOTHING raises. That is not hypothetical — Instagram's v1 `taken_at_timestamp` and `created_at` both went null on every post of every handle, and the artist curator shipped a seven-month-old Beyonce post as a fresh pick (2026-09-15, fixed by moving the IG read to `/v2/instagram/user/posts`). Reported by EVERY timeline read on every platform -- X (`utils/twitterio.py`), Instagram and TikTok (`utils/scrapecreators.py`) -- so the tripwire covers the whole class, not just the provider that broke. `undated` equal to `count` on a `phase=user` read is that state: `['tootsies'] \| where event == 'curator_fetch' and phase == 'user' \| extend u=toint(parse_json(detail)['undated']) \| summarize dead=sum(u), posts=sum(count) by platform`. On a miss: error (unprovisioned\|no_handle\|empty_query\|HTTP <status>\|breaker_open\|<exc>) — one twitterapi.io read for the content curator (the fresh-posts fetch from a seed account) OR, `phase=trends` (#1272), X's NATIVE trending topics (`/twitter/trends`) that source the discourse trending-clip surface. The fail-open X source; ops-monitor tracks its integration health (a silent crater = the curator quietly stops finding posts). The metered client is guarded like SGO (rate limiter + circuit breaker + retry); a crater trips its own `circuit_breaker` (`integration=twitterio`) | | `curator_posted` | cogs/curator.py (#curator) + cogs/artist_curator.py | guild_id, channel_id, delivered (`room` = posted to the curator channel; `staged` = auditioned in #bot-logs -- **the content curator graduated 2026-09-01 (#2796), so on it `staged` can no longer occur; the artist curator still routes on its own `artist_curator` stage, so `staged` there is a real audition**), handle (the source account), external_id (the posted source id, the dedup key), engagement (the chosen post's blended magnitude), source (`in_channel`\|`learned`\|`roster`\|`adjacent`, the discovery tier — `roster` = the ARTIST CURATOR's pick, a top artist off the kworb watchlist via the `utils/artist_socials` handle map; **the artist curator (2026-09-07) emits this same kind stamped `surface=artist_curator`, the content curator carries no surface stamp -- split them on `surface`**), mode (`image`\|`text`, which fit-judge ran, #1232 Phase 1.5), via_repost (#curator: True = the pick came from a seed's REPOST, resolved to the endorsed original — watch this rate to see the repost-supply path landing, esp. for aggregator-seeded channels like #her) — the curator posted a fresh on-theme link into a curator channel (the SUCCESS half of the ship funnel). The pick cleared the structural gates (dedup + original-with-media) and a Haiku fit-judge (`claude.pick_curator` vision, or `pick_curator_text`) against the channel's own recent posts, chosen from an account-diversified slate (no engagement pre-filter); posts the source permalink (fixupx-rewritten so it unfurls). Gated by the `curator` experiment + master kill switch + mood; deduped by the durable `curator_seen` set (never reposts an id) | | `watch_link` | utils/watch_link.py (#music-video-linking, #trailer-lane) | guild_id, surface (`music_news` since 2026-10-01: a newsroom `release` that ships as text asks for its video), ok, query (the search phrase), count (hits weighed); on ok=True: source (`source_link` \| `youtube` \| `tmdb_trailer` \| `youtube_trailer` \| `youtube_moment` — WHICH rung answered; `source_link` is rung 0, the video the SOURCE POST itself linked, kind=`source`), url_host; on ok=False: reason (`not_music_video`\|`declined`\|`no_results`\|`no_fresh_results`\|`no_link`\|`unprovisioned`\|`pick_error`) — resolved (or declined to resolve) the real WATCHABLE link for a wire post that's about a music video. A discourse take sourced from an X wire post ships to @tootsiesbar with **NO link**: its source link is stripped (an x.com link would render the quote card the owner banned for discourse) and the wire post carries only photos, so there's nothing to re-host natively — a take about a music video shipped as a take + a stock press photo with nothing to click, the whole payoff missing. So this searches YouTube and links the REAL video, which X unfurls into a **playable embed** (shipped WITHOUT the photo so the link drives the embed — the same reason the `video_link` fallback drops its still). **The claim comes from the SOURCE POST, not her take** — that's what makes it work: her take names the PEOPLE ("Travis Scott directing James Blake and Ludwig Göransson") while the source post names the RELEASE ("The video for 'When I'm Home' was directed by..."); searching the take finds nothing. **RUNG 0 — TRUST THE SOURCE'S OWN LINK** (#watch-link-source, owner steer "trust the link"): before any classify, if the source post already links a video in its text ("Taylor Swift performs a medley … Watch: youtu.be/_9jaJtmraXA"), take THAT link and skip the rest. It is the source's own pointer to the exact clip — no classifier can refuse it and no search can pick the wrong upload — and it is exactly the case the classify path is built to refuse, a LIVE PERFORMANCE the wire linked (the reported Taylor Swift miss: the classifier correctly declined `not_music_video`, so nothing was ever linked, while the youtu.be link sat in the post). The link comes from `SourcePost.link_urls` (the expanded `entities.urls`, since the tweet body carries only the `t.co` shortlink); `source_video_link` keeps only YouTube/TikTok/Instagram (the hosts X unfurls), never an x.com quote. Only when the source links no video does the classify path run. **CLASSIFY, THEN ROUTE** (owner steer), the same shape as the image resolver (`entity_image.resolve_desk_image`) **including its deterministic-first discipline**: `claude.video_subject` (Haiku) reads the post into a clean `{kind, title, artist}` subject, and the KIND routes to an authoritative source — **`film`/`tv` → TMDB's own `/videos`** (`tmdb.trailer_url`, a LOOKUP: the studio's curated trailer keys, so a hit is the real trailer by construction, nothing to pick between or gate on) and **`music_release` → a YouTube search + pick** (the one kind with no authoritative endpoint — nobody publishes "the official video for X" as a lookup — so `search_query_for` builds the query → "Kendrick Lamar squabble up" and `claude.pick_music_video` (Haiku, text-only) confirms the hit on SONG IDENTITY + PROVENANCE) — load-bearing rather than ceremonial, since the live search for this story returns the right song's official upload alongside a DIFFERENT James Blake single, a soundtrack trailer, and three fan re-uploads. **The two-stage split is what closed the leak the single-call design couldn't:** the POST-level question ("is this even announcing a video") used to sit inside the picker beside two CANDIDATE-level tests, so it competed with them and a chart-position post linked 5/5 — and both attempts to strengthen the rule IN PLACE measured worse (one made the lead frame fight the rule, the other blew `max_tokens` on reasoning and started accepting fan channels). Moving the question to its own classifier is the restructure those rewrites were reaching for, and it deleted the brittle half too (the quoted-span regex, the capitalized-word scan, the stopword list). `artist` is a **name to SEARCH WITH, not necessarily the credited act** — measured: requiring the crediting artist declined the exact reported post 5/5, because a wire post names the title and the DIRECTOR ("The video for 'When I'm Home' was directed by Travis Scott") and not the singer; anchoring on whoever the post DOES credit restored it at no off-lane cost. The name must appear IN THE POST — inferring it from model knowledge is the wrong-artist failure mode this codebase keeps re-learning. **FAIL-CLOSED throughout** — it only ever ADDS a link to a post that would otherwise ship without one, so every uncertain path leaves the post exactly as it was (a wrong video is far worse than none); `not_music_video` is the common, healthy outcome (most takes aren't about a video). **The question stays POST-LEVEL and that restraint IS the design** (owner steer): "does a video exist for this subject" is true of nearly every film and song, so it would attach a link to almost every take — loud, and at X's ~$0.20 link price against ~$0.015; "is this post ANNOUNCING a video" is what keeps the surface quiet. **SPORTS is deliberately absent** — Highlightly needs both team names AND the game date, which a wire post's prose doesn't carry (the commentator posts clips because it holds a live game object with those fields), so the case where we actually have a game is already served there. **MEASURED** (n=5/case end-to-end through the real resolver, live lookups, `scripts/dryrun_watch_link.py`): on-lane **29/30**, off-lane **30/30** — every off-lane case declines at the SUBJECT stage before any lookup runs, including two that name a subject which really does have a video (a live performance; a casting note for a film with a trailer), the exact subject-level behaviour the post-level question refuses. The one on-lane miss is the reported post's music lane, re-measured at **11/12** on its own (~92%, the hardest case — its anchor name is the DIRECTOR so the candidate list is genuinely ambiguous), not a regression; the earlier 5/5 reading was equally consistent with 92%, since n=5 can't separate the two. Watch the `pick_error` rate (a BROKEN picker — the one reason that's a problem); a climbing `not_music_video` share is just the classifier doing its job. **COST:** a resolved link ships as `crosspost` `mode=tweet` with `has_link=true`, which X prices at ~$0.20 vs ~$0.015 for a link-free post; bounded because most posts decline, but an `ok=true` rate approaching the surface's crosspost rate means the picker has gone loose and is spending that ~13x broadly. **#trailer-lane (2026-08-06) changed two things.** (1) **CALLERS.** It was wired into `discourse` ONLY, while three wire desks that post about trailers every day never called it — the reported miss is a cinema-desk take reading "the Awarapan 2 trailer is out" that shipped a TMDB poster and nothing to watch, with **no `watch_link` event at all** because nobody asked. All three wire desks (`cinema_news`, `pop_desk`, `sports_desk`) now call it, each stamping its own `surface`, and only when the source post carries no clip of its own (a native mp4 re-share beats any link). (2) **A SECOND RUNG under the film/TV lookup.** TMDB is community-maintained, so its `/videos` list is empty exactly when the post is most current: at post time that title's record held ONE clip, a six-week-old Teaser flagged `official:false`, which `trailer_url` correctly refuses — while the real trailer sat on the production house's own YouTube channel. So a TMDB miss now falls to the same YouTube search-and-judge the music lane uses, with `claude.pick_trailer` (THREE candidate tests where the music picker has two: title identity, **it-is-the-trailer**, provenance — the third is earned, because 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: `"<title> trailer"` puts the studio trailer in the top few, `"<title> official trailer"` demotes it and pulls in an AI-made farm upload, the bare title leads with soundtrack songs. **The rung split is the thing to watch:** `source` separates `tmdb_trailer` from `youtube_trailer`, so a deterministic rung that goes silent (TMDB auth dead, a shape change) shows up as its share collapsing while the lane still looks healthy **#lane-commentary-video (2026-09-14) added a THIRD lane, `moment`** (owner ask: "why not use youtube to find the actual videos for all lane commentary"). Before it the resolver answered 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 and those takes shipped whatever clip the WIRE account had attached, unchecked. The `moment` kind captures `{anchor name, the moment in searchable words}`, searches YouTube for both, and judges the hits with `claude.pick_moment_clip` (three tests: SAME OCCASION, it-is-the-footage-not-talk-about-it, it-is-that-moment-alone — a moment has no owning channel, so provenance cannot carry the judgment the way it does for a trailer). **Its deterministic rung is a RECENCY filter, and it is the load-bearing half:** `fresh_moment_results` drops every candidate over 14 days old — and every UNDATED one (fail-closed) — before the judge sees it, because a name-plus-words search returns old footage of the RIGHT person and that reads as a fine candidate to anything comparing titles. A moment a wire is reporting today cannot have been filmed years ago, so the filter settles that whole class without asking a model. `no_fresh_results` is its own reason: the search worked, the footage just isn't up yet. **Watch** the moment lane's `declined` + `no_fresh_results` share against `youtube_moment`: all-decline means the judge has gone too strict to ever fire, while `youtube_moment` rising toward the desks' post rate means it has gone loose and is spending the ~13x link price broadly. **MEASURED live end-to-end** (`scripts/dryrun_watch_link.py`, n=5/case): the reported post resolves 5/5 to the New York Giants' OWN channel — "Odell Beckham Jr.: 'I just know I'm going to give my all'" — and the off-lane set holds 55/55, transfer / injury / written-statement / signing posts all declining at the subject stage before any search runs. The classifier wording is ablated, not guessed: a "report the FOOTAGE, not a fact about a person" rule refused the reported post 5/5 (a quote IS a fact about a person), a "would it read the same with no camera in the room" test measured completely inert (10/25 on-lane and 50/50 off-lane either way, so it is cut rather than kept), and rewording the kind's definition to lead with *something a person SAID OUT LOUD or DID* is what took the press-conference case 0/5 → 5/5. Known miss: a red-carpet ARRIVAL with no quote and no action stays 0/5. 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 | | `curate_run` | cogs/curate.py (#curate) | guild_id, channel_id, delivered (`room` = posted to the curator channel; `staged` = auditioned in #bot-logs -- **the curate feed graduated 2026-09-01 (#2796) on the `curator` key, so it always resolves PRODUCTION and `staged` can no longer occur**), handle (the source X account), external_id + platform (`x`, the shared `curator_seen` dedup key), engagement (the chosen post's blended magnitude), mode (`image`\|`text`, which fit-judge ran), via_repost (True = the pick was a repost), has_caption (True = a short Toots caption shipped on the marker line above the link — ARTIST curation only, i.e. a repost of a distinct artist; False = the bare `🔁 link`, either not an artist repost, no caption composed, or it failed the short/no-link gate — the fail-CLOSED default), trigger (`scheduled`) — the scheduled curate-feed surface posted: it distributes a mod-configured X account's posts + reposts (set on `/menu`) across the curator channels. **Each due slot DRAINS all eligible on-theme posts for a channel, best-first (owner steer — "curate all eligible recents vs slow drip"), not one:** the slot is the trigger, but on it the loop repeatedly composes the best UNSEEN eligible post via the curator's fit-judge + delivers it (marking it seen, so the next compose slides to the next-best) until the judge declines or nothing eligible remains — bounded by `_MAX_DRAIN_PER_SLOT` per slot (the remainder carrying to the next slot; `curator_seen` makes that safe), so `curate_run` fires once PER drained post (multiple per slot). discord.py backs off + retries on a 429 so a batch never drops a post to rate limits. Rides the curator's `/menu` calendar cadence + the curator's experiment (one toggle for both); gated by that experiment + master kill switch + mood; dedup shared with the curator (`curator_seen`), so a tweet lands in one room and never double-posts. A drained-nothing slot (or the terminal compose that ends a drain) emits `curator_evaluated` (`source=curate_feed`) with the skip reason (`no_home`/`none_fit`/`pick_error`/`no_candidates`/`no_posts`) instead | | `wire_identity` | utils/wire_identity.py (#wrong-person follow-up) | ok, source (the desk surface), guild_id, count (replies read), query (the resolved name, on ok/`no_web_check`), reason (`no_replies`\|`no_id`\|`no_web_check`) — the thread-identity path ran on a wire moment about an UNNAMED person: the replies under the source post identify who's in an unnamed viral clip (the @DailyLoud "Brazilian model is going viral" → Gabriela Moura case), where a web search on the post's vague words matches the WRONG story (the Larissa Nery substitution). A Haiku gate (`claude.wire_subject_named`) stops the path on a named subject BEFORE the metered replies fetch (emitting nothing — only real identity attempts count); an unnamed one pools TWO reply pages (the newest slice + the post's first `EARLY_WINDOW_SECS` minutes, where "who is this" gets answered), like-ranks them, extracts a CREDIBLE identification (`claude.identify_from_replies` — detail or cross-reply agreement, never a lone bare name/joke/lookalike gag, answer-first `NAME:`/`NONE` verdict), and web-verifies the name (a Perplexity bio check; a crowd ID alone never ships a name — fail-closed), then runs the PASS-biased Sonnet vision **frame tripwire** (`claude.identity_frame_mismatch`, reason=`frame_mismatch` on a reject: the wire's frame plainly shows a DIFFERENT person the model recognizes — the crowd-misnames-a-famous-person case; an unknown/niche face passes, measured: Gabriela Moura passes 3/3, a Taylor Swift mis-claim rejects 3/3). A resolved identity replaces the vague-words confirm and lands in the compose blob via `thread_identity_block` (name + the wire's view count as the moment's hard number + a no-body-comment line; the compose still holds the final coheres-with-the-media judgment). Wired on pop_desk + sports_desk (the named-subject gate makes it near-free on sports, whose wires name their subjects); the ops-monitor renders the funnel every run ("Wrong-person guardrails") and flags `wire_identity_verify_down` (medium) on a sustained `no_web_check` rate — the fail-closed verify leg breaking means identities silently stop shipping | | `sports_diversity` | cogs/sports_desk.py (`_diversified`, via `utils/sports_stories.apply_sport_diversity`, #sports-news-diversity) | guild_id, count (ranked stories in the batch), sport (the BEAT that leads AFTER the re-weight), + folded detail{recent (the beats of the desk's recent posts, newest-first, `?` for an unclassified one), top_penalty (the leader's magnitude multiplier), leaders (`<sport>:<penalty>` for the top compose candidates)} — the sports desk re-weighted a ranked batch against the beats it POSTED recently, so one sport cannot own every slot on engagement alone. Emitted once per ranked batch, on BOTH the slot path and the off-slot breaking path. It exists because the problem it fixes is INVISIBLE in the ship events: `sports_desk_posted` records what shipped, so a desk quietly reverting to all-football reads exactly like a busy transfer window. This event records the DECISION. Read it against the `sports_beats` panel: `top_penalty` at 1.0 on nearly every batch means the desk is already alternating beats and the rule has nothing to do; a leader that keeps winning at 0.125 is a genuinely huge moment beating a full penalty, which is the rule WORKING. The failure to watch for is a `sport` column that returns to all-`soccer` while `detail.recent` is also all-`soccer` AND `top_penalty` reads 1.0 — that combination means the beat is not being counted at all (the `sport` column on `sports_desk_events` stopped being written, or the classifier stopped reading the handle), not that football is winning fairly. No MONITOR: there is no rate that separates a legitimately football-heavy news day (a transfer deadline) from a broken re-weight, and the whole point of the change is that a beat mix is a judgement call, not a threshold. The `sports_beats` panel builds the baseline first. | | `sports_beat` | cogs/sports_desk.py (`_resolve_sports`, #3426) | count (stories this pass, deduped by signature), + folded detail: asked (headlines sent to the model this pass; 0 when the cache covered them), resolved (tags the model returned that passed the `SPORT_TAGS` vocabulary check), cached (served from the durable by-signature cache), disagree (stories where the model's tag and the rules' tag both exist and differ: the beat the rules would have counted wrong), fallback (stories the model did not place, so `classify_sport` decides them in `apply_sport_diversity`). The model answers FIRST for every story since #3426 (before, only a story the rules left unknown was asked, so a wrong rule tag never reached it); `count - cached - resolved` is the residue neither could place this pass. `fallback` rising toward `count` means the model is not answering (an outage, a vocabulary drift) and the desk has silently gone back to the keyword table; `disagree` is the running measure of how often the table is wrong (6 of 105 both-tagged stories on the 2026-09-18 A/B). Dashboard: the `sports_beat_reads` panel (`scripts/axiom_setup.py`) graphs all five per pass. No monitor: the desk fails open to the rules, and the `sports_diversity` panel's `sport` column shows the effect. | | `media_coherence` | utils/x_crosspost.py (`_media_gate` + `_clip_gate`, #wrong-person follow-up, #lane-commentary-video) | guild_id, surface, ok (False = flagged), mode (`log`\|`enforce`), reason (`mismatch`\|`unconfirmed`\|`verified_source`\|`copy_mismatch`), detail.kind (`clip` on a VIDEO verdict, absent on a photo one — the two halves stay separable in a query), detail.note (what the image showed), detail.source (the ART RUNG judged — on EVERY verdict, not only the exempt one, so a rung the judge keeps destroying is visible in aggregate; the `gate_rung` dashboard panel groups on it, and a `mismatch` row on a deterministically-verified rung is the #2238 false-positive shape repeating) — the DELIVERY-TIME media↔take coherence gate ran on an image crosspost: the LAST line of defense for the whole wrong-person class, at the ONE chokepoint every art resolver feeds (`maybe_crosspost`), so it catches the NEXT resolver bug regardless of which upstream path fails. A Sonnet vision tripwire, PASS-biased: our own charts/number/logo cards, artwork, unrecognized faces, and any uncertainty all PASS — the only reject is an image the model recognizes as a different specific person/work than the take names (measured on the shipped cases: the Jack Harlow cover on the Dua Lipa take flags 3/3 with a crisp note, every control incl. a real number card passes 15/15; the Larissa/Gabi clip does NOT flag — a niche unknown face structurally can't, that class is owned by the upstream brief-discipline + thread-identity fixes). **A `copy_mismatch` verdict NEVER drops the image** (the owner's "art failure" report, 2026-08-31). The judge returns three verdicts — `YES` / `COPY` / `NO` — and only `NO` (plainly a different person or work) is a drop. `COPY` means the image shows the RIGHT subject and only the take's wording or numbers disagree with what the image displays; the image ships and the event records `reason=copy_mismatch`, because what is wrong there is the COMPOSE, not the art. The answer used to be binary, so the judge had exactly one token for "something here is off" and it put wording complaints in it: **10 of the 40 confirmed drops over 30 days named no wrong subject at all** — "the graphic says 5.3B but the post copy says 5.2B", "the chart shows five new entries, not four", and the report itself, "the post calls #2 a 'Dracula remix' but the chart credits 'Dracula - Jennie Remix'" (its own note said "Image matches the chart subject fine"). Each one deleted a CORRECT branded chart card and shipped the flawed prose alone, which is backwards: the card is rendered from the parsed rows, so dropping it removes the correction and keeps the error. Measured on the real Hot AC card, n=5 per case: the shipped take goes `subject` 5/5 → `copy` 5/5, a wrong-figure take and a miscount take both go `subject` 5/5 → `copy` 5/5, an exact take stays clean 5/5, and the true catches hold — a wrong-act take on the same card stays `subject` 5/5, a Shakira portrait under a Stray Kids take stays `subject` 5/5, and correct portraits stay clean 5/5. The ops monitor counts these APART from the wrong-art rate (**`media_copy_mismatch`**, medium) so the compose bug has an owner instead of being buried inside a drop. A **`verified_source`** flag is likewise not a drop and not wrong art that shipped: the gate KEPT it because a deterministic upstream identity check (`_IDENTITY_VERIFIED_ART`) already vouched for the art. It counts as `overruled` — still a judge flag for the rate, excluded from the "shipped wrong art" number, so a known judge false positive cannot raise the strongest wrong-art alarm on art we know is right (Codex review, #2748). **`mode=enforce` is the DEFAULT in production** (owner call #2219; re-affirmed 2026-08-22): a flagged image is DROPPED and the take ships as TEXT, and the ops-monitor flags **`media_mismatch`** medium (verify the drop). It ran log-only first to measure itself — 1025 checks over 14 days, 50 flagged (4.9%), notes reading as true positives (Julie Chen Moonves on a Big Brother CONTESTANT's card four times, an "X-Men: Apocalypse" poster on an MCU X-Men post). #2216 is what tipped it: the gate caught a KAROL G album cover on a Drake market card, wrote the note, and the post shipped anyway 1.2s later because log mode does not drop. **Enforce is opt-OUT (`X_MEDIA_GATE_ENFORCE=0` returns to log-only), not opt-in — `_media_gate_enforce`.** It used to be opt-in ("1" turns it on), with the Railway variable as the single revert point; that point failed silently — the variable was set to "1" on 2026-08-09, drifted back to "0" the same day (after the #2238 false positive), and was never turned on again once #2238 was fixed. The gate ran log-only for 13 days and shipped ~32 confirmed wrong-person images (a Mary J. Blige photo on a Chris Brown & Usher touring card among them); it CAUGHT every one, log mode just could not act. Enforce-by-default means a LOST or reset variable can never again silently return the timeline to shipping wrong art. NOTE the scope — it runs inside `maybe_crosspost`, so it guards the X send ONLY; the Discord room post is unchecked (#2219 item 2). Skipped for image-only posts; fully fail-open.<br /><br />**2026-09-14 — THE GATE'S BLIND SPOT IS THE OCCASION, and the live record says so outright.** A `pop_desk` take — "Odell Beckham Jr. says his Giants return is about showing 'who I am, not who I was.'" — shipped over a FOUND still from an old football-freestyle promo (`pop_desk_posted` 02:23:34Z, `source=found`). The gate ran on it in `enforce` mode and returned **`ok=true`**, with the note *"Shows Odell Beckham Jr. in a Giants jersey during an 'F2 vs Odell' soccer trick shot video"*. It SAW the wrong occasion, wrote it down, and passed — because its question is "a different specific person or work?" and the person was right, the work was right, the OCCASION was years off. This is a different failure mode from every incident above it: those are the gate destroying CORRECT art, this is the gate describing WRONG art and shipping it. **No prompt wording fixes it** — a wrong occasion genuinely is not a wrong person, so the question itself has to change, and the four exemption incidents above are what a widened photo-gate question costs when it misfires. So the occasion question ships FIRST on the path that has no gate at all.<br /><br />**The CLIP half (`_clip_gate`).** A wire post's own mp4 is re-shared natively and the photo gate is SKIPPED on that path (the photo is the clip's own thumbnail and does not ship), so a wire account's stale footage reaches the timeline unchecked. That path now gets its own judge (`claude.crosspost_clip_mismatch`) asking whether the frame could be from the occasion the take describes, with "the right person in the wrong situation is still NO" stated outright. It reads the clip's still thumbnail — all vision can see of a clip, and enough for this class: a studio promo against a field mock-up does not look like a press-conference podium. **MEASURED on the reported post's own frame** (`scripts/dryrun_clip_gate.py`, n=5): flags 5/5, note *"This is a still from an 'F2 vs Odell' soccer freestyle/challenge video, not footage of Beckham discussing a Giants return."* A confirmed flag drops the clip AND its thumbnail (a frame of the same wrong footage), so the take ships as text. Same mode/reason vocabulary and the same fail directions as the photo half — enforce by default, a drop needs a second agreeing vote, a split verdict ships and records `unconfirmed`, an errored check never blocks a post — minus `copy` (a clip displays no numbers to disagree with) and `verified_source` (no rung verifies that a wire attached footage of the occasion it wrote about). The `clip_gate` dashboard panel reads these per surface, because the `gate_rung` panel groups on `detail.source`, which a clip verdict does not carry. **Watch** the flagged rate per wire desk: a rate that climbs is a wire account attaching stale footage to its posts.<br /><br />**The FOUND-PHOTO half (#3259, 2026-10-18).** When the identity verdict passes on a `found` photo (the open-web search, `x_crosspost._OCCASION_CHECKED_ART`) and its note is non-empty, `_occasion_differs` asks a cached Haiku label (`claude.photo_occasion_mismatch`, `claude_api purpose=photo_occasion`) whether that NOTE names a different occasion than the take reports. It reads the note the vision judge already wrote, so it adds no vision call. It is lenient by the owner's decision (2026-10-15): a post about a release, chart, tour announcement or milestone is SAME whatever the photo shows, a note that names no occasion is SAME, and every catalog and verified rung is skipped. A drop needs two agreeing DIFFERENT votes (separate cache rows). The outcome rides `detail.occasion` (`different` / `same` / `error`) on the ordinary verdict; a drop emits `reason=mismatch` with `detail.verdict=occasion`, so the drop accounting is unchanged. Measured before shipping: the Odell golden 14/14 DIFFERENT; 150 real shipped (take, note) pairs over 30 days, 2 runs, 1 single-vote DIFFERENT per run and 0 two-vote drops; 82 real notes that name an event, 2 runs, 0 drops; three wrong-occasion controls 10/10 each, four same-occasion or no-occasion controls 10/10 SAME. Exposure is small: 12 `source=found` verdicts in the 30 days before the change. Query: `where event == 'media_coherence' and isnotnull(parse_json(detail)['occasion']) | summarize count() by tostring(parse_json(detail)['occasion']), mode`. The `gate_rung` panel groups on `occasion` too. The `watch_link` `moment` lane above still fixes the reported post from the other side. **2026-10-25 — the check did not run on the incident it was built for.** A replay of the real Odell frame through `_media_gate` shipped 10/10: the vision judge often answers on ONE line ("YES. The thumbnail reads 'F2 VS ODELL' ..."), the parser read the note only from a second line, and the occasion check skips an empty note. In 14 days of production, 91 of 1739 passing verdicts had no note. The four one-word-verdict image judges now share `claude_client._verdict_and_note`, which takes the rest of the first line when there is no second line. Replay after the fix, n=10 per case on the real models: the incident drops 10/10 (`detail.occasion=different`); five real right-occasion controls (an Odell Giants game photo, a World Series anthem, two concert photos, a press-conference card) ship 50/50 (`same`).<br /><br />**#2238 — the first enforce drop was a FALSE POSITIVE, which paused enforce; the fixes below make it safe, so enforce is back ON as the default (2026-08-22).** A Taylor Swift Billboard post lost her own "The Life of a Showgirl" cover; the note read "That looks like Blake Lively … not Taylor Swift." The art was correct: an artist-verified iTunes row, her own lead release. What the gate actually does is READ THE TITLE printed on artwork and match it against memory — Sonnet declines face identification outright, so it never judged her face. A release newer than the model's knowledge has no match, and it filled the blank with a guess ("looks like a Kylie Minogue album"). Ablation, real images, 10 samples each: the full cover rejects 10/10, the SAME photo with the title text cropped away rejects 1/10, and `1989` (an album the model knows) rejects 0/10 — the title text is the whole trigger. Two conditions are needed: the post names only the ARTIST (no title in the text to match) and carries a SUPERLATIVE (a bare chart line does not make it reach). Two changes followed: an ABSTENTION sentence in the prompt (25 real cases × 5: false drops 8% → 0%, true catches unchanged at 75%; the cut-the-work-clause ablation fixed nothing and cost 8 points of recall), and a CONFIRM-ON-REJECT second vote in enforce mode only — the same card/take rejected 3 of 30 times on repeated runs, so one sample was never a safe basis for discarding art (that run was not at temperature 0: the judge's 0.0 is dropped before the request, #3248). A split verdict ships the image and records `reason=unconfirmed`. **#2241 — the class sweep.** The same protection now reaches every card-shipping surface, not just the music newsroom: `MarketCard.art_source` carries the ART RUNG (distinct from `image_source`, which names the card KIND), and the market / betting / pop / sports / cinema desks pass it through. `sports_api` joins the trusted set (a game card's crests come from the structured `game_teams` payload the betting surfaces stamp, so no extraction can get the subject wrong). The exclusions are each backed by a real catch: `tmdb_poster` (the X-Men: Apocalypse poster), `tmdb_person` ("that's Michelle Pfeiffer, not Michelle Hadley" — a namesake), `deezer_artist` (the LOOSE artist-name match only — see the 2026-08-29 entry below), and `found`/`native`/`market_leg` all keep the gate's full authority (`youtube_chart` was on this list until 2026-09-09 — see below). **A measured dead end, so nobody re-derives it:** asking the model to CONFIRM its own flag by naming the subject — with an explicit "a guess is worse than UNKNOWN" — does not work. It does not know that it does not know. It answered "The Life of a Showgirl — Kylie Minogue" 5/5 rather than abstaining, while abstaining 4/5 on a BTS cover that was a REAL catch; 4/8 separation, wrong on exactly the cases that matter. Only an upstream deterministic check helps.<br /><br />**2026-08-29 — #2238 repeated on the rung it did not cover, so `deezer_artist_exact` is now exempt.** A `market_alert` Kalshi card ("Daily Top Music Videos USA", Taylor Swift at 16%) resolved her own official Deezer ARTIST portrait — the "The Life of a Showgirl" underwater press photo — and the judge flagged it "a woman submerged in water who does not appear to be Taylor Swift". The second opinion agreed, so the drop was CONFIRMED and the post shipped as a bare number card (`crosspost mode=number_fallback`, 14:55Z). The art was verified correct: the cached bytes are pixel-identical to Deezer artist 12246 (the real Taylor Swift, 12.7M fans), and the take scored 0.92 and named the true frontrunner. Same artist and same era artwork as #2238 — the fix there listed rungs, and this rung was not on the list. `entity_image._artist_rung` now splits the portrait into `deezer_artist_exact` (Deezer's row is NAMED what the take calls the artist — a deterministic identity check, exempt) and `deezer_artist` (loose token overlap, still gated, because it really does return a namesake: "Michelle Hadley" → "Michelle Marly", live-checked). Watch `reason=verified_source` with `detail.source=deezer_artist_exact` — it counts portraits the judge would have destroyed.<br /><br />**2026-09-09 — #2238 repeated on a THIRD rung, so `youtube_chart` is now exempt.** A `market_outcome` winner line ("Dai Dai won Top Daily Music Video Global on YouTube for September 8", `KXYTDAILYTOPVIDEOG-26SEP08`, scored 0.95, Kalshi settled the leg YES) carried the leg's own video thumbnail, resolved by video id off the chart the market settles on. The judge wrote "Image shows Shakira in a music video (World Cup themed styling with jerseys), not 'Dai Dai'", the second vote agreed, and the correct card shipped as a bare number (`crosspost mode=number_fallback`, 16:54Z). The art was verified correct: the cached bytes are the official Dai Dai (Shakira & Burna Boy) Vevo thumbnail. The cause is structural. A thumbnail is a video FRAME with no title printed on it, so the judge has no text to read; its only lever is recognizing a famous face, and a Kalshi leg is a bare song title with no artist, so a famous face it recognizes and a title it does not know read as a mismatch. That fires on exactly the biggest artists. Measured over 30 days: 9 verdicts on this rung, 0 true catches, 2 flags, both this false positive — and the same thumbnail PASSED 4 times on `market_drop`, whose take names Shakira because `_youtube_grounding_block` feeds it kworb's upload title. The exclusion had been reasoned from the RESOLVER (most-viewed phrase match among several candidates), which is a residual the judge cannot see either. So a UNIQUE match is trusted like a catalog credit: the video id is the chart's own identity for the leg. The trust stops there (Codex review on #3109): `kworb.find_video_match` reports whether exactly one charting video carried the phrase on a complete read of both kworb pages for a chart day within two days of today (a half read, or a dated market the settle sweep renders late, never reports unique), the resolver names that `youtube_chart` and a most-viewed PICK among several `youtube_chart_pick`, and the pick stays gated — the same split as `deezer_artist_exact` over `deezer_artist`. Live chart 2026-09-09: 9 of 15 open legs resolved, 7 unique ("Dai Dai" among them), 2 picks ("Golden", "Shararat"). A wrong TAKE on this family stays gated upstream and deterministically (`take_references_leg` on the drop, `winner_line_score` on the outcome lane; since CB-02 #4566 the drop uses `youtube_grounding.take_names_a_leg`, and #4579 deleted `take_references_leg`). Watch `reason=verified_source` with `detail.source=youtube_chart`.<br /><br /><br /><br />**2026-09-13 — #2238 repeated on a FOURTH rung, so an UNPICKED `source_photo` is now exempt.** A `cinema_news` post (“Buddy” passes $20 million domestically…) carried @DiscussingFilm's own photo of the film's title character, a costumed orange unicorn. The judge answered "not the dog from 'Buddy'", the second vote agreed, and the take shipped bare on X (`crosspost mode=tweet`, 22:02Z). There is no dog: TMDB 1514026 is "a magical, singing orange unicorn holds his cast hostage in a surreal TV-show dimension", and Toots' own desk had posted "Buddy the Unicorn" nine days earlier. Take right, art right. Two days before, the same rung dropped a `sports_desk` photo of Mastantuono celebrating in the Fiorentina kit because "Mastantuono plays for Real Madrid, not Fiorentina" — API-Sports has him at Fiorentina for 2026. Both reasons are assertions about the POST'S subject out of the model's own stale or absent world knowledge, and neither NAMES the person or work in the picture. The rung's one TRUE catch in 30 days does name it: "This is Christian Coulson … not Billy Barratt", where the wire attached BOTH the newly-cast actor and a 2002-film reference still and `pick_subject_image` took the reference. That is the whole separator, and it is deterministic: Buddy and Mastantuono each carried ONE wire photo (nothing to choose), Barratt carried TWO. `gated_source_photo` now returns `source_photo` for a lone photo (exempt) and `source_photo_pick` for a chosen one (still gated) — the same split as `deezer_artist_exact` and `youtube_chart`. The exemption covers art that cleared `gated_source_photo` and only that: the music newsroom's first-party FEED image is downloaded and stamped with no vision check, so it now carries its own `feed_photo` rung and stays gated (Codex review on #3247 — sharing the `source_photo` label would have handed an unchecked image the checked rung's exemption). Coverage over 30 days of wire posts: 624 single-photo against 273 multi-photo, so the gate keeps authority over roughly a third of the rung. Watch `reason=verified_source` with `detail.source=source_photo`. **Two measured dead ends from this incident, so nobody re-derives them.** (1) REWORDING the judge prompt does not work: requiring a positive NAME in the NO definition, and telling the judge a title does not reveal what a work looks like, were ablated on 14 real cases (7 correct-art, 7 wrong-art, all three drops above included) — neither moved false drops outside noise, both cost recall (the Julie Chen catch fell 10/10 → 4/10), and two runs disagreed on sign. (2) The CONFIRM-ON-REJECT second vote is far weaker than it reads: the judge is NON-STATIONARY, returning 15/20, then 10/10, 9/10, then 0/10 NO on byte-identical input over ~20 minutes, so both votes land in the same regime a second apart rather than sampling independently. `_JUDGE_TEMPERATURE` is also inert on this call — `claude-sonnet-5` is not in `_SAMPLING_MODELS`, so the 0.0 is dropped before the request and every "at temperature 0" claim in this ledger predates that. A drop rate that looks stable in one hour may not be.<br /><br />**Reading a flag's NOTE is not verification — open the image.** A fabricated note is as fluent and specific as a real catch; that is exactly how this class survived 14 days of log-mode review (it flagged 3 times and was read as correct)<br /><br />**A `mismatch` here can mean the TAKE is wrong, not the image (#youtube-take-fabrication).** On `KXYTDAILYTOPVIDEOG-26AUG23` the note read "Shakira ... not Stray Kids": the art was the CORRECT "Dai Dai" (Shakira & Burna Boy) thumbnail and the TAKE had invented "After You by Stray Kids", so the gate dropped the right art and the post shipped as a bare number. A YouTube-video market's take now has a DETERMINISTIC upstream gate (`market_drop` decline `reason=leader_fabrication`, on `market_drop_scored` `phase="declined"`), so a `media_coherence mismatch` on that family should be rare — a rising rate there means the upstream gate is not holding. Query the two together to tell a wrong IMAGE from a wrong TAKE. **`detail.verdict` splits the two DROP classes (2026-09-16, the College Campus Tabby Tour post).** `subject` is a different specific person or work -- the RESOLVER's failure class. `unrelated` is an image that is not about the subject at all and says so itself -- an advertisement, a publisher's badge or magazine cover, a site placeholder -- the SCRAPER's failure class. It exists because a Billboard "ADD OUR PASS" house ad shipped as a tour post's art with this gate reading `ok=true`, note "Generic Billboard branded ad banner, not tied to any specific artist so it doesn't contradict the post". The judge was obeying its prompt: the YES list read "a branded graphic, a logo, generic artwork", written to keep our OWN cards, and the wrong-SUBJECT question cannot see a graphic that names no subject. That clause is cut and the fourth verdict names the class (measurement in `claude_client.crosspost_media_mismatch`: 0/100 false drops on our own shipped images, 25/25 recall on wrong-subject swaps, the ad and Billboard's square placeholder 0/20 -> 20/20). Both verdicts share the `mismatch` / `unconfirmed` / `verified_source` reasons, so `MediaCoherenceStats` and the `media_mismatch` finding count them together as wrong art, which they are; read `verdict` on the `gate_rung` panel to see which class a rung is producing. An `unrelated` run on `feed_photo` or `found` says a scraper is handing us page furniture; an `unrelated` verdict on a VERIFIED rung is overruled like any other flag and is almost certainly the judge misreading a cover, so watch it the way `verified_source` is watched. **A `subject` flag on a VERIFIED rung is overruled unless the art PRINTS another act's name (2026-09-20, the third layer of #3521).** `_media_gate` then asks a second, separate question (`claude_client.crosspost_media_text_evidence`, `claude_api purpose=media_text_evidence`): does the printed text name a different act than the post? A DIFFERENT answer emits the ordinary `reason=mismatch` (a drop in enforce mode) with `detail.source` still the verified rung and the quoted text appended to `detail.note` as ` | text: ...`, so the drop accounting is unchanged; a SAME answer falls through to `verified_source` as before. Query the new drops with `where event == 'media_coherence' and reason == 'mismatch' and tostring(parse_json(detail)['note']) contains '| text:'`. A title alone is never evidence there (a song's cover prints its album's title), so a wrong TITLE still belongs to the deterministic `cover_rung` and shows up as `catalog_cover_fuzzy`, never here. | | `curate_routing` | cogs/curate.py (#curate ops) | guild_id, candidates (# fresh posts routed this tick), routed (# assigned a best-home room), dropped (# routed NOWHERE — fits no room / route classifier failed) — the best-home ROUTE funnel, the DROP signal: a high `dropped/candidates` rate = the feed is sending ~everything away, so a curate feed dropping everything is watchable + flaggable (ops-monitor renders a **Curate-feed health** line + flags **`curate_dark`** when it ships ~nothing over enough attempts, dominant-reason-guided like `curator_dark`; `source=curate_feed` skips are kept OUT of the curator's funnel so the two don't cross-contaminate) instead of silent. A route-classifier API failure also emits `error` (`source=curate_route`) so it hits error-triage | | `x_artist_tag` | utils/x_artist_tags.py (owner ask 2026-09-30) | guild_id, surface, ok (the artist's X account verified), reason (`verified` \| `reviewed` a handle from the owner-approved list, Grok skipped \| `pinned` \| `not_reviewed` in `reviewed` mode, a name with no approved handle: not looked up, not tagged -- the list of who to review next \| `cached` \| `cached_none` \| `grok_none` Grok said NONE or named two handles \| `grok_error` \| `no_profile` \| `handle_mismatch` \| `protected` \| `automated` \| `unverified` no badge \| `low_followers` \| `name_mismatch` the name equals neither the display name nor the handle -- the check that refuses a blue-check fan account \| `id_mismatch` a reviewed handle now belongs to a different account (released or sold) \| `fan_bio` \| `unprovisioned` \| `error` \| `empty` \| `spelling` a lane name without the dots of a dotted reviewed name ("Bob" is not B.o.B) \| `not_artist` a one-word or `_SCAN_SKIP` name in the post that Haiku says is not the artist ("Queen of the night"; `x_artist_tags.scan_names`, #4199) \| `check_failed` the same check had no answer (error, no client, or over the per-post limit), so the name is dropped \| `by_not_artist` / `longer_not_artist` / `by_check_failed` / `longer_check_failed` a name the position rules held (before "by", or beside a capitalized word) that the same check, with a position hint (`purpose=x_artist_position`, CB-27), refused or did not answer; `fan_bio` is now Haiku `x_bio_owner` FAN, or the phrase rule on UNSURE / error), mode (`off` \| `log` \| `reviewed` (the default: tag only approved handles) \| `on` -- `reviewed` and `on` change the post), detail{artist, handle} -- one artist name checked for an @tag on a crosspost, one row per name (max 2 per post). A refusal is the DESIGNED outcome (prefer absent: the post keeps the plain name), so a low `ok` rate is not a fault and there is no monitor; read `summarize count() by reason` instead. A run of `grok_error` or `error` means the resolver itself is down, and the post still ships untagged. Grok latency and spend ride `grok_search` purpose `x_artist_handle`. `ok=true` means the handle RESOLVED; whether the tag LANDED in the post is `crosspost.count` (the number of tags applied; absent when none). Panels `x_artist_tag` and `x_artist_tags_landed` on the media board | | `crosspost` | utils/x_crosspost.py | guild_id, surface (`release_week` = the first-week new-releases card, gated on `release_board_x`, 2026-10-08\|`release_albums` = the per-album song boards, `release_drops` = the daily / "new music friday" drops board, `release_top` = the Friday Biggest Drops card, all three on the same `release_board_x` experiment and opt-in picker rows, 2026-10-26; at STAGING each of the four emits `delivered=staged` from `MusicReleases._post_x`\|`music`\|`market_drop`\|`discourse`\|`market_alert`\|`bet_board`\|`bet_alert`\|`bet_value`\|`cinema_desk` — which drop surface sourced it; **`cinema_desk`** mirrors the movie/TV big-number card (take + card image + Kalshi/TMDB link), the cinema sibling of the market_drop crosspost; the **curator is deliberately excluded** (owner steer) so its reposts of OTHER accounts don't flood the timeline and drown out Toots' own tweets — crossposting is reserved for her original-voice surfaces), mode (`tweet`\|`quote`\|`repost`\|`media`\|`video`\|`video_link` — an X-origin link in the drop (a discourse trending-X reaction, auto-detected from the line via `x_fetch.x_tweet_id`) QUOTE-tweets when she added a take, else NATIVE-REPOSTS the bare link; a chart rides as `media`; **`video` (#video-upload)** is a WIRE DESK (pop_desk/sports_desk/cinema_news) re-sharing the source post's OWN clip on her standalone take by fetching the source X post's mp4 and uploading it as NATIVE media (`utils.video_fetch.fetch_clip_bytes` → `XPoster.upload_video`), so the video plays inline on her own broadcast — NOT a quote/link to the wire account (the #video-detection-failure fix). **A found photo passed alongside is DROPPED once the clip uploads** (owner steer "we didn't need the image beside it, it's of the same video" — the desks' still is usually a frame of the same clip, so the earlier video+photo mixed-media tweet doubled the same moment; the photo is FALLBACK media in the ladder below, never a companion, and the `_media_gate` vision check now runs only when the photo actually ships). The clip fetch prefers the highest-quality rendition ≤1080p (X re-encodes 4K, so it's wasted bytes; `x_fetch._largest_mp4`), capped by `X_CLIP_MAX_BYTES` (64 MB). **`video_link` is the FALLBACK when the clip can't be downloaded** (an undownloadable/HLS-only clip, a CDN block from the datacenter IP, an oversize file, **or a clip over X's native-video ceiling whose TRIM then failed** — `utils.video_fetch.exceeds_x_video_limit` / `X_MAX_VIDEO_SECONDS`, default 120s: the @tootsiesbar account tier refuses a longer video with `403 "This user is not allowed to post a video longer than 2 minutes."`, and it refuses at POST time, AFTER the multi-MB download + chunked upload, then eats a second identical 403 on the video-only retry — and too late for this very fallback, which only fires when the UPLOAD failed, so the take was **dropped from the timeline entirely** (6 of 61 video crossposts over 14 days). fxtwitter reports the duration on the resolve, so `fetch_clip_bytes` knows the length BEFORE the download; the TikTok path reads the same ceiling off `aweme_detail.video.duration` (milliseconds), and the Instagram path off `xdt_shortcode_media.video_duration` (seconds, verified live 2026-10-25; #2209 -- before it the reel uploaded whole and X refused the post). `x_clip_too_long` carries `platform` (`tiktok` \| `instagram`; absent = X). An UNKNOWN duration is never blocked — fail-open, better to spend the call than silently downgrade a clip that would have posted. Emits `x_clip_too_long`. **The over-ceiling clip is now CUT, not dropped (#video-trim):** the ceiling used to send the take straight to the deep-link, and the deep-link embeds only sometimes, so the reported Drake post shipped as a bare truncated link with nothing to play (a 153s source, `mode=video_link`, 11 ceiling hits over 30 days). `utils.video_trim.trim_clip` downloads the clip and cuts the first `x_trim_target_secs()` seconds — the ceiling less `X_TRIM_MARGIN_SECONDS` (default 2), because a cut lands on a frame boundary and a 118s cut measures 118.07s. The cut is a STREAM COPY (`-c copy`, no re-encode): both feeds already serve h264/yuv420p/aac in mp4, so the output is X-ready by construction and the cut costs under a second where a libx264 pass would cost minutes of container CPU. Verified live end-to-end on that source post → a 118.07s 1080x1080 h264/yuv420p/aac mp4 that decodes clean. Emits `clip_trim`; a FAILED cut still lands here on `video_link`, so this fallback's old behavior is intact): rather than settle for a still, it appends the source `.../status/<id>/video/1` deep-link IN-TEXT (`_video_deep_link`, host-normalized to x.com), which X **SOMETIMES embeds as the playable video** (owner steer "it works sometimes") — a shot at the real video beats the photo, so it's posted WITHOUT the photo so the link can drive the embed (and it's the link-priced post, `has_link=True`). The whole thing is fully fail-open along a LADDER: native `video` re-upload → `video_link` deep-link → the found photo (`mode=media`) → a link-free text take — never a dead in-text link. It takes the media slot over the found photo (video wins); the take is kept clean of any x.com link so the quote auto-detect stays off and it rides the plain-tweet path. **She LIKES the quoted/reposted post** (a fail-open courtesy that rides the same quote, never touching the quote that already shipped). **A borrow-the-audience REPLY under the quoted post was tried (#1732) and REMOVED:** @tootsiesbar's X API tier 403s any reply to a post where she isn't mentioned/author ("You can only reply to or quote posts where you are mentioned or are the author"), so a reply under someone else's tweet can't land — and unlike the quote (which survives that same limit via the in-text-link card fallback) a reply has no equivalent workaround. So there's no `reply` crosspost path; the quote + like are the amplification. delivered (`staged` = the `release_week` first-week card auditioned in #bot-logs under the `release_board_x` experiment at STAGING, nothing sent to X; `posted` = tweeted to @tootsiesbar's X timeline \| `staged` = auditioned in #bot-logs, the `x_crosspost` experiment in STAGING \| `skip`), reason (None on posted/staged; `unprovisioned` = production but the 4 `X_*` OAuth env vars aren't set; `post_rejected` = X rejected the tweet; `surface_off` = the per-guild picker disabled this surface; `room_off` = a `market_drop`/`market_alert` post from a market room that is not ticked on the "market rooms on X" picker (`is_market_room_on_x`, 2026-10-18); `x_streams_not_new` \| `x_chart_depth` \| `x_forecast_flat` (`x_genre` until 2026-10-14) = the X content gate (`utils/x_gate.py`, #3996, 2026-10-13) kept a music post on Discord only: a streams post about a release older than 14 days and outside every reference top 10 (a milestone also passes when released this year or by a top-10 watchlist artist), a chart position below the top 10 (#11-20 still passes outside iTunes for a top-10 watchlist artist or a release from this year), or a live Kalshi forecast that moved less than 10% from the last forecast card that went to X for the same release, week and metric (the last X value is in kv_cache namespace `x_forecast`) -- emitted by `x_gate.x_skip_reason` from music_desk, music_alert and music_news, and counted by the `x_gate` panel on the X board; `over_length` = the take overshoots X's ceiling; `dedup` = another guild already tweeted this content; `same_song` = another music surface (music, music_desk, music_alert, music_news) posted the same song to X in the last 30 minutes (#4650: the drop passes its song, so a pair that includes a drop matches, in either order; shows on the "Why a post was skipped" panel); `error` = the call raised — a `no_art` skip floor was tried here and REMOVED per owner steer "if the post is good don't skip": a good take with genuinely no art still SHIPS as a text tweet; art coverage is the desks' multi-layer resolution (alternate entities + the composed-take retry), not a boundary filter) — a drop was MIRRORED to the @tootsiesbar X timeline (the outbound half of the X integration). Fires at each surface's real production delivery (a STAGED drop never crossposts) — normally AFTER the room send, or INSTEAD of it on the **X-ONLY sentinel lane** (#2137, the Discord/X separation: a surface whose /menu CHANNEL picker is EMPTY stops posting to Discord but keeps composing for X when the guild would actually tweet it — `utils.x_crosspost.x_only_active` + the `X_ONLY_CHANNEL_ID` sentinel channel; the surface's own `*_posted` event then reads `delivered=x_only` + `channel_count=0`. That read is emitted BEFORE the crosspost, so on most surfaces it means "sent to X", not "X accepted it"; this `crosspost` event is the outcome. Since #3644 `maybe_crosspost` and `crosspost_with_quote` RETURN a `CrosspostResult` (`posted`, `reason` = the same word this event records, `tweet_id`), and `market_alert` reads it: its X-only lane emits `market_alert delivered=x_dropped` when X did not take the post, records no shared post history for it, and its `rt_reconcile` / `market_outcome` lanes emit `shipped=false` for it. With X off in any way an unconfigured surface stays fully off, the old behavior) — ONLY for a guild that OPTED IN (the per-guild `x_crosspost` experiment != off), and only if not excluded by the optional `X_CROSSPOST_GUILD_ID` single-guild lock. The cog-level disposition; the API call itself emits `tweet_posted` (client latency/outcome), mirroring the image_reply / image_generated split. Fail-open: a crosspost is a courtesy mirror of a post that already shipped, so any miss never touches the Discord delivery. **`has_link` reads False on the market surfaces since the exchange-link strip** (`strip_market_links`, owner steer "kalshi links on discord, not twitter" + "for both predictions, poly too"): the tweet carries the take + the chart image, the Discord card keeps its market button. So a drop in the link-priced share of `crosspost` is expected from that change, not a compose regression — the strip is host-scoped (`_MARKET_LINK_HOSTS`: `kalshi.com`, `polymarket.com`), so a market post that still carries another link (a `cinema_desk` TMDB link, a sportsbook link) keeps `has_link=True` | | `tweet_posted` | utils/x_poster.py | ok, duration_ms, chars (tweet length) / bytes (media upload), mode (`tweet`\|`quote`\|`reply`\|`media`\|`repost`\|`like`\|`upload`\|`upload_video`), delivered (`posted`\|`skip`), tweet_id (on success, folded into `detail`); on a miss: error (`unprovisioned`\|`empty_text`\|`post_rejected`\|`no_user_id`\|`no_media_id`\|`too_large`\|`init_failed`\|`processing_failed`\|`HTTP <status>`\|breaker/exception class), **`reject_detail`** (X's OWN one-line reason for a refused write, parsed off the error body by the pure `_reject_detail`; folded into `detail`). That sentence IS the diagnosis: three unrelated bugs hide behind one `HTTP 403` — a video over the account's 2-minute ceiling, `duplicate content`, and the third-party quote/reply limit — and the body was previously only LOGGED, so telling them apart meant grepping Railway rather than reading the telemetry — one X API v2 write call (`POST /2/tweets`, `.../retweets`, `.../likes`, or v1.1 `media/upload`; OAuth 1.0a user-context as @tootsiesbar). The outbound half of the X integration (the read side is `curator_fetch`/`x_fetch`), provisioning-gated on the 4 `X_*` env vars; posts/quote-tweets/**in-thread replies** (`reply`, #1497 — the "correct" reply under a game winner) + **likes** the post she amplifies (`like` — the courtesy favorite that rides alongside a crosspost quote/repost, `maybe_crosspost`, fail-open) + uploads chart media (`upload`) or a **chunked async video** (`upload_video`, #1497 — INIT/APPEND/FINALIZE/STATUS, since X rejects bare audio so an iTunes preview clip ships as `tweet_video`); guarded by a circuit breaker (`integration=x_poster`) + rate limiter + retry, fail-open. Its integration health is rolled up as `x_poster` in the ops-monitor (a silent all-fail — bad creds / X down / write-cap wall — flags `integration_unhealthy`, benign `unprovisioned`/`empty_text` excluded), the p99 gated by the `tweet_posted` latency ceiling | | `x_filter_rule` | utils/x_filter_rules.py (#1497) | op (`add`\|`update`\|`delete`\|`get`), ok, duration_ms; `add` carries rule_id on success, `update`/`delete` carry rule_id + (update) is_effect, `get` carries count; on a miss: error (`unprovisioned`\|`no_rule_id`\|`HTTP <status>`\|exception class) — one twitterapi.io tweet-filter RULE call, the "no polling" answer-detection for the X guess-the-song game. A game round registers a `to:tootsiesbar` filter rule (`add_rule` → inactive → `update_rule is_effect=1` to activate), twitterapi.io POSTs matching replies to `/x/webhook`, and the round `delete_rule`s it at close. Reads (`get`) use `retry_http`; rule WRITES stay single-attempt (a retried `add` could duplicate the rule). Same prepaid `TWITTERIO_KEY` credits as the read `XProvider`; fail-open. The precise conversation-id match is done in-handler, so the rule grammar stays coarse/robust | | `x_webhook` | utils/healthcheck.py `/x/webhook` (#1497) | ok, rule_tag, tweets (# in the payload), dispatched (routed BY `rule_tag`: `xgame_*` → the `XGame` cog, `xmentions` → the `XMentions` cog, both via `handle_webhook_tweets`); on a miss: reason (`unauthorized` — X-API-Key mismatch \| `bad_json` \| `dispatch_error`) — a twitterapi.io filter-rule webhook POST was received (a game reply OR a mention). Verified on the echoed `X-API-Key` shared secret (`TWITTERIO_KEY`); always 200s on a valid authorized POST so the sender never greys out the endpoint (parsing/dispatch fully fail-open). The route exists independently of any cog, so a missing cog is a clean no-op | | `x_mentions` | cogs/x_mentions.py (#x-mentions) | kind (`rule`\|`scan`\|`boards`\|`prediction`\|`reply`), ok, guild_id. `rule`: reason (`created`\|`removed`\|`create_failed`) as the standing `@tootsiesbar` filter rule is reconciled to the gate (a 15-min loop; OFF removes it, so no read spend while dark -- x_mentions graduated 2026-09-01 (#2796) to MASTER-ONLY, so OFF now means a non-master guild). `scan`: reason (`hit`\|`no_artist`\|`rate_capped`\|`scan_capped`\|`bare_name` = a reply under her post whose whole text is one act name, a reaction ("Queen" under her Karol G post), so no lane runs (#3567)\|`parent_mismatch` = the mention named a title ("Why is Day and Night missing?") that the parent's act does not hold (not the parent's song, not in the parent post, not on the act's live chart rows), so no lane runs (#3568)), query = the matched artist; a hit resolved off HER OWN parent post (the mention itself named no act but ASKED a question -- "what about the debut?" under a Miley post; a reaction like "Temazo!!" stays `no_artist`) folds `via=parent` (owner steer 2026-09-08: the parent is fetched keyless via FixTweet only when the mention replies to her post, `x_mentions.reply_parent_id`; the gate is the scan's `asks_question` flag through `x_mentions.mention_asks` (#3419), else the wording regex `x_mentions.asks_question` -- a "?", a leading question word, or a request-shaped phrase like "update on the debut", never a bare noun; every `scan` hit / no_artist event folds `detail.asked` (the resolved answer) and `detail.asks_via` (`classifier` \| `bounded` = a true on a wordless text, or a false on a text carrying a literal "?", so the regex decided \| `regex` = no flag); the parent is fetched only once that gate passes; the second Haiku read claims a second scan allowance, so `scan_capped` bounds model calls, not tweets; and a parent-derived act feeds ONLY the `reply` lane, never `boards`/`prediction` -- PR #3081 review P1). `reply` (2026-09-08): the HAND-POSTED answer under the mention -- reason (`shipped`\|`no_facts` = no parent, no chart row, no market, so nothing to answer from\|`declined` = the compose returned EMPTY\|`send_failed`\|`experiment_off` = the `x_replies` experiment is OFF, its default since 2026-10-08, so no draft and no parent-post read\|`not_asked` = the mention asks nothing, so there is no answer to draft (#3567)\|`qualifier_no_clean_cut` = her parent post's wrong qualifier has no clean cut (#4467)\|`cut_qualifier` = the draft states a cut qualifier word again (#4467)), delivered (`bot_logs`\|`skip`), query = the act; a `shipped` reply folds `detail.charts_via` (`classifier` = the scan's `charts` field chose which chart rows lead the facts \| `regex` = the wording cues `x_mentions.charts_named` did, because the reply carried no field; #3549 -- a `regex` share that climbs after a deploy means the scan stopped returning the field). The lane gathers the grounded facts (`x_mentions.format_mention_facts`: her parent post, then EVERY live Kalshi ladder for the release each named by its metric -- `readings_for_subject(max_lead_days=None)`, the window lifted, the metric read off the question by `luminate.metric_from_text` and applied only when a live ladder carries it, one release's ladders only with siblings joined on the event-ticker release key; the parent rides only when it is about the resolved act -- then the act's strongest chart blobs; market lines come first and are never cut, a line over budget is dropped whole, and the chart rows take only the room left), composes with `claude.draft_mention_reply` (purpose `x_mention_reply`), and queues TWO messages in #bot-logs (the `x_reply_draft` header + the mention's fixup link, then the bare draft) for the owner to post by hand; the draft text is cached under the `x_reply_text` ns so the nightly `x_reply_edit` join covers it; `bot_logs.post` now returns whether it SENT, a dropped post is `send_failed`, never `shipped`, and the `x_reply_text` entry is written only after delivery. It NEVER posts to X (the standalone boards/prediction posts stay automated; the reply is manual for now). Watch: `where event == 'x_mentions' and kind == 'reply' | summarize count() by reason` -- a run of `no_facts` on hits means the chart/market reads are dark for the acts people ask about. `boards`/`prediction`: reason (`shipped`, or a skip cause: `weekly_dupe` (boards; the once-a-week-per-(artist,chart) gate)\|`no_boards` (boards; the act has no 2+-title presence on any chart)\|`row_tally` (boards; the take named a top-10 count the board's own block does not state -- the grounded-tally backstop the desk's board path runs, wired here in #2662 because the standing board made this lane COUNT-LED and the 0.6 judge scores a wrong-but-grounded-looking tally 0.95)\|`fall` (boards; a single-entry card about a title that FELL on its chart, cut before the weekly claim and the compose, 2026-10-30, owner steer "cut all downward movement in general". "Choosin' Texas drops two spots to No. 26 on Spotify Global Daily" shipped from this path on 2026-10-29. A board is never cut: it shows the fall in its rows)\|`cooldown` (prediction)\|`no_market`\|`declined`\|`below_floor`\|`topic_dupe`\|`unprovisioned`\|`fetch_failed`), delivered (`staged` = #bot-logs audition \| `room` = posted to the music channels \| `x_only` = crossposted, no room \| `skip`), score — an @tootsiesbar MENTION naming an artist gets that act's PER-CHART cards (BOARDS lane: a `chart_boards.watch_artist_board` where the act holds 2+ titles on a chart, else a single-entry number card for exactly one — across the Billboard Hot 100 / 200 / Global 200 via `utils.billboard` + the live Spotify/Apple charts via kworb; UP TO 3 per mention, strongest chart first, deduped WEEKLY per (artist, chart)) + their live Kalshi first-week forecast (PREDICTION lane, watched acts only, `music_markets.reading_for_subject` → `projection_figure`). Detection: a deterministic watchlist scan (`x_mentions.scan_watched`) + a Haiku extractor for aliases + the song title, recognition BROADENED to any literally-named charting act (the boards lane confirms by chart presence). SINGLETON (one @tootsiesbar) → master guild only; gated by the `x_mentions` experiment (OFF default, ships dark) + master switch; per-tweet dedup + the boards weekly-per-(artist,chart) gate + the prediction per-(artist,lane) cooldown + cross-surface topic dedup. Crossposts on the `x_mentions` picker (opt-in). Fail-open | | `x_game_round` | cogs/x_game.py (#1497) | action (`open`\|`close`), ok, guild_id; on open ok: delivered (`room` = posted to the @tootsiesbar X timeline \| `staged` = auditioned in #bot-logs, no post to X), tweet_id + has_rule (production); on open miss: reason (`no_song`\|`no_clip`\|`unprovisioned`\|`upload_failed`\|`post_failed`); on close: delivered + won (did anyone guess it) — an X guess-the-song round opened or closed. Toots posts an urban-90s+ song CLIP to @tootsiesbar, a `to:tootsiesbar` filter rule catches the replies (webhook → `handle_webhook_tweets`), the first correct guess ends the round with a single board reply (a music-drop-toned reveal + a light nod to the winner + the running leaderboard) under the clip + a music-drop quote of the clip with the answer's Apple Music link, else a NO-WINNER round skips the board/leaderboard entirely and just quote-reveals the answer (no "nobody got it" complaint). ACCOUNT-global (one round open at a time across guilds); gated by the `x_game` experiment (OFF default — posts to a public external timeline like `x_crosspost`) + master switch + mood. Song pool reuses the `/guess` game's `generate_song_pool` (urban bundle + 90s-onward era), each pick iTunes-verified to a real preview + art. Fail-open | | `x_game_answer` | cogs/x_game.py (#1497) | ok, won=True, username (the X handle, folded into `detail`), count (their new win total) — the FIRST correct guess in an X guess-the-song round. Fires once per round (guarded by the atomic `x_game_claim_winner` first-winner claim, so a race between two correct replies still yields one winner + one point); the win ENDS the round with the single board reply (crown + reveal + leaderboard) under the clip, and a leaderboard point. A wrong guess emits nothing | | `curator_evaluated` | cogs/curator.py (#curator ops) + cogs/artist_curator.py (stamped `surface=artist_curator`; there `no_seeds` = the watchlist was down or no artist on it has a mapped handle) | guild_id, channel_id, reason (`no_seeds` = channel has no source accounts, post links \| `no_history` = channel empty/unreadable \| `no_posts` = the source fetch returned nothing \| `no_candidates` = every fetched post is already seen or non-postable (reply/repost/no-media) \| `none_fit` = the fit-judge LOOKED and declined every candidate \| `pick_error` = the fit-judge ERRORED (it choked, or the API rejected an attached image) \| `account_cap` = only accounts at their share cap had fresh posts, #3054 \| `no_channel`), and for `source=curate_feed` also `detail.dropped` (how many posts the judge passed over while the drain WALKED the pool -- a decline advances the walk instead of ending the slot, and the slot still reports exactly ONE skip event so the walk cannot inflate the `curate_dark` denominator), mode (`image`\|`text`, when known) — the curator ran a slot but posted NOTHING (the SKIP half of the ship funnel). With `curator_posted` it makes a surface going quietly DARK visible: the ops-monitor renders a **Curator health** line (post rate + skip-reason funnel + source/mode split) every run and flags `curator_dark` (high when `none_fit`-dominated = users see silence; medium when `no_seeds`/`no_history` = channels just need links). **`pick_error` was split OUT of `none_fit` (2026-08-30).** Both used to emit `none_fit`, so the funnel read "the judge rejects everything" while the judge was in fact FAILING on an image it could not load -- every slot, for days, in #backpage. The curate feed already separated the two, and that separation is what made the outage findable. A judge error and a judge decline need different fixes, so they need different reasons. The curator has no 0-1 score event (the judge is pick-or-skip), so this is its surface-dark telemetry | | `curator_mode_check` | utils/curator.py `judged_image_mode` (#3429); called from cogs/curator.py and cogs/curate.py | guild_id, channel_id, source (`curator` \| `curate_feed`), mode (`image` \| `text`, the mode the room got), + folded detail: verdict (`image` \| `link` \| `error` = no verdict). One row each time a curated room's media density is under `CURATOR_MEDIA_MODE_RATIO` and the Haiku room check (`claude.classify`, purpose `curator_image_room`, cached per channel + name + topic for a day) is asked IMAGE or LINK. A cache hit also emits; the real model calls are the `claude_api` rows with this purpose. `error` falls back to the old density + topic-list rule, so `mode` on an `error` row is what the old code would have chosen. A room at or over the ratio emits nothing (density decides). | Panel `cur_mode_check` (Curator board). No monitor: on 2026-10-16 no live room was under the ratio, so the row is rare, and an error only restores the old rule for that room. | | `sticker_scan` | utils/tiktok_stickers.py (#4812) | ok, duration_ms, source (`scrapecreators`\|`ensembledata`), count (unique stickers), pages (folded); reason = why paging stopped (`page_failed`\|`cursor_stuck`\|`no_cursor`\|`page_cap`\|`comment_found`\|`time_limit`; `comment_found` is for a comment link, which stops at the page holding that comment; `time_limit` is a scan that stopped starting pages after its time limit); status (on `page_failed`); error only when the FIRST page failed (`breaker_open`\|`HTTP <n>`\|`api_error`\|`not_provisioned`\|`no_url`\|exception class) — `/stickers` read a TikTok video's comment pages (`/v1/tiktok/video/comments`, 1 credit per page of 20) and pulled out each unique sticker (`content_type == 17`, `cmt_sticker_struct`). A later page that fails keeps what was found (`ok=True`, `complete=False` on the command event). Each ▶ press that reads more pages (a "dig", #4818) emits its own row: at most 5 pages, 15 s. `via` (folded) says what started the read: `discord` or `page` (a ▶ press; `page` is the page's `POST /stickers/<id>/more`), `background` (the loader that reads the rest of the list after the first reply), or `replies` (the reply walk on `/v1/tiktok/video/comment/replies`: `pages` = reply pages read, `count` = replies with a sticker, `reason=comment_found` when a comment link was found among them). `hedge_requests` (folded, #4818) counts the repeat requests sent for pages with no answer after 4 s; read it with `toint(parse_json(detail)['hedge_requests'])`. A rising share of scans with a repeat says ScrapeCreators is stalling more often. Rolled into the ops-monitor `scrapecreators` health rate with the /ask tools; `not_provisioned`/`no_url` are benign. **`source=ensembledata`, `via=ensembledata`** (2026-10-30) is the comment check a comment link runs first (`check_comment`, EnsembleData `/tt/post/comments`, 30 comments and 1 unit per page, at most 5 pages): `pages` read, `reason=comment_found` and `count` = 1 when the comment carries a sticker, else 0; error `not_provisioned`\|`no_url`\|`HTTP <n>`\|exception class. It fails open and rolls into its OWN ops-monitor health key `ensembledata` | | `sticker_fetch` | utils/tiktok_stickers.py (#4812) | ok, duration_ms, url_host, status, bytes, error (exception class\|`bad_url`\|`http_status`\|`too_large`\|`empty`) — one sticker file (animated WebP) downloaded from TikTok's CDN, capped at 8 MB. Fail-open: a miss leaves that sticker out. Ops-monitor health key `tiktok_cdn` (`too_large`/`bad_url` excluded as file misses, not an outage). A row with `reason=short_link` is the one `HEAD` (redirects not followed) that resolves an app short link, because the redirect target carries a comment link's `share_comment_id`; error `no_redirect` or an exception class. Those rows go to their own ops-monitor key `tiktok_short_link`, not `tiktok_cdn`, because the `HEAD` hits vm./vt.tiktok.com; a miss quietly turns a comment link into a whole-video scan | | `sticker_command` | cogs/stickers.py (#4812) | ok, duration_ms, guild_id, user_id, target (`chat`\|`sticker`\|`emoji`), mode (`video`\|`comment`), reason (`no_guild`\|`bad_link`\|`unprovisioned`\|`capped`\|`no_stickers`\|`comment_not_found`\|`sticker_hidden`\|`scan_failed`\|`none_converted`\|`error`), count, complete (folded), pages (folded), skipped, has_link — one `/stickers` run. The ◀ ▶ presses after it emit no row of their own; a dig shows as a `sticker_scan` row. A refusal (DM, bad link, daily cap) stays `ok=True` with a `reason`; `ok=False` only on `scan_failed`/`none_converted`/`error`. An exception after the defer also goes through `emit_error(source="sticker_command", recoverable=False)`. `sticker_hidden` (2026-10-30): EnsembleData saw a sticker on the linked comment, and the search did not find it; `no_stickers` on a comment link with `pages=0` is the check answering without a search | | `comment_read` | utils/comments.py (#1272 follow-up) | url_host (≤40 chars), count (# comments after like-rank), ok; on a miss: error (`unprovisioned`\|`unsupported_url`\|`fetch_failed`\|`bad_shape`) — the `read_comments` /ask tool ran: the **audience REACTION** on a specific TikTok/YouTube/Instagram/Reddit/**X (Twitter)** post (top comments ranked by likes), returned as TEXT for the model to synthesize ("read the room", no links; the crowd's-eye complement to `read_media`'s transcript). TikTok/YouTube/Instagram/Reddit ride **ScrapeCreators** (`bot.scrapecreators`); **X REPLIES ride twitterapi.io** (`bot.xprovider_primary` → `XProvider.fetch_tweet_replies` over `/twitter/tweet/replies` — ScrapeCreators has no tweet-replies endpoint, so the X reply thread is the "comments" there). Per-platform mappers normalize to one `Comment(text, likes)` shape; NSFW-agnostic (comment text only). Offered when EITHER client is provisioned; fail-open. Pure mappers + formatter in `utils/comments.py` (unit-tested); handler `cogs/ask.py:_read_comments` | | `social_lookup` | utils/social_profile.py (#1272 follow-up) | platform (`tiktok`\|`instagram`\|`youtube`\|`twitter`, normalized from `x`/`ig`/`yt`/`tt` aliases), handle (≤40 chars), ok; on a miss: error (`unprovisioned`\|`bad_platform`\|`no_handle`\|`fetch_failed`\|`not_found`) — the `social_profile` /ask tool ran: **ONE account's PROFILE** (follower count, verified badge, post/video count, bio, account age) on TikTok/Instagram/YouTube/X-Twitter via ScrapeCreators (`/v1/tiktok/profile`, `/v1/instagram/profile`, `/v1/youtube/channel`, `/v1/twitter/profile`). The **structure complement to `social_search`** (which FINDS posts) — the model relays the real stats, never invents a follower count. Per-platform pure mappers normalize to one `SocialProfile` shape; provisioning-gated on `bot.scrapecreators`; fail-open. Pure mappers + formatter in `utils/social_profile.py` (unit-tested); handler `cogs/ask.py:_social_profile`. Distinct from `search_socials` (find posts) and `discord_lookup` (this server's members) | | `reddit_search` | utils/reddit.py (#1272 follow-up) | query (≤80 chars; a subreddit scope folds in as `r/<sub>: <query>`), count (# posts after score-rank + NSFW filter), ok; on a miss: error (`unprovisioned`\|`empty_query`\|`fetch_failed`\|`bad_shape`) — the `search_reddit` /ask tool ran: **Reddit DISCUSSION/opinion** on a topic via ScrapeCreators (`/v1/reddit/search`, or `/v1/reddit/subreddit/search` when scoped), returned as **TEXT for the model to SYNTHESIZE** — **NO links surfaced** (owner steer: Reddit is an INPUT source we work into her own voice, never a URL she pastes — "not reddit-nerdy"; `RedditPost` has no permalink field, AND `url_guardrail.strip_urls` removes any link from the post's own selftext -- #2232, where the field-level guarantee was walked past by a url inside the author's body text; the same one-line fix covers `comment_read` and `ktt2_fetch`). Each post carries title + subreddit + the discussion text + a traction signal (score + comments); NSFW (`over_18`) posts are dropped, results score-ranked. Provisioning-gated on `bot.scrapecreators`; fail-open. Pure mapper + formatter in `utils/reddit.py` (unit-tested, incl. a "never links" assertion); handler `cogs/ask.py:_search_reddit`. For opinion/discussion, distinct from `web_search` (breaking news) | | `music_desk_scored` `phase=ktt2_chatter` | cogs/music_desk.py (#2230) | guild_id, score, reason, shipped, matchup (`<artist>: <n> replies`), post_preview — the FORUM-CHATTER lane's self-gate. Two things make this row unlike every other desk phase. (1) The score comes from **`discourse_score`, not `music_desk_score`**: measured on the live board, the desk judge scored real chatter takes 0.00–0.20 and said why — "forum chatter, not a music number" — which is correct, because this lane never reports a chart position or a consumption figure. The discourse judge grades whether a post earns a reply, which is what this lane is for, and scored the same story 0.62–0.78. (2) The lane reports DISCOURSE about a named real person. The take NAMES what the room is arguing about — the thread title + opening post, handed over as the argument's SUBJECT (`utils/ktt2_chatter.context_block` + `FRAMING`) — and gives one honest read, but never states the allegation as a thing that happened (the constitution's THE ROOM IS NOT A SOURCE + STAY IN YOUR LANE). It composes through **`claude_client.compose_forum_chatter`, ISOLATED from the numbers-desk `compose_market_drop`** (#2230 quality fix): that composer's task is "lead with the number", and the reply count was the only number, so the count became the headline ("569 replies deep on Drake at 24 an hour") — a dead post that said nothing about the actual chatter. The isolated composer leads with the argument and treats the count as texture. A/B on the live Drake thread (Sonnet + Opus): the old path led with the count every sample; the new path led with the subject every sample and stayed hedged ("allegedly"), never ratifying the claim. Expect this phase to be QUIET — measured yield is ~1–2 watched-artist threads a day clearing the heat floors, heavily Drake-weighted, and 3 of 5 composes shipped in the dry run. A phase that goes silent for days is NORMAL; a phase that suddenly posts several times a day means a floor stopped biting. Gated by its own `ktt2_chatter` experiment (STAGING by default) and its own opt-in X toggle | | `music_desk_scored` `phase=watch_board` | cogs/music_desk.py (#2321, #2662) | guild_id, score, reason, shipped, matchup (`<board.kind>: <act>`), post_preview — the WATCHED-ARTIST BOARD's self-gate. One act's whole presence on ONE kworb chart, replacing the per-move cards the watch sweep used to ship in a burst. **`matchup` is what tells the two board kinds apart**, because both ride this one phase: `watch_artist: <act>` is the board several NOTABLE MOVES in one sweep make, and `watch_standing: <act>` is the board the act's TOP-10 PRESENCE makes on its own (`chart_boards.MIN_TOP10_STANDING`, TWO or more of the chart's top 10 since the owner steer of 2026-09-05; it was three, and the kind was called `watch_takeover`). A row rejected BEFORE the judge (the empty-response and number-backstop guards) carries the bare act name instead, since those guards see only the subject. The **`watch_boards` panel** (Content Desks, #2662) splits this phase by that prefix and buckets the un-prefixed rows as `pre_gate_reject`. It exists because the STANDING board's failure mode is SILENCE, and silence is exactly how its absence hid: on 2026-08-28 Rod Wave held 7 of the Apple Music US songs top 10 all day, only one of his titles moved per sweep, so no board ever formed and the desk posted single-song climb cards eight hours apart — nothing counted the board that did not happen. **Read it this way, and read it HARDER after 2026-09-05:** a `watch_standing` count of zero across a week while `watch_artist` keeps firing means the presence path stopped forming boards. That column should now be the BUSIER of the two, for two reasons at once — the floor came down to two of the top ten, and the STANDING PASS seeds a board off the chart page with no mover at all, where the board used to need one. So the old expectation (quiet, bursty, one per slot) is the REGRESSION signal now: a return to the pre-2026-09-05 rate means the standing pass stopped seeding. Still no MONITOR, and the reason is unchanged — a standing is event-driven and genuinely bursty (an act with an album out holds the page for days, then nobody does for weeks), so no rate separates a quiet chart from a broken lane; the panel builds the new baseline first, and a monitor is worth revisiting once a week of the new rate is on it. The board is still capped at one per slot and still deduped on the top-10 COUNT it states, so a standing that holds steady posts once and not again. **The sibling CARD lane (`phase=watch_chart`) gained two story kinds in the same steer** -- `top_climb` and `top_fall`, a move of any size inside a chart's top ten, where every kind before them needed an event. They ride `music_desk_scored phase=watch_chart` with no new event and no new field; `matchup` carries the chart and the title as before, and the kind is visible in the card's eyebrow rather than the event. Expect this phase's VOLUME to rise and then settle: the dedup key carries the position, so a title posts once per position it reaches inside the 9-day window, and `_MAX_TOP10_MOVES_PER_SLOT` (1) holds the share of each slot. Read a `watch_chart` rate that does NOT rise as the signal that the new kinds are not deriving -- most likely the charts' `pos_change` column stopped parsing, which would also flatten the `jump` and `top<N>` kinds beside them | | `ktt2_fetch` | utils/ktt2.py (#ktt2) | source (`ktt2`), query (the section slug — `music` by default — or `thread` for a thread-page read), count (threads parsed off the page; replies kept, on a `thread` read), ok, duration_ms, cache_hit; on a miss: reason (`unavailable`\|`bad_shape`) + error (`HTTP <status>`\|`breaker_open`\|`no_next_data`\|`no_threads`) + http_status — one **KTT2 forum section read**, the source behind BOTH the `search_ktt2` (discourse) and `ktt2_breaking` /ask tools. KTT2 has no API and no key: every section page server-renders its Apollo GraphQL cache into `__NEXT_DATA__`, so this parses JSON off a page rather than calling an endpoint (access reasoning + the three pre-ship checks are in the module docstring). Fail-open (a miss → an empty section), so this ok-rate is the ONLY signal either tool went quiet — nothing raises. **`error=no_next_data` / `no_threads` is the SCHEMA TRIPWIRE and the row to watch**: the page answered 200 but carried no data, which is what a KTT2 redesign or a bot-block looks like. It does NOT self-heal (the reader stays dark until `utils/ktt2.py` is updated) and `/debug/integrations` CANNOT see it — the `ktt2` probe still reads green, because the page is still returning 200. That gap is why this event carries its own monitor (**KTT2 reader broken**, >3 shape failures in an hour) while the ok-rate deliberately does not: both tools are ON-DEMAND, so a quiet hour means nobody asked, and a low-volume ok-rate would be noise. Guarded by limiter (6/min) + breaker (`integration=ktt2`) + retry + a 10min durable cache, so a burst of tool calls in one answer collapses onto one fetch. **The `thread` read (#4366)** is `Ktt2Client.thread_detail`: the `ktt2_chatter` desk lane reads the first page of the ONE thread it picked (the full opening post + the most-liked replies), so it adds at most one GET per slot. It reports the same error values, so the schema monitor covers a thread-page redesign too. Panels: **KTT2 forum reads** (health by read + error) and **KTT2 schema tripwire** on the Media dashboard; the two tools themselves ride the generic `tool_vol`/`tool_ok` panels on the Ask dashboard | | `social_search` | utils/social_search.py (#1272) | query (≤80 chars; the search terms, `#<tag>` for hashtag, or `trending:<REGION>` for the trending tool), platform (`all`\|`tiktok`\|`youtube`\|`instagram`), count (# merged hits returned), ok; on a miss: error (`unprovisioned`\|`empty_query`\|`bad_platform`) — the `search_socials` / `discover_trending` / `search_hashtag` /ask tool ran: **unified cross-platform social DISCOVERY** via ScrapeCreators, MERGED + engagement-ranked, so the model FINDS fresh social content uniformly (the "search all social platforms uniformly" ask) instead of a per-platform tool. **`search_socials`** is a keyword SEARCH (TikTok `/v1/tiktok/search/top` + YouTube `/v1/youtube/search` + Instagram reels `/v2/instagram/reels/search`); **`search_hashtag`** is the `#`-tag sibling (TikTok `/v1/tiktok/search/hashtag` + YouTube `/v1/youtube/search/hashtag`); **`discover_trending`** is the no-keyword TRENDING feed (TikTok `/v1/tiktok/get-trending-feed` region-scoped + YouTube `/v1/youtube/shorts/trending`). Each hit carries the platform, caption/title, author (with a **✓ verified** badge), engagement (likes/plays/views), **duration**, and the LINK (Discord unfurls it). Provisioning-gated on `SCRAPECREATORS_API_KEY` (`bot.scrapecreators`); fail-open per platform (a source miss contributes nothing). Pure mappers + orchestrators in `utils/social_search.py` (unit-tested); the handlers are `cogs/ask.py:_search_socials`/`_discover_trending`/`_search_hashtag`. Full field-coverage ledger in `docs/INTEGRATIONS.md`. Distinct from `read_media` (read ONE known URL) and `web_search` (a general web fact) | | `music_dedup` | cogs/music.py | guild_id, channel_id, channel_name, decision (`similarity_gate`/`song_gate`), signal (`same_link`/`text_similarity`/`shared_run`/`content_overlap`/`same_song`), post_preview — a composed drop was too similar to a recent one (the shared take-text/link dedup, `similarity_gate`) OR it re-picked a SONG already dropped in the last ~30 days (the durable song-identity reuse block, `song_gate`/`same_song` — the "Folded keeps re-dropping" fix; scheduled drops only, one retry then skip the slot) | | `music_link_missing` | cogs/music.py | guild_id, channel_id, channel_name, must_post, attempt, post_preview | | `music_house_gate` | cogs/music.py | guild_id, channel_id, channel_name, ok, reason (`off_genre`/`new_release`/`watched`/`certified`/`unknown_artist`/`unresolved`/`unparsed_track`), attempt, must_post, artist, genre, watched, certified — a composed drop was graded against the HOUSE POLICY (`utils/music_policy.py`, owner steer 2026-09-14 after CMAT's "EURO-COUNTRY" reached @tootsiesbar): the bar plays rap, R&B and pop, and a record that is not a new release must be by an act on the kworb watch list or one RIAA has certified. One row per graded attempt, and the ops monitor counts **every** attempt, retries included — counting only the first would make `gate_dark` report a dark channel in the window where every first attempt is refused and every retry passes, so read the rate as attempt quality rather than slot quality. `ok=false` refuses the drop, which triggers the same single retry the link and song gates use and then skips the slot. A refusal emits nothing else: it deliberately does NOT fall into `music_dedup`, which would file every policy rejection as a `same_song` duplicate that never happened. **Read `reason` as the tuning signal, not just the health signal.** `off_genre` carries the iTunes tag in `genre`, and an IN-HOUSE tag appearing there is the list being too narrow (iTunes files old records under catch-alls — measured 2026-09-14, Prince's "Kiss" is tagged Soundtrack), so the fix is to add the tag to `music_policy._IN_HOUSE_TAGS`, not to loosen the rule. A climbing `unknown_artist` share says the compose prompt is fishing outside the room's artists; a climbing `unresolved` share says the catalogue read is degrading, not that the picks got worse. `watched=true` on an `unknown_artist` row is impossible by construction and would mean the verdict and the fetch disagree. **`verdict` (#3443, folded into `detail`)** names the genre step that decided: `list` (on the allow list, no model call), `not_asked` (off-list, no call: blank tag, blank artist or title, or a `_NEVER_RESCUE` tag such as Indie Pop), `model_yes` (the Haiku check `claude.house_genre_check`, purpose=`house_genre`, filed the record as rap, R&B or pop), `model_no` (it answered NONE), `model_error` (no verdict; refused as before). Blank when the gate stopped before it read a genre (`unresolved`, `unparsed_track`). The newsroom's `music_news_filtered reason=off_genre` carries the same `detail.verdict`. A `model_yes` row is a tag the model let through: read its `genre` and artist when you audit the check. A rising `model_error` share means the check is down and the gate is back to the bare list; the calls also show in `claude_api` with purpose `house_genre`. No new monitor: an error only restores the old refusal, and the panel splits by `verdict`. | | `commentary_posted` | cogs/commentator.py | guild_id, channel_count (rooms sent to; 0 on a staged audition), trigger (pregame\|goal\|halftime\|period\|final\|interval\|clutch_interval), sport, game_key, delivered (`room` = posted to the sports channel(s), `live_scores` experiment in production; `staged` = the line auditioned in #bot-logs, experiment in staging, room kept quiet), post_preview (the shipped line, `POST_PREVIEW_CHARS`; graded by the live-log eval pass on `delivered=room`), depth_legs (a comma-joined list of which optional depth blocks landed on this post — `odds` #476, `props` #390, `team_stats`/`pregame`/`standings` #432, `payout` #432 Epic E, `market_edge` #622, `bets` #623 — folding the former 8 `has_*` booleans into ONE field, #1296; leg names are non-overlapping so `depth_legs contains '<leg>'` reads each leg's landing rate), depth_shed (#779: API-Sports near its daily-request cap so the OPTIONAL per-game depth — leaders/team-stats/pregame/standings, all API-Sports — was SKIPPED to preserve budget for the load-bearing live scores + bookie settlement, reverting to score+events only; read from `bot.usage_status` at the 0.85 LOW_PCT bar, fail-open). The live sports commentator posted a line for a live game; gated by the `live_scores` experiment + mood + master kill switch, paced by the pure `utils.sportsdata.cadence` decision. **Ops-monitor watches the SHIPPED depth mix** (a "Live sports commentary depth" line: room-post count + trigger spread + each `depth_legs` leg's landing rate) so a silently-broken depth leg — the standings fetch dying, `final`/`pregame` never firing — is visible every run, backed by the api_sports `market_fetch` integration-health rate | | `commentary_scored` | cogs/commentator.py | guild_id, game_key, trigger, sport, score (0-1), reason, shipped (>= the 0.6 ship floor), post_preview — the commentator's self-gate (`commentate_score`, Haiku) scored a composed line before sending. Parity with `discourse_scored`/`music_scored`: a sub-floor line is dropped (no `must_post` here), and the ops monitor watches it for `low_quality`. Fail-open: a scorer exception ships the line anyway | | `commentary_deferred` | cogs/commentator.py | guild_id, game_key, trigger, sport, reason (`stagger`) — cross-game spacing (#502) held a routine read back so concurrent live games don't post a wall to one channel. The guild already hit its per-tick post budget (`_MAX_ROUTINE_POSTS_PER_TICK`); the deferred game's `PostState` is left untouched so the cadence re-fires it next tick. MILESTONE triggers (goal/final) are never deferred (a reaction loses value if held) but count toward the budget so routine reads space around them | | `highlight_fetch` | utils/highlightly.py | sport, query (away @ home + date), ok, result_count (# VERIFIED clips), duration_ms; on a miss: error (`no_key`\|`unmapped_sport`\|`HTTP <status>`\|`<exc>`) — a Highlightly `/highlights` API call, the commentator's post-game video-clip source (#432, covers soccer + NBA). Key-gated on `HIGHLIGHTLY_API_KEY`, fail-open to [] | | `highlight_posted` | cogs/commentator.py | guild_id, game_key, sport, delivered (`room` = posted to the sports channel(s); `staged` = auditioned in #bot-logs, `live_scores` experiment in staging), source (the clip's aggregation source, e.g. youtube/twitter), ok, channel_count (room) — the commentator posted a post-game highlight clip for a completed game. **Once per game** (durable dedup via `sports_post_state.highlight_posted_ts`); clips lag the whistle 0-48h so a completed game is re-checked on a pace (`_HIGHLIGHT_RECHECK_SECS`, 60min) until a VERIFIED clip lands. Gated by the `live_scores` experiment + mood + kill switch (#432) | | `abuse_warned` | utils/abuse_tracker.py | guild_id, user_id, violations | | `abuse_silenced` | utils/abuse_tracker.py | guild_id, user_id, violations | | `bot_power` | cogs/settings.py | guild_id, user_id, enabled (the master kill switch / "complete off button" was flipped on `/menu`. `enabled=False` takes Toots fully dark in the guild: no mentions, voice, chime-ins, scheduled posts, memory writes, or slash commands; only `/menu` stays reachable so a mod can flip her back on. Distinct from `mood=off`, which only mutes the proactive surfaces. Per-guild `servers.bot_enabled` flag, default on, checked at every entry point via `db.is_bot_enabled`: the listeners/schedulers gate inline and the slash surface is gated globally by `bot._GatedTree.interaction_check`) | | `scheduler_firehose` | cogs/calendar_view.py | guild_id, user_id, enabled (≥1 surface on firehose), count (# surfaces across all three cadences), cadence (which cadence the mod just edited — `half_hour` / `hourly` / `two_hourly`; **folded into `detail`**, so query it as `parse_json(detail)['cadence']`) — the **PER-SURFACE, PER-CADENCE** scheduler firehose set was changed on the `/menu` calendar (the 🔥 button → a surface-picker sub-view, `_FirehoseView`, one multi-select per cadence). A surface in one of the guild's three firehose SETS fires at EVERY working-hours slot OF THAT CADENCE (its per-slot calendar assignment ignored); the rest of the grid still governs the other surfaces — resolved in the single chokepoint `schedule_calendar.calendar_hours` (`surface in` one of the three sets → `firehose_slot_times(working_hours, cadence)`, else the stored calendar). The three sets are disjoint on write, so a surface has exactly one cadence; `calendar_hours` reads them lightest-first. **`cadence` is what makes a `slot_replan` correlate:** that finding says a surface's plan SHRANK mid-day, and this row says which cadence a mod moved it to and when. **Per-surface, not all-or-nothing** (the affordable shape per the #firehose cost pull: firehose the cheap token-only surfaces curator/discourse — no metered wall — and leave the metered market_drop/bet_board, which are SGO-capped and already resource-bound, on the normal calendar). Still thinned by each surface's channel config + dedup + the 0.6 self-gate + rate limits; never overrides a muted (OFF) mood; grid stays fully editable. Off by default | | `reaction_added` | utils/reactions.py + cogs/starboard.py | source (`chimein`/`starboard`/…), guild_id, channel_id, message_id, emoji | | `starboard_report` | cogs/starboard.py | guild_id, user_id, period (1h\|1d\|1w), threshold, channels_swept, messages_scanned, eligible (# cleared-but-not-promoted), wall_known — the `/star` sweep listed messages that cleared a board but aren't on the wall, as jump links for a human to react to (MEE6 ignores the bot's own reaction, so only a person reacting re-triggers MEE6; an earlier bot re-react mode was removed for that reason). Runs in the background and posts the list when done. Filters out already-promoted messages via the jump-link ids on the wall's MEE6 posts | | `walloffame_leaderboard` | cogs/starboard.py | guild_id, user_id, entries (total bangers across members), contributors (# members with a banger; 0 = no seed yet) — the bangers board ran (in the `/leaderboard stats` hub): ranks all-time BANGERS per member (posts that CLEARED the wall bar, 10+ 😂/😭) read STRAIGHT FROM the `member_engagement` seed (no scan). The seed's would-be-wall count — a SUPERSET of MEE6's actual wall promotions (the seed doesn't capture those; the card's separate "wall bangers" flex still reads the wall). All-time, uncapped; names resolve via the guild / identity map | | `reaction_leaderboard` | cogs/starboard.py | guild_id, user_id, contributors (# members with reactions; 0 = no seed yet) — the reactions board ran (in the `/leaderboard stats` hub): ranks all-time reactions pulled per member, read STRAIGHT FROM the `member_engagement` seed (no scan; the windowed 6mo/1w/1d live scans were dropped when it moved to the seed). All-time, uncapped; embed-fixer reposts folded onto the real user, MEE6/other bots skipped; names resolve via the guild / identity map | | `funniest_leaderboard` | cogs/starboard.py | guild_id, user_id, contributors (# members with laughs; 0 = no seed yet) — the funniest board ran (in the `/leaderboard stats` hub): ranks all-time laughs pulled per member by `laugh_reactions` (😭/😂), read STRAIGHT FROM the `member_engagement` seed (no scan) — the comedy-VOLUME twin of the bangers board (wall-clearing posts) and the leaderboard mirror of the awards' Funniest. A public board in the shared toggle (alongside bangers + reactions); all-time, uncapped; names resolve via the guild / identity map | | `card_leaderboard` | cogs/starboard.py (built off cogs/playercard.py) | guild_id, user_id, contributors (# active members ranked; 0 = no seed yet) — the card OVR board ran (in the `/leaderboard stats` hub): active members ranked by their `/card` OVR off the SAME `member_engagement` seed the card reads (so a member's board OVR matches their card), rendered through the shared `_board_pages`. Rows show the OVR number. Gated on the `playercard` experiment (PRODUCTION=everyone, STAGING=mods-only preview, OFF=hidden — the same gate `/card` uses); it joins the toggle picker (bangers / reactions / card / besties) only in PRODUCTION, 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`) | | `besties_leaderboard` | cogs/starboard.py (off utils/social_graph.py) | guild_id, user_id, scope (`everyone`\|`current` — the `show:` membership filter, defaults `current`), pairs (# pairs ranked; 0 = no seed yet / none cleared the floor) — the besties board ran (in the `/leaderboard stats` hub): the server's closest member PAIRS ranked by **Ochiai closeness** (== cosine similarity) over the engagement seed's reply+mention graph, activity-normalized so it surfaces genuine duos not the loudest hubs. Reads the same seed `/card` does (no scan); playercard-experiment-gated. A pair board (`A ↔ B · %` rows) IN the shared `/leaderboard` toggle picker (no separate command). `scope` is the UNIVERSAL `show:` filter (here-now / everyone) that applies to every leaderboard board, defaulting `current`: keeps only pairs where both are still in the guild; `everyone` includes departed members (resolved to real names via the identity map). Pure closeness core in `utils.social_graph.closest_pairs` (unit-tested) | | `engagement_snapshot` | cogs/stats.py (#1017) | guild_id, members (# rows snapshotted), trigger (`scheduled` = after the daily catch-up fold; `bootstrap` = the first snapshot on cog-load so windows aren't empty until the next daily run; `backfill` = a `/stats period:` reconstructed snapshot stamped at a PAST day so the windowed board reads accurately immediately) — a SNAPSHOT of a guild's engagement seed was frozen into `engagement_snapshots` so the windowed (weekly/monthly) `/leaderboard stats` boards can diff against it (window = current seed − snapshot from N days ago). The daily one is a ~1-line server-side copy of `member_engagement.stats` (NOT a re-scan), idempotent per UTC day; the `backfill` one is a reconstructed `(current − scanned window)` blob (`reconstruct_snapshot`) past-dated to `today−N`. Retention bounded by `prune_engagement_snapshots`. One daily snapshot serves BOTH weekly + monthly by subtraction — no extra scan jobs | | `stats_seed` `detail.labels_*` (the Jev labeling pass) | cogs/stats.py + utils/engagement_labels.py (#4318) | No new event kind and no new top-level field. When a `/stats` rebuild or catch-up ran the labeling pass (every rebuild and catch-up when `TYPESAFE_API_KEY` is set; no experiment gate since the owner's 2026-10-17 "ship to prod"), its `ok=true` `stats_seed` event carries a `detail` map of counts: `labels_seen` (messages the pass saw), `labels_by_model` (Jev answered), `labels_no_text` (nothing to judge, so the shape-rule verdict and no request), `labels_fallback` (in a request Jev did not answer, so the shape-rule verdict), `labels_batches`, `labels_failed_batches`, and `labels_flip_question` / `labels_flip_laugh` / `labels_flip_hype` (Jev disagreed with the shape rule). Counts only: no message text, ever. Each request is one `typesafe_api` event from the shared client (#4310) with `purpose=engagement_labels`, so the ops monitor's `typesafe_api` OkRate sees a silent failure. A run with `labels_failed_batches > 0` still wrote a complete seed (the shape rules filled in). Panel: "Engagement labels: Jev share + disagreement" (`scripts/axiom_setup.py`). No monitor: the pass runs at most once a day per guild, and the `typesafe_api` OkRate already flags failures. Query: `['tootsies'] | where event == 'stats_seed' and isnotnull(detail) | extend d=parse_json(detail) | project _time, guild_id, mode, seen=toint(d['labels_seen']), jev=toint(d['labels_by_model']), failed=toint(d['labels_failed_batches'])` | | `memory_write` | cogs/memory.py | guild_id, tier (hourly\|daily for the room's attributed memory; self_hourly\|self_daily for Toots's own-takes pyramid), ok, chars\|skipped (low_activity\|empty\|truncated), message_count, channel_count, rolled_up (# of hourlies synthesized into a daily), backfill. **Since #4528** the fence's code check (`output_checks.scrub_memory_note`, rule 203) runs in `_tag_and_store` before every store: it deletes URLs, raw `<@id>` mentions and Discord ids, and drops the `@` of a bare `@name`. `detail.fence` lists what it removed on a stored note (`url`, `mention`, `discord_id`). `skipped=fence` (ok=false) means nothing was left after the scrub, so no note was stored; a fenced-out rollup still flags its hourlies consumed, like an `empty` one. Measured before the change (30 days to 2026-10-25): 30 of 582 room notes held a bare `@token` (9 `@everyone`/`@here`, 9 member display names, 12 other, 1 `@toots`); 0 held a URL, a raw mention or an id. Query: `where event=='memory_write' and isnotnull(parse_json(detail)['fence'])`. | | `memory_forget` | cogs/memory.py | guild_id, user_id, notes_deleted, facts_deleted (consented `user_facts` rows wiped alongside the observational notes) | | `user_fact` | cogs/ask.py | guild_id, user_id, stored (True = a new consented fact saved; False = blank/duplicate/db_error), reason (None on store; `duplicate`\|`db_error` on skip) — Toots stored (or skipped) a fact a regular told her to remember about THEMSELVES via the `remember_about_you` /ask tool. Consent-based, per-`(guild, user)`, storage uncapped with the per-reply read-back bounded to the newest `USER_FACTS_READBACK`; the fact only personalizes her replies to that user (read back via the `<about_them>` block), never disclosed to others; `/forget` wipes it | | `poll` | cogs/polls.py | guild_id, channel_id, user_id (the asker; absent on anonymous reads), action (create\|list\|read\|update\|end), ok, reason (None on success; `invalid`\|`no_permission`\|`not_found`\|`forbidden`\|`not_mod`\|`already_closed`\|`error`); create also: options (count), duration_hours, multiple, emojis (# answers given an emoji); list also: count — Toots created or managed a native Discord poll on a regular's behalf via the `create_poll`/`list_polls`/`read_poll`/`update_poll`/`end_poll` /ask tools (#888). create_poll can attach a sanitized emoji per answer; **reads are GUILD-WIDE** (every readable channel, current first — Discord has no guild-poll API) and read_poll surfaces who voted each option (public votes). Create/list/read are open to anyone (poll-create rides the ask bucket); end/update are mod-gated. Discord can't edit a live poll, so `update` CLOSES it + reposts fresh (votes reset). Fail-open: a bad request / missing permission / discord blip degrades to a soft status line | | `versuz` | cogs/versuz.py (#2874) | guild_id, channel_id, action (offer\|start\|prefill\|song\|queued\|catalog\|button\|poll_open\|revote\|poll_close\|round\|end\|card\|recap\|resume\|reattach\|score_edit\|round_fix\|first_to_edit\|repeat\|merge\|reopen\|round_add\|poll_link\|archive\|cue\|anon\|opener_dm\|void\|switched_off\|standings), ok, round (the round number), reason, count, duration_ms, hit, source, error, mode, user_id, message_id — Toots hosting a Versuz night. `song` is the voice-chat listener: ok=true with reason `jockie` (a Started-playing line credited to a player), `shorthand` (typed `A: song`) or `fix` (the card's Fix songs form set the slot: a link read by the catalog, or `Title - Artist`) or `clear` (an emptied box on the Fix a round page dropped the slot's song, owner ask 2026-09-09; the card waits on that player's next song); ok=false with `bad_link` (the Fix songs form got a link no catalog could read: the slot stays, the reply says what to paste) or `unattributed` (a track started with no recent play command from either player: Jockie changed its shape, a queue replay, or the players are not who the host named) or `not_a_player`, or `no_track` (#2878: a player's play command got no Started line inside the attribution window, Jockie down, out of the channel or another instance answered; the card says so once per play; since #2967 a command the bot answered with an Added Track line, the track waiting in the queue, is never `no_track`) or `extra_a` / `extra_b` (the same player's next queued track started before the other player played, past the redo window: their pick stays, the card notes the extra track). `offer`: two different people summoned the music bot in a voice chat with no match on it inside 5 min (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), and Toots posted the prefilled Set up button there (one per voice channel per 30 min; `versuz_offer` error when the send failed). ok=true `reason` = voice (posted in the voice chat) \| home (Toots could not post there, so it went to the last channel a versuz ran in; that channel is prefilled on the form either way); ok=false names why an offer that had two players did NOT post (once per 30 min cooldown, not once per play): `off` (the surface is off for the guild), `no_send` (Toots cannot post in that voice chat and no versuz has run in the guild yet, so there is no fallback channel), `no_home` (she cannot post in the voice chat and cannot post in the last match channel either), `no_member` (a player is not in the guild), `stage_error` (the stage read failed), `arcade_only` (the server is not Tootsie's or the master guild, so it runs /fight alone; 2026-10-18), `no_voice` (two people played from text channels inside the window and neither sits in a voice channel, so there is nowhere to offer; `channel_id` is the text channel, once per guild per cooldown; 2026-09-03 23:40 UTC: Jockie bound to the text chat, both players playing from it); 2026-09-03: two people played and nothing posted, because the send check treated a voice channel as unpostable, and nothing said so. `start` reason = quick (`/versuz @a @b` opened the match with no form: no name, no first-to, the voice channel the host sits in; owner ask 2026-09-11) \| form (every other door: the Set up form, the auto-start offer's prefilled form). A blank name box is not a miss -- the match titles itself `<a> vs <b>`. `reattach` (owner ask 2026-09-03, resiliency): the match was picked back up from `resumable_sessions`, `reason` db = a card button pressed while the match was not in memory (before the on_ready restore, or after one that failed: the `on_interaction` listener resumes it and handles the press), command = `/versuz` or the Set up form run again on a channel whose match is on (a fresh card at the bottom, no second match). `score_edit`: the Edit score form set the counts by hand, `reason` = `<old a="">-<old b="">-<old ties="">><new a="">-<new b="">-<new ties="">`; the rounds keep their own results (the tie count is the tied rounds plus a `tie_adjust` the form sets, so a night that started before Toots was hosting can show its ties). `repeat` (owner idea 2026-09-03): a song that already ran this match started again, `round` = the round it started in, `count` = the round it first ran in, `user_id` = the player; the card and an H3 line say so, the pick stands. `round_fix` (#2904): the Fix a round form corrected a SCORED round (a song replaced, the result set), `reason` = `<old winner="">><new winner="">` (`-` when the result did not change), `count` = songs replaced; the running score follows the result and the round's `versuz_rounds` row is replaced. `first_to_edit` (owner ask 2026-09-05): the First to box on the round page changed the match length mid-game, `reason` = `<old>><new>` (`-` = no limit), `round` = the round in play; a first-to the leading score already reaches is refused in the ephemeral reply and emits nothing. `button`: one control-card press, `reason` = the button (open\|close\|revote\|round\|recap\|anon\|end; fix\|score from a card printed before the three correction forms merged into Fix a round, 2026-09-03), `duration_ms` = the handler's time (Discord shows "This interaction failed" past ~3 s), ok=false + a `versuz_button` error when the handler raised. `switched_off` (#3313): a control-card press in a guild whose master switch (`/menu` power) is off. The tree's gate covers slash commands only, so the card checks `is_bot_enabled` itself (fail-open on a DB error) and declines ephemerally; `reason` = the button. The auto-start offer's form declines the same way and emits nothing. `queued`: Jockie's "Added Track" embed named a player as the requester of a track now on the queue (it starts later and is credited by title, see the Jockie quirks in ARCHITECTURE). `poll_open` reason auto (both played, second song ended) \| clock (the poll timer: both in and the second song `versuz_poll_seconds` into its play, default 60 s (120 until 2026-09-04, owner ask), 0 = wait for the end; owner steer 2026-09-03; a `versuz_clock` error means the timer task died) \| manual (Open poll now) \| revote (Re-vote round: the round that just closed runs back, a tie or an End poll tapped too early; the `revote` action right before it carries the result it undid as `reason` a\|b\|tie and the point comes off the score) \| invalid \| forbidden \| error. `poll_close` reason manual (End poll) \| auto (the `versuz_poll_close_seconds` knob ended it, owner ask 2026-09-09; 0 = off) \| linked (a hand-made poll linked on the round in play had already ended, so it was tallied at once) \| end (closed by End versuz) \| expired (Discord finalised the poll without the End poll tap: a mod ended it from the poll's own menu, or the hour ran out; the message-update listener tallies it within a second, a 5 s watcher is the fallback, a `versuz_poll_watch` error means the watcher died) \| void (a correction after the poll went up, not scored; the poll message is deleted, owner ask 2026-09-03, or ended when the delete is refused; no chat line since 2026-09-05, owner ask) \| not_found \| error, `count` = votes, `duration_ms` = the wait for Discord to finalise the counts. `round` reason a\|b\|tie. `end` reason manual\|first_to\|idle, `count` = rounds closed. The finish ends the `versuz_matches` row BEFORE it writes the standings, and writes them only when that update ended a LIVE row (#3134). `standings` ok=false reason `already_ended`: the row was already ended, so this finish wrote no `game_scores` rows and no crown. `resume` ok=false reason `match_<status>` (ended\|merged\|hidden): the boot restore found a resumable row for a match that is no longer live (a deploy between the finish's row update and `forget_session`), so it deleted the row and did not resume the match. Both are expected to be rare; a run of them means the finish is being interrupted often. `card` (#2877): the drawn scoreboard PNG posted at the finish (`reason` = final) or by the Recap button (`reason` = recap), ok=false + reason `send_failed` when the text board went instead (a render exception is an `error` with source `versuz_card`). `card reason=buried` (owner ask 2026-09-04) is a different thing under the same action: the CONTROL card re-posted at the bottom of the match channel because `versuz_card_messages` messages (`count` = the knob, default 10, 0 = off) buried it while no poll was open; at most once per 20 s. `prefill` (owner ask 2026-09-04): the match opened with the songs the two of them traded in that voice chat BEFORE it was set up, `count` = songs seeded (1 to 5: the last two complete pairs, plus the newest song when it has no answer yet, which sits in the round in play waiting on the other player; since 2026-09-05, when the complete-pairs rule seeded nothing for one played song and one queued one), `round` = the current round after the seed; the seeded pairs wait for Open poll now. A track the music bot's Added Track line named a player for BEFORE the match (`_pre_queued`) is carried into the match's queue book at the start, so its Started line minutes later is credited by title, not `unattributed`. `recap` (owner ask 2026-09-03): the Recap button posted the board of the match so far, `count` = rounds scored. `merge` (owner ask 2026-09-08): the Merge earlier match button on the `/versuz` pickup reply folded the ended match(es) of the night in this channel into the live one, `count` = rounds absorbed, `round` = the round in play after the renumbering; a `versuz_merge` error with `recoverable=false` means the state merged in memory but the standings rows were not fixed (the leaderboard counts the night twice until they are). `reopen` (owner ask 2026-09-09): the Reopen versuz button on the wrapped card, or the Reopen offer on the `/versuz` reply, brought the channel's last ended match of the night back at its next round, `count` = rounds scored, `round` = the round in play; its standings rows came back off in the same transaction (`versuz_reopen` error = the read or the write failed, nothing changed). `round_add` (owner ask 2026-09-09): the add-a-round entry in the Fix a round picker appended a fresh round for the host to fill by hand, `round` = its number (an empty round in play is reused and emits nothing). `poll_link` (owner ask 2026-09-09): the Poll link box on the round page attached a hand-made two-answer poll to a round, `message_id` = the poll, `reason` = open (the round in play took it as its open poll; the bot watches it) \| final (it had already ended: tallied at once, a `poll_close reason=linked` follows) \| scored (a scored round's result re-read from it, `count` = votes; a changed result moves the score like `round_fix`). Each answer is matched to a slot by the player's name or the song's title; when neither matches, nothing is linked and the host is asked which player the top answer belongs to (two buttons); the pick links it. `archive` (owner ask 2026-09-09): the Archive button on the `/versuz` reply and its picker posted a past match's drawn board into the channel, `reason` = final (an ended match) \| recap (the live one) \| send_failed, `count` = rounds scored; a `versuz_archive` error is the listing or the round read failing. `catalog` (owner steer 2026-09-03): the lead artist + features lookup for one registered song (`utils/versuz_catalog`: Deezer's track `contributors`, then iTunes billing, then Genius's `featured_artists` for a cast both leave blank), `hit` = a catalog credit replaced the Jockie/typed text, `source` = deezer\|itunes\|genius on a hit (genius, #2885: both catalogs left the cast blank and Genius's `featured_artists` named it; the catalog's lead and link stay), `duration_ms` = the whole lookup (bounded at 8 s), ok=false only on an exception (a miss is ok=true hit=false: fail-open, the song keeps its text and the regex feature). The ops monitor reads the hit rate as the `versuz_catalog` integration-health line. A card edit or send, a poll post and an End poll ride one retry on a transient Discord error (`_twice`: a 5xx, a 429, a timeout); a card edit that still fails is a `versuz_card_edit` / `versuz_card_send` error, a poll post that still fails notes it on the card ("tap Open poll now to try again"), and a state save that fails is a `versuz_remember` error (the state a redeploy would lose). `void` (owner ask 2026-09-12, "delete a versuz, so it doesn't count against stats"): a MOD deleted a wrapped night from the Delete a night picker on the `/versuz` reply. The match row goes to `status = 'hidden'` with `end_reason = 'voided'`, its `game_scores` rows are removed (`count` = how many, normally 2 -- one per player), and the crown its finish gave comes off the winner's streak (`best_streak` stays, the rule `versuz_reopen` and `versuz_merge` already follow). `message_id` = the match id. The ROUND rows stay, so a night voided by mistake can be rebuilt. All three doors back into a match -- the archive picker, Reopen versuz, Merge earlier match -- filter on `ended`/`live`, so `hidden` removes it from every one. A failed write emits no `void` event and a `versuz_void` error with recoverable=false: the mod was told nothing changed, and it is a transaction, so nothing did. **Watch:** `versuz_void` errors mean a mod asked to unscore a night and it did not happen, so the leaderboard still counts it. `anon` (owner ask 2026-09-12): the control card's **Anonymous** toggle turned the match's blind mode on or off, `reason` = on\|off, `round` = the round in play. While it is on, the poll answers and the card's slot lines read `pick 1` / `pick 2` instead of the player names, the two run in TITLE order rather than play order, and the card holds both songs back until the round is set; the tally reveals the map when the round closes. The button is refused 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 match row, so Reopen versuz brings a blind night back blind. **Watch:** nothing new -- `anon` is a host action on an existing surface, not a rate, so it earns a row in the `versuz_actions` panel (which counts every action) and no monitor of its own. `opener_dm` (owner ask 2026-09-12): an anonymous match sent the drawn first player to the two players by DM, because the room is not told who plays next. One ok=false event per player who could not be reached (`reason` = the exception class, `user_id` = who), then a rollup with `count` = how many of the two landed and `reason` = sent (both) \| partial. It is FAIL-OPEN: a player with DMs closed just settles the order with the other player, and the match runs either way. **Watch:** a guild where every `opener_dm` rollup reads partial means the DM path is not reaching anyone there, so the draw is effectively not being delivered. `cue` (owner ask 2026-09-09): one cue in the match's voice channel, `reason` = open (the poll posted) \| warn (`versuz_cue_warn_seconds` before the auto-close) \| close (End poll tapped), `mode` = speech (Toots says the line in her ElevenLabs voice, owner ask 2026-09-10) \| tone (the fallback: no key, or the synthesis failed; a `versuz_cue_speech` error carries a synthesis exception, the `tts_synthesize` event the call itself) \| staging (the `versuz_voice` experiment is STAGING: the line went to #bot-logs, she did not join; OFF emits nothing), `round` = the round, `duration_ms` = the connect (first cue of the night) + the whole play (the ok event fires when the player finishes, not when it starts); ok=false + `error` = no_voice (the match has no voice channel) \| stage (a stage channel, not handled) \| no_permission (no Connect or Speak) \| no_deps (PyNaCl / davey missing) \| no_ffmpeg \| busy (another live match in the guild holds the guild's one voice connection; plays again once it ends) \| timeout (the connect) \| ffmpeg_exit_<code> (ffmpeg ended with an error mid-tone; the player reports no error for that, so the exit code is read at completion) \| an exception class name (the connect, or the player failing mid-tone; a `versuz_cue` error carries the traceback). The first five turn the rest of the match's cues off, so they show once per match; the ops monitor reads the ok rate as the `versuz_cue` health line with no_voice, stage and busy excluded. **Watch:** `song ok=false` at any rate means the listener stopped understanding the music bot; `poll_close ok=false` means rounds are not being scored; `versuz_remember` errors mean a redeploy loses the match; `cue ok=false error=timeout` on every cue means the voice connection is not coming up (a Railway egress or a voice-gateway change). | | `game_match` | utils/arcade/hub.py (#3687) | surface (arcade), game_key (ring_kings\|deathmatch), guild_id, ok, reason, mode, count, tier, duration_ms, detail {room_kind, spectators; Ring Kings: round, kind, winner_side; Deathmatch: city, map, fighters, winner_side (the winner's slot), winner_ai, kills (the round's total), pvp, bot_member_faces / bot_toots_faces (bots wearing a member's PFP / Toots' face because the server had too few members with an avatar) and no_avatar (fighters with no avatar URL, #3985), bot_artists (chart-artist bots, #4016; they have no avatar by design and count in neither bot_member_faces nor no_avatar) and bot_member_models (member-face bots that wear that member's stored model, #4030; panel `arcade_dm_faces`), and tick on a void; both games: live_edits, live_edit_fail, live_render_fail, live_render_p50_ms, live_render_max_ms} — ONE event per Tootsies Arcade match, emitted when it ends. The five `live_*` keys (in `detail` only, no top-level field) are the LIVE FIGHT CARD counters for the match: edits sent to the room's message (the lobby edits in that channel count too), failed edits, failed renders, and the render time p50/max in ms. They are present only when the room had a card to edit, and they stop at the result: the final card goes out after the event. A room with a card and `live_edits == 0` means the card stopped updating (panel `arcade_live`). ok=true: the match reached a result, `reason` = ko\|tko\|decision\|draw (Ring Kings) or score\|last\|time\|draw (Deathmatch: score = the first to the kill target in the free-for-all, the default since #3985 \| one fighter left standing (a limited-lives round) \| the most kills when the 3:00 timer ran out (2:00 before #3985) \| a tie for the most kills). ok=false: the match was VOID and wrote nothing, `reason` = void_disconnect (a Ring Kings fighter's socket stayed closed past the 10 s grace time; in Deathmatch EVERY human fighter's socket did, because one gone fighter is only taken out of the round) \| void_shutdown (a deploy or a cog reload closed it) \| void_error (the match loop raised; an `error` event with source `arcade_match` carries the traceback) \| void_quit (2026-09-26: the host pressed Lobby mid-match; the room stays open). `mode` = pvp\|ai (Ring Kings) or pvp\|bots (Deathmatch: bots filled slots), `count` = human fighters (Ring Kings: 2 = member vs member, the only case that writes `game_scores`; Deathmatch writes only when `detail.pvp` is true = 2 or more humans played to the end). The Deathmatch live card uses the same five `live_*` counters. `detail.hard` (2026-09-26): the host's Hard switch was on (the AI or the bots at skill 95). `detail.loop_late_max_ms` / `detail.loop_late_n` (live 2026-09-26): the worst lateness of the match loop in ms, and the snapshots sent over 100 ms late. The loop shares the bot's event loop, so a stall anywhere in the bot shows here first. Nothing is emitted per tick, per input or per shot. Its `duration_ms` is the match LENGTH, not a wait, so the ops monitor keeps it out of the latency map (`scripts/ops_monitor.py:NOT_LATENCY_KINDS`). Every field is a SHARED or already-registered name (field budget). **Watch:** a rising share of `void_disconnect` means sockets drop mid-fight (the Railway proxy, the heartbeat, or phones sleeping); `void_error` at any rate is a sim bug. | | `loop_stall` | utils/loop_watch.py (started by cogs/health.py) | duration_ms (the whole stall), source (the deepest repo frame running 1s into the stall, `path:line function`), detail.stack (repo frames, deepest first). One per stall of 1s+, at its end, at most 6 a minute. Added 2026-09-28 because arcade matches froze for 2-4s and neither `game_match` (how late) nor discord.py (10s+ only) said what held the loop. Panel `loop_stalls` on the Engagement board, beside the arcade panels. Kill switch `LOOP_WATCH_ENABLED=0`. Since the arcade moved to its own thread (`ARCADE_THREAD_ENABLED`, default on), a `loop_stall` on the bot loop no longer freezes matches, and `game_match` `loop_late_*` measures the ARCADE thread: a late snapshot there now means the arcade itself, or one long C call holding Python's lock. | | `arcade_auth` | utils/arcade/hub.py + discord_auth.py (#3687) | action, ok, reason, kind, guild_id, count, duration_ms, status, error, surface, tier — **`tier=games_only`** (2026-10-18): set on this event when its server never ran /menu (games-only mode, `cogs/arcade.py` `gate_open`; `ArcadeHub.tag`); absent otherwise. Panels `games_only_servers` and `games_only_cost` read it. the arcade's sign-in. `hello`: a game socket's first frame; ok=false `reason` = malformed\|bad_signature\|expired (the web token; `malformed` also covers an Activity `instance_id` that is not 1-128 of `[A-Za-z0-9_-]`, refused before any Discord call), wrong_room (a valid token for another room or a user never given a link to it), no_room (the room closed or a restart cleared it), gated (the bot or the `arcade` experiment is off in the guild), activity_off (an Activity hello while DISCORD_CLIENT_SECRET is unset), activity_unverified (Discord did not confirm the player is in that Activity instance, or the token is not one this app issued), full (the hub or the guild holds its room limit), bad_hello, hello_timeout, rate (close 4429: one access token sent more than 20 Activity hellos in a minute, OR one address sent more than 300, OR one address had 20 failed Activity verifies in the last minute. The per-address counts are high because players behind Discord's proxy share its address), closed (the room closed during the hello; close 4410). draining (a deploy drain runs and the hello is not for a running match, or an Activity hello needs a new room; close 1012, so the client reconnects to the new process). Token hellos are also limited to 120 per address a minute (`rate`, close 4429). A room with 24 open sockets turns away a new spectator (close 4404 reason `full`, which the client shows as "This room is full."; no event); a fighter always gets back in. ok=false events are limited to 5 a second (burst 5); the next one carries the number skipped in `count`, so a flood of bad hellos cannot flood the stream. ok=true `kind` = web\|activity; ok=true `reason=stale_client` (#3697) = the page runs an old `app.js` (its build hash differs from the server's), and the server asks it to reload once. **Ops-monitor health:** `misc_health["arcade_client"]` counts each ok hello and each `client_error`; a stale hello or a client error is a fail, so a page that breaks without a server error flags `integration_unhealthy`. `rate_limit`: a socket closed for sustained flooding (more than 200 dropped frames within 10 s; the bucket holds 2 s of frames). Before 2026-09-26 it closed after 80 drops over the socket's whole life, and a server stall kicked both players of a round at once. `token`\|`me`\|`instance`: the three outbound Discord calls of the Activity path (the OAuth code swap, `oauth2/@me`, `activity-instances/{id}`), each timed with `status`. `me` ok=false with `reason=rate` and no `duration_ms`: the global verify limit (5/s, burst 20) refused the call before the network, so junk hellos cannot make Discord block the bot's IP. A score or champion write that fails emits an `error` event with source `arcade_score` (recoverable=false); `arcade_ws` errors are recoverable=false too. **Watch:** `instance` ok=false at any rate means Activity players cannot get in (a bot-token or API change). | | `arcade_room` | cogs/arcade.py + utils/arcade/hub.py (#3687) | action (open\|launch\|link\|close\|live\|game\|client_error\|bump\|cities\|result\|challenge\|roster\|roster_view), ok, guild_id, kind, reason, error, count, game_key, scope, surface, tier, duration_ms — **`tier=games_only`** (2026-10-18): set on this event when its server never ran /menu (games-only mode, `cogs/arcade.py` `gate_open`; `ArcadeHub.tag`); absent otherwise. Panels `games_only_servers` and `games_only_cost` read it. `roster` (#4191, 2026-10-15): the lobby's **Fighters** page loaded a tab (`ArcadeHub._roster`, the hook `Arcade.fighter_roster`). One event per group per open (the first page, `at` 0), not one per page. `scope` = the tab (`artists` \| `members`), `count` = the fighters listed in it, `reason` = the reply state: `ok`, `off` (the server has 3D fighters off) or `error` (the list read failed; ok=false, and the cog also sends an `error` event with source `fighter_model`). Since the 2026-10-16 redesign (the page opens straight into the 3D view with one strip of every fighter) each open sends one `roster` per group, so a members count of zero now means the server has no member fighters, not that nobody looked. `roster_view`: a fighter went on show in the 3D viewer; `scope` = its group (`members` or `artists`), so the panel splits member views from artist views; at most one a second per page, and only for a fighter that stayed picked 200 ms (the one a held arrow key stops on). **Trend break at the redesign deploy (2026-10-16, #4205):** a view now also counts the fighter the page OPENS on, so every open adds one view where the old page added none until a tap; and every open adds one `roster` per group. Compare opens and views before and after that date with care. It names no fighter and no member (data minimization). The viewer's own model failures arrive as `client_error` with `error` starting `roster model:`, and a model the idle clip cannot drive as `roster: no idle rig`; a page sends at most one of these, and the ops monitor's `arcade_client` health rate leaves them out (one member's model is not a broken client). Panel `arcade_roster`. NO monitor: a few opens a day; error triage and the `arcade_client` health rate already see the failures. Every field is a SHARED or already-registered name. `challenge` (2026-09-26): `/fight` named opponents; `count` = people pinged. ok=true when the ping went out (on a new card, or as a reply to an open room's card); ok=false + `error` when the reply failed to post. `result` (2026-09-26): the result card decision after a match; ok=true `reason=posted` when the card went to the channel, ok=false `reason=not_pvp` (fewer than 2 people, or vs AI) or `bot_won` (a Deathmatch a bot won) when it was not posted (owner ask: announce human-vs-human results only); `game_key` = the game. `cities` (2026-09-26): `/fight` read the server's Deathmatch memory cities (utils/arcade/memory_cities.py), at most once per 6 h per server; `count` = cities found, `duration_ms` = the read; ok=false + `error` when the database read failed (the room then shows only the built-in cities). Since CB-32 (2026-10-18) Haiku confirms each regex nomination (`claude_api purpose=memory_city_check`), and `duration_ms` includes those calls; `detail.asked` = notes the model was asked about, `detail.dropped` = notes it called OTHER (a person's name or a title). A failed model step falls back to the regex result and still reports ok=true. Shown in the `arcade_client` panel. NO monitor: a wrong arena is harmless, and a dead model step leaves the old regex result, not a blank. `game` (#3697): the host's game switch in the lobby; ok=true `game_key` = the picked game, ok=false `reason` = not_host\|not_in_lobby\|bad_value. `bump`: the lobby card moved to the bottom of the channel (a fresh post, the old one deleted) after a first join or a match start; ok=false + `error` when the post failed and the card was edited in place instead. `client_error` (#3697): a script error the game page reported, `error` = its message cut to 120 characters, at most 3 per socket. Since the 3D Deathmatch (2026-10-12) it also carries the 3D view's own failures, once each per page: `dm3d fallback: no WebGL` / `no three.js` (the device fell back to the raycaster), `dm3d build: ...` (the 3D world failed to build) and `dm3d frame: ...` (a draw threw). Since #3985 it also carries `avatar load failed (activity|web)`: the first PFP on a page that would not load (the fighter shows a plain body), once per page; panel `arcade_dm_faces` counts them, and the ops monitor's `arcade_client` health rate leaves them out (one member's dead avatar URL is not a broken client). `perf` (Deathmatch perf work, 2026-10-13): the client's frame rate for the round it just played, one per socket per match, sent when the round ends. Since 2026-10-14 Ring Kings sends it too (one per match, at the result; `fps` is the mean over the fight phase; `gl` is true when the 3D view ran (rk3d.js), with its `dpr` / `dpr0` / `shadows`, and false on the 2D fallback), and the event carries `game_key` (`ring_kings` or `deathmatch`); older rows have no `game_key` and are all Deathmatch. Panel `arcade_dm_perf` splits by `game_key`. `detail` holds numbers and bools only: `fps` (mean over the play phase), `slow_pct` (frames over 33 ms), `frames`, `gl` (3D or the raycaster), `touch`, `shadows` (still on at the end), `unlit` (Ring Kings desktop only: the adaptive quality's last step swapped the stored props to unlit materials, #4022; the panel's `props_unlit`), `dpr` / `dpr0` (where the adaptive pixel ratio settled / started) and `map_n`. A report needs 30 frames. Panel `arcade_dm_perf`. Pivot on `error` starting `dm3d` to read the 3D health on its own. `open`: `/fight` (the command was `/arcade` until 2026-09-25) made a room; ok=false `reason=lobby_post` + `error` when the room opened and the caller was launched but the lobby card could not be posted in the channel. `launch`: Join game or `/fight` opened the Activity; ok=false + `error` (the exception class, any exception) when the launch call failed, and the member got a browser link instead. `link` ok=true `reason=relaunch`: a member pressed Join game again within 2 minutes of a launch and never connected to the room, so they got the browser link (the Activity opened but its sign-in failed). `close` (hub): a room closed; `reason` = ended (the host pressed End game) \| empty (no open socket for 2 minutes) \| idle (15 minutes with no action) \| shutdown (a deploy) \| drain (a deploy drain closed it: at the drain start for a room with no running match, or after its match ended during the drain); `count` = the matches finished in that room; `detail` (2026-10-18) = `room_s` (seconds open) and `players` (members who had a socket in it). `usage` (2026-10-18, `ArcadeHub._flush_usage`): what the room used since its last report, emitted after EVERY match and once at the close (just before the close row), so a room that stays open still reports its cost (Codex review of #4480). `detail` = `usage_s` (seconds counted), `bytes_out` (every frame the hub sent to the room's sockets: broadcasts, snapshots and one-socket frames such as welcomes, Deathmatch start headers and roster pages) and `step_ms` (CPU time of the match steps and snapshots). Its `tier` is the server's tier when that count STARTED, so usage built up in games-only mode stays games-only after a /menu run. Panel `games_only_cost` and the cost monitor read these rows. `live`: an Activity room (no lobby message) got its ONE live fight card message in the Activity's channel at its first match; ok=false `reason=live_post` (+ `error` when the post raised) when Toots cannot post there (Send Messages, Embed Links, Attach Files), and the room gets no card for its life. A failed card render or edit is an `error` event with source `arcade_live`, recoverable=true. The open count against `game_match` is the funnel: rooms made, matches finished. | | `arcade_drain` | utils/arcade/hub.py + utils/deploy_drain.py | action (start\|match\|end), ok, surface (arcade), count, guild_id, game_key, reason, duration_ms, detail {void, left} — the DEPLOY DRAIN (2026-10-15). Railway sends the old process SIGTERM when the new one is live; when `RAILWAY_DEPLOYMENT_DRAINING_SECONDS` gives a budget (see utils/deploy_drain.py) and a match runs, the old process closes its Discord gateway, unloads every other cog, and lets running matches end before it exits. `start`: one per drain; `count` = matches running at SIGTERM. Rooms with no running match close at once (`arcade_room action=close reason=drain`, code 1012). `match`: one per match that ended during the drain; ok=true `reason=finished` (the result was written as usual), ok=false `reason` = void_disconnect\|void_error\|void_quit\|void_ended (the host pressed End game); `duration_ms` = time since the drain start. The room closes 8 s later with code 1001. `end`: one per drain; ok=true `reason=done` (every room closed), ok=false `reason=cap` (the budget passed; `bot.close()` then voids the rest as `game_match reason=void_shutdown`); `count` = matches recorded, `duration_ms` = the drain length, `detail.void` = matches that ended void, `detail.left` = rooms still in a match at the end (a room that only shows its result closes with 1001 first). No event is emitted when there is nothing to drain (no budget or no running match). **Watch:** a `start` with no `end` means Railway killed the process inside the budget (the budget is longer than the Railway window); `reason=cap` rows mean matches outlast the budget. Panel `arcade_drain`. Every field is a SHARED or already-registered name. | | `fighter_model` | utils/arcade/fighter_models.py + cogs/arcade.py (#4012) | action, ok, duration_ms, provider (`openai`\|`xai`\|`tripo`), model, user_id, trigger, scope, score, attempt, decision, phase, reason, error, status, bytes, count, credits_remaining, context, detail {credits_used, triangles, texture_px} — Arcade generated fighters (every member by default; Ring Kings and Deathmatch). ONE event per pipeline step: `sheet` (GPT Image draws the full-body sheet from the avatar; `reason=safety_reject` on a content refusal; when the OpenAI call fails for any reason and `GROK_API_KEY` is set, the same call is made on Grok as a second `sheet` event with `provider=xai`, model `grok-imagine-image-2.0` and `decision=failover` (on every Grok call, so it counts every OpenAI failure path); a Grok 400 is `reason=safety_reject_xai` (final), a Grok 429/5xx `reason=xai_http_<status>` (owner, 2026-10-14); since 2026-10-18 a failed draw also carries `detail.error_code` = the provider's JSON error code (`moderation_blocked`), and an OpenAI refusal code (`moderation_blocked`, `content_policy_violation`) decides `safety_reject` exactly, the old word test only when no such code is present (#4526 refresh)), `likeness` (the vision judge, `ClaudeClient.fighter_likeness_score`, on one sheet: `score` 0-1, `attempt` 1-3, ok = score >= 0.7; ok=false `error=no_score` = the judge did not answer), `pose` (2026-10-16: the pose judge, `ClaudeClient.fighter_pose_score`, on the same sheet: `score` 0-1 = its LIMBS score (two arms and two hands, both hands clear of the face, head and body, a full front body), the gate; `context.eyes` 0-1 = open eyes with visible whites, a preference only (among sheets that pass both gates the higher eyes wins; it never blocks a sheet and never starts a rebuild); `attempt` 1-4, ok = limbs >= 0.7; ok=false `error=no_score` = no limbs answer; a sheet must pass likeness and limbs, up to 4 draws), `upload`, `model` (Tripo image-to-model), `rig_check` (ok=false `reason=not_biped` + `context.rig_type` = the rig type Tripo proposed; ONE event), `rig`, `convert_hi` / `convert_lo` (the two levels of detail), `download`, `validate` (the GLB contract check, `phase` = hi\|lo: `reason` = bad_glb\|no_skin\|missing_bones\|too_many_triangles\|bad_orientation\|bad_pose\|bad_scale\|bad_texture\|too_large (`bad_pose`, 2026-10-16: the rig's rest pose has a hand within 0.3 of the skeleton height of the Head joint, or above its Arm joint; `context.pose` = hand_at_head\|hand_raised; a member's retry draws a new sheet, never resumes; an artist import with it is final), `context.missing` = up to 6 missing bones, `count` = textures shrunk, `detail.triangles` / `detail.texture_px`). A Tripo step reused by a retry emits ok=true `reason=resumed` (no credits); a retry whose saved task cannot be re-read fails with `reason=poll_failed` or `resume_timeout` and is retried later, never paid twice. An opt-out that cancels a queued or running run emits `refused reason=opted_out trigger=opt_out`. Then ONE `generate` per run: ok (`reason=resumed` on success when it reused an earlier run's sheet and tasks), `reason` = the step that failed (or `crash` / `opted_out` / `avatar`), `decision=final` = a failure a retry cannot fix (banned, safety_reject, safety_reject_xai, not_biped: the row turns final and is flagged), `trigger` = backfill\|join\|pfp_open (`pfp_open` = the member opened the arcade with a picture other than the one the model was made from; until 2026-10-15 this was `pfp_change` and it never fired) (on `opt_out`/`opt_in`: `lobby` = My fighter in the game (a lobby button until 2026-10-17, then the Fighters page; the value stays `lobby`), `panel` = the private panel from an old card's My 3D fighter button, 2026-10-15), `scope` = member\|artist, `score` = the kept sheet's likeness, `decision=review` = no sheet passed both the likeness and the pose gate in 4 tries, `context.pose` = the kept sheet's pose score (the best was kept and the row is flagged `needs_review` for the owner), `context.tasks` = every Tripo task id, `detail.credits_used` = Tripo credits for the run (only finished tasks), `credits_remaining` = the Tripo balance after a stored model. `refused` (ok=true): a run that did not start, or a queued run paused at its start (then the row is deferred, no try counted), `reason` = a member's own: opted_out\|final\|running\|retry_wait\|cooldown\|likeness:own_pfp_only, or global (it stops the backfill tick): unprovisioned\|breaker_open\|balance_unknown\|daily_cap\|credit_cap\|balance_floor\|likeness:<condition> (a constitution.GENERATED_LIKENESS_CONDITIONS name that did not hold; `likeness:no_constitution` = the tuple is missing, so nothing runs). The backfill emits a refusal only when its reason changes. A refusal at the start check carries `count` = the member runs queued or running at that moment (several run at once since 2026-10-15, up to `fm.PARALLEL_RUNS`). **Chart artists (#4016):** `generate` with `trigger=artist_import`, `scope=artist`: the import RESUMES the pilot's finished Tripo tasks (`artist_roster.TRIPO_TASKS`), so a healthy import shows `reason=resumed` on every step and `detail.credits_used` 0; `error=not_reusable` (the failed step in `reason`) with `decision=final` = a saved task expired: the import is resume-only (`build(create=...)`) and never pays for a new task, except one `convert_lo` when the saved lo was made at another face count (the artist stays absent). `refused trigger=artist_import`: `reason=off_chart` = the artist has no week in the bot's chart history within 12 months; a stored model is deleted only when a successful read returned a dated week outside the window, never on a failed read. `chart_unread` = the chart-history read failed (nothing imported or deleted; a stored model stays served), `off_roster` = a stored artist row whose id left the roster was deleted, `unprovisioned` = no Tripo key, so no import, `likeness:chart_artists` = a name off the roster. **Artist portraits (#4016 follow-up):** `portrait` with `trigger=artist_portrait`, `scope=artist`, `provider` = deezer\|wikidata: one read of a roster artist's official photo per source tried (`utils/arcade/artist_portrait.py`, pinned catalog ids), `bytes` = the 256 px JPEG; ok=false `reason` = no_photo (the pinned id gave no photo, or named someone else) \| download (the image read failed or the host was off the list) \| bad_image (the bytes did not decode) \| fetch (an exception, `error` = its type). A miss keeps the letter tile and waits 30 min. `cached=True` (folded into `detail`, #4756): the portrait came from `kv_cache` (7-day TTL) with no outbound read, so a restart no longer re-reads every roster id from Deezer. Check the fix with `['tootsies'] \| where event == 'deezer_lookup' and query startswith 'artistpic_id:' \| summarize n=count(), d=dcount(query) by bin(_time, 30m)`: after a boot, `n` must be close to `d`. `opt_out` / `opt_in`: a member opted out (the models and the sheet deleted) or back in. **The pose audit, at most 30 rebuilds ever (20 until the eyes rebuilds) (2026-10-16, cogs/arcade.py `_pose_audit_tick`):** `pose_audit` = one served model built before the pose gate, judged once (its stored sheet by the pose judge, its stored GLB by the rig check): ok = no rebuild, `score` = the judge's limbs score (the eye score is not used), `reason` = ok\|no_score (judged again later)\|sheet\|rig:<fault>\|listed (a model the owner named, always rebuilt)\|eyes (a member the owner named for dark or smeared eyes, `fm.EYES_REBUILD`: judged although its pose was already scored, rebuilt once; its rebuild stops BEFORE Tripo with `generate ok=false error=eyes_gate reason=sheet` when no new sheet has an eye score of 0.7 or more), `decision=rebuild` = a rebuild was asked for; `reason=quality` = a member on the quality list (`fm.QUALITY_REBUILD`, 2026-10-16), rebuilt once on v3.1 on its own slots, with `generate trigger=quality_rebuild` and `decision=reuse_sheet` when it built from the stored sheet. Every unscored model is judged first (the listed ones first); then the failing ones are rebuilt listed first, then rig faults, then sheet-only failures by ascending score. The rebuild is a normal run with `trigger=pose_rebuild` (`generate`, every step event too): the row stays `ready` with the old model until the new one is stored, and a failed rebuild keeps the old model and is not retried. A rebuild whose new sheets all fail the pose gate (or get no score) stops BEFORE Tripo: `generate ok=false error=pose_gate reason=sheet`, no credits, and the member's slot stays used. `pose_audit ok=false reason=cut_off` = a rebuild a restart cut off after it started (its slot still read `started` at the next boot), `count` = the cuts so far; the old model stays and the rebuild starts again on the same slot until it was cut 6 times (`_POSE_CUT_TRIES`, 3 until 2026-10-17; before 2026-10-16 it was never retried), and since 2026-10-17 it resumes its saved Tripo tasks (`generate reason=resumed`, kv `fighter_rebuild_run`), so a restart does not pay for them again. A `no_score` verdict is asked again after 1 hour. A second `validate reason=bad_pose` for the same avatar is final (`decision=final`), so a false positive of the rig check costs at most two builds. At most 30 rebuilds ever (`fm.POSE_REBUILD_MAX`, 20 until the eyes rebuilds, durable kv slots), each member at most once; `refused trigger=pose_rebuild reason=pose_rebuilt\|pose_no_slot\|pose_no_avatar` = no rebuild for that reason (`pose_no_avatar`, `count` = failures: the member's avatar read failed 3 times, so the rebuild is given up; until then the same verdict is reused, never judged again). Panel `fighter_pose`. **Ops monitor:** `misc_health["fighter_pose_judge"]` = `pose` + `pose_audit` events that came back with a score, over all (a dead judge fails open, so this rate is where it shows). Breaker changes go out as `circuit_breaker` with integration `openai_image_fighter`, `xai_image_fighter` or `tripo`. The prompt, the avatar and the judge's words never go in an event. Panel `fighter_models` (Engagement board). **Ops monitor:** `misc_health["fighter_models"]` = generate runs, ok over all (a dead pipeline flags `integration_unhealthy` at 5+ runs); `misc_health["fighter_likeness"]` = stored runs not flagged for review over all stored runs (a rising review share means the sheets stopped looking like the members). **Spend:** source `tripo` in the usage poll (`quota` events): `credits` = the balance, `day_credits` = the last 24 h of spend against the `3d fighters` credits-a-day Tunable. | | `wheel` | cogs/wheel.py (#3300) | guild_id, channel_id, user_id, action (build\|panel\|generate\|regenerate\|spin\|save\|render\|button), ok, mode (manual\|theme), count (slices on every action; saved wheels on `list`), query (the THEME, <=80 chars), reason on a miss, duration_ms (`render` only) — the random wheel. Slice LABELS are never logged: they are user content, and the theme is the one piece that says what a wheel was about. There is ONE command, `/wheel`, which opens the builder page; every other door is a control on that page or on the posted card (owner steer 2026-09-14). `build` opens the page (reason `new` = a fresh draft, `saved` = opened on a wheel from the shelf). Opening a saved wheel does NOT copy it, so posting it straight back posts the wheel ITSELF and its spins keep counting; the copy happens only when something actually changes, because a saved wheel may already have cards posted in the room. `panel` is one action on the page (reason `add`\|`drop`\|`open`\|`forget`\|`post`; `not_yours` when somebody who neither saved it nor mods tries to take a wheel off the shelf -- which un-saves rather than deletes, so the page and any posted card stay alive). `generate` is a themed draw from either door, the page's carrying reason `panel`, and `regenerate` a redraw from the card (both model calls, whose own latency rides `claude_api` purpose=`wheel_items`). Miss reasons: `too_few` (a typed list under 2 slices), `empty` (the model's draw came back with nothing usable — the fail-open path, see the health line below), `rate_limited` (the per-user daily bucket, a refusal by design), `not_found` (no wheel under that name), `not_yours` (a delete by someone who neither saved it nor mods), `taken` (a save onto a name another member's wheel holds -- the same rule as a delete), `gone` (a button pressed on a wheel whose row is deleted or pruned — expected, a card outlives its row), `wrong_guild`. `render` is the ONLY action carrying duration_ms, and it IS the whole latency of a spin: no model call is involved, so the render is the wait between the tap and the picture. It carries `mode`: `still` is the plain PNG (measured ~0.4 s) and `spin` is the animated GIF a PULL draws (#3347) -- 14 frames plus the still as a held last frame. The first pipeline (20 frames, each rotated at full size and shrunk, each quantized by the GIF writer on its own) measured 1.8 s at two parts and 2.55 s at twenty-four on the dev box and p50 2.6 s / p90 3.8 s in production, and the ceiling went 4 s -> 6 s for it. The pipeline was cut down on the owner's ask (2026-09-17, "cut the latency in half"): plate and disc shrunk once, small-disc rotation per frame, one shared palette quantized before the save, `disposal=1` -- ~0.4 s and ~0.4 MB at ten parts, ~0.5 s at twenty-four on the dev box, so under a second in production by the old 1.45x dev-to-prod ratio. The ceiling went back to 4 s; re-check it against the first day of production spins. The dashboard panel splits p95 BY MODE: one line over both averages two unrelated things, hiding a still that had quietly tripled and reading a shift in the still/spin mix as a regression. Only a real pull animates; a card posted at rest, or repainted for any other reason, stays a PNG. A render that raises is a `wheel_card` error with recoverable=true and the spin posts the slice list instead. **Health:** `wheel_generate` counts `generate` + `regenerate`, ok against the total, excluding `rate_limited` — the draw is fail-open, so an ok rate falling is the only sign the model half stopped working (a prompt that no longer lands, or the narration filter turned too strict and now eats every line). **Watch:** `wheel_generate` under the floor; a `render` p99 past its ceiling means the render is starving for the thread pool -- read it per `mode`, since the two modes are ~6x apart by design. | | `scheduled_event` | cogs/scheduled_events.py | guild_id, user_id (the asker; absent on anonymous reads), action (create\|list\|read\|update\|end\|delete\|start), ok, reason (None on success; `invalid`\|`no_permission`\|`not_found`\|`forbidden`\|`not_mod`\|`already_over`\|`not_scheduled`\|`error`); create also: has_location, has_description; list also: count — Toots created or managed a Discord scheduled event via the `create_event`/`list_events`/`read_event`/`update_event`/`end_event`/`delete_event`/`start_event` /ask tools (#888). read surfaces who's interested (top names, public); `end` leaves a cancelled/over tombstone, `delete` removes the event entirely, `start` flips a not-yet-started event live early. List/read open to anyone; create/update/end/delete/start are mod-gated AND need the bot's Manage Events permission (checked in the cog). External events (free-text location); fail-open throughout | | `memory_search` | cogs/ask.py | guild_id, query, hits, vector (True = a semantic embedding cosine hit answered it; False = the keyword/FTS path did, and aliases/expanded/fell_back only apply to that path), aliases (rename-aware alias terms OR'd into the query), expanded (thin literal match → the concept was expanded to keyword terms via Haiku and re-searched), fell_back (still thin → backfilled with the durable daily arc so conceptual recall like "messiest thing" gets real material to reason over), detail (True = the `recall_detail` hour-grained drill-down), proactive (True = the always-on `[relevant memory]` recall `Ask._relevant_memory` runs on EVERY answer keyed on the incoming message, not the on-demand `search_memory` tool; `hits` is how many notes were injected after dedup) | | `memory_reindex` | cogs/memory.py | guild_id, and ONE of: tagged (# of legacy notes given concept keywords) OR embedded (# given a semantic-recall vector) — the keyword and embedding `/remember` reindex passes each emit their own | | `embedding` | utils/embeddings.py | ok, model, duration_ms; on success: chars, dims, batch (# inputs on an `embed_batch`, absent for single `embed`); on failure: error (`empty_input`\|`no_embedding_in_response`\|`HTTP <status>`\|`<exc>`) — one OpenAI text-embedding call (a memory note at write/reindex time, a recall query, or the Kalshi D3 catalog/query embed) The ops monitor's latency table puts a call with `batch` >= 500 (the background Kalshi index build) on its own `embedding:index` row with a 40s ceiling, so it cannot set the p99 of the interactive lookups on the `embedding` row (#4797). | | `kalshi_shortlist` | utils/markets.py | ok, duration_ms, chars; on success: hits (# shortlisted), catalog (index size); on a query-embed miss: error (`query_embed_miss`) — the D3 (#480) semantic Kalshi discovery ranked the FULL series catalog (cosine over the `SeriesVectorIndex`) for a query and returned the top-k shortlist handed to `pick_kalshi_series` in place of the volume-top-1000 title list (finds an on-topic but low-volume series, and shrinks the stage-1 prompt). Fail-open: an empty shortlist / no index / no `OPENAI_API_KEY` falls back to the title list | | `kalshi_lexical_search` | utils/markets.py | ok, duration_ms, chars; on success: hits (# events returned), catalog (event-index size, ~7k) — the **EVENT-level LEXICAL (BM25) Kalshi discovery** (`KalshiClient.search_events`, `utils/lexical_index.py`). Kalshi has no search API and team/player names live at the EVENT ("Spain vs Saudi Arabia: Goalscorer") + MARKET ("Lamine Yamal: 1+") level, NOT the series title ("World Cup Assists"); a first **semantic** event index leaked badly on proper nouns ("Spain Saudi Arabia" matched Israel–Saudi geopolitics / OPEC / WTA, never the soccer game — entities want exact tokens, not embeddings) and was **reverted** for this BM25 lexical index. Built once per hourly refresh over the ~7k open-event docs (each doc = event title + sub_title + every nested market label, captured for free by flipping the existing open-events walk to `with_nested_markets=true`); pure/in-process/no embeddings, prices re-fetched live per query (`get_event_markets` via `/markets?event_ticker=X`, the filter that works — `/events?event_ticker` is silently ignored). Routes BOTH the general `kalshi` tool (event-first, series-index as fallback) and `kalshi_player_props` (matched matchup events, team-token + `_is_player_prop_event` guards, per-player collapse). **Last-mile SELECT (#kalshi-line):** the FTS ranks a matchup's ~25 markets by lexical relevance, so the game moneyline is rarely the top hit (measured: `KXWCGAME` ranked #4 for "England Argentina", behind first-team-to-score/correct-score/advance), and the old event-first path took `events[0]` blindly — grabbing "First Team to Score" over the moneyline (the ARG@SUI live-bet outage: the `/bet` board offered a game whose `_sides()` was empty because the wrong Kalshi market was folded). The unified **`MarketsManager.kalshi_search(query, *, intent, n, k, expand)`** now does (optional OR-`expand` for recall) → FTS → **`ClaudeClient.pick_kalshi_events`** (a Haiku last-mile pick of 1..N events matching the caller's stated `intent`), so FTS stays the cheap recall narrowing and Haiku is the precision selector over the small candidate set (no whole-catalog model call, no external search service). Callers pass their intent: `KALSHI_INTENT_MONEYLINE` (the game-winner line — bookie reprice + commentator via `fetch_prediction_snapshots`, the betting surfaces, and the /ask game-market tools) vs `KALSHI_INTENT_GENERIC` (the open `kalshi` tool). The selection is cached per (folded_query, intent, n, sport) so the ~60s/live-game reprice+commentator paths collapse to ~one Haiku pick per game (prices stay fetched fresh); fail-open to the top FTS hit if the picker errors / isn't wired. Emits `claude_api` `purpose=kalshi_event_pick`. **SPORT-SCOPING + richer candidate metadata (#kalshi-line):** an OR-rewritten matchup query (`db._or_search_terms`, #1325 — needed since Kalshi indexes the CITY only, so a full-team-name AND-query found nothing) floods on a common city token — "Los Angeles" pulled **361** candidates across SIX sports (MLB/MLS/NBA-summer/WNBA/T20-cricket/NFL) and pushed the MLB moneyline to rank 21, out of the old `k=12`. Two levers fix it: the index now captures Kalshi's `product_metadata.competition`/`competition_scope` + a derived **`sport`** column (`_kalshi_competition_to_sport`: 'Pro Baseball'→baseball, 'FIFA World Cup'/'MLS'→football, …), so `kalshi_search(sport=...)` (the caller's known `game.meta['sport']`, threaded through `fetch_prediction_snapshots`) SCOPES the FTS to that sport (`WHERE sport = $`) and the cross-sport flood drops out; and `k` is widened to **40** (`_KALSHI_SEARCH_K`) so a buried moneyline is still retrieved. **ALL-TERMS-FIRST ranking (#3550):** widening `k` was not enough. `ts_rank_cd` is a cover-DENSITY score, so a spread/team-total event that repeats a team name in ~20 market labels outranks the plain game event that names each team once, and a team sharing a common token with other matchups ("New" England / "New" Orleans / "New" York) pulls those games' dense events into the window too. Measured on the live index (2026-09-20, an NFL Sunday): the game event ranked 51st (Patriots-Steelers), 50th (Ravens-Saints), 62nd (Packers-Jets) -- past 40, so the picker never saw them and three live NFL games were not bettable. `db.search_kalshi_events` now sorts the events that match EVERY query term (websearch's AND on the raw query) ahead of the OR-matched rest, then title-first, then density; a matchup's own events all match every term, so the game event lands at rank 1-10 (measured across 18 matchups in four sports). When no doc matches every term (the city-only index the OR-rewrite serves) the tier is uniformly false and the order is unchanged. Guarded by `tests/test_kalshi_fts_pg.py` (real Postgres, the `fts-postgres` CI job). A sport-scoped fetch that returns EMPTY (stale/unmapped sport) falls back to the UNSCOPED fetch, so scoping only ever narrows. The picker also now SEES the richer per-candidate metadata (`subtitle` matchup+date · `competition`·`scope` market-domain/type · `market_labels` outcome legs, via `_format_kalshi_candidate`) so it tells a game moneyline from a season/futures/prop market of the same name. Dry-run-validated 8/8 across soccer/MLB/NBA/NFL + generics with a real Haiku pick (`scripts/dryrun_kalshi_matrix.py`); the `sport`/`competition`/`scope` columns fill on the next hourly index refresh after deploy. Fail-open to [] | | `catalog_lookup` | utils/apple_music.py + cogs/ask.py | Emitted at two layers: (1) the **apple_music** raw-call event — query, entity (song\|album\|artist\|artist_songs), results, ok, duration_ms (the iTunes-call latency telemetry); (2) the **cogs/ask.py** FAILOVER event (#847) — query, entity, source (`itunes`\|`deezer`\|`none` = which feed answered after the iTunes→Deezer failover), ok (source≠none). The cog event has no duration_ms (stays out of the latency table); its `source` field makes the failover observable — a spiking `deezer` rate = iTunes throttling, a `none` rate = both feeds down. The `lookup_catalog` tool now routes through the `utils.music` facade: Apple Music / iTunes primary (exact durations / tracklists / discographies / the catalog-wide `artist_songs` longest-song scan), falling back to **Deezer's public catalog API** on an empty-or-throttled iTunes result (song/album/artist full parity; artist_songs degrades to the discography with a `(via Deezer; iTunes was throttling)` note), and on a both-miss a throttle PROBE (`apple_music.probe_throttled`) distinguishes a real `(catalog feeds are throttling...)` DOWN message from a genuine `(no catalog match for that)` — the catalog twin of the markets DOWN-vs-empty fix | | `feed_status` | cogs/ask.py | guild_id, degraded (the source keys whose circuit breaker is OPEN — `sgo`/`the_odds_api`/`espn`/`api_sports`/`polymarket`/`kalshi`; `espn` since #4173), near_cap (the source keys at/over the `LOW_PCT` usage threshold from the health cog's cached snapshot) — a proactive **LIVE FEED STATUS** block was injected into an /ask answer (#847): the model was shown which data feeds are DOWN / near-cap right now AND where to route instead (failover from `utils.sportsdata.sources.CAPABILITIES`), so it never reads a downed feed as "no games" or fabricates a slate (the proactive complement to the reactive per-handler DOWN-vs-empty messages). Built from in-memory breaker + usage flags only (`utils.feed_status.build_feed_status`, NO network); the usage snapshot is cached on `bot.usage_status` by the health cog's ~30min poll. Emitted ONLY when the block is non-empty (a degraded/near-cap feed exists); the healthy case injects + emits nothing | | `reference_lookup` | utils/reference.py + cogs/ask.py | also emitted by the standalone `song_credits` /ask tool (#847 — Genius promoted into its own tool, emitting this SAME event with `source=Genius\|none` so its integration-health telemetry survives bypassing the reference dispatcher). query, aspect, source (Genius\|MusicBrainz\|Wikidata\|Wikipedia\|none = the source that ANSWERED), tried (the source names SELECTED by intent, in order — a source in `tried` but NOT `source` matched the intent but returned None and fell through; `source=Wikipedia` + `tried=[Genius, Wikipedia]` = Genius was selected and came back empty, the signal for a silently-dead keyed source like Genius vs one merely never triggered), result_kind (chart\|article\|music\|factoid\|credits\|samples\|annotation\|song\|none), url, entries (chart kind), cache_hit (served from the durable reference cache, namespace `reference`), decision (the movie source's TMDB title check, #3430: `film_judged` = the Haiku pick chose a hit, `film_none` = it said none of the hits is the film, `film_no_verdict` = the pick errored so the source refused, `film_unjudged` = no judge attached, the top hit was used; the pick itself is a `claude_api` event with purpose `reference_film_pick`; the CB-25 sub-page pick sets `subpage_judged` \| `subpage_no_verdict`), routed (in `detail`, CB-25: the route read's label, CREDITS\|SAMPLES\|MEANING\|IDENTIFY\|PERSON\|FOUNDING\|RELEASE\|FILM\|CHART\|ARTICLE, or `regex` when no read decided and the old intent-regex chain ran, or `cached` on a reference-cache hit; the read is a `claude_api` event with purpose `reference_route`, the sub-page pick purpose `reference_subpage`, and the lead-image title check purpose `wiki_title_subject`), ok, duration_ms, error (empty_query\|no_result on a miss) — the `lookup_reference` /ask tool ran: an authoritative citable fact from the reference library, routed by intent across sources — Genius (song credits, samples/interpolations, annotations/meaning, identify-by-lyric; link only, token-gated), MusicBrainz (release metadata), Wikidata (date/age factoids), Wikipedia (Billboard Hot 100 chart-table peaks #292/#335, or the general WIKI READER: resolves the article and hands the model its body prose + tables AND, for a detail/list question, follows the most relevant SUB-article (the filmography, the awards list, the electoral history) and renders its tables too - any topic, not just music) | | `reference_fetch` | utils/reference_types.py (`fetch_json`, the shared transport of the reference sources) | ONE outbound JSON GET per logical read: source (`musicbrainz` \| `wikidata` \| `wikipedia` \| `genius` \| ...), http_status, ok, duration_ms, error (exception class, on failure). attempt = 2 when a paced host's HTTP 503 was retried once (#4168: every musicbrainz.org read queues on ONE host limiter, one read per 1.5s, and a 503 gets one retry; the first token is taken BEFORE the event starts, so duration_ms measures the read and the retry, not the queue). A 503 that fails the retry too is the host's throttle: it emits NO `error` event (a soft miss, #4168: from 2026-10-15 to 10-18 every MusicBrainz `error` was a 503 with reads 1.5s apart; the count rose with read volume, which rises with each deploy because the release-date cache is per process). Every other failure emits `error` with `surface` (the caller's scope) and `http_status`. The ops monitor reads this kind as a per-source health rate (`reference_fetch:<source />` in `_MISC_INTEGRATIONS`), so a throttled or dead source still raises `integration_unhealthy`. Health read: `where event == 'reference_fetch' and source == 'musicbrainz' | summarize count() by http_status, tostring(attempt)` | | `mention_routed` | cogs/ask.py | guild_id, user_id, intent (recap\|discourse\|icebreaker\|music), period (1h\|1d\|today for recap, else null). **`intent=ask` + `reason=other_channel` (2026-09-03):** the router said recap but the message pings another channel, so the cog overrode it to ask (recap reads the current channel only). **`intent=discourse` (retired 2026-10-15, #1871):** no longer emitted. `_dispatch_routed` returns False for it, so the mention is answered by the normal /ask path. Since #4406 the router runs at the same time as the abuse gate; the event is unchanged. | | `reply_suppressed` | cogs/ask.py | guild_id, channel_id, user_id, reason (`broadcast`) — someone replied to one of Toots' own BROADCASTS (a bookie/market card, commentary line, discourse/music/icebreaker post, chime-in) WITHOUT @-mentioning her, so she stayed silent. The reply-restriction firing: she only answers a no-mention reply when it's a reply to her actual conversational answer (`MessageType.reply`); a reply to a broadcast (top-level, `MessageType.default`) is suppressed. Emitted ONLY for this narrow case (not the firehose of every unaddressed message); an explicit @-mention bypasses the skip (she answers) so only genuine suppressions log | | `links_copied` | cogs/clipboard.py | guild_id, user_id, channel_id, period (1h\|1d\|1w\|1mo\|6mo), buffer (the buffer name), count (total links in the buffer after the merge), added (# this copy added; 0 = an idempotent no-op, the buffer already held them all), messages_scanned (# messages walked), hit_ceiling (the scan hit `_MAX_SCAN_MESSAGES` so the reply warned older links may be missing — no SILENT loss) — `/copy` scooped a channel's links (over the window, bot posts NOT skipped so embed-fixer webhook reposts are caught, deduped in original order) into a named per-user buffer. **Re-copying MERGES** (union, deduped, order-preserving) rather than overwriting: every prior link is kept (no loss) and a re-copy of the same window adds nothing (idempotent). URLs are extracted as the history streams, so only the link set is held in memory. Mod-only; the scan runs in the background | | `links_pasted` | cogs/clipboard.py | guild_id, user_id, channel_id, buffer (the buffer name), count (links in buffer), delivered (# actually sent), via (always `bot` — posted as Toots via `channel.send`, no webhook), ok (all links sent) — `/paste` posted a saved link buffer into a channel (possibly in a different server), one link per message (each unfurls), paced + run in the background | | `links_distributed` | cogs/clipboard.py | guild_id, user_id, buffer (the buffer name), count (links in the buffer), routed (# a room was found for), dropped (# fit no room → left in the buffer), delivered (# actually posted), channel_count (# rooms it placed into) — `/paste out:curate` sorted a buffer's links across the guild's curator channels by best fit (/paste run through curation): each link enriched (`utils.link_enrich`) → routed to its single best-fit room by `claude.route_curator` (the curate feed's best-home judge, reused) → posted there fixup-rewritten so it unfurls; a link fitting no room stays in the buffer. Mod-gated, per-run bounded (`_DISTRIBUTE_MAX`), background task | | `ask_answered` | cogs/ask.py | guild_id, channel_id, question (the asker's prompt — the message she answered, not the wider room buffer), post_preview (HER FULL answer, untruncated). Logged so the live-log eval pass grades the real QUESTION+ANSWER pair for fabrication (#315), same framing as the synthetic eval. Internal ops telemetry for the judge — carries the whole answer + the question, an owner-approved departure from the 120-char/no-content preview convention (answer is her own output; question can carry the asker's info, so logged consciously) | | `answer_length` | cogs/ask.py + cogs/voice.py | guild_id, user_id, chars, sentences — measures a reply's length on the way out (a typed @mention via `cogs/ask.py`, or a spoken voice-note summons via `cogs/voice.py`'s shared `deliver`; the typed-mention-voiced case records in ask.py, so the voice cog only emits for `user_sent_voice` to avoid a double count), **observation only, it never trims or rewrites her words**. Length is owned by the model: the ask prompt carries a declarative **LENGTH POLICY** case dictionary (a take/opinion/banter = two sentences; a real how-it-works explainer / a requested list / code runs as long as it must; a deflection = one line) and she matches her answer to the case and adheres. This event records what she actually shipped so a drift back toward wordy takes (the Spielberg-films report) surfaces as a climbing length distribution (a rising p90 `sentences`/`chars`) — the early-warning that the policy prompt needs tightening (a prompt edit, **never a length value**). Deliberately **trust + observe**, not a regex enforcement guard (owner steer on #676): the live dry run showed she self-adheres to a clean case dictionary, so a hard backstop is only added back if the telemetry proves she drifts. **`sentences` stepped DOWN with #3022:** before it, `count_sentences` ended a sentence at every period, so "336.5M" or "No. 1" added a sentence; it now uses `output_checks.split_sentences`. Read a drop in the p50/p90 sentence panels at that deploy as the fix, not as a change in her length | | `tts_synthesize` | utils/tts.py | ok, mode (speak\|sing); on success: chars, bytes, duration_ms; on failure: error (+ chars/duration_ms/detail) | | `stt_transcribe` | utils/stt.py | ok; on success: bytes, chars, duration_ms, plus (when the upstream returns them) `language` (detected language code) and `speakers` (# distinct speakers diarized — a multi-speaker clip comes back as a speaker-attributed transcript, surfaced via `client.last_language`); on failure: error (+ bytes/duration_ms/detail) | | `video_transcribe` | utils/video_fetch.py | ok, source (captions\|audio\|x_text\|scrapecreators\|none\|unavailable); on success: chars, video_secs, duration_ms, lang (captions only), has_meta; on failure: reason (no_info\|no_captions_no_stt\|too_long\|transcribe_failed\|no_text_no_video) (+ video_secs/has_meta; `too_long` also carries `limit`, the cap in secs: 1800 ambient, 7200 for a `read_media` request). A long audio transcription (over 10 min) also folds `pieces` (how many Scribe uploads) + `pieces_failed` (pieces with no text) into `detail`; each piece emits its own `stt_transcribe`. `x_text` = an X/Twitter post resolved to its tweet-text floor via fxtwitter (#491); `audio` covers a YouTube clip OR an X post's mp4; **`scrapecreators` = a TikTok/Instagram transcript (+ TikTok metadata) resolved via ScrapeCreators (#1272) — yt-dlp resolves those two hosts poorly from a datacenter IP, so `fetch_video` routes them there first when provisioned, a miss falling through to yt-dlp**. A long/foreign transcript is then summarized to English by a `claude_api` call with `purpose=video_summary` | | `x_fetch` | utils/x_fetch.py | ok, duration_ms; on a hit: has_text, has_video; on a miss: error (no_tweet_id\|HTTP <status>\|no_tweet\|empty_tweet\|<exc>) — an X/Twitter post resolved via the public fxtwitter (FixTweet) JSON API, the durable replacement for yt-dlp's dead guest-token Twitter extractor (#491). Public content only, fail-open (None → caller degrades to no transcript) | | `x_media_fetch` | utils/video_fetch.py (`_download_media`) | ok, duration_ms; on a hit: http_status, bytes; on a miss: error (HTTP <status>\|too_large\|empty\|<exc>) — the X CDN mp4 download for a tweet's video (plain aiohttp, no yt-dlp/ffmpeg, #491). Fail-open (a miss → the X post keeps its tweet-text floor, `source=x_text`), so this success rate IS the open datacenter-IP reachability signal for `video.twimg.com` (ops-monitor integration health + `integration_unhealthy` finding) | | `clip_trim` | utils/video_trim.py (`trim_clip`, #video-trim) | ok, duration_ms, limit (the ceiling the cut must stay under, secs), **target (the cut length ACTUALLY applied)**, **method** (`snap` = moved back to a shot boundary \| `no_boundary` \| `detect_failed` \| `fixed` = snapping off), **count** (shot boundaries found in the window), source_bytes (the downloaded clip), video_secs (the SOURCE clip's length); on success: clip_bytes (the cut mp4); on failure: error (`empty_input`\|`ffmpeg_exit_<rc>`\|`no_output`\|`<exc>`), detail (ffmpeg stderr, truncated) — the ffmpeg cut that brings an over-long wire clip under X's native-video ceiling. **The cut END snaps to a shot boundary (#2215):** a fixed cut lands wherever the clock lands, so the clip ended mid-shot; a second short ffmpeg pass (`select='gt(scene,N)'` + `metadata=print`, `X_TRIM_SCENE_THRESHOLD` default 0.25) reports the shot changes in the `X_TRIM_SNAP_WINDOW_SECONDS` (default 10s) before the cut, and the LATEST one at or before the ceiling wins. Measured on the clip that prompted the trim: boundaries at 106.63 / 111.00 / 115.50 / 118.07 against a 118s cut → cut at **115.5s**, giving up 2.5s to end on a real cut; the pass costs ~0.4s wall over a 10s window. `-copyts` is what makes the reported timestamps absolute rather than rebased to the seek. **Deliberately NOT vision:** Claude vision reads stills, and stills carry neither motion nor audio — the two things that mark where a moment ends — so 30 sampled frames (~45k input tokens) would buy a worse answer than the free scene score. **A failed snap is NOT a failed trim:** `detect_failed` / `no_boundary` keep the fixed cut and the clip still ships, so `ok` stays True and only `method` moves. Watch `method` for a `detect_failed` share climbing off zero (the ffmpeg scene filter or the stdout shape changed); it is cosmetic, not user-impacting, which is why it earns a dashboard panel and NOT an ops-monitor finding so it uploads as NATIVE media instead of riding a `/video/1` deep-link X embeds only sometimes. Always follows an `x_clip_too_long` (the ceiling hit) and an `x_media_fetch` (the download it cuts). A STREAM COPY (`-c copy`), not a re-encode: both feeds that reach it (the X CDN mp4, TikTok's `play_addr_h264`) already serve h264/yuv420p/aac in mp4, so the cut is X-ready by construction and costs under a second. **Fail-open, and that is why it needs watching:** a failed cut raises nothing — the crosspost just drops back to `mode=video_link` and the take ships with nothing to play — so error-triage never sees it and this ok rate is the only signal (ops-monitor `misc_health` → `clip_trim`, `integration_unhealthy` finding; the plausible causes are ffmpeg missing from the image after a Dockerfile change, no writable temp space, or a source container ffmpeg cannot copy) | | `clip_pick` | utils/clip_pick.py (`clip_youtube`, behind `POST /debug/clip`, the music-drop skill) | ok, duration_ms, limit (the ceiling, secs), **signal** (`most_replayed` = the peak of YouTube's replay heatmap via ScrapeCreators `/v1/youtube/video` \| `loudness` = the window with the highest mean ebur128 momentary loudness \| `start` \| `given` = the caller pinned `start`), **start** (where the clip opens in the source, secs), target (the cut length applied), method + count (the END snap, the same `_snap_to_shot` as `clip_trim`), source_bytes (the yt-dlp download), video_secs (the source length); on success: clip_bytes; on failure: error (`missing_url`\|`download_miss`\|`ffmpeg_exit_<rc>`\|`no_output`\|`<exc>`), detail — the moment pick + cut for a YouTube video the owner hands the skill (a music video's chorus, a trailer's climax). Runs on the bot because a session's egress cannot fetch YouTube media (measured 2026-09-08: CDN 403 from a session, 100-190 successful YouTube audio downloads a day from Railway). The START also snaps back to a shot change (`CLIP_START_SNAP_WINDOW_SECONDS`, default 4) so the clip opens on a cut. A manual surface with no monitor: a failed cut is seen by the session that asked for it. Dashboard: the `clip_pick` panel (cuts by signal). | | `media_edit` | utils/media_edit.py (`run_edit`, the `<media op=...>` tag on a typed @mention) | **op** (`gif`\|`mp4`\|`trim`\|`speed`\|`frame`\|`mute`\|`reverse`\|`resize`\|`rotate`\|`flip`\|`convert`), ok, duration_ms, **mode** (the INPUT kind: `video`\|`image`\|`unknown`), source_bytes (the upload), video_secs (the source clip's length, from ffprobe); on success: **bytes** (the finished file), detail.duration_secs (the output window), **detail.retried** (the first encode missed the room's upload limit, so it re-ran at a smaller shape -- a FOLDED field, so a panel reads it as `parse_json(detail)['retried']`); on failure: error (`empty_input`\|`input_too_big`\|`unsupported_op`\|`image_edit_failed`\|`no_command`\|`ffmpeg_failed`\|`empty_output`\|`output_too_big`\|`<exc>`) — one mechanical edit of an attached file: the model classifies the ask in the SAME call that composes the reply (the tag is the classifier, so there is no second pass), and this runs it on ffmpeg + Pillow. **The field is `mode`, not `kind`:** `@instrument` passes the event's fields as keywords into `safe_emit(kind, **fields)`, so a field literally named `kind` collides with that positional and raises out of the emit. **LOCAL work — no API money — so the cost signal is duration_ms, not a quota.** Measured on a 60s 720p clip: a 6s gif 1.2s, an accurate 30s-to-40s trim 1.5s, an mp4 that needed the retry 13.3s (two encodes). Three ceilings bound it: `MAX_INPUT_BYTES` (25MB) refuses a big upload before any work, each op carries its own output-seconds ceiling, and the guild's own upload limit decides whether the result can ship. **Fail-open, so it needs watching:** every failure returns None and the reply degrades to text, raising nothing error-triage would see, so this ok rate is the only signal. Watch `error`: a jump in `ffmpeg_failed` means ffmpeg left the image after a Dockerfile change or temp space ran out; a jump in `unsupported_op` means the model is naming ops for the wrong kind, which is a PROMPT problem (`claude_client._media_directive` is keyed per kind); a steady `output_too_big` share means the ceilings are set too generously for the rooms using it. Ops monitor: the `media_edit` health line (ops-monitor `misc_health` -> `media_edit`, `integration_unhealthy` finding), with `empty_input`, `input_too_big`, `unsupported_op` and `output_too_big` EXCLUDED (`_MEDIA_EDIT_BENIGN_ERRORS`) -- those are what somebody uploaded or what the model chose, not a broken edit path, and counting them would false-flag an ordinary day. Dashboard: the `media_edit` panel (edits by op, ok rate, p95 latency, retry share) + the `expr` reply-delivery panel, which now counts `media_reply` beside voice/image/gif. | | `media_reply` | cogs/ask.py (`_deliver_media`) | guild_id, user_id, op, delivered (`media` = the edited file shipped to the room \| `text` = it did not, and the caption went instead), reason (`edit_failed`\|`send_failed`) on a text fallback, has_caption on a delivered one — the room-facing half of the pair: `media_edit` says whether the EDIT worked, this says whether the ROOM got it. The same split as `image_generated` / `image_reply`. A `send_failed` with a successful `media_edit` is the one case that points at Discord rather than at us (usually a file over the guild's real limit, which the edit already checks, so it should be rare). No daily cap to read here, unlike `image_reply`: the edit costs container CPU and no money, so the per-user mention limit is its only budget. | | `voice_note_sent` | utils/discord_voice.py | channel_id, bytes, duration_secs, is_reply | | `voice_reply` | cogs/voice.py (shared `deliver`, fired for voice-note replies AND typed `@mention` answers the model nominated for voice — a plain text mention with no `<voice>`/`<sing>` tag skips `deliver` entirely and emits nothing) | guild_id, user_id, delivered (speak\|sing\|text; `text` = voice unprovisioned, a gate veto, or a synth/send fallback), gate_reason (`reply_in_kind` = a voice note; `model_candidate` = the model nominated voice on a typed mention; `no_trigger`/`disabled`/`too_long` = stayed text), model_candidate, sing — voice is a **permanent surface**: it speaks whenever ElevenLabs is provisioned (`bot.tts`) and the gate clears | | `image_generated` | utils/image_gen.py | ok, model; on success: chars, bytes, duration_ms, `revised` (bool: OpenAI returned a safety-rewritten revised_prompt, surfaced full-text to the caller via `client.last_revised_prompt` but only flagged — not logged — here, since the revised text is user-derived); on failure: error (`empty_prompt`\|`too_long`\|`no_image_in_response`\|`HTTP <status>`\|`<exc>`), plus chars/duration_ms/detail where available (chars only, never the prompt text) | | `image_reply` | cogs/ask.py | guild_id, user_id, delivered (`image`\|`capped`\|`text`; `image` = PNG + caption sent to the room; `capped` = per-guild daily image budget spent, caption went out as text; `text` = the model nominated an image but generation/send failed so the caption went out as a plain reply), has_caption (on a delivered image), reason (`generate_failed`\|`send_failed`\|`unsupported_source`\|`daily_cap`, on a text/capped fallback). Image is a **permanent surface**: it posts whenever OpenAI is provisioned (`bot.image`), bounded by the per-guild `image_daily_cap` budget (#326) | | `gif_search` | utils/gifs.py | query, ok, results (# candidates), duration_ms, error (on failure) — a Giphy search ran (`GIPHY_API_KEY`-gated client) | | `x_reply_draft` | cogs/x_reply_draft.py | guild_id, channel_id (the room the content shipped in; None for scout), ok, delivered (`channel`\|`bot_logs`\|`skip`), reason (`experiment_off` = not the master guild, or the `x_replies` experiment is OFF (its default since 2026-10-08)\|`dedup`\|`daily_cap`\|`fetch_failed`\|`declined`\|`send_failed`\|`error`\|`qualifier_no_clean_cut` = the reply lane's source tweet or quoted post has a wrong qualifier with no clean cut (#4464)\|`cut_qualifier` = the draft states a cut qualifier word again (#4464)), **trigger (`listener`\|`reply`\|`banger`)** — Toots drafted the X reply she'd leave under a sourced tweet and posted the draft to the owner's working queue — **master guild only, and with NO ping** — with the tweet's **BARE fixup link** so Discord unfurls it inline under the draft; the OWNER posts the reply on X by hand. **The surface graduated 2026-09-01 (#2796) to MASTER-ONLY, so the master-guild gate owns on/off** (it replaced the per-guild experiment stage of 2026-08-06): a non-master guild stops drafting entirely (`reason=experiment_off` — **the string is UNCHANGED so the Axiom panel keeps working**, but it now means "not the master guild", not "a mod set the experiment off"), and STAGING can no longer occur, so `delivered=bot_logs` in the master guild is now purely the FAIL-OPEN (no drafts channel picked, or she cannot post there). Historically STAGING queued to **#bot-logs** (`delivered=bot_logs`, the DEFAULT because it was exactly what the surface did before the knob existed), PRODUCTION sends to the drafts channel picked on the /menu "on X" page (`delivered=channel`; an unset or unreachable channel falls back to #bot-logs rather than dropping the draft, so the queue can't go silent by omission). **The header's @-mention of the master users was removed in the same change:** the surface delivers ~120 drafts a day, so the ping sent ~120 notifications a day for a queue the owner reads in batches; both messages now send with `AllowedMentions.none()`. Watch `delivered=channel` after a guild flips to PRODUCTION — a PRODUCTION guild still reading `bot_logs` means the channel picker is unset or she can't post there — the hand-off loop that scales replies past the API tier's third-party-reply 403 (#1732, epic #1793; measured: 12 owner replies out-viewed 48 originals ~13×). **Two triggers, one path:** `listener` = a tweet she linked in a Discord post; **`reply` = the wire desks' THIRD lane** (owner steer: alongside biggest + breaking, named "reply") — each pop_desk/sports_desk/cinema_news slot drafts the freshest-hottest story (`pick_reply_stories`: the same `order_for_breaking` ranking on a wider 3h `X_REPLY_WINDOW_HOURS`) the posting lanes did NOT claim (those get listener drafts on delivery), riding the same slot + wire fetch via the fire-and-forget `kick_reply_lane` (never delays desk delivery; `X_REPLY_PER_SLOT`=3, and 0 turns the lane off). Grounded on the fetched tweet (fxtwitter, keyless) **+ the live context beat** (`utils.context_beat.gather_context_beat`, Grok X-crowd + Perplexity, `framing="breaking"`, `purpose=x_reply_draft` — the draft USES the context/angle the beat surfaces (that's what it's paid for), with only its NUMBERS fenced from being quoted as her own stat; fail-open to a tweet-only draft) + steered to ADD not echo (the owner rejected auto-quotes as dead-similar); the model is the per-guild **`x replies` Models-page knob** (`tunables.SETTING_X_REPLY_MODEL` → `claude_client.x_reply_model_id`, resolved live so a `/menu` flip needs no restart), **defaulting SONNET** (a deliberate COST call, #1839 — see the model note below); it gets its OWN row rather than borrowing `discourse`'s or `market drop`'s, because those families share their donor's OUTPUT SHAPE while a reply draft is vision-heavy (~2.8k input tokens/call) and leans on WORLD KNOWLEDGE — a different axis — so a guild must be able to run discourse on Sonnet while replies run Opus; self-declining (EMPTY → `reason=declined`, a path that has never actually fired — deliberate, since the owner is the filter and a weak draft still surfaces the reply TARGET). **De-hollowed (#1834, owner report "her x replies are hollow"):** a real-wire dry run scored only 13% of drafts substantive — the rest captions for the photo ("that artwork is doing something"). The cause was three clauses that left captioning as the only legal move — a hard brevity wall (under 15 words, no comma-chained clause) that forbade carrying a specific AND a read, the chatter block being fetched then BANNED from use (`compose_market_drop`'s framing reused on a surface with no grounding blob to quote instead — so the one source of outside substance was paid for and thrown away), and "react to what you actually SEE" inviting the caption directly. Rewording the three (never adding a "be substantive" rule, per `docs/PROMPT_OPTIMIZATION.md`) took substance 13% → 38% — **that reword is what carries this surface**, and it is model-independent. **The MODEL was a second, optional lever, tried and then REVERTED on cost (#1839, owner steer "move back to sonnet opus is too expensive"):** on the same rebuilt prompt and identical material Opus scored **66% vs Sonnet's 34%** (n=50 each, +32pp, z≈3.4) — it brings real world knowledge where Sonnet reaches for the visual (it clocked a "puss puss" caption as Swedish for "kiss kiss" and the panicking repliers as wrong; it caught that a July 30 Harry Potter birthday post is a day early since the 30th is Neville's) — so it briefly shipped on Opus (#1834), but the owner priced that ~26pp against the spend and chose Sonnet — and the gap is **CONCENTRATED**, which is what makes Sonnet-by-default a fair trade rather than a flat loss: on tweets carrying their OWN facts (a transfer clause, a meeting, a date) both models score 5/5, and the whole premium buys the tweets needing outside knowledge (a foreign-language caption, an NBA read). So expect any residual hollowness on the **pop/celebrity image** posts, not the sports/transfer wire. **This is a SETTLED tradeoff, not an oversight** — don't "helpfully" route it back to Opus; it's now a **`/menu` Models-page pick** (`x replies`), so re-buying the gap is a dropdown, not a code change. Ablations that came back a WASH and were therefore dropped: cutting the image clause entirely (40% vs 43% — the residual captioning is the image's salience in vision, not the instruction) and adding explicit "say something only you'd know" / "not a caption" blocks (50% vs 43%, inside noise). **Scoped to an ALLOWLIST of the channels explicitly configured for the X-post surfaces** (the union of the /menu channel sets — discourse/music/music-news/music-market/cinema/cinema-news/pop/sports-desk/prediction/betting, via `_CHANNEL_SOURCES`; so curator rooms, #bot-logs, and any unwired room are out by construction, and the scope tracks the /menu config with no code edit — owner steer); durable per-tweet dedup (`x_reply_draft` kv ns, 7d) + a runaway daily cap (`X_REPLY_DRAFT_DAILY_CAP`, **0 = off by default** on the owner's call "remove the draft cap just let it run" — set it above 0 to re-arm the backstop for a bulk-op burst) + master kill switch + the experiment stage + staging-audit/own-tweet/draft-message guards; fully fail-open | | `x_reply_tic_guard` | claude_client.py (`draft_x_reply`) | ok (the retry came back clean and replaced the draft), count (tics on the first pass), kind (the first tic) — the **verbal-tic guard** (#1975, owner report "these idiosyncrasies in her prose are horrible": a Discord search showed **21 hits for "flex"** and walls of "the real X" / "nobody's talking about"). `utils.x_reply_draft.tic_hits` is the deterministic detector, calibrated against BOTH real corpora — it trips **39%** of a live week's drafts against **6%** of the replies the owner actually hand-posted, so it catches a habit rather than her voice. On a hit the compose re-asks **once, NAMING the offending phrase**; that naming is the load-bearing part, because the obvious alternative (hand the model a block of its own recent drafts and say "vary it", the block the wire desks already use for TOPIC repeats) was measured and made the tics **worse** — any-tic 9/35 → 19/36, register flags 1/35 → 6/36, the primer effect (`docs/PROMPT_OPTIMIZATION.md`). **Fail direction:** the retry is taken ONLY if it comes back clean, so a degraded rewrite can never replace a good line and a retry failure is a no-op. Cost is one extra compose call on the drafts that trip. **Watch the `ok` rate** — a collapse means the re-ask stopped working and the tics are shipping again | | `banger_lane` | cogs/x_reply_draft.py (`_banger_lane`) | surface, ok, duration_ms, fell_back (#3435: True = no headline kept a Haiku name, so every search term came from the regex; a model outage reads as a sustained fell_back=true), count (search hits weighed — a **TOP-LEVEL** `SHARED_FIELDS` column, so query it as `count`, **not** `parse_json(detail)['count']`, which returns null); folded detail: kept (cleared the floors → drafted); ok=False reason (`not_master`\|`unprovisioned`\|`experiment_off` = the `x_replies` experiment is OFF, its default since 2026-10-08\|`no_terms`) — the **BANGER lane** (#1972), the reply surface's third trigger and the first that finds targets by **HEAT rather than by account**. The reply lane can only ever draft under the code-owned wire accounts, because that is the only pool the desks fetch; the owner's own reply wins say the pool is wider — of her top 20 hand-posted replies (2026-08-03) **four** landed under accounts the wire list has never carried (@Rap, @DrewPavlou, @THEUN1VRSE, @Kurrco), and **all 20** sat under parents of 300K–17M views (the 11.7K-like winner under a 17M-view post with 862 replies), while a 1.1M-view viral quote-tweet from a mid-size film account was invisible to us at any temperature. So each pop/sports/cinema_news slot, after the posting + reply lanes, takes the search terms from its OWN top stories (since #3435, Haiku `headline_search_names` names the entities in each headline with `purpose=banger_search_terms`, and `utils.banger_lane.judged_terms` keeps only names found word for word in that headline; a headline with no checked name uses the old regex `search_terms` — quoted/ALL-CAPS titles and capitalized runs — so the query stays inside her lanes), runs ONE `advanced_search` (`queryType=Top`, `min_faves` + `lang:en` + `-filter:replies` + `since_time`, every operator verified live against twitterapi.io's passthrough), and drafts under what clears the floors (`pick_bangers`: inside the 3h reply window, over BOTH a view and a like floor, an ORIGINAL not a reply/repost, not her own account, not piracy/engagement-farm spam (since CB-30, 2026-10-18, Haiku `purpose=banger_spam` labels each post that clears the other floors SPAM / REAL, cached 3 days per tweet id, with the old `_SPAM_RE` words as the SPAM examples and the regex as the fallback when the model fails; folded `detail.dropped` = the posts it labeled spam), ranked by engagement **velocity** — climbing beats merely big, since a forming reply section is the whole point — then capped at one per author). Drafts ride the SHARED path (`produce_draft`, trigger=`banger`), so per-tweet dedup, the daily cap, the kill switch and the model's EMPTY decline all apply unchanged; fire-and-forget so it never delays a desk post. Bounds are env: `X_BANGER_PER_SLOT` (2, 0 disables the lane), `X_BANGER_MIN_VIEWS` (50K), `X_BANGER_MIN_LIKES` (500), `X_BANGER_STORIES` (2), `X_BANGER_SEARCH_LIMIT` (25). **The health line to watch is `kept`:** a sustained `kept=0` with `count>0` means the floors have drifted above what the search actually returns — the fail-open miss that raises no error and would otherwise be invisible, since the lane simply draws nothing. **Watch `reason=no_terms` FIRST, though** — it is the earlier and quieter failure, because a lane that never searches never reports a `count` at all, so the `kept` watch above cannot see it. The lane shipped in #1972 reading `story.title`/`story.text`; no desk story type has either field (they carry `headline` + `subject`), so `getattr(story, "title", "")` returned `""` for every slot, the query was empty, and **the lane failed `no_terms` on 100% of its 73 sweeps across pop_desk/cinema_news/sports_desk for its whole first two weeks in production** — it never ran a single search. The field read now lives in the pure `banger_lane.story_terms`, typed against the `WireStory` Protocol and tested against the three real story dataclasses. A sustained `no_terms` now means the desks genuinely produced nameless headlines; any spike right after a story-model change means the field read broke again | | `chart_history` | cogs/chart_history.py | source (`billboard`\|`story`\|`hits`\|`kalshi`\|`nora`\|`daily` for the walks, `store` for the daily coverage row), phase (`upkeep`\|`backfill`\|`snapshot`\|`coverage`\|`watch`\|`freshness`; the story walk is backfill only; **`watch`** (#3562 A) is one act of the daily walk's release watchlist: its kworb artist songs + albums pages stored to `chart_history_artist_days` on the page's own refresh date, unit `watch:<artist_key>:<day>`, `count` = rows kept, misses `unavailable` (both pages empty), `partial` (one page empty or undated: what arrived is stored and the act is retried next tick, at most 3 reads a day) and `undated` (no refresh date, so nothing stored), and `ok=True count=0 reason=no_artist_id` when neither the store nor kworb's leaderboard gives the act an id (a lookup gap, not a failed read: 7 of 19 acts on the 2026-10-05 dry run, all smaller acts, so it must not drag the daily ok rate); **`freshness`** (#3562 A) is one row per 6-hour slot, `context` = `lags` (days per daily chart, null = never stored), `stale` and the newest `artist_days` date, `ok=False reason=stale count=<stale charts="">` past `chart_history.FRESHNESS_LIMITS`, which the ops monitor files as `chart_capture_stale`; panels `ch_watch` and `ch_fresh`), ok, count (rows written), reason on a miss (`unavailable`\|`week_mismatch`\|`no_story`\|`unverified`\|`empty`\|`incomplete` — `no_story` is the story walk finding no weekly Billboard 200 story in the week's publish window or one naming another chart date, `unverified` a story whose figures matched no stored page row, `incomplete` a daily Kalshi snapshot that missed a read: the missed series and events are kept in the checkpoint and the next tick re-reads only those, and the day is stamped only once nothing is missing) and on a zero-row success (`ok=True count=0 reason=no_projections`: a Nora page read in full that stated no number — a yield of nothing, not a miss, so the per-source ok rate measures the fetch, not what their posts happened to say; and `ok=True count=0 reason=partial` from the STORY walk: a week whose story was found, parsed and verified, and whose stated figures were stored, but which still has empty top-10 ranks because the story names units for only some of them. That is the COMMON outcome, 41 of 59 filled weeks on the live store (#3626). It used to be emitted `ok=False` on top of the `ok=True` its own stores already emitted, so one healthy week read as half a failure; over 24h that held `chart_history:story` at a 54.8% fail rate on a healthy day, five points under the 60% line that files a finding, and a real parser break looked the same as normal. Reclassified 2026-09-22 (#3631), the same 24h reads 7.6%, and the 12 misses left are four specific weeks with no story found, each tried three times. So `chart_history:story` now measures the READ and the PARSE: a billboard.com redesign that still serves a 200 but no longer parses drives it toward 100% and files `integration_unhealthy`. The walk's retry is unchanged, because it reads the RETURNED reason and never the emitted `ok`); a Billboard miss counts ONE attempt per day (the breaker cooldown and the dated-page cache answer several ticks with one read), so a week is skipped after three days, not three ticks; a Kalshi settled page that misses three ticks in a row is dropped and the walk moves on; folded detail: `unit` (the checkpoint unit: `hot100:2026-09-19`, `billboard200:2026-09-19` for a story, `midweek-20:2026-09-17`, `KXALBUMEQUIV:<cursor>`, `2026-08:own`, and for the `daily` walk `sp_us_daily:2026-09-19` on the page's own chart date, `ap_songs:2026-09-21` on the read's date, `grid:<track_id>`; `reason=undated` is the Spotify page naming no date, `short` a chart that parsed to fewer rows than its floor in `DAILY_CHART_FLOORS` and so is not the day's snapshot, `empty_grid` a track page whose daily grid parsed to no dated rows; a grid miss counts one attempt per day and three days skip the track, named in `grid_skipped`); the coverage row carries the per-table row counts, oldest/newest chart week and `weeks_with_units` in `context` — the **durable chart-history store** (owner steer 2026-09-20, `utils/chart_history.py`): every Billboard dated week (two years back) plus the printed units of the Billboard 200 top 10 off Billboard's own weekly chart story (`billboard_story`, verified against the page rows before it is written), every HITS midweek + building document (back to 2025-01), every settled Kalshi music ladder plus a daily implied-figure snapshot, and Nora Music's X posts, written to the four `chart_history_*` tables one bounded unit per 10-min tick, each source's walk checkpointed in the settings KV (`resumable_scan:chart_history:<source />`, guild 0) so a redeploy resumes. **Health:** `chart_history:<source />` buckets in the ops monitor's misc integrations (`_PER_SOURCE_MISC`; `chart_history:story` is the story walk) — a walk that keeps missing raises nothing, the checkpoint just stops moving, so the per-source ok rate is the signal; the daily `coverage` row is the progress read (`['tootsies'] \| where event == 'chart_history' and phase == 'coverage' \| project _time, context`). A live-DB read of the same: `SELECT chart, count(*), min(week_of), max(week_of) FROM chart_history_weeks GROUP BY chart` over `/debug/query`. **Model reads (CB-16, #4557, 2026-10-18):** two sources are Haiku reads, not walks, and each has its own ok rate in the ops monitor. `source=nora_extract phase=extract`: one read per Nora post that holds a closed-format figure (`ch.nora_may_project`), purpose `nora_extract`; `count` = records kept, `skipped` = fields the code check dropped (a sentence not in the post, an act not in the post, a figure not in the sentence), `ok=false reason=error` = the old regex parse decided for that post. `source=credit_split phase=credit`: one per `history_rows` read that asked Haiku to list the artists of the credits the exact rule cannot settle (purpose `chart_credit_split`, cached 30 days per credit); `count` = credits asked, `skipped` = credits whose verdict moved off the old split, `ok=false reason=fallback` = at least one credit got no verdict and kept the old split. Panel `ch_nora_model`. Regression watch after merge: `['tootsies'] \| where event == 'chart_history' and source in ('nora_extract', 'credit_split') \| summarize reads=count(), fallback=countif(ok == false), rows=sum(count), dropped=sum(skipped) by source, bin(_time, 1d)`. **Model reads of the Billboard story (CB-06, #4557, 2026-10-18):** `source=story_figures` is one Haiku read of one story's figures (`billboard_stories.checked_rows`, purpose `billboard_story_figures`), `phase` = the caller (`store` = the story walk, `reveal` = `music_markets.printed_units_for`); `count` = figures kept, `skipped` = figures the word-for-word check refused, `ok=false reason=error|unreadable` = the old regex parse decided. `source=story_leader phase=reveal` is one YES / NO / UNSURE check of whether the story headline names the release as the No. 1 (purpose `billboard_leader_check`); `count` = 1 on YES, `reason` = the label, `ok=false reason=error` = the old substring test decided. This is the denominator the `market_corrected` row below asked for. Each source has its own ok rate in the ops monitor. Panel `ch_story_model`. Regression watch after merge: `['tootsies'] \| where event == 'chart_history' and source in ('story_figures', 'story_leader') \| summarize reads=count(), fallback=countif(ok == false), rows=sum(count), dropped=sum(skipped) by source, phase, bin(_time, 1d)` | | `x_audience` | cogs/x_audience.py | ok; folded detail: followers, following, statuses, count (sample size), tweets_24h/replies_24h (bot output + owner reply pace), median/total views + likes split by replies vs originals; ok=False reason (`fetch_failed`\|`error`) — the **nightly X audience sweep** (epic #1793 item 13): one `user/info` + one raw recent-tweets page via `bot.xprovider_primary`, folded by the pure `utils.x_audience.audience_summary`, one event per UTC day (daily 08:00 UTC + an idempotent boot run, kv-stamped so the every-merge redeploys never dup). The follower curve + the replies-vs-originals split the growth experiment steers by. Bot-wide (not guild-gated), X-source-provisioned, fail-open | | `gif_reply` | cogs/ask.py | guild_id, user_id, query, ok, delivered (`room` = shipped to the channel; `no_result` = search whiffed; `vision_skip` = she looked and none fit; `cooldown` = the per-channel gif gap held it back; `daily_cap` = the per-guild daily gif budget is spent), vision (`picked` = chosen by looking at the candidate frames; `fallback` = the vision pick errored so the top relevance hit was used; `none_fit` = skipped; `no_candidates`) — the model nominated a gif on a typed @mention via a `<gif: query>` tag (utils.gif_signal), the inverse-of-voice taste signal: a gif rides ALONGSIDE the text rather than replacing the delivery channel. gif is a **permanent surface**: it ships whenever Giphy is provisioned (`bot.gifs`), paced by the per-channel cooldown + per-guild daily cap. She **vision-picks** which candidate to send (`claude.pick_gif`, the outbound twin of the inbound gif vision) rather than sending the top text-relevance hit |</track_id></cursor></stale></day></artist_key></exc></status></sing></voice></exc></exc></rc></exc></rc></exc></status></exc></status></exc></status></fault></condition></status></code></status></new></old></new></old></new></new></new></old></old></old></b></a></about_them></exc></status></leg></REGION></tag></status></N></act></act></act></board.kind></n></artist></query></sub></n></id></n></status></status></id></penalty></sport>

x_follow_change cogs/x_audience.py (hourly churn sweep) ok, kind (gained|lost|sweep), reason, count; folded detail: handle, tenure_hours, followers, statuses, low_signal (per-account rows) / gained, lost, lost_low_signal (the sweep row) — the X follower-churn diff (#x-churn). 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 never appear. This walks /twitter/user/followers hourly, diffs against the x_followers snapshot table (keyed on the X user id, so a rename is not a departure plus an arrival), and names each loss. A lost row is confirmed before it is called an unfollow — reason=unfollow means the profile is still alive (real feedback about what we post), account_gone means deactivated/suspended (not feedback), unconfirmed means the per-sweep confirm cap or a failed read. The refusal path is load-bearing: ok=False with reason=incomplete|low_coverage|empty means the follower walk was truncated, and the sweep wrote NOTHING and diffed NOTHING — diffing a short list reports every follower past the cut as an unfollow, which is worse than no data. reason=seeded is the first run. HOURLY because the cadence sets the attribution precision: a departure lands between two sweeps, so an hourly window holds the 1-3 posts that could have caused it, where a daily window holds ~20. Losses also post to the master guild’s #bot-logs. Ops-monitor health, two complementary paths: the API read itself is curator_fetch with phase=followers, so it already rolls into misc_health["twitterio"] via _MISC_INTEGRATIONS with no new wiring; the SWEEP-level refusals are a separate branch feeding misc_health["x_follow_sweep"] (the per-account gained/lost rows are payload and never counted, and reason=seeded is excluded so a fresh deploy cannot flag). That second one exists because a refused sweep is the fail-open trap: it raises nothing and posts nothing, so a broken follower walk looks exactly like a quiet week while the net follower curve carries on looking healthy. Known limit — correlation, not proof: X publishes no per-viewer data to any API (twitterapi.io has no likers endpoint — /twitter/tweet/likers and /twitter/tweet/favoriters both 404, verified 2026-09-14), so this narrows a departure to the posts in its window and cannot prove the account read any of them; the reply/mention/retweet record is the only per-account evidence of a real read. Correlate to the posts in the window (tweet_posted is in the same dataset): ['tootsies'] \| where event == 'x_follow_change' and kind == 'lost' \| extend d=parse_json(detail) \| project _time, tostring(d['handle']), toreal(d['tenure_hours']), reason
x_reply_edit cogs/x_audience.py (nightly sweep) ok, post_preview (the owner-POSTED reply); folded detail: pure_cut (bool — the KPI: the posted text is a contiguous slice of the draft, i.e. the owner KEPT a beat instead of rewriting; measured 2026-08-02 over 223 pairs, pure-cut posts out-viewed rewrites ~2.7×), words_draft, words_posted, slop_score — the owner-edit-fidelity join (#ai-register): the reply cog retains each draft’s text by target tweet id (kv ns x_reply_text, 7d, cogs/x_reply_draft.py), and the nightly x_audience sweep matches the timeline page’s replies by inReplyToId (utils.x_audience.reply_edit_metrics, pure + unit-tested), emitting ONCE per posted reply (kv-stamped x_reply_edit ns, 14d). Metrics only — the draft text is never emitted. A reply with no retained draft (hand-written, pre-feature, >7d late) is silently skipped. Weekly cut-rate APL: ['tootsies'] \| where event == 'x_reply_edit' \| summarize cut_rate=avg(toint(parse_json(detail)['pure_cut'])), med_posted=percentile(toint(parse_json(detail)['words_posted']),50) by bin(_time, 7d)

The slop_score auto-stamp (#ai-register, owner steer “every post every surface”): emit() itself stamps slop_score (the deterministic AI-register score from utils/slop_score.py — vendored antislop lexicon + not-X-but-Y tells, 0..1) onto ANY event carrying a post_preview, before the fold — so every shipped post on every surface (discourse/music/chimein/commentary/market drops/ask/…) is scored with zero per-surface wiring, and a surface added tomorrow is covered by construction. Folded into detail (zero field-budget cost): query parse_json(detail)['slop_score']. An explicit caller-passed slop_score is respected (the x_reply_edit join passes it literally so the schema scan sees the field’s in-source emit). Baseline at ship: 0.00 across every live surface. Consumed by the ops monitor’s slop_high finding (below) and, on the eval cadence, scripts/eval_ai_register.py.

Adding a new event: call emit("your_kind", key1=..., key2=...) and add a row to the table above + the events.py docstring. Use snake_case for kinds and fields. Don’t include full message content (data minimization, per the constitution).

Operability is part of the feature (full original text)

A feature isn’t done until it’s observable. Every new capability — especially a new outbound integration (an API client, a model call, an external fetch) — ships its instrumentation in the SAME change, not as a follow-up. This is mandatory and unprompted on every change going forward: apply the full bar below to anything that adds or touches a surface, integration, or outbound call — every time, without waiting to be asked. “Do we have full instrumentation for this?” should already be yes by the time the PR opens — never a question the owner has to raise. The bar, in order:

  1. Latency + outcome, per distinct call. Wrap the outbound call with @instrument("your_kind") (or timed_event for a block, @timed/timed_span for a heavy flow step) so it emits one event with ok + duration_ms. Never hand-roll a stopwatch — utils/instrument.py owns the clock. Any event carrying duration_ms is auto-picked-up by the ops monitor’s p50/p95/p99 table; add a ceiling to LATENCY_CEILINGS_MS (or a LATENCY_CEILING_PREFIXES rule) for each one if a blown tail should gate, not just render. Instrument EACH distinct external step as its own event, not one event for the whole feature — if a flow does an API resolve, then a CDN download, then an STT call, that’s three events, so the three failure modes stay separable in the logs (the X path’s x_fetch + x_media_fetch + stt_transcribe split is the worked example, #491). One bundled event hides which leg actually broke. Naming convention: an event KIND names WHAT was called (market_fetch, claude_api); a SHARED integration hit from many surfaces (SGO/Kalshi/Perplexity/Claude from bet/commentator/ask/discourse/music) also needs WHO called it. Don’t rename the kind (it’s the stable dashboard contract) — instead wrap the surface’s outermost entry method in @scoped_surface("commentator") (or with surface_scope(...)), and utils/instrument.py auto-stamps every nested event/span with surface= + a hierarchical path (surface.kind[.source], e.g. commentator.market_fetch.sgo) so the latency + error tables group by the CALL SITE, not just the integration. Additive + fail-open (no scope → no surface field, byte-identical to before). SPAN names (@timed) are already hierarchical (surface.function) — keep that. Wire a new surface at its one outermost entry method, never per call. Field budget (the Axiom schema cap): every DISTINCT field NAME across ALL events counts against the dataset’s per-dataset field limit (the tootsies dataset is on the Axiom Cloud plan: 1,024 fields, up from the free plan’s 256 that #1296 hit; the whole EVENT+log stream shares this one dataset, so it’s one budget), and AT the cap a never-seen field name SILENTLY DROPS the whole event at ingest (a 400 naming the field, or a 200-with-failures partial) — invisibly degrading the exact observability the ops-monitor + health cog depend on (#1296, how curator_posted went missing on the old 256 cap). The 1,024 cap stopped the dropping, but the owner may downgrade back to free (256), so the discipline below is kept precisely so free stays a viable fallback — after the #1296 “full maps push” the footprint is ~146, well under 256 (a downgrade would be clean), and the tripwire in tests/test_event_schema.py ratchets it further DOWN as consolidation continues; don’t bump it up. utils/event_schema.py is the single source of truth for the field vocabulary, split into three ledgers: SHARED_FIELDS (the reusable cross-event vocabulary — reuse a name from here when the meaning matches: source/kind/reason/ok/count/query/phase/delivered, …, before inventing mycog_specificthing), _EVENT_SPECIFIC (the top-level fields a dashboard / ops-monitor / consumer actually READS — these keep a real Axiom column), and _FOLD_FIELDS (the ~124 diagnostic singletons that no consumer reads: emit() auto-folds any of these into the ONE shared detail map wherever emitted — the “specific events use maps” model, so they cost NO top-level column and can never hit the cap; query them with parse_json(detail)['<name>'], see the _FOLD_LOG for the transition-window coalesce bridge). To ADD a field: reuse a SHARED name if one fits; else, if a consumer must read it top-level, add to _EVENT_SPECIFIC; else fold it (add to _FOLD_FIELDS). tests/test_event_schema.py fails CI on an unregistered field (the conscious reuse-or-add-or-fold gate) and ratchets the footprint DOWN toward the 256 free fallback. Two more runtime guards back the registry so a leak can’t slip past the static lint: emit() JSON-stringifies any dict field value (_demap), so a variable-key dict can NEVER flatten into per-key Axiom fields (home_pcts.<source>/genre_tags.<genre>/context.<key> were exactly this — put high-cardinality / variable-key data in ONE stringified dict field, not separate fields named after the keys), and utils/axiom.py inspects the ingest response’s failed/failures and logs (rate-limited) when Axiom rejects rows — a silent drop becomes a visible signal. When the count nears the cap: python -m scripts.axiom_setup --fields audits live-vs-registry (dead + drop-risk fields), --vacuum reclaims dead-field slots (renamed/removed events), and a wide/separable event family can route to its OWN dataset via an AXIOM_*_DATASET env (off the main budget). The dataset is currently on the paid Cloud plan ($25/mo, 1,024-field cap) which lifted the immediate wall; the footprint is now under the free 256 (via the fold), so a downgrade is clean — the consolidation levers above stay the primary tools to keep it there.

    Every panel and monitor query RUNS before a push (#3218). A query Axiom rejects renders as an EMPTY table, which looks exactly like a quiet surface, and no CI check can parse APL. python -m scripts.axiom_setup --validate-only runs every query once through the read-only _apl endpoint over the last hour and exits 1 on a 4xx; a 5xx or network error is reported as “not checked”, never as broken APL. The push paths (--dashboards, --monitors, the default run) run the same walk first and refuse to push on a rejection. First run, 2026-10-15: 338 queries, 1 rejected – tootsies-desks / source_changes (mv-expand src=...: Axiom’s mv-expand takes no alias; extend first). Validation does NOT catch a valid query that reads a folded field as a column – that returns empty rows, not a 400 (see #2254 below).

    Folding a field breaks every CONSUMER that reads it top-level (#2254). In the monitor the break is silent: ev.get("kept") returns None, nothing raises, and the branch reports 0. In APL it takes one of two forms, both measured against the live dataset. If the name is nowhere in the dataset schema the query FAILS – the wire panel returned HTTP 400 invalid field: "kept". If the name IS in the schema, because another event writes it top-level or because this one did before the fold, the query RUNS and reads null on every row: sum(source_count) returned 0 for all four X surfaces, and by league grouped every row under a null key. The second form is the dangerous one and the more likely after a fold, because folding leaves the old column in the schema.

    Five monitor branches and five panels shipped this way. Healthy wire_read rows carrying kept=157 counted as an outage, and the curate routing funnel read 0/0 on a lane routing 467 of 492 posts. So when you fold a field, GREP for its readers first. In the monitor, read it with _ev_folded(ev, name); in APL, read it with parse_json(detail)['<name>'] and alias it. Two guard tests hold the line: tests/test_ops_monitor.py parses the monitor and fails on any ev.get("<folded name>") or ev["<folded name>"], and tests/test_axiom_setup.py scans every shipped panel and monitor query for a folded name used as a column.

  2. Errors. Real failures go through emit_error(source=..., exc=..., recoverable=...) so error-triage groups them. Set recoverable honestly (user-impacting → False). At framework boundaries, emit the application exception rather than a generic transport wrapper. The global slash-command handler unwraps discord.py’s CommandInvokeError, so Axiom groups the real cause and the bot-log classifiers can recognize database and prompt failures.
  3. Silent / fail-open degradation is the trap. If the feature is fail-open (a miss → degraded-but-not-crashed, like embeddings → keyword fallback, X enrichment dying, a market source going dark), an ok=False raises NO exception, so error-triage never sees it. Wire it into the ops monitor’s integration-health rate tracking (an OkRate keyed by its dimension + an integration_unhealthy-style finding) so the silent degradation surfaces. This is the single most-missed step.

    “Keyed by its dimension” is the whole load-bearing part (#3040). One event kind that carries many independent reads must NOT share one rate, or a healthy sibling hides a dead feed. Measured on #3042: kworb cut its US YouTube page from 100 rows to 20, the yt_us_weekly read failed on every attempt for a full day, and the monitor said nothing — because chart_fetch was one bucket for every kworb page. Over that window the pooled rate read 4.8% failing (28/578) against a 60% gate, while the dead feed alone read 92% (24/26). The telemetry was already perfect; only the bucketing was wrong. _PER_SOURCE_MISC and _PER_QUERY_MISC in scripts/ops_monitor.py are where a kind declares which dimension splits it, and health_query_key collapses opaque per-entity ids (track_<id> → track) so the map stays bounded — 227 distinct chart_fetch query values over 7 days become 17 real feeds. chart_lookup is keyed per source since #4175 (genius / musicbrainz / omdb / tmdb), so a dead tmdb weekend-budget read (#4120) can raise integration_unhealthy.

  4. Document it. Add the event row to the table above + the events.py docstring, and (if it changes what the QA routine watches) update the ops monitor section. The scripts/ops_monitor.py deterministic core is pure and unit-tested — add a test for any new finding/health branch.

The litmus test: if this surface started silently failing in production at 3am, would a graph or a flagged finding show it by the next ops-monitor run? If not, it isn’t instrumented yet.

Field coverage is part of the feature too. When you add or change an outbound integration, hand the model every answer-valuable field the upstream returns (the data-minimization rule governs telemetry + her replies, NOT the in-context input), and make each surfaced field self-describing in its rendered block or tool description (a legend/label, so the model knows what the value IS). docs/INTEGRATIONS.md is the per-API coverage ledger: it lists, for every integration, the fields we surface and the fields we deliberately omit (with the reason). The rule is 100% of the answer-valuable fields, or an explicit entry there saying what’s missing and why — keep it in sync when you touch an integration.

Railway dashboard queries: filter logs for the EVENT prefix, then parse the JSON suffix. Typical queries: count of event=command per minute, p95 of duration_ms where event=claude_api, sum of output_tokens where purpose=ask for cost tracking.

Axiom time series render: counts are BARS, everything else is a LINE (owner steer). scripts/axiom_setup.py sets the variant per panel automatically: a TimeSeries panel renders as bars when EVERY summarize output column is a bare count() or countif(...); a p99 (or any sum, percentile, gauge, or computed rate) keeps the default line. The variant rides on query.queryOptions.aggChartOpts – a JSON map keyed by the column descriptor ({"alias":"count_","op":"count"} for a bare count) valued {"variant":"bars"}. _bar_variant_opts derives it from the APL, so a NEW count panel gets bars with no extra wiring; tests/test_axiom_setup.py pins the count/line split by panel id.

Ops monitor (the single QA interface, full detail)

scripts/ops_monitor.py + .github/workflows/ops-monitor.yml are the bot’s one automated QA routine, running twice daily (cron: 0 8,20 * * *, + manual dispatch). A deterministic pass pulls the last ~12h of Railway EVENT logs (GraphQL, via RAILWAY_API_TOKEN/RAILWAY_SERVICE_ID) and flags both halves of QA:

THE MONITOR READS ITS WINDOW IN SLICES, AND SAYS SO WHEN IT CANNOT (#3603). Until 2026-09-22 fetch_events_axiom ran ONE query carrying sort by _time asc | limit 65000. Two faults, both silent. The APL limit never bound: an Axiom matches response is capped at 50,000 rows whatever the query asks for (measured – limit 65000 and limit 150000 both return exactly 50,000). And the ASCENDING sort meant the 50,000 kept were the OLDEST, so the most recent hours of every window were discarded – measured 2026-09-22, a 12h window ending 06:19Z was read only to 00:42Z, losing 5h37m. The monitor exists to catch a surface that just went dark, which is precisely what sat in the part it threw away. The run still reported success.

The window is now read as AXIOM_WINDOW_SLICES (8) consecutive time slices, each its own query with its own 50,000-row budget, over ABSOLUTE boundaries resolved from one clock read (a now-Nm string is re-resolved per request, so each slice’s start drifted past the previous slice’s end by its fetch time and dropped ~25-30s of events into the gaps), each sorted DESCENDING (so a slice that does overflow drops its oldest rows, not its newest) and re-sorted to ascending at the end for LatencyStats.trend(). Measured effect on one live window: 50,028 events read before, 117,897 after, and 60 findings where the truncated read produced 53 – seven real findings had been invisible. A slice that still fills the cap inserts a synthetic _window_truncated row that aggregate reads into window_cut_at; evaluate turns it into a HIGH window_truncated finding and render puts a PARTIAL WINDOW banner above everything, because a truncated report cannot say a surface is quiet, only that it did not see it. Volume grew from 94,273 to 116,634 events per 12h window in a single day, so AXIOM_WINDOW_SLICES is the knob to raise; the durable fix is to push the per-source rollups into APL summarize so the monitor stops pulling raw rows.

Three robustness rules came out of testing this, each from an observed failure. A slice body runs to ~150MB and http.client.IncompleteRead killed the read twice, so _axiom_slice RETRIES – without it the whole run falls through to the Railway tail. That fallback is now MARKED with the same truncation marker, because the tail is ~800 events against ~120,000 and an unmarked report renders identically to a healthy one with every surface looking quiet. And AXIOM_TOTAL_ROW_CEILING caps the rows held across all slices: slicing removed the implicit ceiling the 50,000-row response cap used to give, and ~14MB per 2,000 parsed rows puts an unbounded window near a gigabyte resident, where the job is killed and reports nothing at all.

One cut slice no longer drops the whole run (#4359). Measured 2026-10-17 from a session: IncompleteRead cut 6 of 32 slice reads at 8 slices and 2 of 16 at 16 slices. The cut came after 47-88 MB and 3-5 s, while bodies up to 156 MB read clean, so more slices alone do not remove it. With one retry, about 1 run in 4 could lose a slice twice and fall back to Railway. Now _axiom_slice:

  1. reads each slice with AXIOM_SLICE_ATTEMPTS (4) tries through utils.retry, with a 2s/4s/8s backoff;
  2. reads a slice that fails every try again as two halves, and each half may split once more (AXIOM_SLICE_SPLITS = 2), each piece with its own tries;
  3. records a piece that still fails as a GAP and reads the rest of the window.

A gap goes on the truncation marker as gaps (a list of start..end ranges). aggregate reads it into window_gaps. evaluate adds a MEDIUM window_truncated finding that names each range, render adds a PARTIAL WINDOW banner, and the chart_capture_stale “no row” checks stay silent, as they do for a cut window. Only a run where EVERY piece fails raises, so a real Axiom outage still falls back to Railway and names the error.

The fallback ran on EVERY scheduled run from at least 2026-09-22 to 2026-10-16 (#4159). The workflow passes AXIOM_DATASET: $, that secret does not exist, so the variable was SET to “” and os.environ.get(name, "tootsies") returned “”. The query became [''], which Axiom refuses with 400 “can not build key, key missing or empty”. The reports read 0 to 8,268 Railway events in place of about 120,000. Two fixes:

The CI posting-gap query got 403 “forbidden” in the same runs. The session’s key, which is the same as the Railway service’s AXIOM_API_KEY, gets 400 for [''] and 200 for ['tootsies']; an invalid key gets 403 before the dataset is checked. So the AXIOM_API_KEY repository secret is probably not a key that can query the dataset.

Confirmed 2026-10-17. With the dataset fix on main, a manual run still got 403 on every query (run 38996637814, 3,894 Railway events). The secret held a different xaat- key. The owner pasted the Railway AXIOM_API_KEY value over the secret, and the next run read Axiom with no 403 (run 38996944596, 142,927 events). A malformed key (quoted, cut short, “Bearer” pasted in) gets 401 “token not supported”, not 403. So a 403 here means a well-formed key that Axiom does not accept. Overwrite the secret from Railway first.

The report also renders an unconditional Quality spot-check (recent posts + their self-gate scores per surface) so the judge eyeballs voice/quality even on an otherwise clean run — this is what makes it a quality eval, not just a regression alarm.

claude-code-action then judges the flagged samples and files deduped auto-eval issues (it searches open auto-eval issues first, so no spam; “All clear” files nothing). The deterministic core (parse/aggregate/evaluate/render) is pure and unit-tested in tests/test_ops_monitor.py; the Railway I/O is integration-only (pragma: no cover).

This replaces the standalone Railway log-monitor routine — error monitoring now lives here, one interface and one cadence. There is no separate log-monitor workflow.

deploy_failed / deploy_stale — the change that never landed (#2088)

The only ops-monitor finding that does NOT read the event stream, because the question it asks cannot be answered from there. Every other blackout check here reads what the bot EMITTED, so all of them assume the bot is running the code we think it is. A failed deploy breaks that assumption in the one way nothing else can see: main carries the change, Railway keeps serving the PREVIOUS build, and because that build works fine every surface looks healthy — no dark lane, no error, no latency blip. The change simply never happened and nothing said so. (_recent_deployment_ids was already asking Railway for status and discarding it.)

fetch_deploy_findings asks Railway what is actually running and compares it to the commit Actions checked out (GITHUB_SHA, which on the schedule is the default branch’s tip). A local run has no GITHUB_SHA, so it compares against origin/main (main_ref, #4175). It used git rev-parse HEAD until #4175, and a run from a feature branch then compared production against that branch and raised a false deploy_stale. Run git fetch origin main first, so origin/main is current. The commit age is read off the same commit. The pure core is deploy_health and is unit-tested.

finding fires when why it is separate
deploy_failed (high) the NEWEST deployment has status FAILED names the stage — no image digest means it died in the BUILD, which is a different fix from a crash or a failed healthcheck
deploy_stale (high) the live SUCCESS deployment’s commit is not main’s tip (GITHUB_SHA, else origin/main), and that commit is older than DEPLOY_STALE_AFTER_MIN (45m) catches a build failure Railway has already tidied away — the FAILED row can be gone while the old SUCCESS row still serves, so status alone reads clean. Only the COMMIT comparison sees it

Both fail OPEN: no Railway creds, a GraphQL error, or a git command that will not answer all return no findings. A deploy check that broke the ops run would be worse than the gap it closes. The 45m allowance exists because Railway builds in a few minutes — flagging a commit that landed seconds ago would make the check cry wolf twice a day.

Verified against live Railway both ways: silent when production served 005acd7 and main was 005acd7; fired with the correct detail when handed a main SHA production had not reached.

cover_fuzzy_high / cover_overrule_high — the cover exemption’s two health signals (#3520 / #3530)

The delivery media gate trusts a catalog_cover over the vision judge. That trust is earned per row since #3530: the row’s credit must verify exactly (apple_music.credit_verified, no act the request never named) and its core title must equal the request (apple_music.title_verified). A cover the picker resolved but could not verify that way reports catalog_cover_fuzzy and is judged by face. Two findings read that split:

scope_unnamed — the newsroom’s chart list narrowing on us (#2127)

The newsroom used to relay whatever chart a trusted account posted about. It was the only chart surface with no chart list: 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 read the ACCOUNT and the number cue only. Over the 30 days to 2026-08-07 its 135 chart stories named about 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”. The owner reviewed that list and cut most of it; music_news.KEPT_PATTERNS / CUT_PATTERNS is the reviewed scope, applied by _chart_in_scope right after classify so an out-of-scope story costs no verify and no compose.

What this finding watches is NOT the cut. A drop that names its family (family=uk_official) is the owner’s decision working, and it never raises anything, however often it fires. The gate fails CLOSED, so it also drops a story naming no chart it recognizes, as family=unnamed — and that bucket is a different thing entirely: the patterns stopped matching a chart we meant to KEEP.

That failure is invisible to every other check here. It raises no exception. It fails no integration, so no OkRate moves. The story dies at the inbound edge, before the compose and before the self-gate, so surface_dark and low_quality — which read judge scores — see a normal week with slightly fewer posts. The desk simply gets quieter, and the quieter it gets the more it looks like a slow news week.

The finding fires at medium when unnamed is at least CHART_SCOPE_UNNAMED_RATE (25%) of at least CHART_SCOPE_MIN_DROPS (4) scope drops in the window. The floor is far above the measured baseline: 1 of 135 stories before the gate shipped, and that one was an artifact of the 120-character preview the measurement read (the live gate reads the claim plus the whole source post, so it names the chart more often). The minimum count is low on purpose — the gate drops roughly one story a day, so a higher bar would never fire.

The fix is a row in KEPT_PATTERNS, not a threshold change. Re-measure against real posts with python -m scripts.dryrun_chart_filter (it reads the last 30 days of shipped chart stories out of Axiom and prints kept / cut / unnamed with the unnamed lines in full). Dashboard panel: desk_chart_scope on the Content Desks board.

Discord gateway health — the event loop blocked (Axiom monitor, not an ops-monitor finding)

discord.py sends a heartbeat to the gateway from the event loop. When sync code holds the loop for 10s or more, discord.py logs heartbeat blocked for more than 10 seconds with a traceback of the loop thread. Discord then often drops the session: the log shows Attempting a reconnect, then RESUMED. While the loop is blocked, the bot answers nothing, so users see lag.

These lines are forwarded log events (logger discord.gateway / discord.client), not EVENT lines. Nothing read them until 2026-09-28. Over the 30 days before that date:

Signal Count
heartbeat blocked 157
Attempting a reconnect 37
websocket is Ns behind 4
fresh logins (each one a deploy, checked against Railway) 463

The blocking frames were almost all two causes: Pillow card renders on the loop (render_number_card / render_board_portrait through build_market_card, build_number_card and build_board_card), and artist_watch.credited_names rebuilding its name index for every RIAA row. The fix moved the renders to asyncio.to_thread and caches the credit index per list pair.

The RIAA board came back on 2026-10-12 and 2026-10-16 (16 warnings in 7 days, #4291). Every traceback sat in RiaaClient.awards_for_act on the roster walk. Each act’s read is a cache hit, and an L1 hit never yields, so the walk parsed about 60 MB of cached pages and ran ~490k fold_tokens calls as one stretch on the loop. The fix parses every RIAA page read in asyncio.to_thread (riaa._off_loop) and memoizes fold_tokens. On the real cached pages the longest stretch went from 5.6s to 0.04s (local CPU).

['tootsies'] | where event == 'log' and logger == 'discord.gateway'
  and message contains 'heartbeat blocked' | sort by _time desc