# Report, block and hide — user safety on the website and in the app **Status:** plan of record, 2026-10-29. Steps 2 (core), 3 (Module-Rust, Integration Kit) and 4 (Android) built 2026-10-30, and step 5's walk done the same day on a local copy of the demo (§7); step 6 not yet. Each phase stops for the org lead before the next one starts. Built after account deletion ([`ACCOUNT_DELETION.md`](ACCOUNT_DELETION.md)), which is why it is `MODULE_API` 1.21.0. §4 was amended by S11-S14 (§5) when the core was built. **Why now.** Google Play's content-rating questionnaire for the Android app (`android/PLAN.md` M8) was answered "users or user-generated content can be reported". The app shows other players' chat (the Rust live feed) and has no way to report it or to stop seeing someone. Play's user-generated content policy expects both wherever that answer is given. The org lead chose to make the answer true rather than retake the questionnaire (S1). ## 1. What a member can do | Action | Who | Where | Effect | |---|---|---|---| | **Report** | any signed-in member | a chat line (web + app), a forum thread, post or upload (web; app later, §6) | a row in the existing **Reports** queue (`TEAMS.md` §5.6). Changes nothing on its own | | **Hide for me** | any signed-in member | the same items, except an upload (it is shown inside a post, so hiding the post hides it) | that one item disappears from **their own** view, on every device. No report is filed | | **Block** | any signed-in member | the author of any of those items, unless the author is staff (S8) | everything that author writes (chat lines and forum content) disappears from the blocker's view, on the website and in the app, on every device. Undone from *Account → Blocked* | | **Hide for everyone** | staff (and, for their own forum, Team leaders, as today) | the Reports queue, the forum's existing moderation menu, and the item's own menu | the item disappears for everybody except staff, who see it with a *Hidden by staff* marker and can undo it | A report and a hide are separate on purpose, as `TEAMS.md` §5.6 already says: filing a report must never be a way to hide something. *Hide for me* is the member's own tool, and *Hide for everyone* is staff's. ## 2. Decisions (org lead, 2026-10-29) | # | Question | Answer | |---|---|---| | S1 | The questionnaire says chat is reportable, and it is not. Retake it, drop chat from the app, or add report/block? | **Add report, block and hide.** The questionnaire stays as answered | | S2 | Where does a block live? | **On the account, everywhere.** Saved on the site against the blocker's account, so it applies on the website and in the app, on every device | | S3 | "Hide": who hides, and from whom? | **Both.** A member can hide an item for themself, and staff can hide it for everyone | | S4 | The app has no Teams or forum screens. Does the app get the forum? | **Plan it** (§6, `android/PLAN.md` M21). In the app, report/block/hide go on chat lines now | | S5 | When does the Android `edge` → `main` cutover happen? | **After this feature ships**, so the first Play bundle already has it | ## 3. What exists today - **Reports.** `content_reports` (`TEAMS.md` §5.6) is generic by design, but `contentReports.model.js` accepts only `team_forum_thread`, `team_forum_post` and `team_forum_upload`, and finds the owning Team for each. Filing is `POST /player/teams/:slug/forum/report`. The queue is `GET /admin/moderation/reports`, and `POST /admin/moderation/reports/:id/handle` claims, actions or dismisses a report. "Actioned" only records a decision; it does nothing to the content. - **Staff hide in the forum.** `team_forum_moderation` already records `hide`/`unhide` (and pin, lock, delete) by a leader or staff, through `POST …/forum/threads/:id/moderate` and `…/posts/:id/moderate`. - **Chat lines.** Each one is a `rust_events` row in Module-Rust (`kind = 'player.chat'`, with an `id`, `server_id`, `steam_id`, time and raw frame). The website and the app both read them from the public `GET /public/rust/servers/:id/events`. The app polls it signed out or in. That route is already read **per viewer**: it answers `Cache-Control: private, no-store` and varies on `Cookie` and `Authorization`, and the app sends its bearer token on public calls. (The first version of this plan said the feed was shared and cached. It is not, which is S11.) - **No blocks or per-member hides** exist anywhere. ## 4. Design (§5 holds the decisions that shape it) ### 4.1 Core (`website`) - **Module-owned report targets** (`MODULE_API` **1.21.0**; the normative text is [`MODULE_API.md`](MODULE_API.md)). `api.registerReportTargets([{ id, label, snapshot, hide, unhide }])` declares the types a module's content can be reported as (for Rust: `rust.chat`). `snapshot({ targetId, viewer })` answers what the item is **for that viewer** (author, text, time, where it was said), or null when it is gone or the viewer may not see it. Core answers 404 either way, so a report or a block can never confirm an item the viewer could not have seen. `hide`/`unhide` are staff's hide for everyone. Core files the report, stores the snapshot and runs the queue; the module owns the content and its hidden flag. Core's forum types are core's own and are not registered. The Integration Kit re-pins and gains a paragraph. - **Game identities** (1.21.0, S12, S14). `api.registerGameIdentities([{ kind, label, ownersOf }])` names the identity a module's authors are known by (Rust: `steam`). `ownersOf(ids)` answers which website account each is linked to, through the module's own links. - **One filing route for every target**: `POST /player/reports {targetType, targetId, reason, detail?}`. Forum types keep working through the existing forum route, which calls the same model. One resolver (`model/safety/targets.js`) answers "what is this item, and may this reader see it" for every type, so the route needs no Team. - **`content_reports.snapshot JSON`**: what the item said when it was reported. Chat lines are pruned with the rest of `rust_events`, and a queue row must still show what was said after that. Forum reports fill it too, so an author editing a post after the report cannot change what staff read. A module's snapshot is clipped to known fields before it is stored. - **The queue gains *Hide for everyone*** (`POST /admin/moderation/reports/:id/hide`), a separate button beside the status buttons. It hides the item through its own moderation: the forum's hide (a `staff` row in the forum's ledger) or the module's `hide`. The report becomes `actioned` with `content_reports.actioned_with = 'hide'`, and `activity_log` records `moderation.report.hide`. An upload cannot be hidden from here; hiding the post it is in is the act. - **Blocks**: `user_blocks`. A row is either an **account block** (`blocked_user_id`) or a **game block** (`module_id`, `identity_kind`, `external_id`), with a `display_name` snapshot. A block always lands on the author's **account** when there is one (S12): a forum author, and a chat speaker whose identity is linked. Only an unlinked identity is blocked as itself. A game block whose identity is later linked is rewritten as an account block the next time it is read (S14). A block whose target is, or later becomes, staff stops applying and says so (S8). - **A block names an item, never a person**: `POST /auth/me/blocks {targetType, targetId}`. The server reads who wrote it through the same resolver a report uses. - **Per-member hides**: `user_hidden_items (user_id, target_type, target_id, created_at)`. Nothing checks the item exists, so a hide cannot be used to ask whether a thread id exists in a private forum. - **Routes**: `GET/POST /auth/me/blocks`, `DELETE /auth/me/blocks/:id`, `GET/POST/DELETE /auth/me/hidden`, `DELETE /auth/me/hidden/:targetType/:targetId`. OpenAPI regenerated. - **Filtering happens on the server, for every reader-specific read** (S11). Signed-in forum reads drop blocked authors and hidden items, and a thread the reader hid or whose author they block answers 404. A module filters its own per-viewer feeds with **`ctx.safety.forViewer(userId)`**: the reader's account blocks (S14 already applied, staff dropped), this module's own unlinked game blocks, and the reader's hides on this module's targets. **`ctx.safety.staffAmong(userIds)`** answers which accounts are staff, for a module that marks lines `canBlock` (S13). Neither the website nor the app holds a list of its own; each refetches after a block or a hide. - **Web UI.** The forum's thread and post menus gain *Hide for me* and *Block author*, beside the existing *Report*. *Block author* is offered only where the read says `canBlock` (never your own, a deleted account's or a staff member's), and asks to confirm. A new *Blocked* section on the Account page (both `/account` and `/admin/account`) lists blocks with *Unblock*, says when a block no longer applies because its target is now staff, and offers *Show them again* for hidden items. The Reports queue shows a module item from its snapshot. ### 4.2 Module-Rust All of it is in `server/safety.js`, beside the events route that uses it. - `registerReportTargets([{ id: 'rust.chat', … }])` with `snapshot` (the speaker's name and Steam ID, the line, server, channel, wipe, time, and the linked account if there is one; null when the viewer may not see presence on that server, or the line is hidden for everyone and the viewer is not staff) and `hide`/`unhide`. `registerGameIdentities([{ kind: 'steam', … }])` with `ownersOf` reading `rust_account_links`. A `rust.chat` id is a `rust_events` id whose kind is `player.chat`; any other id is not one, so reporting a death by its id is a 404. - **Hide for everyone** sets a flag on the `rust_events` row (`hidden_at`, `hidden_by`, added by `ALTER … ADD COLUMN IF NOT EXISTS` so an existing install gets them). The events endpoint leaves hidden chat lines out for everybody except staff, who get them with `hidden: true`. It is set from core's Reports queue, or from the line's own menu through `POST /admin/rust/chat/:id/hide`, and cleared only there, through `POST /admin/rust/chat/:id/unhide` (admins and moderators, the two roles the queue is open to; both written to the activity log as `rust.chat.hide` / `rust.chat.unhide`). Hiding a line the retention sweep has already pruned does nothing, and the report is still actioned. - **The events endpoint filters per reader** (S11) with `ctx.safety.forViewer`, **in the query**, so a page of 100 lines is still 100 long: a line is left out when its Steam ID is linked to an account the reader blocks, or is one of the reader's own Steam-ID blocks (which core answers only for ids still unlinked, S14); and when the reader hid it. Only `player.chat` rows are filtered: a block hides what someone says, and their deaths and connects stay in the feed. Signed-in readers get `canBlock` on each chat line (S13): false when the line has no Steam ID, or the speaker's linked account is staff (`ctx.safety.staffAmong`) or is the reader. If the reader's blocks cannot be read, the feed is served unfiltered for them with a warning in the log, rather than failed; if who is staff cannot be read, every line says `canBlock: false`. - **The live feed's chat lines gain a menu** (`components/ChatMenu.jsx`, a `⋯` at the end of each line): *Report* (the six reasons and an optional detail), *Hide for me*, *Block player* (where `canBlock`, after a confirmation that says the player is not told), and for staff *Hide for everyone* or, on a hidden line, *Show to everyone again*. A signed-out reader gets *Sign in to report or block*. They call core's `/player/reports`, `/auth/me/blocks` and `/auth/me/hidden` through the shared request primitive, and re-read the feed after anything that changes it. - On the demo, all of this stays **open**: it is what a Play reviewer tests, and the wipe resets it. None of these routes is in `demoLocks`. ### 4.3 Android app (chat lines only, S4) - **Long-press a chat line** for the same menu, in a bottom sheet: *Report* (the six reasons and an optional detail), *Hide for me*, *Block player* (where the line says `canBlock`, after a confirmation that says the player is not told) and, for admins and moderators, *Hide for everyone* or, on a hidden line, *Show to everyone again*. Only chat lines answer the press. A member who is signed out gets *Sign in to report or block*, which opens the app's login screen. Back on the report form or the block confirmation returns to the menu, keeping what was typed. - The calls are core's (`/player/reports`, `/auth/me/blocks`, `/auth/me/hidden`) and the module's two staff routes. Each refusal has its own sentence: already reported or blocked, gone, staff (S8), too many reports, the module not answering, the demo's lock. - The feed is already filtered by the server (S11); the app reads it again after a hide, a block or a staff hide, and never drops a line itself. A report changes nothing, so it does not. Staff see a line hidden for everyone dimmed and marked *Hidden by staff* (S10). - **Account → Blocked** (from *My account*) lists blocks with *Unblock*, mirroring the web page: an account block by its username, a Steam-ID block by its ID and "not linked to an account", and a block whose target is now staff with *Not applied* and why (S8). *Show them again* clears the member's hides. ## 5. Further decisions (org lead, 2026-10-29) | # | Question | Answer | |---|---|---| | S6 | Can a signed-out visitor report a chat line? | **No.** Reporting, hiding and blocking all need an account, as forum reports do today, so the queue is not open to anyone with `curl`. A signed-out viewer sees *Sign in to report or block* | | S7 | Can a member block a game identity (a Steam ID) that is linked to no account? | **Yes** (§4.1's game block). Almost everyone in a Rust chat feed is unlinked | | S8 | Can a member block staff? | **No.** The block is refused when its target is a staff account (admin, editor or moderator), or a game identity linked to one, and the menu does not offer it on staff content. A game block whose identity later links to a staff account stops applying, and *Account → Blocked* says why. *Hide for me* still works on a single staff item | | S9 | What does a block do to the blocked person? | **Nothing beyond hiding** their content from the blocker. They are not told; there are no direct messages to stop | | S10 | Do staff still see a chat line hidden for everyone? | **Yes, marked** *Hidden by staff*, so the hide can be checked and undone, as the forum already treats its own hidden posts | Asked while building the core (org lead, 2026-10-30): | # | Question | Answer | |---|---|---| | S11 | §4.1 said the chat feed is shared and cached, so the website and the app would filter it. It is already read per viewer (§3). Filter on the server or in the clients? | **On the server.** The module filters its feed with `ctx.safety.forViewer`. No client module-API member, and the app needs no lists of its own | | S12 | Blocking "either one hides both" needs core to know which account a Steam ID belongs to. How? | **The RunicGateway account first** ("look up via username first … only if have to … linked steam id"). A block from a chat line lands on the speaker's linked account when there is one; only an unlinked speaker is blocked by Steam ID. A feed checks the speaker's linked account first and the Steam ID only for an unlinked speaker | | S13 | Offering *Block* only on non-staff lines means each line tells a signed-in member whether its speaker is linked to staff. Acceptable? | **Yes.** Signed-in readers get `canBlock` per line; signed-out readers get nothing | | S14 | A Steam-ID block, and the player later links that ID to an account. What happens? | **It becomes an account block** the next time it is read, so it then hides the account's forum posts too. If the account is staff it stops applying and *Blocked* says why (S8) | ## 6. The team forum in the app (planned, not scheduled) The app has no Teams screens. `android/PLAN.md` M21 plans them: the member's Teams, a Team's page, the forum's threads and posts (read, reply, new thread), the same report/hide/block menu on posts and threads, and the forum's push stream. When it ships, the app lets members post content, so the Play declarations gain "user-generated content: collected". M21 says so. ## 7. Build order 1. **This plan** (docs), with §5's answers. 2. **Core** (`website`): §4.1, `MODULE_API` 1.21.0, tests, OpenAPI, `BACKEND_DESIGN.md`, `MODULE_API.md`, `TEAMS.md` §5.6 amended. One PR. **Built 2026-10-30** as `website` `feature/user-safety`, with S11-S14 asked and answered on the way. Server and client suites green; the route, guard and engagement manifests and the OpenAPI spec regenerated. Walked against a throwaway database on the local MariaDB with a core on its own port and no modules: the schema ensured on boot; a player hid one post (gone from their view only); blocking a moderator's post was refused `403 staff` and the read had said `canBlock: false`; blocking a thread's author made an account block and took the thread off the list and its link; unblock brought it back, and another account's block id answered 404; a report through `/player/reports` reached the queue with its snapshot; a player could not read the queue; *Hide for everyone* actioned the report with `actionedWith: "hide"` and the post was gone for members and shown to staff as hidden. S14's rewrite, and its merge when the member had also blocked the account directly, were checked against the same database. The module half (`rust.chat`, `ctx.safety`) is exercised by unit tests here and walks with step 3. 3. **Module-Rust** (`edge`): §4.2 plus the core-ref pin. One PR. **Integration-kit**: the re-pin and a paragraph on `reportTargets`. One PR. **Built 2026-10-30** as Module-Rust `feature/user-safety` (`coreApi` ^1.21.0, pinned to the website#233 merge) and Integration-kit `docs/core-1.21.0`. Server and client suites, the import, bundle, swagger and engagement checks, and the frozen route manifest against a core at the pin (two new routes, all documented) are green. Walked against a throwaway database on the local MariaDB with a core on its own port and this branch's module installed, with chat lines and Steam links written straight into the tables (no game needed), 28 rows over bearer logins: a signed-out reader got every line and no `canBlock`; a member's `canBlock` was true for unlinked and linked players and false for their own line and a moderator's; a report reached the queue with the line's snapshot; a line on a server whose presence is staff-only, a death's id and a missing id were all 404; blocking an unlinked speaker made a Steam-ID block and took their chat (not their death) out of that member's feed only; blocking a linked speaker made an account block; blocking a moderator was `403 staff`; *Hide for me* hid one line for one member; a Steam-ID block whose id was then linked read back as an account block and kept the line out (S14); *Hide for everyone* from the queue actioned the report, took the line out for members and signed-out readers, showed it to staff marked, and made it unreportable; the line's own staff route unhid and hid it (a player got 403, a missing line 404) and wrote the activity log; unblocking and *Show them all again* brought the lines back. Then in the browser: the `⋯` menu signed out (*Sign in to report or block*), as a member (Report with the duplicate-report refusal and a fresh report, Block with its confirmation, no Block on their own or the moderator's line), *Account → Blocked* listing the Steam-ID block and both account blocks, and as a moderator *Hide for everyone* / *Show to everyone again* with the *Hidden by staff* marker and the Reports queue showing both chat-line reports from their snapshots. No console errors. 4. **Android** (`edge`): §4.3. One PR. **Built 2026-10-30** as Android-app `feat/user-safety`. `./gradlew testDebugUnitTest lintDebug assembleDebug` green: 827 tests, 29 of them new (the menu's rules, the repository's refusals, both view models), and no lint findings in the new code. Walked on the `Medium_Phone_API_36.1` emulator against a core on its own port with Module-Rust installed and a throwaway database, chat lines and Steam links written into the tables: signed out, a long press offered only *Sign in*, which opened the login screen; as a member, a report reached `content_reports` with its reason, detail and snapshot, and a second one was refused as already reported; *Hide for me* took one line out; blocking an unlinked speaker made a Steam-ID block and took their chat (not their death) out; blocking a linked speaker made an account block; the moderator's line and the member's own offered no *Block*, and a death offered no menu. *Account → Blocked* listed both; linking the Steam ID turned that block into an account block (S14); making that account a moderator showed *Not applied* (S8); *Unblock* and *Show them again* brought the lines back. As a moderator, *Hide for everyone* dimmed and marked the line for staff, took it out of a signed-out read and wrote `rust.chat.hide` to the activity log, and *Show to everyone again* undid it. The walk found two things, both fixed: Back to put the keyboard away closed the whole sheet and lost a half-written report, and the half-open sheet left *Send report* below the fold. 5. **A walk** on the demo after the next wipe: report, hide and block, each on the web and in the app; *Hide for everyone* from the queue; a blocked Steam ID that later links to an account stays blocked. **Walked 2026-10-30 on a local copy of the demo, not on the demo itself** (org lead: check it locally and leave the demo alone unless it needs something). The copy: website `main` (the 1.21.0 merge) with `DEMO_MODE=true`, Module-Rust `edge` (#72) in its modules directory, a throwaway database seeded by `npm run seed:demo` from the demo's own `deploy/demo/seed.json`. The rig has no players and a Steam ID can only be linked with `/link` in game, so the walk's data was written into the tables instead (org lead: seed what the walk needs): nine chat lines and a death on the `demo` server from four stand-in Steam IDs (two unlinked, demo-player's, and a new one linked to demo-moderator), and that server's presence opened to `public`. S14's link was written as `/link` writes it, to a player account demo-admin created (creating users is open on the demo). - **API**, 30 rows, all passed, over bearer sign-ins as the demo's accounts. None of the routes is demo-locked. Signed out: every line, no `canBlock`, a report refused 401. demo-player's `canBlock` was true for the two unlinked speakers and false for its own line and demo-moderator's. A report reached the queue with its snapshot, and a second report of the line was refused 409; a death's id was 404. *Hide for me* hid one line for demo-player only. Blocking an unlinked speaker made a Steam-ID block and took their chat, not their death, out of demo-player's feed only. Blocking demo-moderator's line was `403 staff`. play-review blocking demo-player's line made an account block. *Hide for everyone* from the queue actioned the report and took the line out for members and signed-out readers; staff saw it marked, and the line's own route showed it again (a player got 403); the activity log has both. A Steam-ID block whose ID was then linked read back as an account block, and the lines stayed out (S14). *Unblock* and *Show them again* undid everything. - **Web**, in Chrome. Signed out, the `⋯` menu said *Sign in to report or block*. As demo-player: *Report* (sent, "Reported to site staff"), *Hide for me*, *Block player* with its confirmation; no *Block* on demo-moderator's line or its own. *Account → Blocked* listed the Steam-ID block and the hidden item, then, after the link, the block as an account block on the new account (S14). As demo-moderator, the Reports queue showed the line from its snapshot, *Hide for everyone* actioned it, and the feed showed the line marked *Hidden by staff* with *Show to everyone again*. No console errors. - **App**, `edge` debug build on the `Medium_Phone_API_36.1` emulator, its data cleared. Signed out, a long press offered only *Sign in*, which opened the login screen. As demo-player: *Report* with a reason and a detail (the row has both, and the snapshot), *Hide for me*, *Block player* with its confirmation (Ash's chat gone, Rook's death still there); no *Block* on demo-moderator's line or its own. *Account → Blocked* listed the Steam-ID block, then the account block after the link (S14), with the speaker's lines still out. As demo-moderator, *Hide for everyone* dimmed the line and marked it *Hidden by staff*, a signed-out read lost it, and *Show to everyone again* brought it back; the activity log has `rust.chat.hide` and `rust.chat.unhide`. **What the walk found about the demo itself.** The org lead's answer (2026-10-30) was **none of them**: the demo's chat audience, its seed and play-review's role stay as they are. That answer was about the demo's data. It was not about holding the features back. **Corrected the same day:** report, block and hide stay in the app, and the demo gets them in the ordinary way, from the module release. Module-Rust#73 cuts `edge` over to `main` as v0.7.0, and the demo's next wipe installs it (DM12). Nothing is seeded for it. - **W1. The demo shows no chat to members.** Its `demo` server inherits the fleet's presence default, `staff`, so demo-player and a signed-out visitor see no feed at all. Only demo-admin, demo-moderator and play-review do. - **W2. There is no chat on the demo to report.** Chat lines come only from players on the rig, and nobody plays there, so even the staff accounts have nothing to long-press. The walk's lines were written into the tables. - **W3. play-review is an admin**, so a Play reviewer is staff. They get *Hide for everyone* in the menu and see staff-hidden lines, and the block refusal and *Not applied* (S8) do not apply to them. They can still report, hide and block. - **W4. The demo runs Module-Rust v0.6.3**, the newest release, which has neither this feature nor account deletion's unlink (#70, #72 are on `edge`). Fixed by the cutover above, Module-Rust#73 (v0.7.0). 6. Then `android/PLAN.md` M8's cutover (S5): Android-app#65 (v0.7.0), with Module-Rust#73 as its server half. Before it merges, the org lead enrolls the release keystore as Play's app-signing key.