diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md index 147f2f1..25f00d5 100644 --- a/link/INTEGRATION.md +++ b/link/INTEGRATION.md @@ -283,11 +283,26 @@ Guilds expose only one in-game event (a member joining), so the roster is polled | kind | fields | notes | |------|--------|-------| -| `guild.update` | `id`, `name`, `abbr`, `members`, `online`, `alliance` (or null), `leader` (actor object or null) | A guild's roster/leader/alliance changed, or its first sight this connection. A **leave** shows up here as `members` dropping. | +| `guild.update` | `id`, `name`, `abbr`, `members`, `online`, `alliance` (or null), `leader` (actor object or null) | A guild's leader/alliance/name changed, its member count moved, or its first sight this connection. | | `guild.remove` | `id` | The guild disbanded (leader gone) or was removed. Drop the row. | | `guild.join` | `id`, `name`, `abbr`, `who` (actor object) | Real-time: a player joined a guild (`EventSink.JoinGuild`). | +| `guild.roster` **(4)** | `id`, `name`, `abbr`, `total`, `seq`, `more`, `members` (array of actor objects **carrying rank**) | The full member list. Emitted whenever the member set changes. **`seq` 0 supersedes whatever roster you hold for that guild; `more: false` ends it.** | +| `guild.leave` **(4)** | `id`, `name`, `who` (serial string) | Real-time: a member left. Advisory — see below. | -The `leader`/`who` **actor object** is `{serial, name, acct?, webId?, player}` — `acct`/`webId` present when the mobile has an account / a linked website user. +The `leader`/`who` **actor object** is `{serial, name, acct?, webId?, player}` — `acct`/`webId` present when the mobile has an account / a linked website user. Note `guild.leave`'s `who` is a bare **serial string**, not an actor object: the mobile has already left, so there is nothing to attribute. + +**A roster member carries rank as well.** `rank` is 0–4 with 4 being Leader, plus `rankCliloc` (the cliloc the game names that rank with) or `rankName` when a shard uses custom rank definitions with literal names. Only the raw rank is sent: ServUO ships no text for those clilocs, so turning 1062960 into "Warlord" is the consumer's job. + +Two things to get right, both of which bite: + +- **Several members can hold rank 4.** `guild.update`'s single `leader` is the guild's founder-leader; it is not the set of leaders. If you need "who leads this guild", read the roster's ranks and treat `leader` as one more entry rather than the answer. +- **An absent `rank` means "not known" — never 0, and never leadership.** It has one deliberate cause: `PlayerMobile.GuildRank` reports Leader for any account at GameMaster or above whatever their real rank, so the bridge omits the rank for staff rather than publishing a claim it knows is false. Defaulting a missing rank to 0 silently demotes them; reading absence as leadership republishes exactly the lie the bridge avoided. + +**On Protocol 4.** Before it, a guild's membership was a *count* and a leave surfaced only as that count dropping. `guild.roster` carries the members themselves, and `guild.leave` names who went. + +`guild.leave` is **advisory**: any change to the member set re-emits the whole roster, so a consumer holding a membership table stays correct even if it ignores every leave event. Handle it when you want a "so-and-so left" feed to update without waiting for the sweep. + +**Rosters can arrive in several frames.** Members per frame are capped so a large guild cannot produce an unbounded line (~69 bytes per member; the default cap is 500). Every realistic guild arrives as one frame with `seq: 0, more: false` and needs no special handling — but if you consume the raw stream, accumulate from `seq` 0 and apply on `more: false`, discarding a partial roster if a frame arrives out of order or the shard reconnects. `GET /guilds` hands you rosters already reassembled. A guild with no members emits one frame with an empty array, so an emptied roster is distinguishable from an absent one. ```json {"kind":"guild.update","id":1042,"name":"The Silver Hand","abbr":"TSH","members":14, @@ -296,8 +311,16 @@ The `leader`/`who` **actor object** is `{serial, name, acct?, webId?, player}` "t":1752489280000} {"kind":"guild.join","id":1042,"name":"The Silver Hand","abbr":"TSH", "who":{"serial":"0x77","name":"Bran","acct":"bran","player":true},"t":1752489281000} +{"kind":"guild.roster","id":1042,"name":"The Silver Hand","abbr":"TSH", + "total":14,"seq":0,"more":false, + "members":[{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true}, + {"serial":"0x77","name":"Bran","acct":"bran","player":true}], + "t":1752489282000} +{"kind":"guild.leave","id":1042,"name":"The Silver Hand","who":"0x77","t":1752489283000} ``` +`acct` is genuinely optional on a member — a character can have no account at all — so do not assume it is present. + Render the current board from `GET /guilds` (§6) on connect, then keep it live with these events. #### Town governors (Protocol 2.0) @@ -853,7 +876,17 @@ GET /guilds "t":1752489280000}, ... ] } ``` -Every guild's latest roster snapshot at once — the live board. Served from the sidecar's projection (no shard round-trip), kept current by the `guild.*` stream (§4). Render on load, then subscribe. Each entry is exactly a `guild.update` payload; ordered by name. Survives a sidecar restart. +Every guild's latest snapshot at once — the live board. Served from the sidecar's projection (no shard round-trip), kept current by the `guild.*` stream (§4). Render on load, then subscribe. Ordered by name. Survives a sidecar restart. + +Each entry is a `guild.update` payload **plus, from Protocol 4, a `roster` key** holding the member list — already reassembled, so the frame-splitting described in §4 never reaches this endpoint: + +``` +→ { "guilds": [ {"kind":"guild.update","id":1042, ..., "roster":[ + {"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true}, + {"serial":"0x77","name":"Bran","acct":"bran","player":true} ]}, ... ] } +``` + +A guild that has had a `guild.update` but no roster yet has **no `roster` key at all** — deliberately distinct from `"roster": []`, which means the guild is genuinely empty. Do not conflate "not known" with "known to be empty". ### Governor board (Protocol 2.0) diff --git a/link/PROTOCOL_2.md b/link/PROTOCOL_2.md index cd9944c..2b4f20b 100644 --- a/link/PROTOCOL_2.md +++ b/link/PROTOCOL_2.md @@ -236,6 +236,14 @@ These are **outbound** streams (shard → website), the natural extension of `PL ### 10.1 Guilds +> **Built in two halves.** 2.0 shipped the board (`guild.update` / `guild.remove`) plus the live +> `guild.join`, and folded membership into the board signature as a serial *sum* — so the site got a +> member **count** and no roster, and the `guild.leave` sketched below was never emitted. **Protocol 4 +> builds the rest of what this section describes**: the member-serial set, its diff, `guild.roster` +> and `guild.leave`. See [`v4.md`](v4.md). One correction from doing it — a *sum* is not a safe stand-in +> for a set, because one member joining and another leaving between two sweeps offset each other and +> the guild reads as unchanged. v4 holds the real set. + **Hook reality (verified):** - `EventSink.JoinGuild` is real — raised at `Scripts/Misc/Guild.cs:1597` when a mobile joins a guild. Usable as a live `guild.join`. diff --git a/link/v4.md b/link/v4.md new file mode 100644 index 0000000..9789d9e --- /dev/null +++ b/link/v4.md @@ -0,0 +1,269 @@ +# Protocol 4 — Guild membership on the wire + +**Status:** Feature-complete on `edge`. All work lands on an `edge` branch in each repo; `edge` → `main` is the v4 cutover. +**Date:** 2026-08-17 +**Codebase:** ServUO 57.4, ``, net48 / x64, Expansion **EJ**. +**Companion to** [`PLAN.md`](PLAN.md) (1.0 read/event plane), [`PROTOCOL_2.md`](PROTOCOL_2.md) (2.0 provisioning + world-state streams), [`v3.md`](v3.md) (3.0 shard content + the visibility framework), [`INTEGRATION.md`](INTEGRATION.md) (website API). + +--- + +## 1. Why 4 + +Protocol 2 gave the website a guild board: name, abbreviation, leader, alliance, and a member +**count**. It could say a guild had 155 members. It could not say who they were, and there was no +event for anyone leaving one. + +That gap was known when 2.0 shipped. [`PROTOCOL_2.md` §10.1](PROTOCOL_2.md) sketched exactly this +design — hold a member-serial set, diff it each sweep, emit `guild.join`/`guild.leave` — and then +2.0 shipped only the half that needed no new state, folding membership into the board signature as a +serial *sum*. Protocol 4 builds the rest of what §10.1 described. + +The immediate consumer is a richer public Guilds page, which is worth the bump on its own merit. The +reason it is being built **now** is that [`../website/TEAMS.md`](../website/TEAMS.md) needs it: +platform Teams are sourced from game guilds, and nothing in Team core can be built against a count. +That is a dependency, not a coupling — nothing in this protocol version knows what a Team is. + +**Two kinds, both additive:** + +| Kind | Shape | Purpose | +|---|---|---| +| `guild.roster` | full member list, possibly split across frames | the membership itself; supersedes whatever was held | +| `guild.leave` | one departing member | the real-time counterpart to the existing `guild.join` | + +Nothing existing changed shape. `GET /guilds` grows a `roster` key; a v3 consumer that ignores it +keeps working. + +--- + +## 2. The shard side + +`BridgeSocial.cs` holds each guild's member serial **set** instead of folding it into the signature +as a sum. Two consequences, both the point of the change: + +- **A set comparison cannot collide.** The old sum could: one member joining and another leaving + between two sweeps offset each other, and the guild read as unchanged. +- **A set can be differenced.** Departures are the prior set minus the current one — which is what + makes a per-member `guild.leave` possible without a core tap, since ServUO raises no event for + leaving, disbanding, or a leader change. + +A changed set re-emits `guild.roster`, the whole member list. That is what lets `guild.leave` stay +**advisory**: a consumer building a "so-and-so left" feed wants the individual events, but a consumer +holding a membership table only needs the roster, so nothing downstream has to replay deltas to +remain correct. A dropped `guild.leave` costs latency, never accuracy. + +On a guild's **first** sweep there is no prior set, so nothing is reported as leaving. An unknown +roster becoming known is not 155 people leaving at once. + +### 2.1 Frames are capped, and split when they must be + +A roster is the only fat frame this bridge emits. Measured against a real 155-member guild: **10,812 +bytes, about 69 bytes per member.** The sidecar's `read_line` has no length bound, so an uncapped +roster is an unbounded line. + +Members per frame are therefore capped (`Bridge.GuildRosterMembersPerLine`, default 500 ≈ 35 KB), and +a guild over the cap is split into frames carrying `seq`, `more` and `total`: + +```jsonc +{"t":1786988708513,"kind":"guild.roster","id":1,"name":"The Silver Hand","abbr":"TSH", + "total":155,"seq":0,"more":true,"members":[ /* 50 actor objects */ ]} +{"t":1786988708514,"kind":"guild.roster","id":1,"name":"The Silver Hand","abbr":"TSH", + "total":155,"seq":1,"more":true,"members":[ /* 50 */ ]} +{"t":1786988708514,"kind":"guild.roster","id":1,"name":"The Silver Hand","abbr":"TSH", + "total":155,"seq":2,"more":true,"members":[ /* 50 */ ]} +{"t":1786988708514,"kind":"guild.roster","id":1,"name":"The Silver Hand","abbr":"TSH", + "total":155,"seq":3,"more":false,"members":[ /* 5 */ ]} +``` + +Reading the flags: **`seq` 0 begins a roster and supersedes whatever was held for that guild**; +`more: false` ends it. Every realistic guild is inside the cap and emits exactly one frame with +`seq: 0, more: false` — the same shape as if chunking did not exist. A guild with no members still +emits one frame with an empty array, or a consumer could never learn that a roster it holds has +emptied. + +`guild.leave` is a small frame naming the departed serial: + +```jsonc +{"t":1786988770099,"kind":"guild.leave","id":1,"name":"The Silver Hand","who":"0x1F5"} +``` + +### 2.2 The reconnect baseline is spread + +`BridgeLink.OnConnected` clears the diff caches, so after a reconnect **every** guild reads as +changed at once. The outbound queue is not the risk — the cap is 10,000 *lines* and a few hundred +guilds is a few hundred lines — but building hundreds of fat JSON frames in a single Core-thread tick +is exactly the stall this bridge exists to avoid. + +So at most `Bridge.GuildRosterGuildsPerTick` guilds (default 25) emit a roster per sweep. A guild over +budget keeps its previously held member set, so it still reads as changed on the next pass; its +`guild.update` has already gone, so the board's counts are current either way. While a baseline is +draining the sweep re-arms itself after 2 s rather than waiting a full `GuildSweepSeconds`, so +catch-up takes seconds instead of one sweep interval per batch. + +### 2.3 What a roster member carries + +Each entry is the standard actor object — `serial`, `name`, `player`, plus `acct` when the mobile has +an account and `webId` when that account is linked — **and the member's rank in this guild**: + +```jsonc +{"serial":"0x1F5","name":"Seed000A","acct":"seed_000","webId":"42","player":true, + "rank":4,"rankCliloc":1062959} +``` + +`acct` is **genuinely optional**: a `PlayerMobile` can have no `Account` at all, and the local test +world contains such mobiles. Consumers must not assume it is present. + +These identity fields are emitted unconditionally, by design — the sidecar is a forwarder, and +deciding who may see them is the website's job. See §4. + +#### Rank + +> **Added 2026-08-17, amending Protocol 4 in place.** The version is **not** bumped: Protocol 4 has +> not reached `main`, and a protocol owes a bump only once it has been released. The roster shipped +> without rank, and Teams phase 2 then found the consequence — the website could learn leadership +> only from the board's single `leader` field, so it could name exactly one leader while a UO guild +> routinely has several. + +| Field | Shape | When | +| --- | --- | --- | +| `rank` | integer 0–4, 4 being Leader (`RankDefinition.Ranks`) | whenever the rank is known | +| `rankCliloc` | integer — the cliloc the game names the rank with | when the rank's name is a cliloc, i.e. the five stock ranks | +| `rankName` | string | when a shard's custom rank definition carries a literal name instead | + +**Rank is on roster members only.** It is a property of a mobile's membership of *this* guild, not of +the mobile, and every other actor the bridge writes is a bystander, a killer or a governor, where +guild rank is meaningless. + +**Only the raw rank is emitted, never a resolved label.** ServUO names the five stock ranks with +clilocs (1062959–1062963) and ships no text for them, so the shard cannot produce "Warlord" without a +client-file table it does not have. The website module does have one, and resolving a game term is +its job in any case. + +**An absent rank means "not known", and a consumer must not read it as 0 or as leadership.** It has +one deliberate cause, which is the trap this amendment found: `PlayerMobile.GuildRank` returns +`RankDefinition.Leader` for anyone at **GameMaster or above**, whatever their real rank. That is a +gameplay convenience so staff can operate a guild stone, not a claim about who leads the guild — and +the true value is in a private field with no accessor. So the bridge writes **no rank at all** for a +staff account rather than publishing a leadership claim it knows to be false. A staff member who +genuinely leads their guild therefore appears unranked, which is a visible gap rather than a lie on a +public roster. + +**The sidecar is unaffected.** It treats roster members as opaque values and never reads a field +inside one, so a new member field needs no sidecar change and no store migration — which is the +forwarder design (§3) doing its job. + +--- + +## 3. The sidecar side + +`guild.roster` writes a **`members` column on the `guilds` board**, not a field inside the existing +`json` column. That column holds the verbatim `guild.update` line, and a roster written into it would +clobber the snapshot — name, abbreviation, leader, online count — that `guild.update` owns. Two +writers across two columns of one row keeps both as plain upserts: neither reads the other's value +first, so there is no read-modify-write and no ordering requirement between the two kinds. Either can +arrive first. + +`GET /guilds` folds the roster back in as `roster` at read time. A guild that has had a +`guild.update` but no `guild.roster` yet simply has **no `roster` key** — the honest representation +of "not known", and deliberately distinct from a guild whose roster is genuinely empty. + +`guild.leave` gets **no board projection at all**. The sidecar persists and broadcasts it like any +event and leaves the board alone; the roster self-corrects on the next `guild.roster`, which the +shard re-emits whenever the member set changes. Keeping the delta out of the projection is what keeps +the sidecar a forwarder rather than a thing that maintains state. + +### 3.1 Split rosters are reassembled in memory + +A roster split across frames is reassembled **before** it is stored, and written once on the frame +that closes it. Appending to the column per frame was rejected twice over: it would make the write a +read-modify-write — the exact thing the two-column split exists to avoid — and it would publish a +torn roster, since a reader hitting `GET /guilds` between frames would see a partial member list +presented as the whole truth. + +This is transport-level reassembly, the same category of work as turning bytes into a line, and it +holds nothing once a roster is complete. The ordinary single-frame case never enters the buffer at +all. Around it: a fresh `seq: 0` supersedes an abandoned partial, an out-of-order frame discards the +partial rather than storing one with an undetectable hole, a continuation with no start is ignored, a +`server.hello` drops every partial (a reconnected shard restarts each roster at 0), and accumulation +is bounded so a shard that never sends a closing frame cannot grow the buffer without limit. + +### 3.2 This is the first bump that needed a store migration + +`store.rs`'s `SCHEMA` is `CREATE TABLE IF NOT EXISTS`, which can add a table but **cannot add a +column to a table that already exists**. Every schema change up to and including Protocol 3.0 added +whole tables, so this never mattered and `ALTER TABLE` appeared nowhere in the repo's history. +`guilds.members` is the first column added to an existing table: without a mechanism it would simply +never reach an installed sidecar, and every roster write would fail. + +The counter is SQLite's own **`PRAGMA user_version`** — an integer in the database header, so it +costs no table and cannot drift from the file it describes. Each step runs in a transaction together +with the bump recording it, so a step lands completely or not at all and an interrupted run resumes +in the right place. + +- **A failure aborts startup**, which was already the behaviour. A half-migrated store answers the + website with confusing partial data, which is worse than being plainly absent — and the shard dials + *out*, so a sidecar that refuses to start never stalls the game. +- **A database written by a newer sidecar warns and continues.** Every step is additive, so a newer + schema has only columns an older reader ignores; refusing to start would turn rolling the binary + back — a recovery path — into a dead end. + +`sqlx::migrate!` was considered and rejected: it checksums each migration file, so editing an +already-released migration hard-fails startup, which is a poor trade for a schema of eleven +JSON-blob tables. + +--- + +## 4. Visibility + +Both kinds map to the existing **`guilds`** feature in the website's visibility framework +([`v3.md` §3](v3.md)). That mapping is required, not cosmetic: rule 2 fails an *unmapped* kind closed +to admin-only, which would have quietly kept rosters off the public Guilds page forever. + +Mapping them is safe because of how the framework strips fields. A roster is the first frame carrying +locked fields inside an **array** of actors rather than a single nested actor — but the projection +walker already recurses into arrays and matches `acct`/`webId` **by suffix, on meaning rather than +spelling**. So a member's account name is stripped below `admin` by exactly the rule that already +strips `guild.leader.acct`. + +The website stores `acct`/`web_id` per member, because that is what lets a linked member be matched to +a site user at all. It never projects them below `admin`. The difference this rule makes is a public +page listing character names versus one publishing 150 account names, so it carries a test of its own. + +--- + +## 5. Cross-repo obligations + +| Repo | Change | +|---|---| +| `servuo-plugins` | member-set diff, `guild.roster` + `guild.leave`, `BridgeJson.Actors`, two `Bridge.cfg` keys, **`overlay.toml` protocol → 4** | +| `link` | `PROTOCOL_VERSION` → 4, `members` column + `user_version` migration, roster reassembly, `GET /guilds` projection | +| `module-uo` | `shard_guild_members`, `guild.roster`/`guild.leave` ingest, the kind→feature map entries | +| `installer` | `backup.rs`'s stated reason for skipping the sidecar DB (§3.2 falsifies it) — docs only | +| `docs` | this file, `PROTOCOL_2.md` §10.1, `INTEGRATION.md` | + +**`overlay.toml` must move in the same PR as the emitters.** CI folds it into the release manifest and +the installer refuses to pair an overlay and a sidecar whose protocol numbers disagree, so a bump +landing separately would silently fail to compose into a bundle. + +--- + +## 6. Verification + +`servuo-plugins` has no CI build — the plugin compiles only inside ServUO — so "it compiled" is not +evidence and neither is a clean boot. What was actually run: + +1. **A throwaway one-guild spike first** (TEAMS.md Phase 0), against the real Rust sidecar rather than + a stub, to retire the unknowns before committing to a four-repo bump. It proved the two-column + board shape, measured the line, and surfaced §3.2 — the missing migration mechanism — which no + amount of reading would have. +2. **A live end-to-end run**: 155 members seeded from real `PlayerMobile`s on the local ServUO tree, + the cap forced down to 50 so the split path actually fired, producing 50/50/50/5 across four + frames; two members then removed on a timer, producing exactly two `guild.leave` frames with the + correct serials and a re-emitted roster at `total: 153`. +3. **A restart test with the shard stopped first**, so its reconnect could not re-emit and fake + persistence. The board's row came back byte-identical. + +Step 2 is what caught the reassembly bug in §3.1: every unit test passed through it, because they all +exercised a single-frame roster. The case does not arise until a guild exceeds the cap. + +Still outstanding for the cutover: the five-rung shard visibility walk against a live shard, confirming +`acct`/`webId` never reach a caller below their rung. diff --git a/modules/rust-dryrun.md b/modules/rust-dryrun.md index 27b2359..f26a7b8 100644 --- a/modules/rust-dryrun.md +++ b/modules/rust-dryrun.md @@ -17,6 +17,11 @@ in. If the contract survives that, "game-agnostic" means something. > Nothing here re-specifies the contract. [`../website/MODULE_API.md`](../website/MODULE_API.md) is > normative; this document only *uses* it. +> +> **Revisited 2026-08-19, for Teams** (`MODULE_API_VERSION` 1.6.0, Teams phase 11). A Rust team is a +> Team, so the design grew a provider and an inverted slot — the two places the contract changed since +> this was written. Everything else stands, including all four findings: the identity gap is still +> open and still the one a real second module hits first. --- @@ -94,13 +99,18 @@ module.exports = function register(ctx, api) { classify: (result) => (result.ok ? { outcome: 'done' } : { outcome: 'retry', error: result.error }), }) + // Teams (1.6.0). A Rust "team" is a Team: core owns the tables, the membership + // sync, the access rules, the forum and the activity feed; this module owns the + // word and the roster behind it. + api.registerTeamProvider(teamProvider) + api.onBoot(async () => { await rcon.connectAll() }) api.onShutdown(async () => { await rcon.closeAll() }) } ``` -Everything above is a call the contract already has, used the way module-uo uses it. Two details are -worth pointing at: +Everything above is a call the contract already has, used the way module-uo uses it. Three details +are worth pointing at: - **`rust.wipe` and `rust.raid` are namespaced**, with no grandfathering request. Module-uo's seven bare stream ids are allowlisted because they were in `notification_subs` before the rule existed @@ -108,12 +118,28 @@ worth pointing at: - **The announce leg goes to in-game chat over RCON**, which is a one-shot delivery with retry — `registerAnnounceLeg`, not `registerPostHook`. The distinction §2.4 draws holds up on a game that has nothing in common with the one it was drawn for. +- **The Team provider is the one registration core calls back into**, and Rust makes two of its rules + bite harder than UO does. `externalId` must survive a rename, and a Rust team has no name at all — + it is a numeric team id in the server's save, which is the right answer and the one a designer is + least likely to reach for. And **`complete` is per SERVER, not per community**: a community running + six servers has six team spaces, so a provider that can reach five of them must leave `complete` + off or core archives every Team on the sixth. Wipes make the same point once a month, on purpose — + a wipe empties every team, and `{ ok: true, complete: true, teams: [] }` is then *true* and core + archiving all of them is *correct*. Which is exactly why an unreachable RCON must answer + `{ ok: false }` instead: the two states are one API call apart and only the module can tell them + apart. ### Tables `rust_servers`, `rust_wipes`, `rust_players`, `rust_player_stats`, `rust_teams`, `rust_events`, `rust_bans`, `rust_maps`. All `rust_`-prefixed, all in one idempotent `schema.sql` fragment. +**`rust_teams` stays this module's table, and core's `teams` stays core's.** They hold the same teams +and neither reads the other: the module ingests from RCON into `rust_teams`, core reconciles by +*asking* the provider, and §2.6's prefix rule forbids the module touching core's table even though +the module is what populates it. A module that wrote `team_members` directly would be racing core's +reconciler for rows it does not own. + **Every table that holds gameplay data carries a `wipe_id`.** That is the whole shape of the game in one column: a leaderboard means "since the last wipe", a base means "on this map", and a player's stats are per-wipe with an all-time rollup kept separately. It has no bearing on the contract — @@ -183,6 +209,13 @@ registry.registerNav('rust', { registry.registerFeatureProvider('rust', 'rust', useRustFeatures) registry.registerExtension('rust', 'admin.users.detail', LinkedSteamAccounts) + +// The INVERTED direction (1.6.0): this module declares places on its OWN team +// page and core fills them. Core publishes no team page — it does not own the +// word — so `/rust/servers/:id/teams/:teamId` is this module's, and core's feed +// and forum are contributed into it. +registry.declareModuleSlot('rust', 'rust.team.detail', { core: 'team.activity' }) +registry.declareModuleSlot('rust', 'rust.team.forum', { core: 'team.forum' }) ``` `Play` is a group core does not have; §3.3 appends an unknown group rather than dropping the items, @@ -190,9 +223,15 @@ so this works and lands at the end of the nav — where an operator can move it, is an ordinary row once it is interleaved. The pages need `PublicLayout`, `PageHeader`, the three `PageState` components, `useAsync` and -`useAuth`: **six of the kit's seven members**, and the seventh (`useSite`) on the wipe-schedule page -for the site's timezone. A second game, unrelated to the first, wanting exactly what the kit -contains is the strongest evidence available that §3.4 was curated at the right altitude. +`useAuth`: **six of the kit's nine members**, plus `useSite` on the wipe-schedule page for the site's +timezone and `Slot` on the team page. A second game, unrelated to the first, wanting exactly what the +kit contains is the strongest evidence available that §3.4 was curated at the right altitude. + +**The team route carries a server id as well as a team id**, which is the Rust-shaped consequence of +the finding two sections down: team `4` on one server and team `4` on another are different teams, +so the module's `externalId` has to be `:` and its page needs both. Core stores +that string and never parses it — an external id is opaque to core by design, and this is the case +that shows why. The map view is the one page that wants something the kit does not have — a pan/zoom canvas. It bundles one, which is the answer §3.4 already gives ("everything else a module bundles itself"), and diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index ed1b5b9..a5e7668 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -525,6 +525,143 @@ 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–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 +here is core-internal — a module must never read or write one, even though a module is what fills +them — and none carries a `_` prefix, correctly: that rule binds modules, and these are +core's. + +| Table | What it holds | +|---|---| +| `teams` | the Team itself. `external_id` is the module's own stable id, opaque to core; `name` is **immutable** for the life of the row; `slug` is derived once at create and frozen with it | +| `team_members` | the membership **projection**. Module-authoritative, and the sync is its only writer. Rows are soft-departed rather than deleted so history and rejoins survive | +| `team_sync_state` | one row per module: last attempt, last success, consecutive failures, last error, and the empty-answer quarantine | +| `team_leader_overrides` | a staff decision about leadership, applied **on top of** the synced value at read time and never written into the projection | +| `team_forum_grants` | the append-only forum grant/revoke ledger, which is also the current state. Created in phase 2 so the access resolver is written once; the grant flow is phase 4's | +| `team_moderation_requests` | the approval queue for the three actions that publish untrusted game-sourced strings | +| `team_activity` | the per-Team feed (phase 3). **Two writers, one table:** core writes its own membership and rename items with `source='core'`, and a module pushes game items through `ctx.teams.activity.push`. `summary` is already-rendered text and core never composes one; `kind` and `payload` are opaque to core | + +| `team_forum_threads` | forum threads (phase 4). The FULL schema lands with announcements, including the `type`, `pinned` and `locked` columns only discussion uses — phase 5 opens paths rather than migrating data | +| `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 `` | +| `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 | +| `team_integration_config` | where a Team's notifications go on another platform (phase 8). One row per (platform, Team) plus a **deployment-wide default** whose `team_id` is NULL — expressed with a generated `team_key AS IFNULL(team_id, 0)` in the unique key, because a NULL cannot live in a primary key and the default row is the base case of the whole override mechanism. `members_ack` is a **precondition, not a preference**: forum posts and announcements are members-only always, core cannot see a channel's permissions, so enabling one requires an attributed operator acknowledgement that the destination is restricted — and changing the channel clears it | +| `team_integrations` | a Team's provisioned resource on another platform — today its Discord **voice channel and the role that opens it** (§7.3, phase 9). Both refs on one row because they are one lifecycle: a role for a channel that no longer exists is a badge for nowhere. `state` is core's BELIEF about the platform, never the platform's answer — the reconciler writes what it just did and the next pass re-derives the truth. A Team that stops qualifying goes to `pending_removal` with `remove_after` rather than being deleted at once, so a Team hovering around the size threshold does not delete-and-recreate its channel and change its id. `synced_at` is separate from `updated_at`, which moves whenever core writes a belief including an error | +| `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 +deliberately turned off — while threads, posts, grants and notification preferences are all untouched. +Re-enabling restores the forum exactly as it was. That is the same principle as the module disabled +guard ([`MODULE_API.md`](MODULE_API.md) §4.5). + +**The author never writes an `` tag, and that is what makes the image policy enforceable.** The +shared sanitiser (`utils/sanitizeHtml.js`) allows `` from any host — it is tuned for the admin +editor, where the author is trusted — so the forum derives its own profile in which `img` is never +allowed in any mode. An author writes a URL; core's renderer decides at READ time whether it becomes a +picture, under `teams_forum_images` (`disabled` | `remote` | `uploads`). Three properties follow: the +policy cannot be evaded, since the only code that can emit an `` is core's; flipping it back to +`disabled` un-renders every image on every existing post with **no data migration**; and there is no +author-supplied `srcset`, `onerror` or `style` to smuggle anything through. `https:` only, on an +extension allowlist, with `referrerpolicy="no-referrer"` and `loading="lazy"` — and **the server never +fetches a user-supplied URL**, which would be an SSRF vector; the browser does. + +**`uploads` mode assumes a hostile uploader**, which the admin upload path never had to. Beyond that +path's 8 MB cap, mimetype allowlist and random filename it adds: magic-byte sniffing (a client's +`Content-Type` is a claim, not a fact), a rolling per-account byte quota, an attribution row per file, +and a nightly sweep that removes soft-deleted files past retention plus never-referenced orphans. The +sweep runs regardless of the current mode — an operator who turns uploads off still has the files. + +**Selecting `uploads` requires a recorded acknowledgement.** `PUT teams_forum_images = 'uploads'` is +rejected **400** unless the same request carries `acknowledge: `; the admin checkbox is how +the gate is presented, never the gate. The accepted TEXT VERSION is stored in +`teams_forum_uploads_ack`, whose `updated_by`/`updated_at` answer who and when, plus an `activity_log` +row. If the wording is ever revised the stored version goes stale — uploads **keep working**, a +persistent banner requires re-acknowledgement, and no other forum setting may be saved until it is +given. `teams_forums_enabled` and `teams_forum_images` are published in `settings.getPublic()`; the +acknowledgement is not. + +**`team_activity` is bounded on purpose.** A feed fed by a game loop is the obvious unbounded-growth +failure, so retention ships with the feed rather than after someone notices: a nightly worker applies +an age horizon (`team_activity_retain_days`, default 90) **and** a per-Team row cap +(`team_activity_row_cap`, default 2000). Both, because either alone has a hole — age lets one busy +guild write a million rows inside the window, and a cap keeps a dead Team's feed forever. + +`dedupe_key` is optional and unique per Team, written with `INSERT IGNORE` — the same idempotence +trick `shard_events` uses, and what makes a sidecar reconnect backfill safe to replay. Core +deliberately emits **no join items for a Team's first roster** (`roster_synced_at IS NULL`): importing +a 155-member guild is one Team arriving, not 155 people joining. + +**A rename is an archive plus a create**, never an edit. Core's identity is (`module_id`, +`external_id`, `name`) taken together: a known id under a new name archives the old row +(`archived_reason='renamed'`, `succeeded_by` pointing at the successor) and creates a new one, so the +old Team keeps its activity, its grants and its forum as a read-only record and its old slug still +resolves. Whether two names are "really" the same guild is the module's judgement, expressed in +whether it reuses the external id. + +**Uniqueness among ACTIVE rows only** is expressed with STORED generated columns, because MariaDB has +no partial index and NULL never collides in a UNIQUE key: `active_key` and `active_slug` on `teams` +are NULL for archived rows, so any number of them may share an `external_id`. + +**`team_forum_grants` departs from the obvious encoding, and the reason matters.** Its marker is +`active_marker AS (IF(revoked_at IS NULL, 1, NULL))` with `user_id` in the KEY rather than the +generated column, because MariaDB refuses `ON DELETE SET NULL` on a foreign key whose column is a base +column of a stored generated column (error 1901) — and `SET NULL` is required here: `CASCADE` would +delete the audit trail of who granted whom, which is exactly what an audit exists to survive. The +semantics are identical: at most one active grant per (team, user), unlimited revoked rows. + +**Account deletion is settled per column, not inherited from the defaults.** Content and audit +survive; preferences and links do not. `team_members.user_id` and every actor column on the grant +ledger and the approval queue go `SET NULL` with a **username snapshot** alongside, so the record +stays readable after the account is gone. Only `team_id` cascades. + +**Two columns exist that the design of record did not contemplate**, both on `teams` and both +serving the refusal gates below: `roster_synced_at`, because `team_sync_state` holds one row per +*module* and a single Team's roster can be left untouched while the others sync — without a per-Team +stamp that Team's page would report the module's last success as its own; and `members_empty_since`, +the per-Team twin of `pending_empty_since`. + +Design of record: [`TEAMS.md`](TEAMS.md) Parts 2 and 5. The contract surface a module sees is +[`MODULE_API.md`](MODULE_API.md); everything in these tables is explicitly *not* it. + --- ## 4. API contract @@ -539,7 +676,7 @@ are authoritative, and they answer different questions: | Artifact | Source of truth for | Generated by | |---|---|---| -| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs CORE serves.** 166 public routes + 2 on the internal listener, sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack | +| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs CORE serves.** Every core URL — the public app plus the internal listener — sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack | | `server/swagger/swagger-output.json` — merged into `/api/docs` | **What each core route means.** Parameters, bodies, response codes, security. | `npm run swagger`, from `#swagger.*` annotations | Both are **core's**. An installed module's routes are in neither: they are in that module's own @@ -636,6 +773,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/*` @@ -654,6 +792,14 @@ and relies on it: its `/player/shard/*` handlers are the identical self-scoped o under `/admin/shard/*`, so the two are interchangeable. This is why a staff account with linked game characters gets its "My characters" and personal notification streams on the mobile client — the group no longer 403s a non-`player` role. +`teams.router.js` joins the group in Teams phase 2, and relies on exactly that rule: a moderator is in +guilds too, and gating this group on the role would 403 them off their own Teams. + +| Method | Path | Notes | +|---|---|---| +| GET | `/teams` | the caller's Teams, each carrying the **reason** it is listed: `membership` \| `grant` \| `both`. Membership and forum access are separate authority paths and the reason is what keeps them distinguishable — `both` is a real state, and a Team **hidden** from public surfaces is still listed here, because suppression is a public-surface rule and a member is not a member of the public | +| GET | `/teams/:slug/access` | the caller's own resolved access on one Team: `allowed`, `viaMembership`, `viaGrant` (kept even when membership also holds, so the grant survives as audit history) and `isLeader` with any staff override applied | + **Password reset.** Uses the same audited pattern as `user_invites`: an opaque 32-byte token whose **sha256 hash only** is stored in `password_resets`, single-use and short-lived (~1h). It also serves SSO-only accounts (null `password_hash`) as their "set an initial password" path. The @@ -772,6 +918,12 @@ from the per-route **siteMode** middleware (§5), never from an auth gate. | GET | `/wiki` | list of pages (slug + title) | | GET | `/wiki/:slug` | single page | | POST | `/contact` | (rate-limited) send mail via SMTP; if unconfigured, respond `{fallback:"mailto", email}` | +| GET | `/teams/by-external/:moduleId/:externalId` | one Team named the way the OWNING MODULE names it. Exists so a module's page can find core's Team without holding core's identifiers, which are core-internal. The module id is matched rather than trusted: an external id is unique only within a module | +| GET | `/teams` | active, publicly visible Teams, paged. Every payload carries `{ configured, stale, lastSyncAt }` so a page can say how recently the projection was confirmed rather than presenting a stale roster as current, plus `enabled` — whether this deployment has Teams at all | +| 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). @@ -842,6 +994,16 @@ file a route sits in — that is the property the route manifest freezes. | POST | `/modules/:id/disable` | the one module action that takes effect immediately — dispatches that module's `onShutdown`, then its routes, nav and client chunk answer 404. A real kill switch, not a visibility flag | | POST | `/modules/:id/purge` | run a **disabled** module's `purge.sql`, dropping its tables and data. `409` while it is still running; `400` if it ships no `purge.sql` | | DELETE | `/modules/:id[?purge=true]` | uninstall: stop, then (with `purge=true`) drop its data, then delete its directory. Non-destructive by default — the row stays `disabled` and the data is left for a reinstall to pick up. The purge option lives here because it cannot live after: `purge.sql` is a file inside the directory being deleted | +| GET | `/teams` | every Team incl. hidden ones, plus the module's **sync state verbatim** — last attempt, last success, consecutive failures, the last error and any held empty answer. Verbatim because an operator debugging a stale projection needs what the provider actually said | +| GET | `/teams/:id` | one Team with its roster (departed members included), its grant ledger and its pending requests. Each roster row carries the **resolved** leadership and `isLeaderSynced` — what the game actually said — so an override reads as a decision rather than as fact | +| POST | `/teams/resync` | run a reconciliation now, **awaited**, so the response carries the outcome including the provider's own refusal reason. The four refusal gates still apply: a manual resync cannot make core act on an answer it does not trust | +| POST | `/teams/:id/archive` · `/teams/:id/hide` | staff archive / hide. **Not gated** — both withdraw a Team from public surfaces rather than publishing anything, and withdrawing has to be possible at once, by whoever is on duty | +| 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) | Every admin write logs to `activity_log`. diff --git a/website/MODULE_API.md b/website/MODULE_API.md index c26d0a1..74c0611 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -26,13 +26,57 @@ here extends the contract first, in this file, before the module is written agai Core exports a single integer-major semver string from `server/src/modules/version.js`: ```js -const MODULE_API_VERSION = '1.5.0' +const MODULE_API_VERSION = '1.6.0' ``` The client half carries the same number (`client/src/modules/version.js`) and a test asserts the two 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.** 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` and `pageUrlTemplate` · `api.registerSlashCommands(...)` · +`registry.declareModuleSlot(...)` with `Slot` in the UI kit — the ninth member of it. + +> **Amended 2026-08-19 (phase 11), on the org lead's decision.** `declareModuleSlot` takes an +> optional `{ core }` naming which of core's contributions belongs in the declared place, and core +> offers contributions instead of naming slots (`CORE_CONTRIBUTIONS`, §3.7a). In 1.6.0 in place, by +> the same rule as the two amendments below: 1.6.0 has only ever been on `edge`. It is a **correction +> and not an addition** — as first written, core filled three literal `uo.guild.*` names, so the +> inverted direction worked for exactly one module and silently did nothing for any other, which the +> integration kit found while trying to teach it to an audience outside this org. +> +> **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. +> +> **The client slots `team.overview` and `team.member.row` are replaced by the INVERTED direction.** +> Both assumed core rendered a Team page. It does not: **Teams is a contract primitive, not a +> surface** — core owns the tables, the sync, the access rules and the activity feed, and does not own +> the word for one, so the module that owns the vocabulary owns the page. In their place, +> `registry.declareModuleSlot(id, name, { core })` lets a MODULE declare a place on its own page for +> CORE to fill, and `Slot` joins the UI kit so the module can render it. See §3.7a. + +**Every member of 1.6.0 is live as of phase 7.** `api.registerSlashCommands` was the last one still +throwing, and it now registers — the staged rollout the paragraphs above describe is finished. A +module may call any member of this version and get the behaviour documented below. + +**`registerTeamProvider` is the first registration where core calls the MODULE and waits.** Every +existing one is either the module claiming a mount or core notifying it; the closest precedent is +`registerAnnounceLeg`'s `dispatch`, which is why this is modelled on it. That direction is what makes +the envelope, the 10-second budget and the refusal semantics contract rather than implementation — +they are how a module says "I cannot answer" without core hearing "there is nothing". + **1.5.0 — Phase 5 slice 3, the page shell.** `PublicLayout` takes an optional **`shell`** prop — `'narrow'`, `'mid'` or `'wide'` — that renders the page-body wrapper core's own pages have always written by hand (§3.4). Found by the acceptance run in @@ -181,6 +225,51 @@ module-uo does not need is on the list. | `ctx.middleware.rateLimit` | `(options) => middleware` | `middleware/rateLimit` | the market search (1.1.0) | | `ctx.middleware.accountChangeLimiter` | middleware | `middleware/rateLimit` | `player/shard.router` (1.1.0) | | `ctx.moduleId` | the id from `module.json` | loader | log tags, table checks | +| `ctx.teams.publish` | `(event) => Promise` | `model/teams/teamSync` | the Team provider's module (1.6.0) | +| `ctx.teams.reconcile` | `({ reason }) => void`, returns at once | `model/teams/teamSync` | after a fresh account link (1.6.0) | +| `ctx.teams.activity.push` | `(items) => Promise`, fire-and-forget | `model/teams/teamActivity` | the Team provider's module (1.6.0) | + +**`ctx.teams` is push only, and that is the contract.** There is no reader: a module *answers* +questions about Teams, it does not ask them. Every Team table is core-internal (§1.2), and a +`getTeamRoster` on `ctx` would be core offering to read back the module's own answer — which the +module already holds, in its own store. + +**`ctx.teams.activity.push(items)`** writes the per-Team feed (TEAMS.md §4.1). Each item is: + +```js +{ externalId, kind, summary, occurredAt?, visibility?, actorMemberKey?, actorUserId?, payload?, dedupeKey? } +``` + +Four things about it are contract rather than implementation: + +- **`summary` is already rendered and core stores it verbatim.** Core cannot phrase "gained 15,000 + gold" for a game whose vocabulary it does not know, and a core that templated it would have + re-acquired exactly the semantics the module system exists to remove. `kind` and `payload` are + likewise opaque — core filters on them and only the module's `team.overview` slot renders anything + richer than the text. +- **A Team is named by the module's own `externalId`**, which core maps, and only that module's + ACTIVE Teams resolve. There is no id a module can send that reaches another module's Team, and an + archived Team is not writable — its feed is a closed record of what happened before the rename. +- **`visibility` defaults to `'members'` — fail closed.** The module chooses it per item; core + enforces it on the read path. +- **It never throws at the call site and never rejects.** This is called from inside a game-event + handler, and a storage problem of core's must not become the module's control flow. A malformed + item is dropped and logged; a `dedupeKey` collision is a successful no-op, which is what makes a + sidecar reconnect backfill safe to replay. + +Both live members are **fire-and-forget**. `publish` is an optimisation that makes a membership +change visible at once; `reconcile` is a debounced *request* that returns immediately and never +rejects. Correctness comes from reconciliation either way, so neither can make a module's own call +site slow or turn a background failure into the module's error. + +The six event kinds `publish` accepts are `team.created`, `team.disbanded`, `team.member.added`, +`team.member.removed`, `team.leader.added` and `team.leader.removed`. Six rather than four because +leadership is its own authority path: a leadership change has to be expressible without pretending +someone joined or left. Every event carries `externalId`; the four member and leader kinds also carry +`memberKey`. **`team.created` and `team.disbanded` only ask for a reconciliation** — core will not +invent a Team from a delta (it would have no name, no roster and no leaders) and will not archive one +from a delta either, because an archive driven by a message that may simply have been repeated is +destruction on no evidence. Three narrowings from `MODULE_SYSTEM.md` §2.1, all deliberate: @@ -217,6 +306,8 @@ api.registerExtension(slot, router) api.registerNotificationStreams(streams) api.registerAnnounceLeg({ leg, label, dispatch, classify }) api.registerPostHook({ onSaved, onDeleted }) +api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders }) // 1.6.0 +api.registerSlashCommands([{ name, description, options, access, handler }]) // 1.6.0 api.onBoot(async (ctx) => {}) api.onShutdown(async () => {}) ``` @@ -316,6 +407,166 @@ meant a `dispatch` that must not be retried and a `classify` that means nothing. Before it existed, core's post controller required `utils/newsGump` directly — core's publish path 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. 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 +two disjoint sets under one table with no rule for merging them. A second registration is a +collision, reported against the module that holds it. Three methods are required — a provider +that could list Teams but not their members would leave core holding Teams it can never populate, +which is not the same as a call that fails. The fourth is optional; see below. + +```js +getTeams() // () => Promise<{ ok, complete?, teams }> +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? } ] } +// the module knows it cannot answer — sidecar down, cache cold, boot not finished +{ ok: false, reason: 'sidecar unreachable' } +``` + +A member is `{ memberKey, displayName?, rankLabel?, leader?, online?, userId? }`. `userId` is +resolved **by the module** — it owns the game↔site link table, and a core that resolved it would be +core reading a module's table by name. + +**Every method returns an envelope, never a bare array, and this is the load-bearing part of the +contract.** A rejected promise, a synchronous throw, a timeout (core's budget: **10 seconds**), a +non-object, a missing `ok`, or a structurally malformed row are all read exactly as a deliberate +`{ ok: false }`. There is **no shape a failure can take that core reads as "zero teams"** — which is +the whole argument for the envelope, since a bare array has exactly one such shape, `[]`, and it is +the one a module returns while its sidecar is still connecting. + +A refusal costs staleness and nothing else: core keeps the projection it has, records the reason, and +surfaces it. It never empties a roster on an answer it does not trust. + +**`projectRoster` is the exception to that last paragraph, and the exception is deliberate.** It +answers *who is allowed to look at a roster*, on the request path, because the audience model and its +configuration are the module's and core does not have one (TEAMS.md §3.3). For a visibility question, +"keep what you have" is a leak: leaving the answer alone means serving the roster unprojected to +whoever asked. So this one call **fails closed**. + +Core distinguishes two refusals, and a module does not have to do anything to get the right one: + +- **no provider, or a provider without `projectRoster`** — there is no audience model to consult and + nothing is being withheld, so core serves the roster whole at its own public shape. This is what + makes the member genuinely optional: bare core, and a module with no rungs of its own, both render + the page core writes. +- **a provider that HAS `projectRoster` and refused, threw, timed out or answered malformed** — core + 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 +published row looks like and re-normalises whatever comes back through its own public shape, so a +module can narrow which rows appear and cannot widen which fields do: the member key and the site +account id are withheld from every public roster whatever a module returns. + +`complete: false` means "valid but partial": core applies additions and updates and performs **no** +removals. It defaults to `true` when omitted, so the ordinary authoritative case needs no ceremony. + +**A malformed row fails the whole call rather than being dropped.** One unreadable member quietly +omitted from a roster is indistinguishable, downstream, from that member having left — core would +mark them departed on the strength of a broken payload. Refusing costs one interval of staleness. + +**`registerSlashCommands(commands)`** — chat-platform commands whose definition AND handler both +belong to the module, live since phase 7 (TEAMS.md §7.1). + +```js +api.registerSlashCommands([{ + name: 'guild', // lowercase, 1-32, no dots + description: 'Show a guild on this shard', // 1-100 characters + options: [ // the restricted schema, below + { name: 'name', type: 'string', description: 'Guild name or abbreviation', required: false }, + ], + access: 'everyone', // 'everyone' | 'linked' | 'staff' + async handler({ command, options, actor }) { + return { title, text, fields, url, ephemeral, notice } // every field optional + }, +}]) +``` + +**The handler runs in the WEBSITE process, never in the bot.** The bot container has no `modules` +volume and cannot load a line of module code, so it pulls the definitions over an internal API and +owns every platform-specific concern — deferral, the acknowledgement deadline, ephemerality, +follow-ups, embeds. A module that wanted to call `interaction.deferReply()` would be a module holding +a Discord handle, and this split is the reason a second platform could implement the same contract. + +**`actor` is resolved by core before the handler is entered**, and is the whole of what a handler +learns about the caller: + +| field | | +| --- | --- | +| `platform` | `'discord'` today; the only platform-shaped thing a handler ever sees | +| `platformUserId` | the caller's id on that platform | +| `guildId` | the platform community the command was run in, or `null` | +| `userId` | the site account, or `null` when the platform identity is not linked | +| `role` | that account's role — a module with audience rungs needs more than a boolean | +| `isLinked` | whether `userId` resolved | +| `isStaff` | `admin` or `moderator`, the same two roles every other Team surface means | + +A **banned or disabled** account resolves as unlinked, so a chat surface is never the one place a ban +does not reach. The Discord provider is found by `auth_providers.kind`, not by its id — the id is an +operator-chosen slug. + +**`access` is enforced twice, and only the server half is the gate.** The bot sets a platform-side +permission default from it where the platform can express one; core re-checks it in the dispatcher on +every call. `'linked'` has no Discord equivalent at all — there is no "has a website account" +predicate — so it is simply not advertised, which is exactly why the client half cannot be the +boundary. + +**The option schema is deliberately small: `string | integer | boolean | user`,** each with +`required` and optional `choices` (`string` and `integer` only). No subcommand groups, autocomplete, +attachments, modals or component interactions — those are the features whose semantics do not survive +a second platform. A command needing them is a bot-side command, written in the bot. + +**A definition the platform would reject fails at `register()`**, not at the next connection: the bot +registers the whole set in one call, so one bad option type would cost every command, the bot's own +included. Names are validated (lowercase, 1-32, no dots), as are description lengths, the option +types, and the ordering rule that a required option may not follow an optional one. + +**Commands are NOT namespaced under the module id**, unlike stream ids and announce legs — Discord's +name grammar has no `.` in it. Collisions are first-come with the holder named, and a name that +collides with one of the bot's own built-ins is dropped by the bot, which is the one collision core +cannot see. + +**A handler's failure is its own.** A throw, or a handler still running after core's timeout, becomes +a refusal the platform renders; the handler never runs in the bot process, so it cannot cost anything +but its own reply. `ok` is core's verdict and sits outside the envelope, so a handler cannot forge it. + +**A disabled module's commands stop answering immediately.** Registration has no removal path — a +claim is made once, at load — so liveness is asked at both the pull and the dispatch: an operator who +switches a module off does not leave a live handler behind it. + **`onBoot(fn)` / `onShutdown(fn)`** — §2.5. ### 2.5 Lifecycle @@ -799,6 +1050,7 @@ The kit is **curated and closed**, not a re-export of `components/`: | `Loading`, `ErrorState`, `EmptyState` | `components/PageState.jsx` | the three states every data page has | | `useAsync` | `lib/useAsync.js` | the fetch/loading/error hook every data page uses | | `useAuth`, `useSite` | `contexts/*` | read-only access to session and site settings | +| `Slot` *(1.6.0)* | `modules/Slot.jsx` | renders a place this module declared for core to fill (§3.7a) | Everything else — tables, chips, tabs, the tiptap editor, dnd-kit — a module bundles itself. Adding to the kit is a **minor** `MODULE_API_VERSION` bump; *changing* a kit component's existing @@ -806,7 +1058,7 @@ props is a **major** one, because that breaks a call already written. Adding an minor (§1.1). That is a real constraint on core and it is the price of the boundary being worth anything. -The kit is those **eight exports** — five rows, because `PageState` contributes three. An earlier +The kit is those **nine exports** — six rows, because `PageState` contributes three. An earlier draft of this table listed a ninth, `AdminPage`, and core has no such component — admin views are plain markup inside `AdminLayout`. It was struck in Phase 2 PR 7 rather than satisfied by inventing a core component with no consumer until Phase 3; adding it later costs a minor bump, which is the case @@ -1035,6 +1287,93 @@ intends to fill permanently, and a slot core *does* intend to fill is a slot tha members of the `registry` object handed to modules, for the same reason `featureProviders()` is not: declaring is core's, and so is reading back who filled what. +### 3.7a Inverted slots — CORE content inside a MODULE's page *(1.6.0)* + +The mirror of §3.7, added for Teams. §3.7 assumes core owns the page and a module contributes to it, +which is right for the footer and the admin user detail. This is the other shape, and the case that +forced it is worth stating because it will recur: + +> **A core primitive whose vocabulary core does not own.** Teams are core's — core owns the tables, +> the reconciler, the access resolver and the activity feed — but core has no word for one. A UO shard +> calls them guilds; the next game will call them clans. A core-rendered `/teams` page would publish a +> noun core invented, beside the module's own page for the same thing. So the **page** is the +> module's, and the parts core cannot hand over — here the activity feed, whose public/members split +> only core can resolve — are contributed to it. + +```js +// In the module's entry chunk, at registration time. The second argument names +// which of CORE's contributions belongs in that place: +registry.declareModuleSlot(ID, 'uo.guild.header', { core: 'team.notify' }) +registry.declareModuleSlot(ID, 'uo.guild.detail', { core: 'team.activity' }) +registry.declareModuleSlot(ID, 'uo.guild.forum', { core: 'team.forum' }) + +// In the module's page, from the UI kit: + + + +``` + +**Core offers a CONTRIBUTION; it never names a slot.** This is the part a second game depends on, and +the first cut of 1.6.0 had it the other way round — core filled the three literal names above, which +worked for `module-uo` and silently did nothing for anybody else: a module declaring `clan.detail` +under its own id got an empty page and no error, because "a fill for a slot nobody declared is not an +error" is exactly the rule that makes an unknown name invisible. It also put a module identifier +inside core, in three string literals `scripts/checkModuleIdentifiers.js` masks by construction and +could never have caught (§5.2). Corrected inside 1.6.0, before it reached `main`. + +So the module says WHERE, in its own vocabulary, and WHICH of core's contributions goes there: + +| Contribution *(1.6.0)* | What core puts in the slot | Why it is core's | +| --- | --- | --- | +| `team.activity` | the Team activity feed | only core can resolve the public/members split on it | +| `team.forum` | the Team forum panel | membership and manual grants are core's rules | +| `team.notify` | the per-Team notification control | core resolves whether the viewer is in the Team | + +`options.core` is **optional** — a module may declare a place it fills itself, or one it is keeping +empty for now. Asking for a contribution core does not offer **throws at the declaration**, and that +asymmetry with an unfilled slot is deliberate: core's catalogue is fixed at build time and the +module's `coreApi` range has already been checked, so an unknown contribution is always a typo or a +version skew, and the alternative failure is a page that renders empty forever with nothing logged. +**Adding a contribution is a minor bump**; removing one is major. + +More than one slot may ask for the same contribution and each gets it. Core has no reason to care how +many places a module wants its feed in, and refusing the second would be core making a layout decision +on a page it does not own. + +**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 +readable at the fill site. + +**Core fills these at MOUNT, not eagerly, and the ordering is why the call exists at all.** Core's +bundle evaluates before every module chunk (§3.1), so at the moment core would like to fill one of +these the slot does not exist. Core registers its intent (`offerCoreFill`, core-only) and +`applyCoreFills()` runs once, from `main.jsx`, after every chunk has evaluated and before the first +render. + +**A contribution nothing asks for is a no-op, never an error.** No game module is installed, which is +the ordinary case on any deployment — the exact mirror of an unfilled slot rendering nothing. Note the asymmetry with §3.7, where an unknown slot throws: there, an unknown +name is always a typo or a version skew, because core declares before any module can name one. + +**First fill still wins**, so a module that fills its own declared slot keeps it and core's fill is +skipped. That is deliberate: the module owns the page. + +**`Slot` is the ninth member of the UI kit** (§3.4) for this. A module could not render one of these +otherwise, and reimplementing it would mean a second error boundary with different behaviour — which +matters more here than anywhere else in the kit, because the thing being contained is *core's* content +failing inside the *module's* page. + +`declareModuleSlot` is on the `registry` object handed to modules. `offerCoreFill`, `applyCoreFills` +and `CORE_CONTRIBUTIONS` are not: offering into one of these is core's, exactly as declaring a §3.7 +slot is. + --- ## Part 4 — The loader's obligations diff --git a/website/TEAMS.md b/website/TEAMS.md index 479a937..7d0d3a3 100644 --- a/website/TEAMS.md +++ b/website/TEAMS.md @@ -100,11 +100,14 @@ module fills with anything live. Core does not grow an SSE stack for this. exactly two shared-secret HTTP channels: - **app → bot**, `utils/botInternalClient.js` → `bot/src/internal/internal.routes.js` - (`/internal/config`, `/internal/status`, `/internal/announce`, `/internal/mod-reverse`), 4s timeout, - never throws, always returns `{ ok, status, data, error }`. + (`/internal/config`, `/internal/status`, `/internal/announce`, `/internal/mod-reverse`, and since + phase 7 `/internal/refresh-commands`), 4s timeout, never throws, always returns + `{ ok, status, data, error }`. - **bot → app**, `SITE_INTERNAL_URL=http://app:3001/internal/bot-config` on the app's *unpublished* internal listener (`server/src/internalApp.js`), with a retry-with-backoff bootstrap so a bot - restart self-heals. + restart self-heals. Phase 7 added `bot/src/site/appInternalClient.js` for `/internal/commands` and + `/internal/commands/dispatch` on that same listener — it derives the base from `SITE_INTERNAL_URL`'s + origin rather than taking a second variable naming the same host. Slash commands are registered from a static array (`bot/src/discord/commands/index.js`) and pushed with `REST.put(Routes.applicationGuildCommands(...))` on ready (`discordManager.js:25`) — a **whole-set @@ -717,7 +720,10 @@ place it bites is uploads, where "forget me" has to mean the bytes go too, not j ``` GET /api/v1/public/teams list (active), paged, {stale,lastSyncAt} GET /api/v1/public/teams/:slug overview + counts -GET /api/v1/public/teams/:slug/members roster, field-projected per audience rung (§3.3) +GET /api/v1/public/teams/:slug/members roster, row-projected per audience rung (§3.3) +GET /api/v1/public/teams/:slug/activity the feed, paged, filtered to what the caller may + see (§4.3) — added in phase 3; a session is + optional on this route and on /members GET /api/v1/player/teams the caller's Teams (membership + grants), with the reason for each: 'membership' | 'grant' | both @@ -756,6 +762,11 @@ they land in core's committed manifest), and a matching `BACKEND_DESIGN.md` edit ### 3.1 Routes and shell +> **Superseded 2026-08-17 (phase 3, org lead).** The four public/player rows below are NOT core's. +> Teams is a contract primitive and core does not own the vocabulary, so the module that owns the word +> owns the page: `module-uo` renders these under `/uo/guilds`. Only the two `/admin/teams` rows are +> core's. See the phase 3 amendment in Part 12. + Core client routes, not module ones: | Path | Page | @@ -815,13 +826,39 @@ named for a *place* and never for a meaning): | Slot | Rendered in | Props | | --- | --- | --- | | `team.overview` | the Team overview page, below the counts | `{ teamId, externalId, moduleId }` | -| `team.member.row` | each roster row, trailing cell | `{ memberKey, userId, displayName }` | +| `team.member.row` | each roster row, trailing cell | `{ displayName, isLeader, linked }` | -Both unfilled on bare core, which renders exactly the page core writes. Neither is typed by content — -`team.overview` is "the spot under the counts", not "where the game puts guild stats". +> **Superseded 2026-08-17 (phase 3, org lead).** Both slots are gone, and the DIRECTION is what +> changed. They assumed core rendered the Team page; core renders no Team page. The replacement is +> `registry.declareModuleSlot(id, name, { core })` — a **module** declares a place on its own page, +> namespaced under its own id, naming which of core's contributions goes there, and **core** offers it: +> +> | Slot | Declared by | Rendered in | Filled by core with | Props | +> | --- | --- | --- | --- | --- | +> | `uo.guild.detail` | `module-uo` | its guild detail page | `team.activity` — the Team activity feed (§4.3) | `{ externalId, moduleId }` | +> | `uo.guild.forum` | `module-uo` | the same page, below the feed | `team.forum` — the Team forum (Part 5), added in phase 4 | `{ externalId, moduleId }` | +> +> Core's contributions are applied at MOUNT, not eagerly: core's bundle evaluates before every module +> chunk, so when core offers one, no module-declared slot exists yet. A contribution nothing asks for +> is a no-op, not an error — the mirror of an unfilled slot rendering nothing. **Core names the +> contribution and never the slot** (amended phase 11, inside 1.6.0: as first built it filled the +> literal names above, which reached `module-uo` and no other game). `Slot` becomes +> the ninth member of the shared UI kit so a module renders the place with core's own error boundary, +> which matters here because the thing being contained is CORE's content failing inside the MODULE's +> page. +> +> The props are the module's own vocabulary. `memberKey`/`userId` are not among them and could not +> be: §3.2 withholds both from every public roster, and a client slot only receives what the browser +> was already sent. ### 3.5 Nav +> **Superseded 2026-08-17 (phase 3, org lead).** None of the three entries below is registered, and +> the `teams` feature flag is not either. Core publishes no Team nav row because a core row would name +> a surface core does not own, sitting beside the module's own row for the same thing in a different +> word. `/admin/teams`'s sidebar entry, which landed in phase 2 and is an operator view of the +> primitive, is unaffected and stays. + One coded public header entry, `{ label: 'Teams', to: '/teams' }`, plus `{ label: 'My Teams', to: '/player/teams' }` in the player portal and `{ label: 'Teams', to: '/admin/teams', group: 'Community' }` in the admin sidebar. All three flow through the existing registered-defaults → admin @@ -1026,6 +1063,44 @@ Every forum route above answers **404** while `teams_forums_enabled` is off, and answer 404 in any image mode but `uploads` — the same guard, applied at two levels, for the same reason (§5.5.1). +> **Amended 2026-08-18 (phase 4).** §3.1's `/teams/:slug/forum/*` core page is gone with the rest of +> them. The routes below are unchanged — every one is `/player` or `/admin` — but the participant +> surface is core's fill of the module-declared `uo.guild.forum` slot (§3.4), so a reader is on +> `module-uo`'s guild page throughout. Two routes were added that this table did not have: +> `GET /api/v1/player/teams/:slug/grants` (a leader has to SEE the guests before managing them) and +> `GET /api/v1/admin/teams/forum/settings`, which serves the one piece of forum state that is not a +> public settings key — whether the uploads acknowledgement has been given, by whom, and whether the +> notice has been reworded since (§5.5.6 keeps that key unpublished). +> +> The grant routes deliberately answer while the forum is switched OFF, which no line below says: a +> 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. @@ -1217,6 +1292,9 @@ text **version**. Recording two booleans would add nothing — there is no reach operator consented to one clause and not the other and proceeded anyway — while the version is what actually answers the question that matters later: *which text did they agree to?* +> **Settled 2026-08-18 (org lead): all three additions below are IN**, and the build ships them — +> 1 and 3 in the help text, 2 in the dialog. + **Three additions proposed on top, marked so they can be dropped.** Each closes a gap the text above does not currently cover; none is liability language, so none changes what is being agreed to: @@ -1252,6 +1330,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 @@ -1287,11 +1393,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. @@ -1307,6 +1448,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. @@ -1337,6 +1492,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 @@ -1406,9 +1590,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 @@ -1424,13 +1622,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. @@ -1446,6 +1674,42 @@ is already being built there. ### 7.1 Slash-command registration +> **Amended 2026-08-18 (phase 7), as built.** Five changes, four of them forced by what the tree +> already looked like. +> +> **The example command is `/guild`, registered by module-uo, and core registers none.** §7.1 wrote +> `/team` as a core command against core's own Team rows. Phase 3 settled that **Teams is a contract +> primitive with no core surface** — core does not own the word for a Team, which is why four core +> Team pages were deleted — and a core `/team` publishes that same invented noun into a channel. The +> module owns the vocabulary, so the module owns the command. Core ships the dispatcher, the actor +> resolver and the transport, and zero commands. +> +> **The deep link comes from `pageUrlTemplate`, because `/teams/:slug` does not exist.** The snippet +> below still says `${siteBaseUrl}/teams/${slug}`; there is no such page. A handler builds its own +> link — module-uo's is `/uo/guilds/{externalId}` — which is the same hole phase 6 found in the mail +> path and closed with the ninth contract member. +> +> **The re-register nudge is its own endpoint, `POST /internal/refresh-commands` on the bot**, not a +> ride on `/internal/config`. That body carries the DECRYPTED bot token: telling the bot that a +> module changed should not require reading a secret out of the database to say it. +> +> **`actor` carries `role` as well as `isStaff`.** The two answer different questions and a boolean +> loses one — `isStaff` is core's gate for `access: 'staff'`, `role` is what a module with its own +> audience rungs needs to place the caller on them. It is the pair `projectRoster`'s viewer already +> carried (§3.3), not a new class of disclosure. +> +> **Deregistration needed a second half this section did not consider.** "A module that is gone is +> simply absent from the next pull" holds across the restart an uninstall asks for. It does not hold +> for the runtime toggle: the registries have no removal path, so a module an operator disables would +> keep a live handler behind a command Discord still advertises. Liveness is therefore asked at both +> the pull and the dispatch, and a disabled owner's command answers `unknown`. +> +> The envelope also gained **`notice`** — a private aside delivered beside a public answer, which is +> how §9 answer 5's "public projection plus an ephemeral prompt to link" is actually expressible: one +> reply cannot be both public and ephemeral, and that it becomes a follow-up is the platform's +> decision, not the handler's. + + **Ownership, decided:** the registrant owns the **definition and the handler**; the handler runs **in the website process** and returns a **response envelope**; the **bot owns every Discord-specific concern** — deferral, the 3-second ack, ephemerality, follow-ups, interaction tokens, embeds. This is @@ -1514,10 +1778,20 @@ ephemeral "link your account for more" — see §9 answer 5. ### 7.2 Notifications bridge +> **Amended after building it (phase 8, 2026-08-18).** The shape below is what was designed; five +> things about it did not survive contact with the tree, and the amendments are inline. The largest +> is that **this section's own visibility gate has no data source and cannot have one** — see "The +> gate, as built" below. The phase entry in Part 12 carries the full list. + The same Team events as §6, delivered to a second consumer. Core emits each Team notification to an internal fan-out with two subscribers: push (§6) and the integration bridge. **Not a second pipeline** — one event, two deliveries. +> **As built**, there is no new fan-out object: `utils/teamNotify.js` already computed the recipient +> set once and handed the event to push and to email, so the bridge is a **third sink in that same +> file** rather than a subscriber to something new. `utils/teamBridge.js` is the sink; the file that +> calls it is unchanged in structure. + ```sql CREATE TABLE IF NOT EXISTS team_integration_config ( platform VARCHAR(32) NOT NULL, -- 'discord' @@ -1530,10 +1804,34 @@ CREATE TABLE IF NOT EXISTS team_integration_config ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` +> **This DDL cannot hold its own default row.** MariaDB coerces every `PRIMARY KEY` column to +> `NOT NULL`, so `team_id NULL` — the deployment-wide default that every override overrides — is +> unrepresentable, and the whole mechanism has no base case. **As built:** a surrogate `id` primary +> key, a generated `team_key INT AS (IFNULL(team_id, 0)) STORED` carrying +> `UNIQUE KEY (platform, team_key)`, and a real `FOREIGN KEY (team_id) … ON DELETE CASCADE` that the +> original had no room for — without it a deleted Team leaves its configuration behind for whichever +> Team next lands on that id. The generated-column trick is the one `teams.active_key` and +> `content_reports.open_marker` already use. Three further columns carry the gate: `members_ack`, +> `members_ack_by` and `members_ack_at`. + Admin-configurable per event type, globally and per Team (a per-Team row overrides the `team_id IS NULL` default). Delivered via `POST /internal/team-notify` on the bot, best-effort, never throwing — identical to `announce` and `mod-reverse`. +> **`announce` and `mod-reverse` are not the same thing.** `announce` rides `announce_jobs` with +> backoff, retries and a per-leg retry button in the admin panel; `mod-reverse` is a one-shot call +> that records failure and stops. **The bridge is one-shot.** A news post is a durable artifact whose +> Discord copy is expected to exist; a Team notification is the moment it describes, and one that +> arrives twenty minutes late is worse than one that never arrives. A second job table and a second +> worker is a great deal of machinery to buy the opposite outcome. +> +> **The admin surface is its own panel under Admin → Teams**, beside the forum settings, and not an +> extension of the Discord Bot panel — a second integration would make the platform a registry lookup +> (§8.2), and what should change then is what fills the panel, not where it is. That holds whether or +> not the capability layer is ever built; Phase 10, which would have built it, is cancelled. It is **admin-only**, the one such corner of a +> staff-wide router: configuring where a Team's content leaves the site for is deployment +> configuration rather than the §2.9 kind of decision a moderator files a request for. + **A Discord message carries content; a push tickle does not.** Stated explicitly because the two look like the same event and are not: ntfy is an untrusted relay reached by an unguessable topic, so the tickle is content-free by design; the Discord server is an operator-configured, trusted destination @@ -1541,11 +1839,76 @@ where an empty "something happened, go look" message would be useless. What *is* allowlist discipline — an event is bridged only if its `visibility` is `public`, or its destination channel is configured for a members-only Team context. +#### The gate, as built + +**Neither half of that last sentence has a data source, and neither can have one.** + +- The four `team.*` streams carry **no `visibility`**. Only `team_activity` rows do, and a + notification is not an activity row. +- Forum threads have **no public/members column**, because a forum is members-only by construction — + every thread in it sits behind `team_forum_grants`. So §7.2's own example configuration, + `['team.announcement','team.forum.post']`, names exactly the two events that can never be public. +- Core **cannot see a Discord channel's permissions**, so "configured for a members-only Team + context" is not a fact core can check. Only the operator can see it. + +So the gate becomes an **attributed acknowledgement**: enabling an event that carries members-only +content requires an explicit confirmation that the destination channel is restricted to that Team's +members, recorded with who gave it and when — the same shape `teams_forum_uploads_ack` uses for the +image policy (§5.5.5). Four properties make it a gate rather than a checkbox: + +1. **It is a precondition, not a preference.** A save that would enable a members-only event without + it is refused **422**, not accepted-and-quietly-degraded. A configuration that silently does less + than it says is worse than one that will not save. +2. **It is re-asked at delivery**, not only at the save, so a row that loses the tick — an admin + repoints it, or a future change reclassifies a stream it already carries — stops carrying those + events immediately rather than at the next save. +3. **Changing the channel clears it.** An acknowledgement is about a *destination*; it cannot survive + the destination changing underneath it, or an operator could confirm a private channel and then + repoint the row at a public one while keeping the permission granted for somewhere else. +4. **A roster-only bridge needs no acknowledgement at all**, and a *disabled* row may carry forum + events without one — drafting a configuration is not publishing to a channel, and a dialog that + appears on saves that did not need it is one people learn to click through. + +Two smaller consequences of the same asymmetry: + +- **The author exclusion stops at the channel.** Push and email both subtract the post's author; the + bridge does not. Excluding is a per-recipient idea, and a channel has no per-recipient anything — + suppressing the message because the author reads that channel would deprive everyone else in it. +- **A roster event carries a count and never a name.** The sync notifies once per run rather than + once per member (§6.2), so a count is all the caller holds. It is also all it should say: a + character name is game-sourced text screened for a *page*, not for a channel. + ### 7.3 One voice channel per Team +> **Amended 2026-08-19, as built (phase 9).** The org lead settled the access model as **a per-Team +> role, always** — the escalation below is gone, and with it `voice_overwrite_max` and the `mode` +> column. Three things this section names turned out not to exist in the tree at all, and one number +> it relies on counts something different from what it says. Each is marked inline; "as built" wins +> over the original wording wherever they disagree. + **Shape.** One voice channel per qualifying Team, under a single shared parent category (`Teams`), created by the bot. **No per-Team role by default**, no auto-created category per Team. +> **As built: a per-Team role, always.** Overwrites-by-default with escalation was designed to spend +> the scarcer guild-wide resource only where the per-channel budget actually ran out. Roles-always is +> one code path instead of two plus a transition, and it makes the grant a thing a member can be given +> and taken rather than a channel-shaped list — but it moves the ceiling, and that is the part worth +> stating plainly: +> +> | | overwrites (designed) | roles (as built) | +> | --- | --- | --- | +> | Limit | ~100 overwrites **per channel** | 250 roles **per guild** | +> | So the ceiling is | how big ONE Team can be | how many TEAMS can have voice | +> | Visible to other members | no | yes — a role shows on a profile | +> +> A limit on the number of Teams is one an operator has to be told about *before* they reach it, so +> the admin panel reports the guild's role count against the cap and the reconciler refuses the create +> rather than letting Discord reject it. The count comes from the bot, not from core's own rows: the +> cap is shared with every role the operator made themselves. +> +> That a Team's membership becomes visible guild-wide on each member's profile is the trade this +> bought. It is not per-deployment configurable. + **Access, and why overwrites are enough — with a stated fallback.** Access is `@everyone` deny + `VIEW_CHANNEL`/`CONNECT` allow per **linked** Team member (path 4, §2.5) + the staff role. Discord's practical per-channel overwrite budget is ~100. A Team of up to ~95 linked members fits with room for @@ -1555,10 +1918,58 @@ members — because roles are the scarcer guild-wide resource (250 cap) and shou overwrites actually run out. So: overwrites by default, role on demand, and the escalation is recorded in `team_integrations.mode`. +> **As built.** The channel carries exactly three kinds of overwrite: `@everyone` denied, the Team's +> own role allowed, and one allow per operator-designated staff role. Membership is the role's member +> list. There is no `mode`, no `voice_overwrite_max` and no escalation. +> +> **"the staff role" does not exist in this codebase.** `guild_config` knows a news channel, a modlog +> channel, an autorole and a filter allowlist; none of them means "staff", and core has no way to +> derive one. Guild administrators bypass channel overwrites anyway, so what is actually missing is a +> way to let **non-admin** staff in — and only the operator can say which of their roles those are. +> As built: `teams_voice_staff_roles`, a list of role ids, **empty by default and a perfectly ordinary +> answer**. A role the operator has since deleted is filtered out by the bot rather than sent, because +> Discord rejects an entire overwrite set for one bad id and that would take the Team's own grant down +> with it. +> +> **The grant set is hop 3, not path 4's "linked".** A role can only be given to somebody Discord +> knows, so the set is Team members who have a site account *and* a `user_identities` row for Discord +> *and* are in the guild. A member missing the last of those is skipped silently — it is §2.6's hop 3 +> without hop 4, an ordinary state, not an error worth a hundred log lines. + **Provisioning gate.** Admin opt-in per deployment, plus `voice_min_linked_members` (default 5). Counted on **linked** members only, since an unlinked member cannot be granted anything on Discord anyway. +> **As built: `teams_voice_min_members`, counting EVERY active member** (org lead, 2026-08-19). The +> question an operator is answering with this number is "is this Team real enough to deserve a +> channel", and link state answers a different one. Note that this is deliberately *not* +> `teams.linked_count` either — that column counts hop 1 (has a site account), which is a third +> quantity again. +> +> **Two more gates the original does not mention, both required:** +> +> - **A hidden Team is never provisioned.** A channel name is a game-sourced string published outside +> the site, which is exactly §2.8's concern — `utils/reservedNames.js` already names "and eventually +> a Discord channel name" among the surfaces it protects. So the screen that suppresses a Team's +> public page suppresses its channel, and a Team that *becomes* hidden takes the grace window like +> any other removal. The interlock costs one `hidden = 0` in one query rather than a second policy +> that could drift from the first. The name published is `display_name_override || name` — §2.8.3 +> lets staff change what is displayed, and a channel is a display surface. +> - **The bot must actually be able to act.** This section assumes it can manage channels and roles; +> nothing in this project has ever checked. The operator invites the bot by hand and there is no +> invite URL with a permission integer anywhere in the tree, so a deployment can sit one unticked +> box away from every call failing with only a column of identical per-Team errors to show for it. +> As built, a **preflight is a precondition**: `PUT /admin/teams/voice` with `enabled: true` is +> refused **422** while the bot is disconnected or missing Manage Channels or Manage Roles, in the +> same shape §7.2's acknowledgement refuses. It is asked again at the top of every pass. Switching +> voice OFF is never gated — an operator disabling a feature because it is misbehaving must not be +> blocked by the misbehaviour. +> +> The preflight also reports the **bot's own role position**, because that is the second, quieter +> failure: Manage Roles lets the bot create a role, but it can only grant roles *below* its own +> highest. A bot at the bottom of the list creates roles it cannot hand to anybody, which looks exactly +> like a channel nobody can enter. + **Lifecycle: delete, but after a grace window.** Justification, since the brief asks for one: - A voice channel holds **no message history**, so deletion destroys nothing recoverable. The @@ -1574,34 +1985,83 @@ So: drop below threshold → `state='pending_removal'`, `remove_after` = now + ` expiry → delete. A Team **archived** (disbanded or renamed) takes the same window, because "disbanded" can be a missed event and 7 days is cheap insurance. +> **As built, with one narrowing.** "Recover inside the window → **no Discord call made**" is not +> quite what happens, and the truer promise is **no DESTRUCTIVE call**. A Team that climbed back above +> the threshold has members who need granting, and the ordinary membership diff is what grants them; +> refusing to call at all would leave the very people who brought it back outside the channel. What +> the recovery cancels is the deletion, and the channel id is unchanged — which is the whole point. +> +> A **failed teardown keeps the expired window** rather than being rescheduled. Granting another seven +> days each time a delete fails means it never happens. +> +> **Switching voice off tears nothing down.** The pass suspends in both directions and existing +> channels are left standing, inert; the panel says how many remain and offers to remove them one at a +> time. A checkbox must not delete structure in somebody's guild, and an operator trying the feature +> out must be able to stop trying it without consequences. Per-row removal is also the only way to +> clean up while voice is off, since no pass will ever reach those rows. + **And never on stale data.** If `team_sync_state` is stale for the module (§2.4), the integration reconciler **skips entirely** — no creation, no deletion, no overwrite changes. A voice channel is never destroyed because a sidecar was down. +> **As built, and proved on the rig** — a stale projection stops the pass before a single Discord call, +> in both directions, with the row not even scheduled for removal. +> +> One boundary worth knowing: `teams.model.syncStatus()` reports `stale: false` when **no** Team +> provider is registered, on the reasoning that a deployment with no game module is not a broken one. +> So on a deployment whose module has been uninstalled this suspension is inactive — which is benign, +> because with nothing updating the projection the member counts do not move and the reconciler has +> nothing to act on. + ```sql CREATE TABLE IF NOT EXISTS team_integrations ( id INT AUTO_INCREMENT PRIMARY KEY, team_id INT NOT NULL, - platform VARCHAR(32) NOT NULL, + platform VARCHAR(32) NOT NULL, -- 'discord' resource VARCHAR(32) NOT NULL, -- 'voice' external_ref VARCHAR(64) NULL, -- the channel id - mode ENUM('overwrites','role') NOT NULL DEFAULT 'overwrites', - role_ref VARCHAR(64) NULL, + role_ref VARCHAR(64) NULL, -- the Team's role: the grant itself state ENUM('none','active','pending_removal','error') NOT NULL DEFAULT 'none', remove_after DATETIME NULL, last_error VARCHAR(500) NULL, + synced_at DATETIME NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uq_team_integration (team_id, platform, resource), + INDEX idx_ti_pending (state, remove_after), CONSTRAINT fk_ti_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` +> **As built** — `mode` and `role_ref`-as-escalation are gone; `role_ref` is now the grant itself, so a +> row with a channel and no role is a broken row. `synced_at` is added: `updated_at` moves whenever +> core writes a belief, including an error, and "when did this last actually reach Discord" is a +> different question. Unlike §7.2's DDL, this one applied to real MariaDB exactly as written. + **Sync** rides the same reconciliation as membership: after a successful Team reconcile, the integration reconciler diffs the desired access set (path 4) against what the bot reports and issues the minimum set of calls. Every call is best-effort; a failure records `state='error'` with the message and retries on the next pass. It never blocks the Team sync. ---- +> **As built, with the diff on the bot's side.** Core sends the DESIRED STATE for one Team — name, +> category, channel, role, staff roles, the member id list — and the bot works out the calls. That is +> the opposite of the split §7.1 and §7.2 use, and it is deliberate: every *decision* is still core's, +> but the diff is a comparison against live guild state that only the bot can see, and doing it in core +> would mean shipping the guild's whole role membership over the wire to compare it and shipping the +> answer back. +> +> **The membership diff is bounded per pass** (50 operations) and the remainder is reported, because +> each grant is its own API call under its own rate limit and an unbounded first pass on a large guild +> outlives its own request timeout — the one failure that leaves core not knowing what was applied. A +> non-zero remainder asks for another pass rather than waiting out the interval. +> +> **A failure is per-Team and never aborts the pass**, the same shape as §2.4's gate 3. A failed sync +> **keeps the refs it could not confirm**: a failure is core failing to confirm a channel, not learning +> it is gone, and clearing them would orphan a real channel and have the next pass build a second one +> beside it. +> +> The pass is **requested, not awaited**, by the Team reconciler — it makes Discord calls, and a roster +> sync must never be slowed, failed or held open by an integration hanging off it. It has its own +> 30-second debounce. ## Part 8 — Keeping the integration layer platform-agnostic @@ -1656,6 +2116,14 @@ installed. **No Matrix implementation is built.** §8 is research to shape the Discord contract, exactly as the brief asks. +> **And no capability registry is built either** (2026-08-19). Phase 10 would have extracted the one +> above from Phases 7–9's Discord code; it is cancelled and deferred until a second integration is +> wanted. Everything in §8 stays as it is — the comparison is what makes the *shape* of the Discord +> work defensible, and it did its job by keeping core's calls phrased as eligibility questions rather +> than as Discord operations. What is not there is the indirection: `discord` is named directly in the +> bridge, the voice provisioner and the command dispatcher, and a second platform is a phase, not a +> configuration change. + --- ## Part 9 — The explicit answers @@ -1888,6 +2356,14 @@ this protocol. ## Part 11 — `MODULE_API_VERSION` bump proposal +> **Amended 2026-08-17, on the org lead's decision.** The seven additions below land under **one** +> 1.6.0, declared in phase 2, rather than a minor bump per phase. `MODULE_API.md` therefore documents +> members before they work, so each is marked with the phase that implements it, and the two that do +> not yet — `ctx.teams.activity.push` (§4, phase 3) and `api.registerSlashCommands` (§7.1, phase 7) — +> are **present and throw** with an error naming that phase. Present rather than absent so a module +> written against the published version fails at registration with an explanation, instead of at +> whatever moment someone first exercises the feature. + **1.6.0 — minor.** Every change is an addition; no member is removed and no existing signature changes, so `MODULE_API.md` §1.1's table gives minor, and `module-uo`'s `coreApi: "^1.3.0"` still resolves. @@ -1934,6 +2410,14 @@ 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 10 is cancelled** (org lead, 2026-08-19), deferred until a second integration is wanted or +it is asked for by name — see its own entry. Phase 11 is therefore the last phase of the bet. + +**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 @@ -1946,7 +2430,28 @@ Emit `guild.roster` for a **single** guild against the local ServUO tree store, survives a sidecar restart, and comes back out of `GET /guilds`. Then build Phase 1. Days, not weeks, and it retires the only unknown in the plan. -### Phase 1 — the roster on the wire (`servuo-plugins` + `link` + `module-uo` + `docs`) +### Phase 1 — the roster on the wire (`servuo-plugins` + `link` + `module-uo` + `installer` + `docs`) + +> **Amended 2026-08-17, after Phase 0 and while building this.** Two corrections to what follows. +> +> **The sidecar had no schema-migration mechanism, and this phase is the first change that needs +> one.** `store.rs`'s `SCHEMA` is `CREATE TABLE IF NOT EXISTS`, which can add a table but cannot add a +> column to one that already exists — and every schema change up to Protocol 3.0 happened to add +> whole tables, so `ALTER TABLE` appears nowhere in `link`'s history and the gap was invisible until +> `guilds.members`. Settled by the org lead: **`PRAGMA user_version` stepped migrations**, each step +> transactional with the bump recording it; a failure aborts startup (already the behaviour, and safe +> because the shard dials *out*), while a database from a *newer* sidecar warns and continues so a +> binary rollback stays a recovery path. Not `sqlx::migrate!`, whose per-file checksums hard-fail +> startup if a released migration is ever edited. +> +> **`installer` joins the phase**, which is why the heading names five repos rather than four. +> `backup.rs` justifies not copying the sidecar database on two claims: that every table is +> `IF NOT EXISTS` (which the migration above falsifies) and that the sweeps repopulate everything +> (already false — `events` is never pruned and the website backfills from `GET /history` on every +> reconnect). The behaviour is unchanged and correct; only its stated reason needed fixing, and a +> wrong reason left in place is what lets someone extend it to a case it never covered. +> +> The spec for all of it is [`../link/v4.md`](../link/v4.md). The prerequisite for everything. Nothing in Team core can be built against counts. @@ -1954,7 +2459,8 @@ The prerequisite for everything. Nothing in Team core can be built against count and `guild.leave`; `overlay.toml` protocol → 4. Sidecar: `members` on the `guilds` board, `PROTOCOL_VERSION` → 4, `GET /guilds` projection. `module-uo`: ingest both kinds, a `shard_guild_members` table, the kind→feature map entry and field projection for the new fields. -`docs/link/PROTOCOL_2.md` + `v3.md` + `INTEGRATION.md`. +A new `docs/link/v4.md` as the spec of record, plus `PROTOCOL_2.md` §10.1 (which sketched this design +in 2.0 and had it half-built) and `INTEGRATION.md`. **Ships:** a richer public Guilds page (real rosters) on its own merit, with no Team code anywhere. **Verify:** the five-rung shard visibility walk against a live ServUO + sidecar, confirming `acct`/`webId` @@ -1969,6 +2475,40 @@ line with a continuation flag for the pathological guild. ### Phase 2 — Team core (`website` + `module-uo` + `docs`) +> **Amended 2026-08-17, while building this.** Five corrections, all found by building or testing the +> thing described below. +> +> **§2.5's SQL and §2.10's decision cannot both hold as written.** §2.5 gives `team_forum_grants` a +> generated column `active_user AS (IF(revoked_at IS NULL, user_id, NULL))` and a `CASCADE` foreign +> key; §2.10 later settles that key as `SET NULL` so the audit trail survives an account deletion. +> MariaDB refuses `ON DELETE SET NULL` on a foreign key whose column is a base column of a STORED +> generated column (error 1901), so the generated column forces the `CASCADE` — and with it, the loss +> §2.10 exists to prevent. **Settled: §2.10 wins.** The marker is derived from `revoked_at` alone and +> `user_id` moves into the unique KEY, which gives identical semantics — at most one active grant per +> (team, user), unlimited revoked rows — with `user_id` free to be `SET NULL`. +> +> **`team_forum_grants` is created in this phase**, not in phase 4, so the four-path resolver is +> written once and its non-contamination tests are real. Nothing writes it yet; the grant flow, the +> per-Team cap and the leader UI stay phase 4's. +> +> **Two columns on `teams` that this document did not contemplate**, both serving §2.4's gates. +> `roster_synced_at`: `team_sync_state` holds one row per *module*, and gate 3 leaves one Team's roster +> untouched while the others sync — without a per-Team stamp that Team's page would report the +> module's last success as its own, which is exactly the staleness the gate exists to surface. +> `members_empty_since`: gate 4's per-Team quarantine, the twin of `pending_empty_since`. +> +> **`leader` on the member shape is not path 2.** §2.3 puts `leader` on a member and §2.5 says the sync +> writes `is_leader` from `getTeamLeaders()`; taking both literally gives one column two writers, and +> the roster's write lands *first* — so a refused `getTeamLeaders()` silently demoted everyone. The +> roster now **seeds** `is_leader` on insert only, so a Team is not leaderless while that call is +> failing, and `getTeamLeaders()` alone moves it afterwards. +> +> **The §2.8.2 matcher needed two narrow widenings**, both real impersonation vectors the whole-word +> rule missed: a single-word term also matches a name word's singular ("Guild of Moderators"), and a +> run of two or more single-letter words is also compared joined ("G.M."). Neither re-admits substring +> matching — only a trailing `s` off the *whole* term is stripped, and the join is of single letters, +> never of the whole name. + `teams`, `team_members`, `team_sync_state`; `registerTeamProvider` + the three `ctx.teams` members (**`MODULE_API_VERSION` → 1.6.0**); the reconciler with all four refusal gates; the four-path resolver with its non-contamination tests; `team_leader_overrides`; the public/player/admin read API; @@ -1995,13 +2535,106 @@ guild called "Admin" cannot put an official-looking page on the site. ### Phase 3 — Team pages, roster, nav, activity feed (`website` + `module-uo`) +> **Amended 2026-08-17, while building this.** Six corrections. The first is the org lead's, and it +> changes what this phase ships; the rest were found by building the thing described below. +> +> **THERE IS NO CORE TEAM SURFACE. Teams is a contract primitive, not a page.** §3.1 puts `/teams`, +> `/teams/:slug`, `/teams/:slug/roster` and `/player/teams` in core and §3.5 registers three core nav +> entries for them. **Settled (org lead): all seven are dropped.** Core does not own the word for a +> Team — a UO shard calls them guilds, and the Rust module that comes next will call them clans — so a +> core page under a noun core invented would sit beside `module-uo`'s existing `/uo/guilds` saying the +> same thing twice, in the wrong vocabulary. Core keeps the tables, the sync, the access resolver, the +> activity feed and the whole API; the **module** builds the pages on that contract. `/admin/teams` +> stays: an operator inspecting the primitive is looking at the primitive. +> +> **So the extension slots invert, and that is a new `MODULE_API` §3.7 direction.** `team.overview` +> and `team.member.row` assumed core rendered the page. They are replaced by +> `registry.declareModuleSlot(id, name, { core })`: a **module** declares a place on its own page, +> namespaced under its own id, and names which of core's contributions belongs there. `module-uo` +> declares `uo.guild.detail` and asks for `team.activity`; core offers the activity feed, because only +> core can resolve whether a viewer is inside the Team and the public/members split is a security +> boundary. **Core names the contribution, never the slot** — amended in phase 11, inside 1.6.0, after +> the integration kit found that the literal-name version worked for one module and silently did +> nothing for any other. Core's contributions are applied at mount rather than eagerly — core's bundle +> evaluates before every module chunk, so at the moment core offers one, no module-declared slot exists +> yet. `Slot` joins the shared UI kit as its ninth member so the module renders the place with core's +> own error boundary. +> +> **A module names a Team in its own vocabulary**, so `GET /public/teams/by-external/:moduleId/:externalId` +> is added: core's row id and slug are core-internal and handing them to a module is how a module ends +> up storing them. The module id is matched rather than trusted — an external id is unique only within +> a module. +> +> **§3.3's projection is an EIGHTH `MODULE_API` member and 1.6.0's list said seven.** Settled by the +> org lead: **1.6.0 is amended in place** rather than bumped, applying the same rule Protocol 4 got in +> phase 2 — a contract owes a bump only once it has landed on `main`, and 1.6.0 is on `edge` only. +> +> **"The module declines" needed splitting in two before it could be implemented.** §3.3 says a module +> that declines yields the public projection, fail closed. But *no module at all* and *a module whose +> rung system could not be consulted* are opposite situations: the first is withholding nothing and +> must serve the roster whole, the second must serve none of it. The refusal therefore carries +> `projects` — `false` for "there is no audience model here", `true` for "there is one and I could not +> ask it" — and only the second fails closed. Also, the module answers with member **keys**, not rows: +> returning rows would let a module widen what is published by handing back a `userId` core had +> withheld, leaving core's field guarantee resting on every module's good behaviour. +> +> **Core's five activity kinds are four here.** `core.forum.thread` has nothing to emit it until the +> forum lands in phase 4. Separately, and not in the doc at all: **the first roster for a Team emits +> no join items.** Importing a 155-member guild is one Team arriving, not 155 people joining, and +> emitting a join per member would bury every real event under the import and reach the row cap on day +> one. `roster_synced_at IS NULL` is the condition, which covers both a new Team and a newly installed +> module adopting an existing one. +> +> **§2.11's route table has no activity endpoint** though §4.3 describes a feed filtered by the +> viewer's access. Added on the org lead's decision: `GET /api/v1/public/teams/:slug/activity`, paged, +> with the visibility resolved from the session and never from a parameter. It is the first public +> route whose *content* depends on identity, which needed a new `optionalAuth` middleware — +> `attachSession` only decodes a token, so a banned or logged-out account would have kept reading the +> members-only half until its JWT expired. + `/teams`, `/teams/:slug`, `/teams/:slug/roster`, `/player/teams`; the linked/unlinked/guest surface; `team.overview` + `team.member.row` slots; nav registration; `team_activity` + `ctx.teams.activity.push` + core's own five activity kinds + the retention prune. **Ships:** the whole public Team experience. Independently valuable with no forum and no Discord. -### Phase 4 — Forum 5a: access model + announcements + admin controls (`website`) +### Phase 4 — Forum 5a: access model + announcements + admin controls (`website` + `module-uo`) + +> **Amended 2026-08-18, while building this.** Six corrections. The first is structural and follows +> from phase 3; the rest were found by building the thing described below. +> +> **The forum had nowhere to live, and §5.4's route table did not notice.** §3.1 gave it +> `/teams/:slug/forum/*` — a CORE page — and phase 3 deleted every core Team page. The routes are +> unaffected (they are all `/player` and `/admin`), but the participant SURFACE had no home. Settled +> by the org lead the same way phase 3 settled the activity feed: **`module-uo` declares a second +> place on its guild page, `uo.guild.forum`, and core fills it.** So this phase spans two repos, not +> the one named above. +> +> **Two slots rather than one**, because a slot holds one component and the first fill wins. Stacking +> the feed and the forum into a single fill would take from the module the ability to place core's +> two contributions separately on its own page, which is the whole point of the module owning it. +> +> **The forum panel navigates by SEARCH PARAM (`?thread=12`), not by route.** A thread has to be +> linkable and core cannot mount a route for one — the route belongs to the module's page. A search +> param gives a shareable URL under whatever path the module chose, with no core route anywhere in +> it. It is why the fill is one component holding both a list view and a detail view. +> +> **`rel` had to be added to the forum sanitiser's allowed attributes to make links SAFER, not +> laxer.** The profile writes `rel="noopener noreferrer nofollow"` through a transform, and +> sanitize-html strips any attribute not in the allowlist — including one its own transform just +> added. Without the entry every forum link shipped without `noopener`, silently. +> +> **The bare-URL linkifier is a second pass, and its ordering is the security property.** §5.5.3 says +> an author writes a URL and core renders the picture, which requires the URL to have become an +> anchor on the way in. Linkifying runs AFTER sanitising, over the sanitiser's own output and only on +> text outside tags: every text node is HTML-escaped by then, so the matched URL is safe in both the +> href and the link text. Running it first would be an injection point. +> +> **The upload sweep runs whether or not `uploads` is the current mode**, which is not obvious and is +> the point. An operator who turns uploads off after a problem still has the files; a sweep that +> switched itself off with the setting would strand exactly the bytes they were trying to be rid of — +> and it is the mechanism behind the dialog's promise that disabling does not delete. + `team_forum_grants`, the grant/revoke flow with audit into `activity_log`, leader vs staff authority, the full forum schema, announcement threads, and the leader/staff grant UI. @@ -2029,6 +2662,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. @@ -2036,45 +2697,324 @@ 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 app, so a Team tickle on mobile opens the app and no more. That is a stated limitation, not an oversight. -### Phase 7 — Discord: slash commands (`website` + `bot` + `docs`) +### Phase 7 — Discord: slash commands (`website` + `module-uo` + `docs`) — **DONE 2026-08-18** `api.registerSlashCommands`, `/internal/commands` + `/internal/commands/dispatch`, the bot's -defer→dispatch→edit path, the actor resolver, the version-bump re-register, and `/team` as the first -command through it. +defer→dispatch→edit path, the actor resolver, the version-bump re-register, and the first command +through it. -**Ships:** a working `/team`, and the seam a module needs for its own commands. +**Ships:** a working `/guild`, and the seam a module needs for its own commands. -### Phase 8 — Discord: notifications bridge (`website` + `bot`) +**THREE repos, not the plan's `website` + `bot` + `docs` — `bot` is not a repo.** It is a workspace +inside `website`, so the bot half lands in the same PR as the server half; `module-uo` joins instead, +because the command that proves the seam belongs to the module and not to core (see the amendment at +the head of [§7.1](#71-slash-command-registration)). -`team_integration_config`, the internal fan-out with push and bridge as two consumers, -`POST /internal/team-notify`, the admin per-event configuration. +**Walked on the live rig before the PRs opened** — real ServUO + real sidecar (protocol 4) + the app +with module-uo installed, with the bot's own pull/execute path driven against it and a fake standing +in for Discord. It proved the audience rung holding over the chat surface (guilds gated to `staff`: +anonymous and linked-player refused, linked admin served, same command), the Discord provider +resolving by `kind` on a deployment whose provider slug is `my-discord`, a banned account resolving as +unlinked, the disable nudge firing with its reason and degrading to a log line with no bot running, +and the pull emptying plus dispatch answering `unknown` for a module switched off at runtime. -### Phase 9 — Discord: voice channels (`website` + `bot`) +**It found two defects, both folded in.** A refusal was posted PUBLICLY — ephemerality is fixed at the +deferral, before the handler has said anything, so the envelope's flag was read and ignored, and "not +shown to your account" announced a member's access level to the channel. And the refusal offered +linking on a shard gated to `staff`, where linking reaches `player` and stops. -`team_integrations`, the threshold gate, the shared category, overwrite management with role -escalation above `voice_overwrite_max`, the grace-window lifecycle, and the stale-sync suspension. +**The bot got its first test harness.** It had no `test` script and no tests at all — CI ran +`npm ci --prefix bot` and nothing else — which was defensible while the bot only wired up its own +static commands. It is not defensible now that it merges a pulled set into a single all-or-nothing +registration and runs the interaction path, and phases 8 and 9 add more. `bot/test/` and a +`bot-tests` job replace `bot-install`. -### Phase 10 — the capability layer (`website` + `docs`) +### Phase 8 — Discord: notifications bridge (`website` + `docs`) — **DONE 2026-08-18** + +`team_integration_config`, the bridge as a third sink beside push and email, `POST +/internal/team-notify`, and the admin per-event configuration. + +**Ships:** a Team's forum posts, announcements and roster changes arriving in a Discord channel the +operator chose, per Team or deployment-wide. + +**ONE code repo, not the plan's `website` + `bot`.** `bot` is a workspace inside `website`, the same +correction phase 7 made — but unlike phase 7 nothing here belongs to a module, so `module-uo` is +untouched: the four streams are core's own and the bridge reads core's own forum. `MODULE_API_VERSION` +does not move. + +**Walked on the live rig before the PRs opened**, per the order phase 5 set. + +#### Five things the tree disagreed with §7.2 about + +1. **`PRIMARY KEY (platform, team_id)` cannot hold the default row.** MariaDB coerces every primary + key column to `NOT NULL`, so `team_id NULL` — the deployment-wide default, and the base case of the + whole override mechanism — is unrepresentable. As built: a surrogate `id`, a generated + `team_key AS (IFNULL(team_id, 0)) STORED` in the unique key, and the foreign key the original DDL + had no room for. Same idiom as `teams.active_key` and `content_reports.open_marker`. +2. **The visibility gate has no data source on either side, and cannot have one.** §7.2 bridges an + event only if "its `visibility` is `public`, or its destination channel is configured for a + members-only Team context". The four `team.*` streams carry no visibility — only `team_activity` + rows do, and a notification is not an activity row — and forum threads have no public/members + column because a forum is members-only by construction, everything in it sitting behind + `team_forum_grants`. So §7.2's own example config, `['team.announcement','team.forum.post']`, + names exactly the two events that are never public. Nor can core see a Discord channel's + permissions to check the other half. + + **As built: an attributed operator acknowledgement**, `members_ack` / `members_ack_by` / + `members_ack_at`, in the shape `teams_forum_uploads_ack` already uses. Enabling a members-only + event without it is refused **422** rather than dropped at delivery, because a configuration that + silently does less than it says is worse than one that will not save. It is re-asked at delivery as + well as at the save, so a row that loses the tick stops carrying those events at once — and + **changing the channel clears it**, since an acknowledgement is about a destination and cannot + survive the destination changing underneath it. +3. **"Identical to `announce` and `mod-reverse`" names two different things.** `announce` rides + `announce_jobs` with backoff, retries and a per-leg retry button; `mod-reverse` is one-shot. The + bridge is **one-shot**: a news post is a durable artifact whose Discord copy is expected to exist, + while a Team notification is the moment it describes, and a message arriving twenty minutes after + the conversation moved on is worse than one that never arrives. A bot that is down drops it, which + is the deal the push tickle already takes. +4. **The author exclusion stops at the channel.** Push and email both subtract the author; the bridge + does not. Excluding is a per-recipient idea and a channel has no per-recipient anything — + suppressing the message because the author happens to read that channel would deprive everyone + else in it. +5. **A roster event has a count and no name.** The sync notifies once per run rather than once per + member (§6.2), so a count is all the caller holds; it is also all it should say. `memberJoined` + grew an optional `{ count }` **for the bridge only** — a channel has no app on the other end to + pull anything after a content-free nudge — and the tickle beside it is unchanged. + +#### Where the admin surface lives, and why it is not in the Discord panel + +Its own panel under **Admin → Teams**, beside the forum settings, rather than an extension of +`DiscordBotAdmin`. A second platform would replace "Discord" with whatever a capability registry +declares (§8.2); what should change then is what fills the panel, not where an operator goes to find +it. Phase 10 would have built that registry and is cancelled — which changes nothing here, because +the reason this panel is not inside the Discord one is that an operator should not have to know which +platform is configured to find it. It is the one +**admin-only** corner of a staff-wide router: this is not the §2.9 kind of decision a moderator files +a request for, it is deployment configuration, and it sits with the role that already holds the bot +token. + +#### What the rig proved, and the two defects it found + +Real ServUO + real sidecar (protocol 4) + the app with module-uo installed, with a fake standing in +for Discord. It proved the default row governing a Team with no row of its own, a per-Team override +beating it (including an override that switches the bridge OFF for one Team while the default stays +on), the 422 on an unacknowledged forum bridge, the acknowledgement clearing on a repoint, forums +switched off silencing the bridge along with the push, and a bot that is down costing the forum reply +nothing. + +**Both defects came out of tests written against the rig's shapes.** A re-acknowledgement given for a +NEW channel kept the OLD attribution — the column was already 1, so "freshly acknowledged" read false +and the row went on naming whoever vetted the previous destination, which is the entire audit value of +the column. And the embed description was clamped to Discord's limit **before** the heading was +prepended, producing a description one heading over the limit; discord.js rejects that outright, so an +over-long forum post would not have arrived at all rather than arriving truncated. + +**Not done here.** No real Discord guild was involved — `channels.fetch` and a real `channel.send` +are the two things this walk could not exercise, the same gap phase 7 recorded for `REST.put`. + +### Phase 9 — Discord: voice channels (`website`) — **DONE 2026-08-19** + +`team_integrations`, the threshold gate, the shared category, **a per-Team role** (not overwrite +management with escalation — see §7.3's amendment), the grace-window lifecycle, and the stale-sync +suspension. + +**Ships:** every Team above the operator's size threshold gets a voice channel of its own in Discord, +visible and joinable by its members and nobody else. + +**ONE code repo, not the plan's `website` + `bot`.** `bot` is a workspace inside `website` — the same +correction phases 7 and 8 made. `module-uo` is untouched and `MODULE_API_VERSION` does not move. + +**Org-lead decisions (2026-08-19), all four settled before any code:** **roles always**, no overwrite +escalation · the bot creates the parent category and the server stores its id in settings · the +threshold counts **every** active member, not linked ones · "staff" is **a list of Discord roles the +admin designates**, because the concept does not otherwise exist. + +**Walked on the live rig before the PRs opened**, per the order phase 5 set — real MariaDB, the real +app, and a fake standing in for Discord that mounts the bot's real internal routes, so everything up +to the Discord API call was production code. 47 assertions. + +#### What the walk proved, and the two defects it found + +It proved: the preflight refusing an enable three different ways and the panel still rendering with a +broken bot; a category, role and channel created with `@everyone` denied and the Team role allowed; +the hidden Team and the below-threshold Team getting nothing; the role granted to the two members in +the guild and **not** to the one who linked Discord without joining it; a drop below the threshold +scheduling a removal **with zero Discord calls**; a recovery inside the window keeping the same +channel id; an expired window deleting the channel *and* the role and forgetting the row; a stale +projection suspending the pass in both directions; voice switched off leaving the channels standing; +and an admin removal working anyway, with a 404 for a Team that has none. + +1. **Every query failed on a duplicate result column.** `desiredTeams` and `holdersWithoutClaim` both + select `t.id AS team_id`, and the shared column list added `i.team_id` beside it — which the + `mariadb` driver refuses outright ("Error in results, duplicate field name `team_id`"). The pass + died at its first query, on the one code path every unit test stubs. It was also the wrong column: + `desiredTeams` LEFT JOINs, so `i.team_id` is NULL for exactly the Teams that have no channel yet. +2. **"Sync now" reported "Nothing was done" while it was doing it.** Saving the settings with voice on + asks for a pass; an operator pressing Sync now next — the obvious thing — got "a pass is already + running" and a panel saying nothing had happened, while the pass they triggered created their + channels. A pass in flight is now joined and its real outcome returned, as `reconcileNow` does. + +#### Two things outside this phase that it had to work around + +- **`npm run swagger` could not run at all on `edge`.** Phase 8 shipped a regex literal followed + directly by `.test(` in a route validator, which makes swagger-autogen's parser run away and the + process die out of memory. Hoisted to a const. Underneath it, `teams.router.js` sits exactly at that + parser's **per-file limit**: at twenty `teamsRouter.*` statements it dies and at nineteen it + generates, and one more statement of any shape tips it — an unannotated route does, and so does a + bare `use`. The voice routes are therefore their own router file, mounted from `admin/index.js`. +- **`last_success_at` is written by MariaDB's `NOW()` and compared against JS `Date.now()`**, so an app + process and a database in different timezones skew every staleness judgement by the offset — which + moves §3's public freshness banner as much as this phase's suspension. Pre-existing and not fixed + here; recorded because it is invisible until something depends on it. + +### Phase 10 — the capability layer (`website` + `docs`) — **CANCELLED 2026-08-19** + +> **Not built, and not scheduled.** The org lead cancelled this phase after Phase 9, deferring it +> until a second integration is actually wanted or it is asked for by name. What follows is what it +> would have done, kept because the argument for it survives its cancellation. Refactor Phases 7–9's Discord code behind the declared-capability registry (§8.2) and prove it by rendering the admin UI from the declaration rather than from a hardcoded "Discord" assumption. Last on purpose: extracting a capability surface from one working implementation is honest; designing it before one exists is speculation. +**Why cancelling it costs little.** The same argument that put it last is the argument for not doing it +yet: with exactly one integration built, the refactor would extract a capability surface from a single +implementation and have nothing to check the extraction against. §8.2's Matrix column is research, not +a second implementation, and a registry whose only consumer is the thing it was extracted from is a +layer of indirection that has not yet been paid for. The work is cheaper *and* better-informed the day +a second platform exists, because that platform is what proves which of the five capabilities the +seam actually needs. + +**What it leaves behind, stated so nobody has to re-derive it.** Phases 7–9 name Discord directly — +in the bridge config (`team_discord_config`), the voice provisioner, the slash-command dispatcher and +their admin panels. That is not a defect and no code is placed differently in anticipation of a layer +that may never come. Two decisions were made *for* this phase, and both stand on their own: +the notification bridge and the voice panel live under **Admin → Teams** rather than inside the +Discord Bot panel (§7.2, §7.3), because where an operator goes to find them should not depend on which +platform fills them; and core's calls are already phrased as questions about eligibility — *these user +ids are eligible for Team 3* — rather than as instructions about overwrites. A second integration +would be a new phase against that surface, not a rescue of this one. + +### 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. + +> **Amended 2026-08-19, as built.** The phase ran before the cutover, as planned, and it found what it +> was meant to find. Four notes. +> +> **The inverted slot did not work for anyone but `module-uo`, and the kit is what proved it.** Core +> filled three literal `uo.guild.*` names, so a second game's module declared its places under its own +> id and core filled none of them — an empty page, no error, nothing logged, because *"a fill for a +> slot nobody declared is not an error"* is exactly the rule that hides an unknown name. Settled by the +> org lead the same day: **core offers a CONTRIBUTION and never names a slot**, amended into 1.6.0 in +> place since it has only ever been on `edge` (website#160, Module-uo#15, docs#165). The kit could not +> have taught the shape honestly without this, which is the argument for having written the book +> before the cutover rather than after it. +> +> **The template grew a real provider rather than a snippet** (org lead, 2026-08-19). It registers +> `registerTeamProvider` over two tables of its own, declares three slots on a clan page, and serves +> its own `/clans` — deliberately not `/teams`, which is core's and which the loader would refuse. The +> guards that matter are the ones a reader would otherwise omit: an unreachable game refuses rather +> than reporting no clans, an empty roster is refused unless the game says the clan is empty, and one +> audience rule serves both `projectRoster` and the module's own page. +> +> **It was walked on a live rig before the PRs opened** — real MariaDB, the real loader, a browser. +> Core reconciled two Teams out of the provider on the first boot, `/public/teams//members` +> answered `projected: true`, and the clan page rendered core's activity feed and forum in the slots +> the module declared. `module-uo`'s guild page was walked on the same core and is unchanged. The walk +> found one defect no test could: `PageHeader` takes `lead`, not `subtitle`, and React drops an unknown +> prop silently — so every page built from the template had rendered its heading with nothing under it +> since the template was written. +> +> **Phase 10's cancellation makes this the last phase**, and nothing in it changed as a result. + +**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 ·