VahterBanBot: temporary vetted-user protection after ham mark (demote auto-delete to report-only, flag off) - #397
Merged
Merged
Conversation
… auto-delete to report-only, flag off) After a vahter (or /vahter unmarkspam) reverses an auto-deletion as a false positive, the author gets a time-limited protection window during which would-be ML/LLM/content-filter auto-deletions are demoted to the existing report-only path (Potential Spam card) instead of deleted, tagged "protected user" for moderators. Never a full ML skip: vahters still see every message, a demotion budget caps abuse, and one KILL click bans as usual. Off by default (SPAM_PROTECTION_ENABLED=false). Claude-Session: https://claude.ai/code/session_01Wi7gKmshkHfVtSB3tA4gmJ Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ests SpamTextCacheEnforceTestContainers is an assembly-shared fixture (Program.fs's AssemblyFixture attribute) — one database shared by every test class that takes it, including SpamTextCacheTests' own karma-autoban test. SpamProtectionCarveOutTests was ham-marking the fixed, reused literal "2222222" to grant protection to a test user; DB.fs's GetUserStatsByLastNMessages matches MessageMarkedHam.text GLOBALLY (not scoped to chat/user), so that ham mark silently made every "2222222" message from any user (including SpamTextCacheTests' unrelated autoBanSpammer) count as ham, suppressing the karma "bad" count and preventing autoban — reproduced deterministically both locally and in CI (run 32245300313). Root cause confirmed via the bot container's dumped app.log (test-artifacts/.../ SpamTextCacheEnforceTestContainers/bot.log): a "marked message ... as false-positive (NOT A SPAM)\n2222222" line written by the carve-out test, followed by four "Deleted spam" lines for a different user's "2222222" messages with no "Auto-banned" line and no exception anywhere in the log. Fix: grant protection via direct event injection (GrantSpamProtection) instead of a real ham-mark round trip, so the carve-out test never writes a MessageMarkedHam event for any shared/reused literal. Verified: SpamProtectionCarveOutTests + SpamTextCacheEnforceTests.Startup rehydration together (2/2 passed), full SpamTextCacheEnforceTests (9/9 passed), full SpamProtectionTests (10/10 passed). Claude-Session: https://claude.ai/code/session_01Wi7gKmshkHfVtSB3tA4gmJ Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
# Conflicts: # src/VahterBanBot/Program.fs # src/VahterBanBot/Types.fs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
When a vahter marks an auto-deleted message as NOT spam (ham), the ML model doesn't change until retraining, so the same user's next messages keep getting auto-deleted (prod data: 10.5% of vetted users get wrongly re-deleted within 72h). This PR grants a time-limited protection window after a ham mark on an auto-deleted message: during the window, would-be auto-deletions are DEMOTED to the existing report-only path (Potential Spam card with SPAM/KILL buttons) instead of deleted. Never a full ML skip — vahters keep seeing every message, and a wrongly-vetted spammer (~3% of vets) is one KILL click away from a total ban.
Locked design rules
MessageMarkedHamfor a message that actually has aBotAutoDeletedevent on its moderation stream — both entry points covered (the "✅ NOT a spam" button and/vahter unmarkspam).until = now + SPAM_PROTECTION_HOURS(default 48h); a new ham mark refreshes the window and resets the hit budget. Grant is event-sourced (SpamProtectionGrantedon theuser:{userId}stream), mirroringReactionTriageNotSpamSet.DeleteSpamwould fire with reasonMlSpam/LlmSpam/ContentFilterSpam, an active protection window swaps it forReportPotentialSpaminstead, tags the card "protected user" (moderator-visible only — never shown to the user, never reveals a shield exists), and appendsSpamProtectionConsumed(the budget counter).SpamTextCacheHit,InvisibleMention,ReactionSpamdeletions and already-banned users are NEVER demoted.SPAM_PROTECTION_MAX_HITS(default 5) revokes (reason="budget") and lets that message delete normally. Explicit revocation on vahter KILL / soft-spam button (reason="killed"),/vahter markspam(reason="markspam"), and a single central hook insideTotalBancovering manual/ban,BanOnReply, and any ML/LLM/Bot autoban (reason="banned", also used by/sbansince it doesn't route throughTotalBan). All revokes are no-ops (metric-silent) when there's nothing active to revoke.SPAM_PROTECTION_NOTIFY_ENABLED, default false): best-effort ephemeral to the vetted user, sameCallIgnorepattern as PR VahterBanBot: ephemeral warning on spam auto-deletion (flag off by default) #395'sSPAM_WARNING_*. Fixed bilingual text, no variables, never reveals relaxed/time-boxed enforcement.Migration
src/vahter-bot/migrations/V43__spam_protection_snapshot.sql— schema-only, addsspam_protection_until/spam_protection_hitsGENERATED columns tosnapshot_user(same style as V38), plus a partial index. No settings are seeded by this migration.Enable SQL (hand-run only — settings are never Flyway-seeded)
After running,
POST /reload-settingsto pick it up without a redeploy.Tests (
tests/VahterBanBot.Tests/SpamProtectionTests.fs)/vahter unmarkspam), flag ON → grant recorded + snapshot updated; flag OFF → no grant, behavior identical to today end-to-end.BotAutoDeleted, Potential Spam card tagged "protected user",SpamProtectionConsumedappended.SpamProtectionRevoked(reason=budget)+ normal deletion, PR VahterBanBot: ephemeral warning on spam auto-deletion (flag off by default) #395's ephemeral warning still fires on that deletion.untilin the past → normal deletion.reason=killed) + total ban proceeds as today.receiver_user_id= vetted user and the exact configured text; OFF → no send.Filtered local run (
--filter FullyQualifiedName~SpamProtectionGrantTests|FullyQualifiedName~SpamProtectionDemotionTests) after fixing two test bugs (an emoji round-tripping through JSON escaping in a raw-bodyContainscheck, and a stale-fake-call cross-test pollution in the/vahter unmarkspamgrant test): both now pass. An earlier full-suite run (268 tests) showed 265 passed / 3 failed — the 2 in this PR's own new tests (now fixed) plus one pre-existingSpamTextCacheTestskarma-autoban test that failed amid heavy concurrent Testcontainers/podman churn from another agent's simultaneous full-suite run on this box; root cause not yet isolated as environmental vs. regression. Per explicit owner instruction, the full local suite gate was deferred to CI for this PR rather than re-run locally against that resource contention — CI will be the authoritative signal;dotnet buildis clean for both touched projects.🤖 Generated with Claude Code
Claude-Session: https://claude.ai/code/session_01Wi7gKmshkHfVtSB3tA4gmJ