Compare commits

4 Commits

Author SHA1 Message Date
12bd24a973 docs(teams): queue the integration kit as phase 11, last before the cutover
The kit is the instruction book for putting a different game on this platform,
written for an audience outside this org. Teams expands the contract that book
teaches against, so the book is the last thing the bet owes before `edge` becomes
`main` (org lead, 2026-08-18).

One sentence in it is already wrong rather than merely incomplete.
`book/02-website-module.md` tells a reader that core declares a slot and a module
may only fill one. Phase 3 inverted exactly that, and by phase 6 module-uo declares
three — a new game's module cannot implement Teams at all without the inverted
direction.

Two shapes are genuinely new and worth teaching: the inverted slot, and
`registerTeamProvider` as the first registration where core calls the module and
waits — with the asymmetry that every call fails stale except `projectRoster`,
which fails closed, because for a visibility question "keep what you have" means
serving the roster unprojected.

The phase explicitly does NOT enumerate the contract. The kit already teaches four
members and has never mentioned notification streams, announce legs or post hooks,
all of which predate Teams. MODULE_API.md is normative; the kit teaches one path
and links out.

Its ordering is awkward and is stated rather than smoothed over: it is written
before the cutover and can only merge after it, because CI clones the pinned sha
and checks the template against that core's MODULE_API_VERSION — and 1.6.0 does
not reach `main` until the cutover lands.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 18:17:26 -05:00
5e284d5468 docs(teams): phase 6 as built — four deviations and a ninth contract member
Part 6 gains an as-built header rather than a rewrite, so the reasoning that
produced the original design stays legible beside what the build learned.

Four deviations. There was no web notification settings screen to add the Team
list to — `/auth/me/notifications/*` was built for the app in M7 and had zero web
consumers, which is survivable for push and not for a sink whose whole argument is
the web-only user. Email defaults to `off` rather than `digest`, on the org lead's
call: digest-by-default would start mailing every member of every Team the moment
an operator connects Gmail. Roster events tickle but do not email. And a ninth
member joined MODULE_API 1.6.0.

`pageUrlTemplate` is the member, and it exists because phase 3 left core with no
Team page and therefore no way to link to one. It joins 1.6.0 in place under the
rule set in phase 2 — a contract owes a bump only once it has landed on `main`,
and 1.6.0 has only ever been on `edge`.

Two further build decisions are recorded where they belong: the digest computes at
send time and keeps no queue (§6.4), and one-click unsubscribe is a stateless HMAC
whose whole capability is muting one (user, Team) pair (§6.4).

BACKEND_DESIGN gains the table, the two `/auth/me` routes and the unsubscribe
endpoint — the only write in the public tier and the only route with no `siteMode`,
because the mail went out before the site went into maintenance.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 14:35:49 -05:00
04eb859a48 Merge pull request 'docs(teams): phase 5 — discussion, the edit window, and reports that route around leadership' (#158) from docs/teams-phase5 into edge
Reviewed-on: #158
2026-08-18 18:37:19 +00:00
aed4ec4166 docs(teams): phase 5 — discussion, the edit window, and reports that route around leadership
TEAMS.md §5.4, §5.5.7 (new), §5.6 and the Part 12 phase entry; BACKEND_DESIGN.md's
schema and admin route tables.

**The largest change is a decision, not a description.** §5.6's first rule said
"a leader may also see and act on reports for their own Team, but staff always
receive them". The org lead settled on 2026-08-18 that the leader half is
**decided against, not deferred**: the gap the section exists to close is that
leaders moderate their own forum and a Team's leaders are exactly the people who
will not report their own Team, so a leader-visible queue hands a complaint about
a leader back to them — and a read-only leader view still tells them who reported
what. Recorded as an amendment rather than by editing the sentence away, because
the reasoning for the original is what makes the correction legible.

**§5.6's `content_reports` DDL does not work as written, and the amendment says so
rather than quietly swapping it.** With `status` in the unique key, CLOSED rows
collide with each other: dismiss a report, let the behaviour recur, dismiss the
second one, and the UPDATE lands on a tuple that already exists — so the queue
starts throwing duplicate-key errors on the first repeat reporter. The shipped
table keys on a generated `open_marker`, the same encoding
`team_forum_grants.active_marker` uses. Three smaller departures are recorded
beside it: `handled_note`, the two username snapshots §2.10 asks for everywhere
else, and a real CASCADE on `team_id`.

**New §5.5.7 for `teams_forum_edit_window_minutes`** (0–1440, default 15), and the
rule under it: the window is resolved on the server TWICE — the read path stamps
`canEdit`/`editableUntil` so a client knows whether to draw the control, the write
re-derives it from `created_at` before allowing anything. The read is advice and
the write is enforcement, because a time-bounded permission must not take its
clock from the party it bounds. That is also why the key is not published: the
client needing the number is the admin screen, and the client needing the decision
already has it per post.

**§5.4 gains three notes its route table does not carry**: thread creation splits
authority by TYPE rather than widening the leader gate (and reports it as two
booleans, since one would make a client guess which right it described); post
moderation is its own route whose validator accepts all eight actions so the model
can say "pin applies to a thread, not to a post"; and a reply's three refusal codes
are chosen to be distinguishable — 404 absent, 400 announcement, 409 locked — with
locked refusing staff too.

The Part 12 entry records what the phase disproved, its four acceptance criteria,
that it spans ONE repo where phase 4 needed two, and the single defect the live rig
found. It also notes that phase 4 shipped `uploads` with the default off, so §5.6's
"pull reports forward if uploads is enabled anywhere" never triggered.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 13:29:56 -05:00
3 changed files with 379 additions and 21 deletions

View File

@@ -525,7 +525,12 @@ have been found earlier, because until then no caller had ever passed a non-null
Design of record: [`MODULE_SYSTEM.md`](MODULE_SYSTEM.md) §2.4; the loader's obligations are
[`MODULE_API.md`](MODULE_API.md) Part 4.
### The eleven Team tables — core's, populated by a module (Teams phases 2–4)
### The eleven Team tables — core's, populated by a module (Teams phases 2–5)
*Twelve rows in the table below: `content_reports` is listed here because Team forum content is its
first consumer, and it is deliberately **not** one of the eleven — it carries no `team_*` prefix, its
`target_type` is an open VARCHAR, and a wiki page or a news comment is meant to become a value in it
rather than a table of its own.*
A Team is a **core** entity that a **module** answers for. The module says what Teams exist and who is
in them, through the team provider; core stores that answer, gates it and displays it. Every table
@@ -547,6 +552,38 @@ core's.
| `team_forum_posts` | post bodies, sanitised on write through the forum's **own** profile (`utils/forumHtml.js`) and served without re-sanitising. No stored body ever contains an `<img>` |
| `team_forum_moderation` | append-only, per Team, recording `actor_role` — WHICH authority was exercised. Deliberately not merged with `mod_actions`/`appeals`, which is Discord-sanction-shaped |
| `team_forum_uploads` | attribution for `uploads` mode: who uploaded what, when, how big, and to which post. Also the sweep's worklist |
| `team_notification_prefs` | per-Team notification preference (phase 6). **Opt-out for push, opt-IN for email** — `muted` defaults 0 and `email_mode` defaults `'off'`, so the two sinks default opposite ways and the asymmetry lives here rather than in a condition anyone has to remember. Team scoping lives in this table and in the recipient computation, never in a stream id. `last_digest_at` is the digest's only state and the worker is its only writer |
| `content_reports` | member-raised abuse reports (phase 5). **Not a `team_*` table and not named for the forum** — `target_type` is a plain VARCHAR so a wiki page or a news comment becomes a value rather than a table. Team forum content is only the first consumer |
**Core had no user-facing report flow of any kind before `content_reports`.** `moderation`,
`mod_notes` and `appeals` are all either staff-initiated or Discord-sanction-shaped; nothing anywhere
let a *member* say "this is a problem". That was survivable while every piece of content on the site
came from staff, and stops being the moment a Team forum lets players write to each other. Four
properties are worth carrying:
- **Reports reach site staff and nobody else.** A Team's leaders moderate their own forum, so a
leader-visible queue would route a complaint *about* a leader back to that leader. There is one
queue, mounted at `/admin/moderation/reports` beside appeals — a staffer working a queue should have
one place to work — and no leader-facing counterpart anywhere
([`TEAMS.md`](TEAMS.md) §5.6, org lead 2026-08-18).
- **A report is not a moderation action.** Filing one changes nothing about the content; it opens a
queue item. That keeps it clear of `team_forum_moderation`, which records things that actually
happened, and stops "report" becoming a way for any participant to hide anything.
- **One OPEN report per (target, reporter)**, enforced by a unique key over a generated `open_marker`
that is `1` while open and `NULL` once closed — the same encoding as
`team_forum_grants.active_marker`, and for the same reason: only the *live* rows may collide. A
closed report frees the slot, so a member whose first report was dismissed may raise the same target
again if the behaviour recurs.
- **Every transition writes `activity_log`, `dismissed` included.** A queue where acting is audited and
declining to act is not is one where the cheapest way to make a report vanish leaves no trace.
**`teams_forum_edit_window_minutes`** (0–1440, default 15) bounds how long an author may edit their own
post; staff are not bound by it. It is resolved on the server **twice** — the read path stamps each
post with `canEdit`/`editableUntil` so a client knows whether to draw the control, and the write
re-derives it from `created_at` before allowing anything. The read is advice, the write is enforcement,
and the split exists because a time-bounded permission must not take its clock from the party it
bounds. It is deliberately **not** in `settings.getPublic()`: the client that needs the number is the
admin screen, and the client that needs the decision already has it per post.
**The forum's tables are guarded at the ROUTE and never at the data.** `teams_forums_enabled` off
means every forum route answers **404** — not 403, which would advertise a feature the operator
@@ -734,6 +771,7 @@ their own router level, and `/sso/:provider/link` carries `requireAuth` per rout
| GET | `/me/devices` · DELETE `…/:id` | cookie / bearer | — | list / unregister own push devices |
| GET | `/me/notifications/streams` | cookie / bearer | — | the subscribable catalog (`personal`/`requiresLinkedAccount` flags) |
| GET · PUT | `/me/notifications/subscriptions` | cookie / bearer | `{streams:[id]}` on PUT | get / replace own opted-in streams (unknown ids dropped) |
| GET · PUT | `/me/notifications/teams` | cookie / bearer | `{teams:[{teamId,muted,emailMode}]}` on PUT | get / replace own **per-Team** preferences (phase 6, [`TEAMS.md`](TEAMS.md) §6.3). One entry per Team the caller could be notified about — active membership or an active forum grant — plus any Team they already hold a preference for; server-side defaults applied. An entry naming a Team the caller has no access to is **dropped, not refused**: a Team left between loading the screen and saving it is a race, not a client bug. The array is required even when empty (`../android/PLAN.md` §11) |
**Role-agnostic self-service (`/auth/me/*`).** The canonical "me" surface for **every** authenticated
role. It reuses the exact `account.controller` handlers as `/player/account/*` and `/admin/account/*`
@@ -883,6 +921,7 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
| GET | `/teams/:slug` | one Team. An **archived** Team still resolves, read-only, and names its successor when it was renamed — an old bookmark or Discord link lands somewhere that explains itself. A **hidden** Team returns 404, indistinguishable from one that does not exist: "absent from every public surface" includes not confirming it is there. Carries `id`/`externalId`/`moduleId` — this route only, since the index has no use for them |
| GET | `/teams/:slug/members` | the roster. In-game display names only — the member key is a game-internal identifier and the user id names a site account, and **neither is published**; `linked` answers whether a character has an account behind it without saying which. **Which rows** appear is the module's audience projection (`projectRoster`), applied per caller: a module that has a rung system and cannot be asked yields an EMPTY roster, not an unprojected one, flagged as `projectionUnavailable`. A session is optional and may widen the result |
| GET | `/teams/:slug/activity` | the Team's activity feed, paged, newest first. `public` items to anyone who can see the Team; `members` items additionally to members and forum-granted users, resolved from the session and never from a parameter. `scope` reports which the caller got, so a client can say "some entries are hidden" instead of presenting a filtered feed as the whole one. A hidden Team's feed does not answer the public but does answer its members |
| POST · GET | `/teams/unsubscribe/:token` | one-click unsubscribe from a Team's notification emails (phase 6, [`TEAMS.md`](TEAMS.md) §6.4). **The only write in this tier and the only route with no `siteMode`** — the reader is in their mail client, not signed in, and the mail went out before the site went into maintenance. The token is a stateless HMAC whose whole capability is "set `muted` for one (user, Team) pair". POST acts and **always answers 200**, valid token or forged: distinguishing them would be an oracle for which (user, Team) pairs exist. GET acts on nothing and redirects to the site's own `/unsubscribe/:token` page, because a mail client's link scanner must not be able to mute Teams |
| — | `/shard/*` · `/atlas/*` | **Served by `module-uo`, not by core** (25 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md); absent entirely when the module is not installed, which is a 404 and not an error. |
Public content GETs pass through the **siteMode** gate (§5).
@@ -960,6 +999,7 @@ file a route sits in — that is the property the route manifest freezes.
| POST | `/teams/:id/unhide` · `/teams/:id/display-name` | the two **gated** actions (§2.9): an admin applies at once, a **moderator** files a pending request and nothing changes publicly. The caller does not choose — the server decides from the role it re-validates on the request |
| GET | `/teams/:id/grants` | the full forum-grant ledger, revoked rows included. Read-only in this phase; the grant flow lands with the forums |
| POST | `/teams/:id/leader-override` · DELETE `…/:memberKey` | set or clear a staff leadership decision, applied **on top of** the synced value at read time. Not gated: it publishes no game-sourced string |
| GET | `/moderation/reports` · POST `…/:id/handle` | the member-raised content-report queue (phase 5, [`TEAMS.md`](TEAMS.md) §5.6) and the staff decision on one. Mounted under **moderation**, not under Teams: a staffer working a queue should have one place to work, and `target_type` is open-ended so the next reportable thing arrives as a row rather than as a screen. Each row carries its target already resolved — a post's excerpt and author, a thread's title, or an upload's uploader, byte size and **sniffed** mimetype — in three batched reads, never one per row. A target hard-deleted since reporting comes back `null` and the row still lists. **There is no leader-facing counterpart to either route**, deliberately |
| GET | `/teams/review` | the reserved-name review queue — Teams auto-hidden because their name matched, each showing which term |
| GET | `/teams/requests` · POST `…/:id/decide` | the approval queue, and the decision. **Admin only** to decide, checked live rather than from a token claim; a request already decided returns `409`, so two admins deciding at once cannot double-apply |
| — | `/shard/*` · `/uo-link/*` | **Served by `module-uo`, not by core** (33 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md) |

View File

@@ -33,14 +33,21 @@ The client half carries the same number (`client/src/modules/version.js`) and a
agree. Duplicated rather than fetched because the value has to be on `window.__rg` before the first
module chunk evaluates, which is earlier than any network round trip could answer.
**1.6.0 — Teams, the whole surface.** Eight additions, no removals and no changed signature, so minor;
**1.6.0 — Teams, the whole surface.** Nine additions, no removals and no changed signature, so minor;
`module-uo`'s `coreApi: "^1.3.0"` still resolves. `api.registerTeamProvider(...)` and
`ctx.teams.publish` / `ctx.teams.reconcile` (§2.3, §2.4a) · `ctx.teams.activity.push` ·
the provider's optional `projectRoster` · `api.registerSlashCommands(...)` ·
the provider's optional `projectRoster` and `pageUrlTemplate` · `api.registerSlashCommands(...)` ·
`registry.declareModuleSlot(...)` with `Slot` in the UI kit.
> **Amended 2026-08-17 (phase 3), on the org lead's decision.** Two changes.
>
> **Amended again 2026-08-18 (phase 6), on the org lead's decision.** A **ninth** member,
> `pageUrlTemplate` on the team provider, joins 1.6.0 in place — same rule as the eighth below, and
> 1.6.0 is still `edge`-only. It is the one thing phase 6 found that the design of record had not
> anticipated: after phase 3 deleted core's Team pages, nothing in this contract could tell core where
> a Team page actually is, so a notification email could name a Team and not link to it. See
> `registerTeamProvider` below.
>
> **The eighth member joins 1.6.0 in place rather than getting a 1.7.0.** The rule is the one Protocol
> 4 was given in phase 2 — *a contract owes a bump only once it has landed on `main`* — and 1.6.0 has
> only ever been on `edge`. `ctx.teams.activity.push` is live now rather than throwing.
@@ -395,7 +402,8 @@ Before it existed, core's post controller required `utils/newsGump` directly —
naming a UO file, and the last thing binding core to the module.
**`registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })`** — added in API 1.6.0. The
module becomes the authoritative source of Teams for this deployment.
module becomes the authoritative source of Teams for this deployment. Two further members,
`projectRoster` and `pageUrlTemplate`, are optional and documented below.
**One provider per deployment.** Unlike every other registry, this holds a single value: Teams have
one authoritative source by construction, and two modules answering "what Teams exist" would produce
@@ -410,6 +418,8 @@ getTeamMembers(externalId) // (string) => Promise<{ ok, complete?, members }>
getTeamLeaders(externalId) // (string) => Promise<{ ok, leaders }> // leaders = [memberKey]
projectRoster(externalId, members, viewer) // OPTIONAL (1.6.0, phase 3)
// => Promise<{ ok, members }> // members = [memberKey]
pageUrlTemplate // OPTIONAL (1.6.0, phase 6) — DATA, not a method
// e.g. '/uo/guilds/{externalId}'
// authoritative
{ ok: true, complete: true, teams: [ { externalId, name, abbr?, meta? } ] }
@@ -447,6 +457,27 @@ Core distinguishes two refusals, and a module does not have to do anything to ge
serves an empty roster and says so in the response (`projected: false`,
`projectionUnavailable: true`).
**`pageUrlTemplate` is the fifth member, it is data rather than a method, and it exists because core
cannot link to a Team page.** Teams are a contract primitive with **no core surface** (TEAMS.md
Part 3): core owns the tables, the sync and the access rules, and the module that owns the vocabulary
owns the page. That is settled and right, and it leaves core unable to write the link a notification
email needs — an email about a forum reply that cannot take you to the thread is most of the way to
useless. So the module that owns the page says where it is.
```js
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders,
pageUrlTemplate: '/uo/guilds/{externalId}' })
```
Core substitutes `{externalId}` and `{slug}` and does nothing else with it. **A relative path only** —
a template naming its own host is refused at registration, since there is no reason for a module to
redirect the site's outbound mail, and a protocol-relative `//host/x` is refused with it. Omitting the
member costs the deployment clickable links in Team notification email and nothing else.
**Data rather than a callback, deliberately.** A function here would put a module hook on the mail
path — one more thing that can hang or throw between a forum reply and the mail about it — to produce
a string that never varies.
Core hands over the roster rows it holds plus a described viewer — `{ userId, role }`, or `null` for
an anonymous caller — and never the `users` row, which would make every column of that table part of
this contract. **The module answers with member KEYS, not rows.** Core keeps ownership of what a
@@ -1201,19 +1232,23 @@ forced it is worth stating because it will recur:
```js
// In the module's entry chunk, at registration time:
registry.declareModuleSlot(ID, 'uo.guild.header')
registry.declareModuleSlot(ID, 'uo.guild.detail')
registry.declareModuleSlot(ID, 'uo.guild.forum')
// In the module's page, from the UI kit:
<Slot name="uo.guild.header" externalId={guildId} moduleId="uo" />
<Slot name="uo.guild.detail" externalId={guildId} moduleId="uo" />
<Slot name="uo.guild.forum" externalId={guildId} moduleId="uo" />
```
**A module declares one slot per PLACE, not one per page.** `module-uo` declares two on the same guild
page — core fills the first with the Team activity feed and the second with the Team forum — because a
slot holds one component and the first fill wins. Collapsing them into one would hand core the
decision about where each of its contributions sits, on a page the module owns. Two also keeps them
independent: a deployment with the forum switched off renders the feed unchanged.
**A module declares one slot per PLACE, not one per page.** `module-uo` declares **three** on the same
guild page — core fills them with the Team notification control, the activity feed and the Team forum
— because a slot holds one component and the first fill wins. Collapsing them would hand core the
decision about where each of its contributions sits, on a page the module owns, and the module does
use that freedom: the notification control goes **above** the roster because muting is an action *on*
the page, and the other two go below it because they are content *in* it. Separate slots also keep
them independent: a deployment with the forum switched off renders the other two unchanged.
**The name must be namespaced under the declaring module's id**, and that is enforced rather than
conventional: it is the only thing keeping two modules from claiming one name, and it makes the owner

View File

@@ -1071,6 +1071,31 @@ reason (§5.5.1).
> toggle-off revokes no grant and the rows stay authoritative (§5.5.1), so the access list has to stay
> manageable during one. What the switch guards is the forum's CONTENT.
> **Amended 2026-08-18 (phase 5).** The 5b routes are as tabled, with three notes the table does not
> carry.
>
> **`POST /forum/threads` splits its authority BY TYPE rather than widening the leader gate.** An
> `announcement` stays leader-authored; a `discussion` may be opened by any forum participant —
> including a granted non-member with no game identity, which is path 3 doing its job. `type` defaults
> to `announcement`, so a phase-4 client keeps meaning what it meant; defaulting the other way would
> silently turn its announcements into discussions. The list response reports the split as **two**
> booleans, `canPost` (may open a discussion) and `canAnnounce` (leader), because a client reading one
> boolean would have to guess which right it described.
>
> **Post-level moderation is its own route**, `POST /forum/posts/:id/moderate`, rather than the thread
> route with a target kind: `pin` and `lock` describe a thread's place in a list and its openness to
> replies, neither of which a post has. The route's validator deliberately accepts **all eight**
> actions so the model can answer `pin` with *"pin applies to a thread, not to a post"* — restricting
> it to the four a post takes turns a nameable mistake into a generic validation error, which is what
> the live rig found.
>
> **Three refusal codes on a reply, chosen to be distinguishable.** 404 for a thread that is absent or
> hidden from this caller; **400** for an announcement, which takes no replies by TYPE and no retry
> fixes; **409** for a locked thread, where the request is well-formed and the resource's state is what
> refuses. Locked refuses **staff too** — they hold `unlock`, so unlock/post/relock reaches the same
> place leaving three ledger rows that say what happened, whereas a moderator's reply in a thread
> nobody else may answer is the last word by fiat.
Under `/player` for the same reason as §2.11: a forum participant may be a plain player, and the tier
gate is `requireAuth`. Every route resolves access through the §2.5 resolver — never by checking
membership directly, which is how paths 1 and 3 would drift back together.
@@ -1300,6 +1325,34 @@ who accepted a liability notice is operator detail, exactly as `failure_reason`
The *rendering* decision is still made server-side. The client is told the mode so it can present the
right composer; it is never the thing that decides whether an image appears.
#### 5.5.7 `teams_forum_edit_window_minutes` — how long an author may edit (phase 5)
An ordinary `settings` key, `0`–`1440`, **default 15**, on the same admin screen as the other two. Set
to `0` it makes posts permanent once written, which is a legitimate operator choice rather than an
off switch — there is no state in which editing is "disabled" as opposed to "bounded at zero", and
inventing one would only give the resolver a decision to get wrong.
**Staff are not bound by it.** The window exists so a post cannot be rewritten out from under someone
quoting it, or under a moderator about to act on a report; a staffer editing another member's post is
already an intervention that writes `activity_log` (§5.3), and time-bounding it would only mean
waiting.
**It is evaluated on the server twice, on purpose.** The read path stamps every post with `canEdit`
and `editableUntil` so a client knows whether to draw the control; the write re-derives it from
`created_at` before allowing anything. Two evaluations of one rule: the read one is advice and the
write one is enforcement. A client may use `editableUntil` to WITHDRAW an offer whose deadline passed
while a page sat open, and can never create one — **a time-bounded permission must not take its clock
from the party it bounds**, which is why the window itself is not a published setting (§5.5.6) and is
served only to the admin screen that edits it.
A hidden or deleted post is editable by nobody, staff included. Restoring it is a moderation action
with a ledger row; quietly rewriting it while it is out of sight is the same act with no record.
The read fails closed to **zero**, not to the default — the opposite of what it looks like it should
do. The risk the window bounds is an author rewriting a post out from under a reader, so the safe
answer during a DB fault is "nobody may edit for the next minute". A stale uploads acknowledgement
freezes this key along with the other two: it is a forum setting.
### 5.6 Abuse reports — the missing half of moderation
**Core has no user-facing report flow of any kind today.** `moderation`, `mod_notes` and `appeals` are
@@ -1335,11 +1388,46 @@ CREATE TABLE IF NOT EXISTS content_reports (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
> **Amended 2026-08-18 (phase 5). The table as shipped departs from the block above in four places,
> three of them corrections and one an addition.**
>
> **The unique key is on a generated `open_marker`, not on `status`, and the spelling above has a
> defect worth recording rather than quietly fixing.** With `status` in the key, CLOSED rows collide
> with each other too: a reporter reports a post, staff dismiss it, the behaviour recurs, they report
> it again — and the second dismissal is an `UPDATE` into a `(…, 'dismissed')` tuple that already
> exists, so working the queue starts throwing duplicate-key errors on the first repeat reporter. The
> shipped column is `open_marker TINYINT(1) AS (IF(status IN ('open','reviewing'), 1, NULL)) STORED`,
> the same trick `team_forum_grants.active_marker` uses: 1 while open, NULL once closed, and MySQL
> treats NULLs as distinct — so any number of closed reports coexist while at most one open one can.
> That is what the prose above actually asks for.
>
> **`handled_note VARCHAR(500)`** was added. §5.6's API takes `{ status, note? }` and the table had
> nowhere to put the note. A queue whose resolution reason lives only in an `activity_log` line is one
> where the next staffer to see a repeat report about the same content cannot find out why the last
> one was closed.
>
> **`reporter_username` and `handled_username` snapshots** were added, per §2.10: who raised a report
> and who decided it must survive the account, exactly as every other Team table already does.
>
> **`team_id` gained a real FK with `ON DELETE CASCADE`.** The block above leaves it a bare
> denormalised column; a deleted Team then leaves a queue full of reports about content that cascaded
> away with it.
>
> Every transition writes `activity_log`, **`dismissed` included**. A queue where acting is audited and
> declining to act is not is one where the cheapest way to make a report vanish leaves no trace — and
> the reports most worth auditing are exactly the ones somebody wanted gone.
Four rules:
- **Reports go to site staff, not to Team leaders.** A leader may also see and act on reports for
their own Team, but staff always receive them — the whole point is a path that routes *around* a
Team's own leadership.
- **Reports go to site staff, and to nobody else.** *(Amended 2026-08-18, org lead, when phase 5 was
built.)* This section originally added "a leader may also see and act on reports for their own
Team". **That half is not implemented and is not deferred — it is decided against.** The gap this
whole section exists to close is that leaders moderate their own Team's forum and a Team's leaders
are exactly the people who will not report their own Team; a leader-visible queue hands a complaint
*about* a leader straight back to them, and a read-only leader view still tells them who reported
what. There is one queue, under `/admin/moderation`, gated to admin + moderator. If a leader-facing
surface is ever wanted it is a fresh design decision, not a refactor — `content_reports.team_id`
makes it *possible*, which is not the same as intended.
- **Reporting is not a moderation action.** A report changes nothing about the content; it opens a
queue item. This keeps it clear of §5.3's leader/staff moderation ledger, which records things that
actually happened.
@@ -1355,6 +1443,20 @@ GET /api/v1/admin/moderation/reports the queue, alongside the exis
POST /api/v1/admin/moderation/reports/:id/handle { status, note? }
```
The player route sits behind the same `resolveForum` guard as the rest of §5.4, so a reporter is by
construction someone who can already see what they are reporting — and the model additionally checks
the target really belongs to the Team the request came through, or the queue's per-Team filter would
quietly be lying. A duplicate answers **409** rather than pretending to succeed: silently accepting is
friendlier for one tap and dishonest for the second, and a member who reports twice because nothing
seemed to happen deserves to be told the first is already in the queue.
The queue resolves every row's target in **three batched reads** keyed by target type, never one read
per row — that is rule 4 above actually paying for §5.5.4's attribution table, and the N+1 version is
how a queue becomes a thing staff avoid opening. A target that has since been hard-deleted comes back
as `null` and the report still lists: "somebody reported this and by the time we looked it was gone"
is a fact a moderator needs, and dropping the row would hide the pattern of a member deleting their
own content the moment it is reported.
Mounted under the **existing** admin moderation section rather than under Teams: a staffer working a
queue should have one place to work, and a report about a forum post is the same job as a report about
anything else.
@@ -1385,6 +1487,35 @@ CREATE TABLE IF NOT EXISTS team_forum_uploads (
## Part 6 — Notifications
> **Built 2026-08-18 (phase 6).** As-built, and it deviates from what is written below in four
> places. Each is recorded here rather than by rewriting the section, so the reasoning that produced
> the original design stays legible next to what the build learned:
>
> 1. **There was no web notification settings screen to add the Team list to.** §6.3 says the per-Team
> mute list is surfaced "under the existing notification settings screen". No such screen existed:
> `/auth/me/notifications/*` had been built for the Android app in M7 and had **zero** web
> consumers. Tolerable while push was the only sink — push needs the app anyway. Not tolerable for
> email, whose entire argument (§6.4) is the web-only user, so the sink and the screen to configure
> it shipped together as `/account/notifications`.
> 2. **Email defaults to `off`, not to `digest`.** §6.4 specifies digest-by-default; on the org lead's
> decision it is opt-IN, because digest-by-default means every member of every Team starts
> receiving daily mail the moment an operator connects Gmail — a decision about other people's
> inboxes, made on their behalf. **Push stays opt-out.** The two sinks now default opposite ways;
> the asymmetry lives in the schema's column defaults and nowhere else.
> 3. **Roster events do not email.** All four streams exist and all four tickle. Only the two forum
> streams reach the email sink: §6.4's argument is the reply nobody hears about, and "someone
> joined the guild" arrives from a fifteen-minute sweep, is already on the activity feed, and is
> how a notification feature earns a spam complaint.
> 4. **A ninth member joined `MODULE_API_VERSION` 1.6.0** — `pageUrlTemplate` on the team provider.
> Phase 3 left core with no Team page and therefore no way to *link* to one, so an email could name
> a Team and not take you to it. The module that owns the page now says where it is. See
> [`MODULE_API.md`](MODULE_API.md) `registerTeamProvider`.
>
> Two further build decisions, neither contradicting anything above: the digest **computes at send
> time** and keeps no queue (§6.4 as-built, below), and one-click unsubscribe is a **stateless
> HMAC** rather than a token table.
### 6.1 What the existing pipeline gives us, and the one thing it does not
Reusable unchanged: ntfy itself (a compose service, declarative config, no per-user accounts), the
@@ -1454,9 +1585,23 @@ CREATE TABLE IF NOT EXISTS team_notification_prefs (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
Applied as a subtraction from the computed recipient set. Surfaced as a mute toggle on the Team page
and as a list under the existing notification settings screen (`GET|PUT
/auth/me/notifications/teams`), which the Android app can adopt without a new screen concept.
Applied as a subtraction from the computed recipient set — **in SQL, not in the caller**: there is no
function in `model/teams/teamNotify.db.js` that returns an unfiltered recipient list, because one
would be a refactor away from being used.
**As built**, the column is `email_mode ENUM('off','digest','immediate') NOT NULL DEFAULT 'off'` plus
a `last_digest_at DATETIME NULL` (the digest's only state, see §6.4), and it is surfaced in two
places:
- **`/account/notifications`**, a new core page in the player portal — stream subscriptions, the
per-Team mute list, and the email mode per Team. `GET|PUT /auth/me/notifications/teams`; the `teams`
array is required on PUT even when empty, per the Android gotcha below.
- **A mute toggle on the Team page**, filled into a THIRD module-declared slot, `uo.guild.header`.
Above the roster rather than below it, because muting is an action *on* the page and the feed and
forum are content *in* it — which is exactly the placement decision a module cannot make if core
stacks everything into one fill. It renders nothing for a viewer with no preference row available,
which is a privacy property and not a tidiness one: whether a preference *exists* for a Team answers
"is this person in it", and the guild page is public.
### 6.4 Email — the third sink, already built and unused
@@ -1472,13 +1617,43 @@ same event**, not a fourth pipeline.
- **Unlike a push tickle, an email carries content** — the same reasoning as the Discord bridge
(§7.2): the recipient's mailbox is a destination they chose, not an untrusted relay reached by an
unguessable topic. It carries the thread title, an excerpt and a link; never the full post.
- **Digest, not per-event, by default.** A busy Team forum sending one email per reply is how a
notification feature gets marked as spam. Default to a daily digest per Team with an immediate
option, stored in `team_notification_prefs` as a `email_mode ENUM('off','digest','immediate')`
column.
- **Digest, not per-event, when email is on at all.** A busy Team forum sending one email per reply is
how a notification feature gets marked as spam. `email_mode ENUM('off','digest','immediate')` in
`team_notification_prefs`.
> **As built, the default is `off` and not `digest`** (org lead, 2026-08-18): digest-by-default
> would start mailing every member of every Team the moment an operator connects Gmail. Email is
> the one opt-IN sink here. Push stays opt-out, because a mute silences something the user already
> has.
- **The digest computes at send time and keeps no queue** (as built). The only state is
`last_digest_at`; the worker asks what arrived after it and re-runs the access resolver. Three
properties fall out, and the third is why it was chosen over a pending-items table: a deployment
down for two days sends **one** correct digest rather than replaying a backlog; a post a moderator
hid after it was written is simply not in the query; and **a user who lost forum access between the
post and the send is no longer in the recipient set**, so they are not emailed content they can no
longer read. `since` is clamped to at most seven days so a long outage cannot produce one enormous
mail, and `last_digest_at` is stamped **only on a successful send** — stamping first would quietly
eat a day of somebody's notifications every time the mail provider had a bad minute.
- **Roster events do not email** (as built). `team.member.joined` and `team.leadership.changed`
tickle and stop there; only `team.forum.post` and `team.announcement` reach this sink.
- **Off unless email is configured.** No `email_config` row means the sink is absent, not broken.
- One-click unsubscribe link honouring the same per-Team mute, so an unsubscribe from the mail client
writes the preference the site shows.
> **As built: a stateless HMAC over `(version, userId, teamId)`, not a token table.** Every property
> that makes a password-reset token a row is absent here — the link sits in a mailbox for months so
> it has no useful expiry, and clicking it twice must mean what clicking it once meant. The
> capability it carries is deliberately the narrowest that does the job: set `muted` for **one**
> (user, Team) pair. It reads nothing, cannot un-mute, and names no other Team. `version` is the
> only revocation a stateless design can offer — bumping it invalidates every outstanding link at
> once — and it exists before it is needed rather than after.
>
> **Two URLs come out of one token, and they are not interchangeable.** The mail *body* carries the
> site's own `/unsubscribe/:token` page, which POSTs once a human is looking at it. The
> `List-Unsubscribe` *header* carries `POST /api/v1/public/teams/unsubscribe/:token`, because RFC
> 8058 lets a client POST to it without rendering anything. **A GET on the API path redirects and
> does not act** — a mail client's link scanner would otherwise silently mute Teams nobody asked to
> leave. The endpoint answers `200` whatever the token was: a response that distinguished a valid
> token from a forgery would be an oracle for which (user, Team) pairs exist, on a surface with no
> session behind it.
Folded into **Phase 6** rather than getting a phase of its own: the recipient set is the work, and it
is already being built there.
@@ -1990,6 +2165,11 @@ makes §0.1's roster possible:
Every phase is independently shippable and leaves the site working. Phases 1 and 2 are the only hard
serial dependency in the list.
**Phase 11 is the exception to "independently shippable", and it is last on purpose** (org lead,
2026-08-18). The integration kit teaches an outside audience to build against this contract; Teams
expands the contract, so the book is the last thing owed before `edge` becomes `main`. It is also the
only phase that cannot merge until the cutover exists — see its own note.
### Phase 0 — a one-guild roster spike (`servuo-plugins` + `link`, throwaway)
**Not a deliverable — insurance on the phase that gates everything else.** `servuo-plugins` has no CI
@@ -2231,6 +2411,34 @@ to run it, at low surface area.
### Phase 5 — Forum 5b: discussion + moderation + reports (`website`)
> **Amended 2026-08-18, while building this.** Five notes. The first is the org lead's decision; the
> rest were found by building the thing described below, or on the live rig afterwards.
>
> **Reports are site administration only.** §5.6's "a leader may also see and act on reports for their
> own Team" is decided against, not deferred — see the amendment there. It is the phase's most
> important property and it is a NEGATIVE one, so it is asserted directly in the test suite rather
> than left to be noticed: the report model's whole function surface is pinned, and `queue`/`handle`
> are checked not to mention leadership at all.
>
> **The edit window is an admin setting, not a constant** (§5.5.7), and it is evaluated on the server
> twice — once as advice on the read path, once as enforcement on the write. That is the phase's other
> structural rule: a time-bounded permission must not take its clock from the party it bounds.
>
> **§5.6's unique key does not work as written**, and the shipped table uses a generated `open_marker`
> instead. See the amendment there; it is the one place in this document where the SQL and the prose
> beside it disagreed.
>
> **This phase spans ONE repo, which is worth saying because phase 4 did not.** Phase 4 needed
> `module-uo` because the forum had no surface after phase 3 and a slot had to be declared. Phase 5
> grows the component that fills that slot, so `uo.guild.forum` is untouched and nothing in the module
> changes.
>
> **The live rig found one defect, and it was a message rather than a behaviour.** The post-moderation
> route's validator listed only the four actions a post accepts, so `pin` returned a generic
> "Validation failed" instead of the sentence written for it — leaving that branch reachable only from
> its own unit test. Walking the surface for real is what turns "documented, tested and unreachable"
> into something anyone notices.
Discussion threads, replies, the edit window, pin/lock/hide/delete, `team_forum_moderation`, the admin
ledger view, and **abuse reporting** (§5.6): `content_reports`, the report control, and the queue in
the existing admin moderation section.
@@ -2238,14 +2446,39 @@ the existing admin moderation section.
Reports land here rather than in Phase 4 only because discussion is what generates them at volume — if
Phase 4 ships `uploads` mode enabled anywhere before Phase 5, **pull reports forward into Phase 4**.
An upload path with a liability acknowledgement and no way for a member to raise a problem is the one
combination this plan should not ship.
combination this plan should not ship. *(In the event, phase 4 shipped `uploads` mode with the default
off, so nothing was pulled forward.)*
### Phase 6 — Team notifications (`website`)
Also lands here, because both had existed since phase 4 with nothing rendering them: the **per-Team
forum moderation ledger** on the admin Teams screen — the `actor_role` column that keeps a leader's
housekeeping distinguishable from a staff intervention was readable only from a DB client — and
`softDeleteUploadsForPost`, which post deletion is the first caller of and which needed an inverse so
`delete` → `restore` does not return a post's words while silently losing its pictures a retention
window later.
**Acceptance, four:**
1. A member opens a discussion and a granted non-member replies to it; the same member is refused an
announcement `403` while a leader is allowed one.
2. A locked thread refuses replies at `409` from every identity **including staff**, and unlock →
reply → relock leaves three rows in the Team's ledger saying so.
3. An author edits their own post inside the window and is refused `403` outside it; staff edit the
same post at any time, and a staff edit of somebody else's post writes `activity_log` while a
member's own edit does not.
4. A member reports a post; the report reaches `/admin/moderation/reports` and answers `403` to the
Team's own leader, to the reporting member and to every other participant; handling it changes the
report's status and **nothing at all** about the content.
### Phase 6 — Team notifications (`website` + `module-uo`) — **DONE 2026-08-18**
Four core streams, `publishToUsers` + `endpointsForUsersStream`, the recipient computation,
`team_notification_prefs`, its settings screen, and **email as the third sink** (§6.4) with digest
mode and one-click unsubscribe.
**TWO repos, not the plan's one.** `module-uo` joined for two lines it alone can supply: a third
declared slot (`uo.guild.header`, for the mute toggle) and `pageUrlTemplate` on its team provider,
without which core cannot write a link to a Team page at all — see the four amendments at the head of
[Part 6](#part-6--notifications).
**Android is deliberately not in this phase** — see the deferred note in
[`../android/PLAN.md`](../android/PLAN.md). The streams exist in the catalog and the app will show
them as toggles automatically, but nothing here builds a Team screen or a deep-link target for the
@@ -2277,6 +2510,56 @@ rendering the admin UI from the declaration rather than from a hardcoded "Discor
purpose: extracting a capability surface from one working implementation is honest; designing it before
one exists is speculation.
### Phase 11 — the integration kit (`integration-kit`) — **the last phase before the cutover**
The kit is the instruction book for putting a *different* game on this platform, written for an
audience outside this org. Teams expands the contract that book teaches against, so the book is the
last thing this bet owes before `edge` becomes `main`.
**One sentence in it is already wrong.** `book/02-website-module.md` states, of extension slots,
"**core declares a slot; a module may only fill one**". Phase 3 inverted exactly that: with
`declareModuleSlot` a MODULE declares a place on its own page and CORE fills it, and by phase 6
`module-uo` declares three. A new game's module cannot implement Teams at all without the inverted
direction, so this is not a stale detail — it is the shape the reader needs and does not have.
**Two genuinely new shapes to teach, and only two:**
- **The inverted slot** (§3.7a) — a module declaring a place for core, why the name is namespaced under
the module's own id, and why a module wants *separate* slots rather than one (it decides where each
of core's contributions sits on a page it owns).
- **`registerTeamProvider`** — the first registration where **core calls the module and waits**. Every
other one is the module claiming a mount or core notifying it. The envelope, the 10-second budget,
and the asymmetry that matters: every call fails **stale** (core keeps what it has) except
`projectRoster`, which fails **closed**, because for a visibility question "keep what you have"
means serving the roster unprojected.
`pageUrlTemplate` is a footnote beside those — one optional string, and the reader meets it while
reading the provider.
**What this phase explicitly does NOT do: enumerate the contract.** The kit already teaches only four
members and has never mentioned `registerNotificationStreams`, `registerAnnounceLeg` or
`registerPostHook`, all of which predate Teams. That is the design, not a gap:
[`MODULE_API.md`](MODULE_API.md) is normative and the kit teaches one path end to end and links out.
The question this phase answers is "did the teaching path change", and the answer is yes in two
places and no everywhere else.
**Then the two mechanical lines:** `ci/core-ref.json`'s sha moves to the cutover commit and
`template/module.json`'s `coreApi` becomes `^1.6.0`, which puts `scripts/checkCoreApi.js` back to
green. That check is an **equality**, and its going red is the mechanism rather than a bug — a
contract bump is meant to turn that repo red until someone has re-read the chapters. Moving the pin is
that person saying they have.
> **Ordering, stated because it is genuinely awkward.** This phase is written *before* the cutover and
> can only *merge after* it. CI clones the pinned sha and checks the template against that core's
> `MODULE_API_VERSION` — and 1.6.0 does not exist on `main` until the cutover lands, so there is no sha
> to pin and no core for the template to build against until then. Write the chapters last, open the
> PR once the cutover merge exists, and put the pin move in it.
**Checks that gate it** (all dependency-free Node scripts, run from the repo root — which is also how a
reader runs them): `checkLinks`, `checkRenameSites`, `checkChapterPaths`, `checkCoreApi --core .core`,
plus the template's own `npm ci` / `check:imports` / `build` / `check:externals` / `npm test` on both
halves. Build the client **before** the client tests; two of them read the built chunk.
### Cross-cutting, every phase that touches the server
`npm run swagger` regenerated and committed · `npm run routes:manifest -- --check` zero-line diff ·