TASK 49 is retired (creator ruling): YouTube owns chat moderation. Its blocked-words filter and hold-for-review run upstream of `liveChatMessages.list` — held messages are never returned (https://stackoverflow.com/questions/74511524/) — so a second local list would duplicate a switch the creator already owns, with a second set of false positives and a second support surface. What we actually owed the platform, and had wrong: - The parser added every item to the overlay. A deleted message arrives as a `tombstone` carrying no text at all ("it has since been deleted … only liveChatId, type and publishedAt are present" — https://developers.google.com/youtube/data/live/reference/rest/v3/liveChatMessages), so every deletion rendered as a BLANK chat row. `hasDisplayContent:false` messages and chat-mode events did the same. - Deletion events (`messageDeletedEvent`/`messageRetractedEvent`/`userBannedEvent`) are documented but have not been delivered to clients for years (https://stackoverflow.com/questions/44346893/), so nothing is built on them; they are skipped if they ever arrive. - Found while testing: Super Chat read `displayString`, but YouTube's field is `amountDisplayString` — every amount silently fell back to a recomposed string, which mangles non-dollar locales. The model's own comment said this was the reason the field exists. Test: `ChatModerationParseTests` — tombstone, no-display-content, chat-mode events all produce no row; a real Super Chat still parses its pre-rendered amount (constraint 4: reward fields are never reinterpreted). Docs: TASK 49 marked retired with the citations, the rule recorded in decisions.md, HANDOFF updated. verify.sh: 550 passed, 0 failed, 0 warnings.
6.3 KiB
TASK 49 — Chat profanity filter (local, opt-in, non-persistent)
Catalog:
TASKS.md— status and requirements live here. Research:TASKS/research-store-certification.md§12. Carved out 2026-09-27; shares a scope lock with nothing else.
Status: ❌ Retired 2026-11-05 — YouTube owns chat moderation (creator ruling). What replaced it:
Services/YouTubeChatService.cs no longer turns YouTube's own moderation markers into chat rows
(tombstone, hasDisplayContent:false, chat-mode events), with tests in
ytLive.Tests/ChatModerationParseTests.cs. The reasoning and citations are below and in
docs/ai/decisions.md; the body of this file is kept so the option is not re-derived from scratch.
Why retired
- YouTube's blocked words filter ("block live chat messages that contain or closely match certain
words") and hold for review both run upstream of the API: blocked/held messages are not returned
by
liveChatMessages.list(community evidence: held messages never appear — https://stackoverflow.com/questions/74511524/). Ours would be a second list, a second set of false positives and a second support surface for a switch the creator already owns. - Deleted messages arrive as a
tombstone— "signifies that a message used to exist … it has since been deleted … only liveChatId, type and publishedAt are present" (https://developers.google.com/youtube/data/live/reference/rest/v3/liveChatMessages). The parser had no filter, so a deletion rendered as a blank chat row. That is the actual defect, and it is fixed. - Deletion events (
messageDeletedEvent/messageRetractedEvent/userBannedEvent) are documented in the resource but have not been delivered to clients for years (https://stackoverflow.com/questions/44346893/), so no client-side moderation machinery can be built on them. The parser skips them if they ever do arrive.
The constraints below are therefore moot for us and binding on YouTube — except constraint 4, which is why the Super Chat parse path still asserts that reward fields are never reinterpreted.
Goal: let the creator decide what reaches the projected chat box, without the app ever becoming a moderator that stores what people said.
Size: S. Local string work, one insertion point, tests.
Why
YouTube's Community Guidelines carry a named Vulgar language policy under "Sensitive content", alongside nudity, child safety, and self-harm — so profanity is a policy surface, not an edge case. Three external reasons it earns a slot, plus the real one:
- YouTube treats it as policy-relevant, so 11.12's "proactive detection" language has something to attach to
- Competitive parity — overlay tools in this lane generally offer it
- It is a reasonable reading of 11.12's proactive-detection language
- The real reason: we control what gets projected on screen. That is the creator's call, not YouTube's.
1. ⛔ Four constraints — non-negotiable
They exist to protect the privacy story, not to complicate the build:
- Local and on-device only. No server, no telemetry of message content, nothing leaves
the machine. A hosted profanity API would invert the entire 11.12/10.5.1 argument in
research-store-certification.md§9 — do not add one. - Non-persistent. Filter at ingest, discard the raw text, keep only the display string. No message history, no file, no crash dump of chat payloads. This is the same property that makes the 11.12 certification answer strong.
- User-supplied word list, opt-in, stored locally. ⛔ Not a hardcoded slur list baked into the binary. That is an unpleasant artifact, generates false positives across dialects and languages, and becomes our problem the moment someone extracts it. A user-curated list is also simply more useful — streamers know their community's euphemisms and we do not.
- Match display text only. ⛔ Never the SuperChat amount or any reward-event field. Those are financial data under 10.5.5, and matching on them would be a real mistake.
2. ☐ Verify the insertion point first
The existing alert box already has a ticker and three display methods
(Controls/OverlayHost.xaml, ViewModels/MainViewModel.Chat.cs), so there is probably a
single insertion point rather than scattered call sites.
Do not assume. rg for the chat-message ingest path and confirm the count. If it is
more than one site, say so in HANDOFF.md before building.
3. ☐ Implementation
- ☐ Settings toggle: off by default (opt-in, per constraint 3)
- ☐ Editable word list in the settings dialog, stored locally via the existing
LayoutStore.Settingspattern — reuse it, do not invent a second store - ☐ Case-insensitive, whole-word matching with sensible boundary handling
- ☐ Smallest sensible replacement (
*or configurable) — do not build a masking engine - ☐ Filtered messages are displayed with the replacement, not dropped, so the chat does not develop visible holes. (Dropping is a separate opt-in if we ever want it.)
- ☐ Nothing logged. The existing
AppLogcheckpoints must not capture message text - ☐ Correctness over performance: a few hundred comparisons per message is nothing
- ☐ Non-ASCII and unicode case handling — a naive
ToLowerInvariantwill miss things - ☐ Ticker layout must survive longer replacement strings
4. ☐ Tests
Follow the existing test conventions (ytLive.Tests/, RuntimeSessionStateTests etc.):
- ☐ Off by default
- ☐ List matches case-insensitively
- ☐ Whole-word only — "class" must not be caught by "ass"
- ☐ Replacement renders, message not dropped
- ☐ Amount/reward fields are never matched or masked (constraint 4 — assert explicitly)
- ☐ Empty list behaves as "no filtering"
- ☐ Nothing persists to disk beyond the user's own list
- ☐ Unicode case folding
Related (not duplicated here)
task-48-distribution-msix.mditem 5 — the privacy policy must state "no retention of chat or reward payloads". This task is what makes that statement true.research-store-certification.md§9 — the 11.12 argument depends on the persist-nothing property in constraint 2. Do not let a future feature (chat history, moderation logs, analytics) quietly break that assumption.