Merge pull request 'docs: plan report/block/hide, decide M8's open items, plan demo content' (#390) from docs/report-block-hide-plan into main

Reviewed-on: #390
This commit is contained in:
2026-10-29 13:10:02 +00:00
4 changed files with 289 additions and 14 deletions

View File

@@ -37,6 +37,7 @@ sidecar as a service, and hands you the values the website needs.
| [SHARD_VISIBILITY.md](website/SHARD_VISIBILITY.md) | Who sees which shard data — the admin-configurable audience framework |
| [TRUSTED_DEVICES_MFA.md](website/TRUSTED_DEVICES_MFA.md) | TOTP two-factor, trusted devices and recovery codes |
| [STEAM_SIGNIN.md](website/STEAM_SIGNIN.md) | **Plan, built 2026-10-19** — Sign in with Steam as a built-in provider beside Google and Discord (OpenID 2.0), and a Steam id for modules |
| [USER_SAFETY.md](website/USER_SAFETY.md) | **Plan, 2026-10-29** — report, block and hide on the website and in the app: an account-wide block, a member's own hide and staff's hide for everyone, with module-owned report targets (`MODULE_API` 1.20.0) |
| [DEMO_MODE.md](website/DEMO_MODE.md) | **Design, fully decided 2026-10-23** — the public `demo.runicgateway.com`: one flag that locks destructive actions, static logins, and a wipe every couple of hours (website#222) |
| [MODERATION_APPEALS.md](website/MODERATION_APPEALS.md) | Moderation actions, content reports and the appeals flow |
| [SPAWN_ATLAS.md](website/SPAWN_ATLAS.md) | The bestiary / spawn atlas: what the shard contains, parsed from the shard's own ServUO files — served over the bridge since Protocol 8, so no shared filesystem |

View File

@@ -282,7 +282,8 @@ Work items:
2. **Deps + manifest.** Add the UnifiedPush connector + the embedded-distributor transport (per #1) to
the version catalog; declare `POST_NOTIFICATIONS` (API 33+ runtime permission) **and
`FOREGROUND_SERVICE` + `FOREGROUND_SERVICE_DATA_SYNC`** (API 34+, for the persistent ntfy
connection); register the receiver and the foreground service in `AndroidManifest.xml`; define the
connection; **now `FOREGROUND_SERVICE_SPECIAL_USE`** and `RECEIVE_BOOT_COMPLETED`, M8,
`Android-app#62`); register the receiver and the foreground service in `AndroidManifest.xml`; define the
notification channels (id/name externalized, §2) — one for real notifications plus a low-importance
channel for the ongoing foreground-service notification — and reuse the "RG" notification icon
**already staged in M6**.
@@ -1003,20 +1004,53 @@ push, and Play (M6–M8) follow the designed app.
for 14 days in a row** before it can apply for production. runicgateway.com `/beta`
collects them.
**Open, each waiting for a decision:**
**The three open questions, decided (org lead, 2026-10-29):**
- **Content rating.** The questionnaire answered "Social or Communication: moderated chat;
users or user-generated content can be reported". The app shows other players' chat (the
Rust feed) but has no report or block action, and Play's user-generated-content policy
expects both where that is declared. Either retake the questionnaire, or add report/block
to the app.
- **Data safety's premise.** The generated notes answer "not collected" almost everywhere,
because the app talks only to a deployment the user chose. The notes call it the answer
most likely to be questioned in review. Declaring user IDs and messages as collected,
encrypted in transit and needed for the app to work, is the conservative alternative.
Rust feed) but had no report or block action, and Play's user-generated-content policy
expects both where that is declared. **Decided: keep the answer and make it true** with
report, block and hide, on the website and in the app. The plan of record is
[`website/USER_SAFETY.md`](../website/USER_SAFETY.md): a block lives on the account and
applies everywhere; a member can hide an item for themself, and staff can hide it for
everyone. In the app it goes on chat lines. The team forum in the app is planned as M21.
- **Data safety's premise.** The generated notes answered "not collected" almost everywhere,
because the app talks only to a deployment the user chose. **Decided: the conservative,
more accurate answers.** Each kind of data the app sends to the deployment is declared
collected, not shared, encrypted in transit and required for the app to work. That is
what Play counts as collection, since the data leaves the device, whoever runs the server.
runicgateway.com's generator (`scripts/playDataSafety.mjs`, from `src/data/collection.mjs`)
changes to say so after an audit of what the app actually transmits. The published privacy
page keeps saying that *we* receive nothing, which stays true.
- **The push foreground service.** Play asks for a declaration (and a video) for the
`dataSync` service that holds the ntfy connection. Google reserves `dataSync` for transfers,
and Android 15 limits it to six hours a day. This brings forward §13's "FCM flavor for
Play?" question.
`dataSync` service that holds the ntfy connection. Google reserves `dataSync` for
transfers, and Android 15 limits it to six hours a day. **Decided: keep UnifiedPush.** The
service changes to the `specialUse` type with a `PROPERTY_SPECIAL_USE_FGS_SUBTYPE` reason,
as ntfy does on Play; `specialUse` has no daily limit. **Push also restarts after a
reboot** when the member had it on and is signed in, which `specialUse` allows from
`BOOT_COMPLETED` and `dataSync` does not. This also fixes a crash: `PushService` does not
handle `onTimeout`, so on Android 15+ with target 36 (`edge` since step 1) the six-hour
cut-off would crash the app. The declaration's video is recorded on the emulator (turn push
on, the ongoing notification, a push arriving), and the org lead uploads it. §13's "FCM
flavor for Play?" is therefore answered **no**.
**The order from here** (org lead, 2026-10-29):
1. **Push** (`edge`): `specialUse` and the restart on boot. One PR. **Built 2026-10-29** as
`Android-app#62`. `core/push/PushBootReceiver` (not exported) listens for
`BOOT_COMPLETED` and `MY_PACKAGE_REPLACED`, and starts `PushService` from the saved
registration when push was on, without re-registering, because the network is often not up
at boot. A sign-out clears the registration, so push stays off after one. Walked on the
`s22_ultra` emulator against a local site whose relay was `ntfy.sh`: the service ran as
`specialUse` (`types=0x40000000`), a tickle arrived, and push came back by itself after a
reboot and after an update. After a sign-out and a reboot it stayed off.
2. **Data safety** (runicgateway.com): the audit and the generator. One PR.
3. **Report, block and hide** across `website`, `Module-Rust`, the kit and this app
(`USER_SAFETY.md` §7).
4. **The cutover**, `edge` → `main`, once 1-3 have landed, so the first Play bundle carries
all of it. **Before it merges, the org lead enrolls the existing keystore as the app-signing
key** in Play Console (App integrity, "use my own key"). `PLAY_SERVICE_ACCOUNT_JSON` is
already set, so the release run will call Play, and the signing choice is permanent. On the
draft app, CI's commit is refused as expected; the first bundle is then uploaded by hand
(step 2 above).
10. **M9 — Native SSO login** (post-v1, additive; independent of M8): in-app "Sign in with Google /
Discord" via the **Mobile SSO Authorization Bridge** (§4.2). **Backend-first**, mirroring M7's
split:
@@ -1828,9 +1862,39 @@ push, and Play (M6–M8) follow the designed app.
just as wrong. A maintenance error kind, read from that body, is the likely fix. It is
outside M20 and waits for a decision.
22. **M21 — Teams and the team forum in the app** (post-v1; planned 2026-10-29, **not scheduled**).
The org lead asked for it with M8's user-safety decision (`../website/USER_SAFETY.md` S4). It
revives the deferred *Platform Teams in the app* below, whose revival trigger (Team pages,
roster and forums on the website) has long since been met.
**Scope:**
- **A Teams entry** in the menu: the member's own Teams (`GET /player/teams`) and the public
list (`GET /public/teams`), then a Team's page (`/public/teams/:slug`, its members and
activity).
- **The forum**, for a Team the member can read (`GET /player/teams/:slug/access`): threads
(`…/forum/threads`), a thread's posts (`…/forum/threads/:id`), reply and new thread
(`POST …/threads`, `…/threads/:id/posts`), and editing their own post (`PATCH …/posts/:id`).
Leaders get pin, lock and hide through the existing `…/moderate` routes.
- **Report, hide for me and block** on every thread and post, as on chat lines
(`USER_SAFETY.md` §4).
- **Deep links** for the four Team push streams, which today open the app and no more, and the
per-Team mute list (`GET|PUT /auth/me/notifications/teams`; its DTO field has no default,
see the trap noted below).
- **Uploads: view only** to begin with. Sending a file from the phone is a later decision,
because uploads carry the operator's liability acknowledgement (`TEAMS.md` §5.5).
**What it changes for Play:** members can then post from the app, so the data-safety
declaration gains "user-generated content: collected" and the content rating stays as
answered. runicgateway.com's `collection.mjs` gains the row in the same change.
**Before it starts:** USER_SAFETY.md's build order is done (the report/block/hide routes M21
reuses), and the questions it will raise (offline drafts, uploads, the order of the Teams tab
in the menu) go to the org lead first.
### Deferred (not a milestone)
- **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is
- **Platform Teams in the app** — **revived as M21 on 2026-10-29** (above); the text below is the
2026-08-17 deferral, kept for its reasoning. **Deferred 2026-08-17, no app work scheduled.** The website is
building Teams as a core platform primitive (pages, roster, activity feed, forums, notifications —
[`../website/TEAMS.md`](../website/TEAMS.md)), and the app has no story for any of it. Deferred
deliberately rather than scheduled: this milestone list is already behind, and adding a large new
@@ -2147,7 +2211,8 @@ it is a UX convenience, not a v1 requirement — deferred at M3, descoped at M6;
No backend publish token — **decided** (the content-free-tickle design does not require one; optional
`NTFY_PUBLISH_TOKEN` is honored if ever set).
- FCM flavor: build it for the Play release or ship Play on UnifiedPush too? Decide at M8. (The M7
Part 2 `PushTransport` seam keeps this swap cheap.)
Part 2 `PushTransport` seam keeps this swap cheap.) **Decided at M8 (2026-10-29): UnifiedPush on
Play too**, with the service moved to the `specialUse` type.
- Deep-link / share targets for wiki pages and posts (share/open-in-app). *Notification-tap* deep-links
are **resolved** for M7 Part 2 (stream→screen map, work item 7).
- iOS: none planned (this is the Android-only choice); revisit only if cross-platform is later

View File

@@ -498,6 +498,8 @@ down.
| DM29 | (step-5 walk, W2, 2026-10-28) `SECRET_WORDS` also hides harmless fields such as "Allow Master Key" and "TC Auth Only". Narrow it? | **No, leave it as is** |
| DM30 | (step 6, 2026-10-28) How does runicgateway.com `/beta` publish the logins? | **Live from the demo.** `/beta` reads this site's `GET /public/settings` `demo.logins` on its server, caches it for a minute, and links to `/account/login` when the demo does not answer (runicgateway.com D82) |
| DM31 | (step 6) runicgateway.com's "See it running" links were UO routes, which 404 here. What do they do? | **Per game.** A deep link names its game, and that site's mounted `demoGames` (`["rust"]` for this demo) says which games' links show. Rust's point at `/rust` and `/rust/servers/demo?tab=…`, so that site now quotes the fixture's server id `demo` (runicgateway.com D83) |
| DM32 | (2026-10-29) The moderator tour shows empty queues, and other surfaces are empty too (§11). Which get seeded? | **All four of §11's groups:** moderation history, Teams with their forum and reports, event history with the calendar, and Rust player stats |
| DM33-DM35 | (2026-10-29) Which Steam IDs, Discord IDs and names do the invented players use? | **Visibly fake IDs** starting `1234567890`, the org lead's own Steam ID as `demo-player`'s link, and **Rust-themed invented names** (§11.3) |
DNS and HTTPS for `demo.runicgateway.com` come from an external reverse proxy, which the org lead is
setting up. The app sits behind it on plain HTTP; core already trusts one proxy hop for
@@ -647,3 +649,80 @@ through that proxy.
locks). The org lead types the password, ticks the full-access attestation and saves. The closed
track does not exist yet (Test and release shows *Get started* for closed and internal testing), so
there is no opt-in URL for `betaOptInUrl`, and `/beta`'s first gate stays shut.
7. **Demo content** (DM32, §11): the seed's two passes and the rig's clans. Planned 2026-10-29.
One PR in `website` (core seed, the wipe's second pass), one in `Module-Rust` (`onDemoSeed`
stats and feed lines, into `edge`), a new baseline, and a walk of every surface §11 names after
the next wipe.
## 11. Demo content (DM32)
**Why.** A read-only count of the live demo on 2026-10-29 found these surfaces empty after a wipe:
every moderation queue (recent actions, filter hits, spam hits, member events, appeals, reports),
Teams (public, the player's own, staff's review queue), event runs and history, the public calendar
(the three seeded events are never scheduled), `demo-player`'s inbox, and on the Rust side the
leaderboard, wipes, clans and the events feed. Only the four demo accounts exist. A reviewer touring
as `demo-moderator` finds nothing to moderate.
### 11.1 Two seed passes
Some of it can be written the moment the site is up. The rest depends on the rig: Teams are
**module-authoritative** and re-synced every 15 minutes (`TEAMS.md`), so a Team written straight
into core would be archived by the next sync, and the Rust stats need the wipe the rig is on. So:
- **Pass 1, `seed:demo`, as today**: accounts, settings, content, events, and the new core rows
below that need nothing from the rig.
- **Pass 2, `seed:demo --after-rig`**, a new step in `demo-wipe.yml` after the rig reports in and
the first Teams sync has run: Team forum content, reports, Rust stats and feed lines. It waits for
the Teams it names, with a time limit, and fails the wipe if they never appear, as the rig smoke
check does.
The rig's wipe id comes from the restored world save, so it is the same on every wipe
(`w-20261005T165054Z` today). Pass 2 reads it from the server state rather than the fixture, so a
new baseline with a new map still works.
### 11.2 What each group seeds
- **Moderation history** (pass 1, core). Discord-shaped rows for six invented members: a few
`mod_actions` (warn, timeout, ban), `warnings`, `filter_hits` and `spam_hits`, join and leave
`member_events`, staff `mod_notes` (one admin-only), and three `appeals`: one pending to claim,
one under review, one resolved. Times are relative to the seed, so the 24 h / 7 d / 30 d counts
are never zero. There is no bot on the demo, so approving an appeal records the reversal as
failed. That is the shipped behaviour when the bot is down, and the appeal still closes.
- **Teams, forum and reports.** Two or three clans **in the rig's baseline** (the Clans plugin's
data, which already reports an empty board), with stand-in members and `demo-player`'s stand-in
Steam ID in one, so sync brings them in as Teams. One clan's name contains a reserved term, so it
lands in staff's review queue. Pass 2 then writes forum threads and posts in them, and two open
reports. Once report, block and hide exist (`USER_SAFETY.md`), it also writes reports on chat
lines.
- **Event history and the calendar** (pass 1, core). Two finished runs of the seeded events, with
their steps, log, participants (stand-ins, and `demo-player` through its link) and published
results, so the history and results pages fill and `demo-player`'s inbox has the results
notification. The three seeded events are scheduled in the coming days, so the public calendar
shows them.
- **Rust player stats** (pass 2, Module-Rust `onDemoSeed`). About ten stand-in players with wipe
stats (kills, deaths, NPC kills, playtime, gather totals, weapon kills) on the rig's current
wipe, an earlier wipe for the Wipes tab, and a day of feed lines (kills, joins, chat), so the
leaderboard, profiles and feed are populated. The online list stays honest at zero.
### 11.3 Identities (DM33-DM35, org lead, 2026-10-29)
- **DM33, Steam IDs: visibly fake, plus the org lead's own.** Every real individual Steam ID starts
`7656119`, so an invented one in that range could belong to a stranger and would appear on public
leaderboards. The invented players use IDs that are visibly not Steam IDs, which start
`1234567890`: `12345678900000001`, `…02`, and so on. They are 17 digits because Module-Rust checks
that length on links, profiles and event steps (`links.model.js`, `eventWorld.js`), and a shorter
one would break those pages. `demoSeed.js` currently insists on `7656119…` for links and is
widened for these. The one real Steam ID on the demo is the org lead's own (`76561198038695917`),
and it **replaces `demo-player`'s stand-in link** (`76561199910000001`, which could belong to
anyone): confirmed by the org lead. So whoever signs in as `demo-player` sees that ID linked, and
the org lead's own play on the demo rig shows under `demo-player`. The default set's `vip`
membership (§10 step 5, DM26), which names the old stand-in on the rig, moves to this ID in the
new baseline that §11's clans need anyway.
- **DM34, Discord IDs: the same rule.** The invented moderation history uses visibly fake ids
starting `1234567890`, padded to a snowflake's 18 digits. Confirmed by the org lead.
- **DM35, names: Rust-themed and invented.** Fun gamer tags from the game's world, such as
"Rusty_Pete", "Scrap Baron", "Naked_On_The_Beach", "Rock Enthusiast", "Big Oil Bandit", "TC
Thief", "Hazmat Hank" and "Bean Can Dan", and clans such as "The Scrap Collective" and
"Outpost Loiterers". Never a real person's name, and nothing offensive: the seeded filter hits
show a placeholder word.

130
website/USER_SAFETY.md Normal file
View File

@@ -0,0 +1,130 @@
# Report, block and hide — user safety on the website and in the app
**Status:** plan of record, 2026-10-29. Nothing built yet. Each phase stops for the org lead
before the next one starts.
**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 | 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.
- **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.** A new optional module hook, `reportTargets`, declares the types
a module's content can be reported as (for Rust: `rust.chat`). Each type answers two questions for
core: `snapshot(id)` (author, text, time, where it was said), and `hide(id, actor)` /
`unhide(id, actor)`. Core files the report, stores the snapshot and runs the queue. The module owns
the content and its hidden flag. This is a `MODULE_API` minor bump, **1.20.0**. The Integration
Kit re-pins and gains a paragraph.
- **One filing route for every target**: `POST /player/reports {targetType, targetId, reason,
detail?}`. Forum types keep working through the existing forum route, which then calls the same
model.
- **`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.
- **The queue gains *Hide for everyone*** beside actioned and dismissed. It calls the forum's
existing hide or the module's `hide`, and records the report as actioned with that action named.
- **Blocks**: `user_blocks (blocker_user_id, kind 'user'|'game', blocked_user_id NULL, module_id
NULL, external_id NULL, display_name snapshot, created_at)`. A game block names a game identity
(for Rust, a Steam ID), because most people in chat have no site account. When that identity is
linked to an account, blocking either one hides both.
- **Per-member hides**: `user_hidden_items (user_id, target_type, target_id, created_at)`.
- **Routes**: `GET/POST/DELETE /auth/me/blocks`, `GET/POST/DELETE /auth/me/hidden`. Their
OpenAPI annotations and spec are regenerated as usual.
- **Filtering.** Signed-in forum reads drop blocked authors and hidden items on the server. The
public chat feed is shared and cached, so it cannot be filtered per reader on the server. The
website and the app filter it with the member's lists instead, read once per session and kept
current after each block or hide. Module client chunks reach those lists through a new client
module-API call.
- **Web UI.** The forum's thread and post menus gain *Hide for me* and *Block author*, beside the
existing *Report*. A new *Account → Blocked* page lists blocks and undoes them.
### 4.2 Module-Rust
- `reportTargets['rust.chat']` with `snapshot` (the speaker's name and Steam ID, the line, server,
time, and the linked account if there is one) and `hide`/`unhide`.
- **Hide for everyone** sets a flag on the `rust_events` row (`hidden_at`, `hidden_by`). The events
endpoint leaves hidden chat lines out for everybody except staff, who get them marked.
- **The live feed's chat lines gain a menu**: *Report*, *Hide for me*, *Block player*, and for
staff *Hide for everyone*.
- On the demo, all of this stays **open**: it is what a Play reviewer tests, and the wipe resets it.
### 4.3 Android app (chat lines only, S4)
- **Long-press a chat line** for the same menu. Report opens a sheet with the six reasons and an
optional detail. A member who is signed out gets *Sign in to report or block*.
- **Account → Blocked** lists blocks and undoes them, mirroring the web page.
- The feed filters with the member's lists, as in §4.1.
## 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 |
## 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.20.0, tests, OpenAPI, `BACKEND_DESIGN.md`,
`MODULE_API.md`, `TEAMS.md` §5.6 amended. One PR.
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.
4. **Android** (`edge`): §4.3. One PR.
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.
6. Then `android/PLAN.md` M8's cutover (S5).