# TASK 49 — Chat profanity filter (local, opt-in, non-persistent) > Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here. > **Research: [`TASKS/research-store-certification.md`](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 — ). 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" (). 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 (), 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: 1. YouTube treats it as policy-relevant, so 11.12's "proactive detection" language has something to attach to 2. Competitive parity — overlay tools in this lane generally offer it 3. It is a reasonable reading of 11.12's proactive-detection language 4. **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: 1. **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. 2. **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. 3. **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. 4. **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.Settings` pattern — 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 `AppLog` checkpoints must not capture message text - ☐ Correctness over performance: a few hundred comparisons per message is nothing - ☐ Non-ASCII and unicode case handling — a naive `ToLowerInvariant` will 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.md`](task-48-distribution-msix.md) item 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.**