# 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.**