Skip to content

VahterBanBot: temporary vetted-user protection after ham mark (demote auto-delete to report-only, flag off) - #397

Merged
Szer merged 3 commits into
mainfrom
vahter-spam-protection
Aug 19, 2026
Merged

VahterBanBot: temporary vetted-user protection after ham mark (demote auto-delete to report-only, flag off)#397
Szer merged 3 commits into
mainfrom
vahter-spam-protection

Conversation

@Szer

@Szer Szer commented Aug 19, 2026

Copy link
Copy Markdown
Owner

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

  • Grant: on MessageMarkedHam for a message that actually has a BotAutoDeleted event 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 (SpamProtectionGranted on the user:{userId} stream), mirroring ReactionTriageNotSpamSet.
  • Demote, never skip: wherever DeleteSpam would fire with reason MlSpam/LlmSpam/ContentFilterSpam, an active protection window swaps it for ReportPotentialSpam instead, tags the card "protected user" (moderator-visible only — never shown to the user, never reveals a shield exists), and appends SpamProtectionConsumed (the budget counter).
  • Carve-outs, untouched: SpamTextCacheHit, InvisibleMention, ReactionSpam deletions and already-banned users are NEVER demoted.
  • Budget & revocation: exceeding 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 inside TotalBan covering manual /ban, BanOnReply, and any ML/LLM/Bot autoban (reason="banned", also used by /sban since it doesn't route through TotalBan). All revokes are no-ops (metric-silent) when there's nothing active to revoke.
  • Grant-time notification (separate flag, SPAM_PROTECTION_NOTIFY_ENABLED, default false): best-effort ephemeral to the vetted user, same CallIgnore pattern as PR VahterBanBot: ephemeral warning on spam auto-deletion (flag off by default) #395's SPAM_WARNING_*. Fixed bilingual text, no variables, never reveals relaxed/time-boxed enforcement.

Migration

src/vahter-bot/migrations/V43__spam_protection_snapshot.sql — schema-only, adds spam_protection_until / spam_protection_hits GENERATED columns to snapshot_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)

INSERT INTO bot_setting (key, value, type, feature_group, description) VALUES
    ('SPAM_PROTECTION_ENABLED', 'true', 'FEATURE_FLAG', 'SPAM_PROTECTION', 'Master flag: grant a temporary protection window after a ham mark on an auto-deleted message, and demote would-be MlSpam/LlmSpam/ContentFilterSpam deletions to report-only for protected users.'),
    ('SPAM_PROTECTION_HOURS', '48', 'FREE_FORM', 'SPAM_PROTECTION', 'Protection window length in hours from grant time.'),
    ('SPAM_PROTECTION_MAX_HITS', '5', 'FREE_FORM', 'SPAM_PROTECTION', 'Demotions allowed per grant before the window auto-revokes (reason=budget) and normal deletion resumes.'),
    ('SPAM_PROTECTION_NOTIFY_ENABLED', 'false', 'FEATURE_FLAG', 'SPAM_PROTECTION', 'Send a best-effort ephemeral to the vetted user at grant time.'),
    ('SPAM_PROTECTION_NOTIFY_TEXT', NULL, 'FREE_FORM', 'SPAM_PROTECTION', 'Grant-time ephemeral text (NULL = use the bilingual code default).')
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, type = EXCLUDED.type, feature_group = EXCLUDED.feature_group, description = EXCLUDED.description;

After running, POST /reload-settings to pick it up without a redeploy.

Tests (tests/VahterBanBot.Tests/SpamProtectionTests.fs)

  1. Ham mark on an auto-deleted message, both entry points (NotASpam button + /vahter unmarkspam), flag ON → grant recorded + snapshot updated; flag OFF → no grant, behavior identical to today end-to-end.
  2. Protected user + ML spam verdict → no delete, no BotAutoDeleted, Potential Spam card tagged "protected user", SpamProtectionConsumed appended.
  3. Budget exhaustion → 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.
  4. Carve-out: protected user + spam-text-cache hit → normal deletion, no demotion.
  5. Expiry: grant with until in the past → normal deletion.
  6. KILL on a protected user's demoted card → revocation (reason=killed) + total ban proceeds as today.
  7. Notify flag ON → ephemeral with 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-body Contains check, and a stale-fake-call cross-test pollution in the /vahter unmarkspam grant 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-existing SpamTextCacheTests karma-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 build is clean for both touched projects.

🤖 Generated with Claude Code

Claude-Session: https://claude.ai/code/session_01Wi7gKmshkHfVtSB3tA4gmJ

Szer and others added 3 commits August 19, 2026 11:58
… 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
@Szer
Szer marked this pull request as ready for review August 19, 2026 12:57
@Szer
Szer merged commit 1202ad5 into main Aug 19, 2026
4 checks passed
@Szer
Szer deleted the vahter-spam-protection branch August 19, 2026 13:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant