Moderation: Reports, Cases, and Notices โ
Status: mostly BUILT. The report -> case -> notices pipeline shipped 2026-06-29 (P0-P4); this doc is the design of record that shaped it. The one still-unbuilt piece is the in-portal appeal flow (ยง6.1 of SEALED_BAN_RECONCILIATION_SPEC.md) - appeals are still the
appeals@ourlantern.appemail path. It complements two existing docs and does not duplicate them:
SAFETY_MECHANICS.md- the broad safety design (block, platform bans, SOS, safe spaces).docs/privacy/SEALED_ACCOUNT_BAN_FLOW.md- the sealed-identity two-control ban (disable the userId, ban the number) that a moderation action ultimately triggers.Scope here: the report -> case -> action workflow, the moderator notices channel, escalation, and the case lifecycle. The moderator-facing UI in
apps/admin/src/admin/moderation/now runs off realuserReports(no longer mock). The user-facing surfaces were designed Storybook first (see Build plan) and are shipped.
1. What exists vs. what this designs โ
| Piece | State |
|---|---|
| Block (user-to-user, private) | Built. users/{uid}/blocks/{blockedUid} via blockService.js; bidirectional invisibility enforced server-side in block.service.js. Multi-block pattern detection is stubbed (blockAggregate). |
| Sealed two-control ban (backend) | Built. POST /auth/moderation/ban (disable userId), /ban-phone (number), /unban (by userId), /unban-phone (lift a standalone number ban with no user attached) in moderation.js. |
| Admin Moderation UI | Built (2026-06-29). Case list + drawer + lifecycle run off REAL userReports (caseService), not mock. The Manual ban view + case Ban call the Express moderation routes via moderationApi.js. |
| Admin Users-page Ban | Built + repointed. Now calls POST /auth/moderation/ban (sealed-aware, blocks re-registration) instead of the legacy banUser Cloud Function (which only disabled Auth login). |
Report intake + userReports | Built (2026-06-29). Client-write intake (reportService.js submitReport, live in Chat.jsx) gated by the userReports rules; evidence to a locked Storage path. |
| Cases (aggregation + lifecycle) | Built (2026-06-29). userReports grouped by reportedUserId + moderationCases state (caseService.js): claim/release, escalate, priority, dismiss; status denormalized back onto the reports. |
| Moderator notices (messaging) | Built (2026-06-29). Server POST /auth/moderation/notice writes per-recipient notices docs (+ FCM push) + adminActions; the user reads them via the in-app inbox (noticesService / ModerationNoticeModal). |
| Escalation | Built (2026-06-29). Priority derived from severity + report volume + a manual escalate toggle; queue sorted by priority. (blockAggregate many-independent-blocks signal still stubbed.) |
The throughline: block, direct bans, AND the report -> case -> notices pipeline are all built (P0-P4, 2026-06-29); the sections below are the DESIGN that shaped them. The one still-unbuilt piece is the in-portal appeal flow (ยง6.1): appeals still use the appeals@ourlantern.app email path, not the OTP-proven, number-blind, one-click-reinstate flow. (This doc reads as design because it predates the build; the model still holds.)
2. Report vs. Block (the core distinction) โ
These are two different controls that are easy to conflate. They are complementary, not alternatives.
| Block | Report | |
|---|---|---|
| Who acts | A user, for themselves | A user, flagging for staff |
| Visibility | Private to the blocker | Surfaced to moderators (a case) |
| Effect | Immediate bidirectional invisibility | None on its own; triggers review |
| Staff involvement | None | Yes (review, possible ban) |
| Mental model | "I don't want to interact with them" | "They broke the rules; someone should look" |
| Reversible by the user | Yes (unblock) | No (it is a report on record) |
| Carries evidence | A private reason note (never sent anywhere) | User-submitted evidence sent to staff |
Key considerations:
- They stack. A user can block and report, block only, or report only. Blocking is the immediate self-protection; reporting is the escalation. The UI should make both available without forcing one.
- E2EE makes the reporter the source of truth. Because chat is end-to-end encrypted, the server cannot read the conversation to gather evidence. A report therefore carries the reporter's own description/context (and optionally screenshots they choose to attach). See chat retention + E2EE moderation model. Admins act on user-submitted evidence, not server-read content.
- The private block-reason is not a report. Today the block flow collects an optional reason and stores it only in the blocker's private subcollection (
blockService.js). That is intentionally private; it must not be silently turned into an admin-visible report. Reporting is an explicit, separate, consenting act. - Volume of blocks is a signal, not a report. Many users independently blocking one account is a soft signal (the stubbed
blockAggregate). It can feed escalation/prioritization, but it is not the same as a filed report.
3. The case model โ
A case is the unit moderators work. It is the aggregation of all reports filed against one reportedUserId (the correlation field). One reported account = one case, with N reports attached.
Lifecycle: open -> reviewing -> actioned | dismissed.
open- in the queue, no moderator has picked it up.reviewing- a moderator has claimed it (owns it). See section 7.actioned- a ban/restriction was applied.dismissed- reviewed, no violation.- Reinstating a banned account returns the case to
reviewing.
Proposed userReports schema (one doc per report, modeled on the existing venueClosureReports pattern):
userReports/{reportId}
reportedUserId string // correlation key; the account being reported
reportedBy string // reporter userId (Lantern identity, not PII)
category string // harassment | spam | impersonation | threats | other
reason string // user-submitted description (evidence)
evidence? string[] // optional user-attached references
status string // open | reviewing | actioned | dismissed (case-level, denormalized)
createdAt Timestamp
reviewedBy? string // moderator userId once claimed
reviewNotes? stringCases are derived by grouping userReports on reportedUserId (or materialized into a moderationCases doc if we want per-case status/assignment/notes/activity to live in one place - decide at build time). Firestore rules: create if reportedBy == auth.uid; read only by isAdmin(); admin-only status updates; no delete (audit trail) - mirroring venueClosureReports.
4. Functionality A - Reporting (intake) โ
The buildable user-facing entry point.
- Action: a "Report" affordance in the app (in the same surface as Block, or a combined "Block and report"). Opens a small flow: pick a category, write a description (the evidence), optionally also block.
- Write: a
userReportsdoc keyed byreportedUserId. - Read: the admin Moderation page queries
userReports, groups byreportedUserId, and renders cases. - Identity: everyone is shown to moderators by Lantern name + userId only (no PII), consistent with the sealed-identity model. The reported account's userId is the key; the reporter's Lantern identity is recorded for follow-up notices.
5. Functionality B - Moderator notices (messaging) โ
"Can we message the reporter and the reported user?" Yes, with a hard E2EE boundary.
- Boundary: admins cannot enter the E2EE conversation (cannot read or write user chats). Notices are one-way system messages, separate from chat.
- To the reporter: acknowledgement and outcome ("we reviewed your report; action was taken").
- To the reported user: a warning, a restriction notice, or a ban notice with the appeal address (
appeals@ourlantern.app, already used by the ban gates). - Channel: a transactional notices mechanism (push + an in-app "notices" inbox). Decide whether to reuse existing push or add a dedicated channel at build time.
- Tone/templates: predefined, neutral templates with a free-text field; logged to the case activity and the
adminActionsaudit trail.
6. Functionality C - Escalation โ
Not built; the levers:
- Volume. N reports against the same account auto-flags/raises priority. The prototype already aggregates reports per
reportedUserId, so "3+ reports -> flag" (theSAFETY_MECHANICS.mdheuristic) is a small step. The stubbedblockAggregate(many independent blocks) is a secondary soft signal. - Severity. Certain categories (threats, safety) jump the queue immediately, independent of volume.
- Manual. A moderator escalates a case to a senior/admin.
- Mechanics. Either an
escalatedflag/priority field on the case, or queue ordering by (severity, report count, age). Keep it simple first: a priority derived from severity + count, plus a manual escalate button.
7. Open vs. Reviewing - the toggle โ
reviewing should mean a specific moderator owns the case, not just "someone glanced."
- Prototype today: auto-flips
open -> reviewingon open. Fine for a solo moderator; it claims a case just by looking. - Target: an explicit "Claim" / "Start review" action, ideally with assignment to a moderator.
reviewing= claimed by @moderator. This prevents two moderators working the same case and gives accountability. Swapping the auto-flip for a claim button is a one-line change in the prototype.
8. Privacy and sealed-identity integration โ
- No PII. Reporters and reported users are Lantern name + userId only. No real names, emails, or phone numbers in the case UI or
userReports. - E2EE. Evidence is user-submitted (section 2); the server never reads chats.
- Ban integration. When a moderator bans from a case, it drives the two-control sealed ban: disable the userId (login) and, when the number is available, ban the phoneHash (re-registration) - or capture the number at the banned user's next login. The case never stores a phone-to-userId link. See
SEALED_ACCOUNT_BAN_FLOW.md. The admin Users-page Ban and the Moderation Manual ban view already call this backend viamoderationApi.js(POST /auth/moderation/ban|/ban-phone); the case-driven ban will reuse the same client. - Reporter exposure. The reporter's Lantern identity is visible to moderators (for follow-up) but never disclosed to the reported user in a notice.
9. Build plan - Storybook-first for user-facing, Admin direct โ
Two tracks, deliberately different:
- Admin (moderator UI): keep iterating directly in
apps/adminagainst mock data, then wire touserReports/moderation.jswhen the backend lands. It is an internal tool; live iteration is fine. - User-facing surfaces: design in Storybook first, design-review, then integrate into the app. These touch every user, so they get the design gate. Surfaces to prototype as stories:
- Report a user - category + description + optional "also block" (sheet/modal).
- Block vs. Report - how the two relate at the point of action; relative to the existing
BlockConfirmationModal(which already has a story). - Moderation notice - the in-app notice a user receives (warning / outcome / ban + appeal).
Sequence: Storybook design play -> approve -> build userReports + rules + notices backend -> wire the Admin page off mock data -> integrate the user-facing surfaces.
Issues to file: (a) report intake + userReports collection + rules; (b) moderator notices channel; (c) escalation rules; (d) claim/assign for the case lifecycle.
10. Open questions / decisions needed โ
- Claim vs. auto-on-open for
reviewing(section 7). Recommend explicit claim + assignment. - Block + Report combined, or separate actions? Recommend separate, with an optional "also block" checkbox in the report flow (keeps the private block and the consenting report distinct).
- Auto-flag threshold - is 3 reports the escalation trigger? Severity overrides?
- Notices channel - reuse existing push, or a dedicated notices inbox?
- Case storage - derive cases from
userReportson the fly, or materialize amoderationCasesdoc for per-case status/assignment/notes/activity? - Reporter anonymity - confirmed never shown to the reported user; confirm retention of
reportedByfor repeat-reporter abuse detection.