feat(moderation): shame-tag community moderation prototype (LC-342) #359

Merged
nrupard merged 1 commit from feat/lc-342-shame-tags into main 2026-06-18 21:37:21 +02:00
Owner

What (prototype)

An opt-in, per-enclave "shame tag" community-moderation prototype.

  • Members of an enabled enclave flag a message with a moderation tag: spam / abusive / off-topic / misinformation (one vote per user per tag, toggle).
  • A message that passes 3 distinct in-window votes on a hide-worthy tag (spam/abusive) renders default-hidden behind a click-through ("Hidden - flagged as spam. Show anyway"). The other two tags annotate only.
  • Votes older than 30 days stop counting (aging).
  • A room manager can force-show / force-hide / clear; the override wins over the community decision.

Gating decision

The spec said "build/config flag". I refined that to a per-enclave shame_tags_enabled column (mirrors coyote_mode): it still dark-ships (opt-in per enclave), but it is testable and avoids touching every test's AppState literal. Off by default.

Implementation

  • Migration 0065: message_tags(PK message_id,tag,voter), message_tag_overrides(message_id PK), enclaves.shame_tags_enabled.
  • db::shame_tags: taxonomy/threshold/decay consts, toggle_vote, aged tag_counts, override-aware hidden_state + batch hidden_states_for_messages (one override query + one threshold query).
  • Routes: GET /messages/{id}/tags (lazy control), POST /messages/{id}/tags/{tag} (vote, room-access gated, validates tag), POST /messages/{id}/tag-override (manager gated).
  • Render: the room timeline batches hide-state in one query and renders the click-through wrapper; the hover-menu "Flag" popover lazy-loads the control on first open (toggle once), so list render emits no extra per-message request.
  • Settings toggle (manager-gated) + en/es i18n.

Findings / productionization notes (for the issue)

  • Hide-state is room-timeline only in this prototype. DM / thread / search render normally (they have separate MessageView builders); productionizing means threading the batch into those builders too.
  • Not live. A vote/override updates the popover in place, but other viewers' hidden wrapper refreshes on reload, not over WS. A follow-up would broadcast an OOB #msg-{id} re-render.
  • Anti-weaponization is minimal: any member can vote; the only backstop is the manager override. Brigading to hide legit content is possible until productionization adds rate/abuse limits.
  • Thresholds/taxonomy are code-side constants; revisit whether they should be per-enclave configurable.
  • DMs are excluded (no enclave); reasonable for community moderation.

Test

  • db_shame_tags.rs: vote toggle + counts, threshold (hide-worthy only), aging cutoff, override precedence (force-show/hide/clear), batch returns only hidden.
  • routes_shame_tags.rs: 404 when feature off, member vote when on + unknown-tag 400, override manager-gated (member 403 / manager 200).
  • LC-77 webhook/email golden fixtures regenerated (whitespace-only from the new body wrapper; no content change).
  • just check, just test, just test-saas all pass; en/es i18n parity holds.

LC-342.

## What (prototype) An opt-in, per-enclave "shame tag" community-moderation prototype. - Members of an enabled enclave **flag** a message with a moderation tag: `spam` / `abusive` / `off-topic` / `misinformation` (one vote per user per tag, toggle). - A message that passes **3 distinct in-window votes** on a **hide-worthy** tag (`spam`/`abusive`) renders **default-hidden behind a click-through** ("Hidden - flagged as spam. Show anyway"). The other two tags annotate only. - Votes older than **30 days** stop counting (aging). - A **room manager** can **force-show / force-hide / clear**; the override wins over the community decision. ## Gating decision The spec said "build/config flag". I refined that to a **per-enclave `shame_tags_enabled` column** (mirrors `coyote_mode`): it still dark-ships (opt-in per enclave), but it is testable and avoids touching every test's `AppState` literal. Off by default. ## Implementation - Migration `0065`: `message_tags(PK message_id,tag,voter)`, `message_tag_overrides(message_id PK)`, `enclaves.shame_tags_enabled`. - `db::shame_tags`: taxonomy/threshold/decay consts, `toggle_vote`, aged `tag_counts`, override-aware `hidden_state` + batch `hidden_states_for_messages` (one override query + one threshold query). - Routes: `GET /messages/{id}/tags` (lazy control), `POST /messages/{id}/tags/{tag}` (vote, room-access gated, validates tag), `POST /messages/{id}/tag-override` (manager gated). - Render: the room timeline batches hide-state in one query and renders the click-through wrapper; the hover-menu "Flag" popover lazy-loads the control on first open (`toggle once`), so list render emits no extra per-message request. - Settings toggle (manager-gated) + en/es i18n. ## Findings / productionization notes (for the issue) - **Hide-state is room-timeline only** in this prototype. DM / thread / search render normally (they have separate MessageView builders); productionizing means threading the batch into those builders too. - **Not live.** A vote/override updates the popover in place, but other viewers' hidden wrapper refreshes on reload, not over WS. A follow-up would broadcast an OOB `#msg-{id}` re-render. - **Anti-weaponization** is minimal: any member can vote; the only backstop is the manager override. Brigading to hide legit content is possible until productionization adds rate/abuse limits. - **Thresholds/taxonomy are code-side** constants; revisit whether they should be per-enclave configurable. - **DMs are excluded** (no enclave); reasonable for community moderation. ## Test - `db_shame_tags.rs`: vote toggle + counts, threshold (hide-worthy only), aging cutoff, override precedence (force-show/hide/clear), batch returns only hidden. - `routes_shame_tags.rs`: 404 when feature off, member vote when on + unknown-tag 400, override manager-gated (member 403 / manager 200). - LC-77 webhook/email golden fixtures regenerated (whitespace-only from the new body wrapper; no content change). - `just check`, `just test`, `just test-saas` all pass; en/es i18n parity holds. LC-342.
feat(moderation): shame-tag community moderation prototype (LC-342)
All checks were successful
check-secrets / Nosey parker (push) Successful in 3s
check-secrets / Kingfisher (push) Successful in 4s
check-secrets / TruffleHog (push) Successful in 5s
check-secrets / Nosey parker (pull_request) Successful in 3s
check-secrets / Kingfisher (pull_request) Successful in 4s
check-secrets / TruffleHog (pull_request) Successful in 5s
Check / clippy + fmt + tests (pull_request) Successful in 2m16s
Create release / Create release from merged PR (pull_request) Has been skipped
866539fb85
Opt-in per-enclave prototype (enclaves.shame_tags_enabled, dark-ships; gate refined from the spec's build flag to a per-enclave column so it is testable and avoids AppState-construction drift). Members of an enabled enclave flag a message with a moderation tag (spam / abusive / off-topic / misinformation, one vote per user per tag); once a hide-worthy tag (spam / abusive) passes 3 distinct in-window votes the message renders default-hidden behind a click-through. Votes older than 30 days stop counting. A room manager can force-show / force-hide / clear, which wins over the community decision and is the one absolute control.

Migration 0065: message_tags (PK message,tag,voter), message_tag_overrides, enclaves.shame_tags_enabled. db::shame_tags holds the taxonomy/threshold/decay constants, vote toggle, aged counts, override-aware hidden-state (single + batch). Routes: GET /messages/{id}/tags (lazy-loaded control), POST /messages/{id}/tags/{tag} (toggle vote, room-access gated), POST /messages/{id}/tag-override (manager gated). The room timeline batches hidden-state in one query and renders the click-through; the hover-menu "Flag" popover lazy-loads the control on open so list render emits no extra per-message request. Settings toggle + en/es i18n.

Prototype scope: hide-state renders on the room timeline only (DM / thread / search show normally); votes/overrides update the popover in place but the message's hidden wrapper refreshes on reload, not live; taxonomy/threshold/decay are code-side. Findings + productionization notes on the issue.

#LC-342

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nrupard deleted branch feat/lc-342-shame-tags 2026-06-18 21:37:21 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!359
No description provided.