From b369e728c2ed83199cdeabe74fab11e0a84ff70d Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 17 Aug 2026 13:01:05 -0500 Subject: [PATCH 01/17] =?UTF-8?q?docs(link):=20protocol=204=20=E2=80=94=20?= =?UTF-8?q?guild=20membership=20on=20the=20wire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds v4.md as the spec of record for `guild.roster` and `guild.leave`, and corrects the two older documents that Protocol 4 makes wrong. PROTOCOL_2.md §10.1 already described this design — hold a member-serial set, diff it each sweep, emit join/leave — and 2.0 then shipped only the half needing no new state, folding membership into the board signature as a serial *sum*. The section has read ever since as though the whole thing were built. It now says which half shipped, and carries the correction that doing it produced: 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. INTEGRATION.md gains both kinds in the event catalogue, the `roster` key on GET /guilds, and the three things an integrator gets wrong otherwise — that `guild.leave`'s `who` is a bare serial rather than an actor object (the mobile has already left, so there is nothing to attribute), that `acct` is genuinely optional on a member, and that a guild with no `roster` key is not the same as one with an empty roster. v4.md documents what the phase found as well as what it built: the missing store migration and the user_version decision, why the roster lives in its own column rather than inside the guild.update snapshot, why a split roster is reassembled in memory rather than appended to the column, and why guild.leave gets no board projection at all. §6 records that the reassembly bug was invisible to every unit test — they all exercised single-frame rosters — and only the live rig caught it. TEAMS.md is amended where this phase disagreed with it: Phase 1 spans five repos, not four, because installer/backup.rs justifies skipping the sidecar database on reasoning the migration falsifies. The user_version decision is recorded there too, since the design of record did not contemplate a migration mechanism at all. Co-Authored-By: Claude --- link/INTEGRATION.md | 32 +++++- link/PROTOCOL_2.md | 8 ++ link/v4.md | 232 ++++++++++++++++++++++++++++++++++++++++++++ website/TEAMS.md | 26 ++++- 4 files changed, 293 insertions(+), 5 deletions(-) create mode 100644 link/v4.md diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md index 147f2f1..05cc847 100644 --- a/link/INTEGRATION.md +++ b/link/INTEGRATION.md @@ -283,11 +283,19 @@ 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) | 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. + +**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 +304,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 +869,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..726f73b --- /dev/null +++ b/link/v4.md @@ -0,0 +1,232 @@ +# 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: + +```jsonc +{"serial":"0x1F5","name":"Seed000A","acct":"seed_000","webId":"42","player":true} +``` + +`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. + +--- + +## 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/website/TEAMS.md b/website/TEAMS.md index 479a937..dd4d6e4 100644 --- a/website/TEAMS.md +++ b/website/TEAMS.md @@ -1946,7 +1946,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 +1975,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` -- 2.49.1 From 680a4866acb61dc26bb815549ddedfc93a04b18c Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 17 Aug 2026 15:32:21 -0500 Subject: [PATCH 02/17] docs(teams): Team core, MODULE_API 1.6.0, and what phase 2 disproved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents phase 2 of docs/website/TEAMS.md across the three files that had to change, and records the five places building it disagreed with the design. ## MODULE_API.md — 1.6.0 The Team surface becomes contract: `api.registerTeamProvider(...)`, `ctx.teams.publish` / `ctx.teams.reconcile` / `ctx.teams.activity.push`, `api.registerSlashCommands(...)`, and the two client slots. Additions only, so minor; module-uo's `coreApi: "^1.3.0"` still resolves. Per the org lead's decision, one 1.6.0 covers the whole surface rather than a minor per phase -- so the document names the phase against each member, and the two that cannot work yet are marked as present-and-throwing rather than left to be discovered at runtime. `registerTeamProvider` gets the fullest treatment because it is the first registration where core calls the MODULE and waits for an answer. The envelope, the 10-second budget and the refusal semantics are all contract, not implementation: they are how a module says "I cannot answer" without core hearing "there is nothing". `ctx.teams` is documented as push-only, with the reason there is no reader — a module answers questions about Teams, it does not ask them. ## BACKEND_DESIGN.md The six Team tables, the rename rule, the active-only uniqueness encoding, the per-column account-deletion decisions, and all eighteen routes across the three tier tables. Two entries there exist to stop a future reader "fixing" them: why `team_forum_grants` does not use the obvious generated column, and why the two columns TEAMS.md never mentioned have to exist. ## TEAMS.md — five amendments, marked as amendments with their date - **§2.5's SQL and §2.10's decision cannot both hold.** MariaDB refuses ON DELETE SET NULL on a base column of a stored generated column (1901), so §2.5's `active_user` forces the CASCADE that §2.10 exists to prevent. §2.10 wins; the marker is re-encoded for identical semantics. - **`team_forum_grants` lands in phase 2**, so the four-path resolver is written once and its non-contamination tests are real. - **Two columns the document did not contemplate**, both serving §2.4's gates: `roster_synced_at`, because sync state is per MODULE and gate 3 leaves one Team behind while the others sync; and `members_empty_since`, gate 4's per-Team quarantine. - **`leader` on the member shape is not path 2.** Taking §2.3 and §2.5 both literally gives one column two writers, and the roster writes first — so a refused `getTeamLeaders()` silently demoted everyone. Found by its own test. - **§2.8.2's matcher needed two narrow widenings**, both real impersonation vectors the whole-word rule missed: a term matches a name word's singular ("Guild of Moderators"), and a run of single-letter words is compared joined ("G.M."). Neither re-admits substring matching. Pairs with website (Teams phase 2) and Module-uo (the provider). Refs docs/website/TEAMS.md Part 12 phase 2 Co-Authored-By: Claude --- website/BACKEND_DESIGN.md | 69 +++++++++++++++++++++++++++++ website/MODULE_API.md | 91 ++++++++++++++++++++++++++++++++++++++- website/TEAMS.md | 42 ++++++++++++++++++ 3 files changed, 201 insertions(+), 1 deletion(-) diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index ed1b5b9..ec76ec5 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -525,6 +525,55 @@ 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 six Team tables — core's, populated by a module (Teams phase 2) + +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 this phase so the access resolver is written once; the grant flow lands with the forums | +| `team_moderation_requests` | the approval queue for the three actions that publish untrusted game-sourced strings | + +**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) Part 2. The contract surface a module sees is +[`MODULE_API.md`](MODULE_API.md); everything in these tables is explicitly *not* it. + --- ## 4. API contract @@ -654,6 +703,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 +829,9 @@ 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` | 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 | +| 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 | +| 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. The module's per-audience field projection lands with the Team pages | | — | `/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 +902,15 @@ 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 | `/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..0da19c5 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -26,13 +26,31 @@ 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.** Seven 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` · +`api.registerSlashCommands(...)` · the client slots `team.overview` and `team.member.row`. + +**The number covers the whole surface; the members arrive by phase, and each is marked below.** Three +are live now. `ctx.teams.activity.push` and `api.registerSlashCommands` are **present and throw**, with +an error naming the phase that will implement them — chosen over leaving them absent so that a module +written against the published version fails at registration with a sentence explaining itself, rather +than at whatever moment someone first exercises the feature. Do not call them yet; do not treat a +throw as a bug. + +**`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 +199,28 @@ 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` — **throws until the Team activity feed lands** | — | (1.6.0, declared) | + +**`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. + +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 +257,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([...]) // 1.6.0, throws until phase 7 api.onBoot(async (ctx) => {}) api.onShutdown(async () => {}) ``` @@ -316,6 +358,53 @@ 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. + +**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. All 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. + +```js +getTeams() // () => Promise<{ ok, complete?, teams }> +getTeamMembers(externalId) // (string) => Promise<{ ok, complete?, members }> +getTeamLeaders(externalId) // (string) => Promise<{ ok, leaders }> // leaders = [memberKey] + +// 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. + +`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)`** — declared in API 1.6.0 and **not yet implemented**: calling it +throws with an error naming the phase that will. Present rather than absent so a module written +against the published version fails at registration with an explanation, instead of at the moment +someone first types the command. + **`onBoot(fn)` / `onShutdown(fn)`** — §2.5. ### 2.5 Lifecycle diff --git a/website/TEAMS.md b/website/TEAMS.md index dd4d6e4..18961bc 100644 --- a/website/TEAMS.md +++ b/website/TEAMS.md @@ -1888,6 +1888,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. @@ -1991,6 +1999,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; -- 2.49.1 From 0622ed00e0177cd50e91b6b02cdfee1932efe51d Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 17 Aug 2026 17:44:04 -0500 Subject: [PATCH 03/17] docs(link): roster members carry guild rank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amends Protocol 4 in place rather than bumping it: the protocol has not reached `main`, and a bump is owed only once a protocol has been released. The roster shipped as the standard actor object, which carries no rank. Teams phase 2 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, and TEAMS.md §2.5 treats multiple leaders as the normal case. Roster members now carry `rank` (0-4, 4 being Leader) plus `rankCliloc`, or `rankName` where a shard's custom rank definitions use literal names. 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. Both files carry the trap this found, because it is the kind of thing a consumer gets wrong silently: **`PlayerMobile.GuildRank` returns Leader for anyone at GameMaster or above, whatever their real rank.** It is a gameplay convenience so staff can operate a guild stone, and the true value has no accessor -- so the bridge omits the rank entirely for a staff account rather than publish a leadership claim it knows is false. An absent rank therefore means "not known", and a consumer must read it as neither 0 (which silently demotes them) nor leadership (which republishes the lie). INTEGRATION.md also states the other half plainly for an outside integrator: `guild.update`'s single `leader` is the founder-leader, not the set of leaders, so "who leads this guild" is a read of the roster's ranks. Recorded too: the sidecar needs no change and no store migration, because it treats roster members as opaque values and never reads a field inside one. That is the forwarder design paying off, and it is worth having written down the next time someone adds a member field. Pairs with servuo-plugins (the emitter) and Module-uo (the ingest and the provider). Co-Authored-By: Claude --- link/INTEGRATION.md | 9 ++++++++- link/v4.md | 41 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md index 05cc847..25f00d5 100644 --- a/link/INTEGRATION.md +++ b/link/INTEGRATION.md @@ -286,11 +286,18 @@ Guilds expose only one in-game event (a member joining), so the roster is polled | `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) | 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.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. 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. diff --git a/link/v4.md b/link/v4.md index 726f73b..9789d9e 100644 --- a/link/v4.md +++ b/link/v4.md @@ -102,10 +102,11 @@ 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: +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} +{"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 @@ -114,6 +115,42 @@ 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 -- 2.49.1 From fd9c02130cc5009274905bf28614a87f1d15d5ec Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 17 Aug 2026 20:17:05 -0500 Subject: [PATCH 04/17] docs(teams): Team pages and the activity feed, and what phase 3 disproved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TEAMS.md gains a dated amendment on phase 3 with five corrections, all found by building the thing it describes: - §3.2 and §3.4 contradict each other about `team.member.row`'s props, and §3.2 wins because it is the security rule. A client slot can only receive what the browser was sent, so §3.4's `{ memberKey, userId, displayName }` means publishing both identifiers in every public roster, module installed or not. The slot is redeclared with what core can honestly supply. - §3.3's projection is an EIGHTH MODULE_API member where 1.6.0 listed seven. Settled by the org lead: 1.6.0 is amended in place, on the rule Protocol 4 was given in phase 2 — a contract owes a bump only once it has reached `main`. - "the module declines" needed splitting in two before it could be built. No module at all withholds nothing and must serve the roster whole; a module whose rungs could not be consulted must serve none of it. Only the second fails closed, or bare core shows an empty roster on every Team page. - the module answers with member KEYS, not rows, so it can narrow what is published and cannot widen it. - core's five activity kinds are four until the forum lands, and a Team's FIRST roster emits no join items at all. §2.11's route table gains the activity endpoint it never had, and MODULE_API.md documents `projectRoster`, the inverted fail-closed semantics that make it different from every other provider call, and `ctx.teams.activity.push`'s item shape and its four contractual properties. BACKEND_DESIGN.md: the seventh Team table, its retention, and the three public routes' new behaviour — `enabled` on the index, the slot props on the single Team, the per-caller row projection on the roster, and the feed. Co-Authored-By: Claude --- website/BACKEND_DESIGN.md | 21 ++++++++--- website/MODULE_API.md | 76 +++++++++++++++++++++++++++++++++------ website/TEAMS.md | 58 ++++++++++++++++++++++++++++-- 3 files changed, 138 insertions(+), 17 deletions(-) diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index ec76ec5..6db4234 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -525,7 +525,7 @@ 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 six Team tables — core's, populated by a module (Teams phase 2) +### The seven Team tables — core's, populated by a module (Teams phases 2–3) 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 @@ -541,6 +541,18 @@ core's. | `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 this phase so the access resolver is written once; the grant flow lands with the forums | | `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_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 @@ -829,9 +841,10 @@ 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` | 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 | -| 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 | -| 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. The module's per-audience field projection lands with the Team pages | +| 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, which the client's `teams` nav flag resolves from | +| 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` for the `team.overview` extension slot — this route only, since the index has no slot to feed | +| 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 | | — | `/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). diff --git a/website/MODULE_API.md b/website/MODULE_API.md index 0da19c5..7342d3b 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -33,17 +33,23 @@ The client half carries the same number (`client/src/modules/version.js`) and a agree. Duplicated rather than fetched because the value has to be on `window.__rg` before the first module chunk evaluates, which is earlier than any network round trip could answer. -**1.6.0 — Teams, the whole surface.** Seven additions, no removals and no changed signature, so minor; +**1.6.0 — Teams, the whole surface.** Eight 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` · -`api.registerSlashCommands(...)` · the client slots `team.overview` and `team.member.row`. +the provider's optional `projectRoster` · `api.registerSlashCommands(...)` · the client slots +`team.overview` and `team.member.row`. -**The number covers the whole surface; the members arrive by phase, and each is marked below.** Three -are live now. `ctx.teams.activity.push` and `api.registerSlashCommands` are **present and throw**, with -an error naming the phase that will implement them — chosen over leaving them absent so that a module -written against the published version fails at registration with a sentence explaining itself, rather -than at whatever moment someone first exercises the feature. Do not call them yet; do not treat a -throw as a bug. +> **Amended 2026-08-17 (phase 3), on the org lead's decision: 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`. The same +> amendment marks `ctx.teams.activity.push` and both client slots live, and narrows +> `team.member.row`'s props (TEAMS.md §3.4). + +**The number covers the whole surface; the members arrive by phase, and each is marked below.** Seven +are live now. `api.registerSlashCommands` is **present and throws**, with an error naming the phase +that will implement it — chosen over leaving it absent so that a module written against the published +version fails at registration with a sentence explaining itself, rather than at whatever moment +someone first exercises the feature. Do not call it yet; do not treat its throw as a bug. **`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 @@ -201,13 +207,36 @@ module-uo does not need is on the list. | `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` — **throws until the Team activity feed lands** | — | (1.6.0, declared) | +| `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 @@ -364,14 +393,16 @@ module becomes the authoritative source of Teams for this deployment. **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. All three methods are required — a provider +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. +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] // authoritative { ok: true, complete: true, teams: [ { externalId, name, abbr?, meta? } ] } @@ -393,6 +424,29 @@ 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`). + +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. diff --git a/website/TEAMS.md b/website/TEAMS.md index 18961bc..5411619 100644 --- a/website/TEAMS.md +++ b/website/TEAMS.md @@ -717,7 +717,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 @@ -815,7 +818,14 @@ 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 }` | + +> **Amended 2026-08-17 (phase 3).** `team.member.row` was specified with +> `{ memberKey, userId, displayName }`, which §3.2 forbids: a slot component runs in the browser, so +> those props can only reach it by being published in the roster response to every visitor. The two +> identifiers are dropped. `team.overview`'s three are kept — a core row id, a game-side group id and +> a module name name no person — and they are served on `GET /teams/:slug` only, not on the index, +> which has no slot to feed. 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". @@ -2059,6 +2069,50 @@ 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.** Five corrections, all found by building or testing the +> thing described below. +> +> **§3.2 and §3.4 contradict each other, and §3.2 wins.** §3.2 says a member key is a game-internal +> identifier and a user id names a site account, and that **neither is published**; §3.4 then declares +> the `team.member.row` slot with props `{ memberKey, userId, displayName }`. A client-side slot can +> only receive what the browser was sent, so honouring §3.4 means putting both identifiers into every +> public roster response — for every visitor, module installed or not. **Settled: the slot is declared +> with `{ displayName, isLeader, linked }`.** module-uo leaves it unfilled, because the useful thing to +> put there is a link to the character behind a row and these props do not identify one; filling it +> with a guess from a display name is worse than an empty cell. A future phase that wants this back +> needs an opaque per-response row token, not the raw key. +> +> **§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. Without that split, bare core serves an empty roster on +> every Team page. +> +> **The module answers with member KEYS, not rows.** §3.3's "the module returns the rows it permits" +> would let a module widen what is published by handing back a `userId` core had withheld, and core's +> field guarantee would then rest on every module's good behaviour. Core asks which rows, keeps what a +> row looks like, and re-normalises whatever comes back through its own public shape. +> +> **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. -- 2.49.1 From d78cc99c80698c58f01c2227ba93f1e0f0a4e597 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 17 Aug 2026 20:58:40 -0500 Subject: [PATCH 05/17] =?UTF-8?q?docs(teams):=20Teams=20is=20a=20contract,?= =?UTF-8?q?=20not=20a=20surface=20=E2=80=94=20record=20the=20correction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The org lead's correction to Part 3, and the inverted extension-slot direction it forces. TEAMS.md: §3.1's routes, §3.4's two slots and §3.5's three nav entries are all marked superseded in place, and Part 12's phase 3 entry gains the amendment explaining why — core does not own the word for a Team, so the module that owns the vocabulary owns the page. The five corrections found by building are kept alongside it. MODULE_API.md: 1.6.0's list swaps the two client slots for registry.declareModuleSlot + Slot in the UI kit, and a new §3.7a documents the inverted direction: what forced it, the enforced namespace, why core's fills are applied at mount rather than eagerly, and why a fill for an undeclared slot is a no-op where §3.7's unknown slot throws. BACKEND_DESIGN.md: the by-external-id lookup route. Co-Authored-By: Claude --- website/BACKEND_DESIGN.md | 5 ++- website/MODULE_API.md | 69 +++++++++++++++++++++++++++---- website/TEAMS.md | 85 ++++++++++++++++++++++++++------------- 3 files changed, 123 insertions(+), 36 deletions(-) diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index 6db4234..0645246 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -841,8 +841,9 @@ 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` | 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, which the client's `teams` nav flag resolves from | -| 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` for the `team.overview` extension slot — this route only, since the index has no slot to feed | +| 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 | | — | `/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. | diff --git a/website/MODULE_API.md b/website/MODULE_API.md index 7342d3b..b44ea4e 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -36,14 +36,21 @@ module chunk evaluates, which is earlier than any network round trip could answe **1.6.0 — Teams, the whole surface.** Eight additions, no removals and no changed signature, so minor; `module-uo`'s `coreApi: "^1.3.0"` still resolves. `api.registerTeamProvider(...)` and `ctx.teams.publish` / `ctx.teams.reconcile` (§2.3, §2.4a) · `ctx.teams.activity.push` · -the provider's optional `projectRoster` · `api.registerSlashCommands(...)` · the client slots -`team.overview` and `team.member.row`. +the provider's optional `projectRoster` · `api.registerSlashCommands(...)` · +`registry.declareModuleSlot(...)` with `Slot` in the UI kit. -> **Amended 2026-08-17 (phase 3), on the org lead's decision: 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`. The same -> amendment marks `ctx.teams.activity.push` and both client slots live, and narrows -> `team.member.row`'s props (TEAMS.md §3.4). +> **Amended 2026-08-17 (phase 3), on the org lead's decision.** Two changes. +> +> **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)` 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. **The number covers the whole surface; the members arrive by phase, and each is marked below.** Seven are live now. `api.registerSlashCommands` is **present and throws**, with an error naming the phase @@ -942,6 +949,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 @@ -1178,6 +1186,53 @@ 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: +registry.declareModuleSlot(ID, 'uo.guild.detail') + +// In the module's page, from the UI kit: + +``` + +**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 (`fillModuleSlot`, core-only) and +`applyCoreFills()` runs once, from `main.jsx`, after every chunk has evaluated and before the first +render. + +**A fill for a slot no installed module declares is a no-op, never an error.** The declaring module is +simply not 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 eighth 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. `fillModuleSlot` and +`applyCoreFills` are not: filling 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 5411619..81dc47e 100644 --- a/website/TEAMS.md +++ b/website/TEAMS.md @@ -759,6 +759,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 | @@ -820,18 +825,34 @@ named for a *place* and never for a meaning): | `team.overview` | the Team overview page, below the counts | `{ teamId, externalId, moduleId }` | | `team.member.row` | each roster row, trailing cell | `{ displayName, isLeader, linked }` | -> **Amended 2026-08-17 (phase 3).** `team.member.row` was specified with -> `{ memberKey, userId, displayName }`, which §3.2 forbids: a slot component runs in the browser, so -> those props can only reach it by being published in the roster response to every visitor. The two -> identifiers are dropped. `team.overview`'s three are kept — a core row id, a game-side group id and -> a module name name no person — and they are served on `GET /teams/:slug` only, not on the index, -> which has no slot to feed. - -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)` — a **module** declares a place on its own page, namespaced +> under its own id, and **core** fills it: +> +> | Slot | Declared by | Rendered in | Filled by core with | Props | +> | --- | --- | --- | --- | --- | +> | `uo.guild.detail` | `module-uo` | its guild detail page | the Team activity feed (§4.3) | `{ externalId, moduleId }` | +> +> Core's fills are applied at MOUNT, not eagerly: core's bundle evaluates before every module chunk, +> so when core registers a fill the slot does not exist yet. A fill for a slot no installed module +> declares is a no-op, not an error — the mirror of an unfilled slot rendering nothing. `Slot` becomes +> the eighth 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 @@ -2069,18 +2090,32 @@ 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.** Five corrections, all found by building or testing the -> thing described below. +> **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. > -> **§3.2 and §3.4 contradict each other, and §3.2 wins.** §3.2 says a member key is a game-internal -> identifier and a user id names a site account, and that **neither is published**; §3.4 then declares -> the `team.member.row` slot with props `{ memberKey, userId, displayName }`. A client-side slot can -> only receive what the browser was sent, so honouring §3.4 means putting both identifiers into every -> public roster response — for every visitor, module installed or not. **Settled: the slot is declared -> with `{ displayName, isLeader, linked }`.** module-uo leaves it unfilled, because the useful thing to -> put there is a link to the character behind a row and these props do not identify one; filling it -> with a guess from a display name is worse than an empty cell. A future phase that wants this back -> needs an opaque per-response row token, not the raw key. +> **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)`: a **module** declares a place on its own page, namespaced +> under its own id, and **core** fills it. `module-uo` declares `uo.guild.detail`; core fills it with +> 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's fills are applied at mount rather than eagerly — +> core's bundle evaluates before every module chunk, so at the moment core registers a fill the slot +> does not exist yet. `Slot` joins the shared UI kit as its eighth 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 @@ -2091,13 +2126,9 @@ guild called "Admin" cannot put an official-looking page on the site. > 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. Without that split, bare core serves an empty roster on -> every Team page. -> -> **The module answers with member KEYS, not rows.** §3.3's "the module returns the rows it permits" -> would let a module widen what is published by handing back a `userId` core had withheld, and core's -> field guarantee would then rest on every module's good behaviour. Core asks which rows, keeps what a -> row looks like, and re-normalises whatever comes back through its own public shape. +> 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 -- 2.49.1 From 0e665078e8bb9050fc01c1f4255fc86a9b3fb009 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 18 Aug 2026 07:25:05 -0500 Subject: [PATCH 06/17] =?UTF-8?q?docs(teams):=20phase=204=20=E2=80=94=20th?= =?UTF-8?q?e=20forum's=20access=20model,=20switches=20and=20image=20policy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records what building phase 4 settled, and what it disproved. The structural correction first: TEAMS.md 3.1 gave the forum a CORE page and phase 3 deleted every core Team page. The ROUTES were unaffected — they are all /player and /admin — but the participant surface had no home, and 5.4's route table did not notice. Settled the way phase 3 settled the activity feed: module-uo declares a second place on its guild page and core fills it, so the phase spans two repos rather than the one the plan named. Two slots rather than one, because a slot holds one component and the first fill wins; the panel navigates by search param because a thread must be linkable and core cannot mount a route on a page it does not own. Two findings from the sanitiser worth not re-deriving: `rel` has to be on the allowlist for the transform that WRITES it to survive, or every forum link ships without noopener; and the bare-URL linkifier runs after sanitising, over escaped text only, which is the property that makes it safe rather than an injection point. Also recorded: the upload sweep runs regardless of the current image mode, which is the mechanism behind the dialog's promise that disabling uploads does not delete what is already there; the two routes the table lacked; and the org lead's decision that all three proposed acknowledgement additions ship. BACKEND_DESIGN gains the four forum tables and the reasoning a reader of the schema alone would miss — why the guard is at the route and never at the data, why no stored body ever contains an , what `uploads` mode hardens, and what the acknowledgement actually records. MODULE_API's inverted-slot section gains the rule a module needs: one slot per PLACE, not one per page. Co-Authored-By: Claude --- website/BACKEND_DESIGN.md | 43 +++++++++++++++++++++++++++--- website/MODULE_API.md | 8 ++++++ website/TEAMS.md | 55 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 102 insertions(+), 4 deletions(-) diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index 0645246..b436b29 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -525,7 +525,7 @@ 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 seven Team tables — core's, populated by a module (Teams phases 2–3) +### The eleven Team tables — core's, populated by a module (Teams phases 2–4) 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 @@ -539,10 +539,47 @@ core's. | `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 this phase so the access resolver is written once; the grant flow lands with the forums | +| `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 | + +**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 @@ -583,7 +620,7 @@ serving the refusal gates below: `roster_synced_at`, because `team_sync_state` h 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) Part 2. The contract surface a module sees is +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. --- diff --git a/website/MODULE_API.md b/website/MODULE_API.md index b44ea4e..347caaa 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -1202,11 +1202,19 @@ forced it is worth stating because it will recur: ```js // In the module's entry chunk, at registration time: registry.declareModuleSlot(ID, 'uo.guild.detail') +registry.declareModuleSlot(ID, 'uo.guild.forum') // In the module's page, from the UI kit: + ``` +**A module declares one slot per PLACE, not one per page.** `module-uo` declares two on the same guild +page — core fills the first with the Team activity feed and the second with the Team forum — because a +slot holds one component and the first fill wins. Collapsing them into one would hand core the +decision about where each of its contributions sits, on a page the module owns. Two also keeps them +independent: a deployment with the forum switched off renders the feed unchanged. + **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. diff --git a/website/TEAMS.md b/website/TEAMS.md index 81dc47e..b7925c8 100644 --- a/website/TEAMS.md +++ b/website/TEAMS.md @@ -833,6 +833,7 @@ named for a *place* and never for a meaning): > | Slot | Declared by | Rendered in | Filled by core with | Props | > | --- | --- | --- | --- | --- | > | `uo.guild.detail` | `module-uo` | its guild detail page | the Team activity feed (§4.3) | `{ externalId, moduleId }` | +> | `uo.guild.forum` | `module-uo` | the same page, below the feed | the Team forum (Part 5) — added in phase 4 | `{ externalId, moduleId }` | > > Core's fills are applied at MOUNT, not eagerly: core's bundle evaluates before every module chunk, > so when core registers a fill the slot does not exist yet. A fill for a slot no installed module @@ -1057,6 +1058,19 @@ 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. + 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. @@ -1248,6 +1262,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: @@ -2150,7 +2167,43 @@ guild called "Admin" cannot put an official-looking page on the site. **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. -- 2.49.1 From aed4ec41668ecd23d4f1758b443a93a95bb3a49d Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 18 Aug 2026 13:29:37 -0500 Subject: [PATCH 07/17] =?UTF-8?q?docs(teams):=20phase=205=20=E2=80=94=20di?= =?UTF-8?q?scussion,=20the=20edit=20window,=20and=20reports=20that=20route?= =?UTF-8?q?=20around=20leadership?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TEAMS.md §5.4, §5.5.7 (new), §5.6 and the Part 12 phase entry; BACKEND_DESIGN.md's schema and admin route tables. **The largest change is a decision, not a description.** §5.6's first rule said "a leader may also see and act on reports for their own Team, but staff always receive them". The org lead settled on 2026-08-18 that the leader half is **decided against, not deferred**: the gap the section exists to close is that leaders moderate their own forum and a Team's leaders are exactly the people who will not report their own Team, so a leader-visible queue hands a complaint about a leader back to them — and a read-only leader view still tells them who reported what. Recorded as an amendment rather than by editing the sentence away, because the reasoning for the original is what makes the correction legible. **§5.6's `content_reports` DDL does not work as written, and the amendment says so rather than quietly swapping it.** With `status` in the unique key, CLOSED rows collide with each other: dismiss a report, let the behaviour recur, dismiss the second one, and the UPDATE lands on a tuple that already exists — so the queue starts throwing duplicate-key errors on the first repeat reporter. The shipped table keys on a generated `open_marker`, the same encoding `team_forum_grants.active_marker` uses. Three smaller departures are recorded beside it: `handled_note`, the two username snapshots §2.10 asks for everywhere else, and a real CASCADE on `team_id`. **New §5.5.7 for `teams_forum_edit_window_minutes`** (0–1440, default 15), and the rule under it: the window is resolved on the server TWICE — the read path stamps `canEdit`/`editableUntil` so a client knows whether to draw the control, the write re-derives it from `created_at` before allowing anything. The read is advice and the write is enforcement, because a time-bounded permission must not take its clock from the party it bounds. That is also why the key is not published: the client needing the number is the admin screen, and the client needing the decision already has it per post. **§5.4 gains three notes its route table does not carry**: thread creation splits authority by TYPE rather than widening the leader gate (and reports it as two booleans, since one would make a client guess which right it described); post moderation is its own route whose validator accepts all eight actions so the model can say "pin applies to a thread, not to a post"; and a reply's three refusal codes are chosen to be distinguishable — 404 absent, 400 announcement, 409 locked — with locked refusing staff too. The Part 12 entry records what the phase disproved, its four acceptance criteria, that it spans ONE repo where phase 4 needed two, and the single defect the live rig found. It also notes that phase 4 shipped `uploads` with the default off, so §5.6's "pull reports forward if uploads is enabled anywhere" never triggered. Co-Authored-By: Claude --- website/BACKEND_DESIGN.md | 39 +++++++++- website/TEAMS.md | 158 +++++++++++++++++++++++++++++++++++++- 2 files changed, 192 insertions(+), 5 deletions(-) diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index b436b29..33fa4c4 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -525,7 +525,12 @@ have been found earlier, because until then no caller had ever passed a non-null Design of record: [`MODULE_SYSTEM.md`](MODULE_SYSTEM.md) §2.4; the loader's obligations are [`MODULE_API.md`](MODULE_API.md) Part 4. -### The eleven Team tables — core's, populated by a module (Teams phases 2–4) +### The eleven Team tables — core's, populated by a module (Teams phases 2–5) + +*Twelve rows in the table below: `content_reports` is listed here because Team forum content is its +first consumer, and it is deliberately **not** one of the eleven — it carries no `team_*` prefix, its +`target_type` is an open VARCHAR, and a wiki page or a news comment is meant to become a value in it +rather than a table of its own.* A Team is a **core** entity that a **module** answers for. The module says what Teams exist and who is in them, through the team provider; core stores that answer, gates it and displays it. Every table @@ -547,6 +552,37 @@ core's. | `team_forum_posts` | post bodies, sanitised on write through the forum's **own** profile (`utils/forumHtml.js`) and served without re-sanitising. No stored body ever contains an `` | | `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 | +| `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 @@ -960,6 +996,7 @@ file a route sits in — that is the property the route manifest freezes. | POST | `/teams/:id/unhide` · `/teams/:id/display-name` | the two **gated** actions (§2.9): an admin applies at once, a **moderator** files a pending request and nothing changes publicly. The caller does not choose — the server decides from the role it re-validates on the request | | GET | `/teams/:id/grants` | the full forum-grant ledger, revoked rows included. Read-only in this phase; the grant flow lands with the forums | | POST | `/teams/:id/leader-override` · DELETE `…/:memberKey` | set or clear a staff leadership decision, applied **on top of** the synced value at read time. Not gated: it publishes no game-sourced string | +| GET | `/moderation/reports` · POST `…/:id/handle` | the member-raised content-report queue (phase 5, [`TEAMS.md`](TEAMS.md) §5.6) and the staff decision on one. Mounted under **moderation**, not under Teams: a staffer working a queue should have one place to work, and `target_type` is open-ended so the next reportable thing arrives as a row rather than as a screen. Each row carries its target already resolved — a post's excerpt and author, a thread's title, or an upload's uploader, byte size and **sniffed** mimetype — in three batched reads, never one per row. A target hard-deleted since reporting comes back `null` and the row still lists. **There is no leader-facing counterpart to either route**, deliberately | | GET | `/teams/review` | the reserved-name review queue — Teams auto-hidden because their name matched, each showing which term | | GET | `/teams/requests` · POST `…/:id/decide` | the approval queue, and the decision. **Admin only** to decide, checked live rather than from a token claim; a request already decided returns `409`, so two admins deciding at once cannot double-apply | | — | `/shard/*` · `/uo-link/*` | **Served by `module-uo`, not by core** (33 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md) | diff --git a/website/TEAMS.md b/website/TEAMS.md index b7925c8..58800f3 100644 --- a/website/TEAMS.md +++ b/website/TEAMS.md @@ -1071,6 +1071,31 @@ reason (§5.5.1). > toggle-off revokes no grant and the rows stay authoritative (§5.5.1), so the access list has to stay > manageable during one. What the switch guards is the forum's CONTENT. +> **Amended 2026-08-18 (phase 5).** The 5b routes are as tabled, with three notes the table does not +> carry. +> +> **`POST /forum/threads` splits its authority BY TYPE rather than widening the leader gate.** An +> `announcement` stays leader-authored; a `discussion` may be opened by any forum participant — +> including a granted non-member with no game identity, which is path 3 doing its job. `type` defaults +> to `announcement`, so a phase-4 client keeps meaning what it meant; defaulting the other way would +> silently turn its announcements into discussions. The list response reports the split as **two** +> booleans, `canPost` (may open a discussion) and `canAnnounce` (leader), because a client reading one +> boolean would have to guess which right it described. +> +> **Post-level moderation is its own route**, `POST /forum/posts/:id/moderate`, rather than the thread +> route with a target kind: `pin` and `lock` describe a thread's place in a list and its openness to +> replies, neither of which a post has. The route's validator deliberately accepts **all eight** +> actions so the model can answer `pin` with *"pin applies to a thread, not to a post"* — restricting +> it to the four a post takes turns a nameable mistake into a generic validation error, which is what +> the live rig found. +> +> **Three refusal codes on a reply, chosen to be distinguishable.** 404 for a thread that is absent or +> hidden from this caller; **400** for an announcement, which takes no replies by TYPE and no retry +> fixes; **409** for a locked thread, where the request is well-formed and the resource's state is what +> refuses. Locked refuses **staff too** — they hold `unlock`, so unlock/post/relock reaches the same +> place leaving three ledger rows that say what happened, whereas a moderator's reply in a thread +> nobody else may answer is the last word by fiat. + Under `/player` for the same reason as §2.11: a forum participant may be a plain player, and the tier gate is `requireAuth`. Every route resolves access through the §2.5 resolver — never by checking membership directly, which is how paths 1 and 3 would drift back together. @@ -1300,6 +1325,34 @@ who accepted a liability notice is operator detail, exactly as `failure_reason` The *rendering* decision is still made server-side. The client is told the mode so it can present the right composer; it is never the thing that decides whether an image appears. +#### 5.5.7 `teams_forum_edit_window_minutes` — how long an author may edit (phase 5) + +An ordinary `settings` key, `0`–`1440`, **default 15**, on the same admin screen as the other two. Set +to `0` it makes posts permanent once written, which is a legitimate operator choice rather than an +off switch — there is no state in which editing is "disabled" as opposed to "bounded at zero", and +inventing one would only give the resolver a decision to get wrong. + +**Staff are not bound by it.** The window exists so a post cannot be rewritten out from under someone +quoting it, or under a moderator about to act on a report; a staffer editing another member's post is +already an intervention that writes `activity_log` (§5.3), and time-bounding it would only mean +waiting. + +**It is evaluated on the server twice, on purpose.** The read path stamps every post with `canEdit` +and `editableUntil` so a client knows whether to draw the control; the write re-derives it from +`created_at` before allowing anything. Two evaluations of one rule: the read one is advice and the +write one is enforcement. A client may use `editableUntil` to WITHDRAW an offer whose deadline passed +while a page sat open, and can never create one — **a time-bounded permission must not take its clock +from the party it bounds**, which is why the window itself is not a published setting (§5.5.6) and is +served only to the admin screen that edits it. + +A hidden or deleted post is editable by nobody, staff included. Restoring it is a moderation action +with a ledger row; quietly rewriting it while it is out of sight is the same act with no record. + +The read fails closed to **zero**, not to the default — the opposite of what it looks like it should +do. The risk the window bounds is an author rewriting a post out from under a reader, so the safe +answer during a DB fault is "nobody may edit for the next minute". A stale uploads acknowledgement +freezes this key along with the other two: it is a forum setting. + ### 5.6 Abuse reports — the missing half of moderation **Core has no user-facing report flow of any kind today.** `moderation`, `mod_notes` and `appeals` are @@ -1335,11 +1388,46 @@ CREATE TABLE IF NOT EXISTS content_reports ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` +> **Amended 2026-08-18 (phase 5). The table as shipped departs from the block above in four places, +> three of them corrections and one an addition.** +> +> **The unique key is on a generated `open_marker`, not on `status`, and the spelling above has a +> defect worth recording rather than quietly fixing.** With `status` in the key, CLOSED rows collide +> with each other too: a reporter reports a post, staff dismiss it, the behaviour recurs, they report +> it again — and the second dismissal is an `UPDATE` into a `(…, 'dismissed')` tuple that already +> exists, so working the queue starts throwing duplicate-key errors on the first repeat reporter. The +> shipped column is `open_marker TINYINT(1) AS (IF(status IN ('open','reviewing'), 1, NULL)) STORED`, +> the same trick `team_forum_grants.active_marker` uses: 1 while open, NULL once closed, and MySQL +> treats NULLs as distinct — so any number of closed reports coexist while at most one open one can. +> That is what the prose above actually asks for. +> +> **`handled_note VARCHAR(500)`** was added. §5.6's API takes `{ status, note? }` and the table had +> nowhere to put the note. A queue whose resolution reason lives only in an `activity_log` line is one +> where the next staffer to see a repeat report about the same content cannot find out why the last +> one was closed. +> +> **`reporter_username` and `handled_username` snapshots** were added, per §2.10: who raised a report +> and who decided it must survive the account, exactly as every other Team table already does. +> +> **`team_id` gained a real FK with `ON DELETE CASCADE`.** The block above leaves it a bare +> denormalised column; a deleted Team then leaves a queue full of reports about content that cascaded +> away with it. +> +> Every transition writes `activity_log`, **`dismissed` included**. A queue where acting is audited and +> declining to act is not is one where the cheapest way to make a report vanish leaves no trace — and +> the reports most worth auditing are exactly the ones somebody wanted gone. + Four rules: -- **Reports go to site staff, not to Team leaders.** A leader may also see and act on reports for - their own Team, but staff always receive them — the whole point is a path that routes *around* a - Team's own leadership. +- **Reports go to site staff, and to nobody else.** *(Amended 2026-08-18, org lead, when phase 5 was + built.)* This section originally added "a leader may also see and act on reports for their own + Team". **That half is not implemented and is not deferred — it is decided against.** The gap this + whole section exists to close is that leaders moderate their own Team's forum and a Team's leaders + are exactly the people who will not report their own Team; a leader-visible queue hands a complaint + *about* a leader straight back to them, and a read-only leader view still tells them who reported + what. There is one queue, under `/admin/moderation`, gated to admin + moderator. If a leader-facing + surface is ever wanted it is a fresh design decision, not a refactor — `content_reports.team_id` + makes it *possible*, which is not the same as intended. - **Reporting is not a moderation action.** A report changes nothing about the content; it opens a queue item. This keeps it clear of §5.3's leader/staff moderation ledger, which records things that actually happened. @@ -1355,6 +1443,20 @@ GET /api/v1/admin/moderation/reports the queue, alongside the exis POST /api/v1/admin/moderation/reports/:id/handle { status, note? } ``` +The player route sits behind the same `resolveForum` guard as the rest of §5.4, so a reporter is by +construction someone who can already see what they are reporting — and the model additionally checks +the target really belongs to the Team the request came through, or the queue's per-Team filter would +quietly be lying. A duplicate answers **409** rather than pretending to succeed: silently accepting is +friendlier for one tap and dishonest for the second, and a member who reports twice because nothing +seemed to happen deserves to be told the first is already in the queue. + +The queue resolves every row's target in **three batched reads** keyed by target type, never one read +per row — that is rule 4 above actually paying for §5.5.4's attribution table, and the N+1 version is +how a queue becomes a thing staff avoid opening. A target that has since been hard-deleted comes back +as `null` and the report still lists: "somebody reported this and by the time we looked it was gone" +is a fact a moderator needs, and dropping the row would hide the pattern of a member deleting their +own content the moment it is reported. + Mounted under the **existing** admin moderation section rather than under Teams: a staffer working a queue should have one place to work, and a report about a forum post is the same job as a report about anything else. @@ -2231,6 +2333,34 @@ to run it, at low surface area. ### Phase 5 — Forum 5b: discussion + moderation + reports (`website`) +> **Amended 2026-08-18, while building this.** Five notes. The first is the org lead's decision; the +> rest were found by building the thing described below, or on the live rig afterwards. +> +> **Reports are site administration only.** §5.6's "a leader may also see and act on reports for their +> own Team" is decided against, not deferred — see the amendment there. It is the phase's most +> important property and it is a NEGATIVE one, so it is asserted directly in the test suite rather +> than left to be noticed: the report model's whole function surface is pinned, and `queue`/`handle` +> are checked not to mention leadership at all. +> +> **The edit window is an admin setting, not a constant** (§5.5.7), and it is evaluated on the server +> twice — once as advice on the read path, once as enforcement on the write. That is the phase's other +> structural rule: a time-bounded permission must not take its clock from the party it bounds. +> +> **§5.6's unique key does not work as written**, and the shipped table uses a generated `open_marker` +> instead. See the amendment there; it is the one place in this document where the SQL and the prose +> beside it disagreed. +> +> **This phase spans ONE repo, which is worth saying because phase 4 did not.** Phase 4 needed +> `module-uo` because the forum had no surface after phase 3 and a slot had to be declared. Phase 5 +> grows the component that fills that slot, so `uo.guild.forum` is untouched and nothing in the module +> changes. +> +> **The live rig found one defect, and it was a message rather than a behaviour.** The post-moderation +> route's validator listed only the four actions a post accepts, so `pin` returned a generic +> "Validation failed" instead of the sentence written for it — leaving that branch reachable only from +> its own unit test. Walking the surface for real is what turns "documented, tested and unreachable" +> into something anyone notices. + Discussion threads, replies, the edit window, pin/lock/hide/delete, `team_forum_moderation`, the admin ledger view, and **abuse reporting** (§5.6): `content_reports`, the report control, and the queue in the existing admin moderation section. @@ -2238,7 +2368,27 @@ 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.)* + +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`) -- 2.49.1 From 5e284d546858e5eba641f9510682aaca7dba5677 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 18 Aug 2026 14:35:49 -0500 Subject: [PATCH 08/17] =?UTF-8?q?docs(teams):=20phase=206=20as=20built=20?= =?UTF-8?q?=E2=80=94=20four=20deviations=20and=20a=20ninth=20contract=20me?= =?UTF-8?q?mber?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 6 gains an as-built header rather than a rewrite, so the reasoning that produced the original design stays legible beside what the build learned. Four deviations. There was no web notification settings screen to add the Team list to — `/auth/me/notifications/*` was built for the app in M7 and had zero web consumers, which is survivable for push and not for a sink whose whole argument is the web-only user. Email defaults to `off` rather than `digest`, on the org lead's call: digest-by-default would start mailing every member of every Team the moment an operator connects Gmail. Roster events tickle but do not email. And a ninth member joined MODULE_API 1.6.0. `pageUrlTemplate` is the member, and it exists because phase 3 left core with no Team page and therefore no way to link to one. It joins 1.6.0 in place under the rule set in phase 2 — a contract owes a bump only once it has landed on `main`, and 1.6.0 has only ever been on `edge`. Two further build decisions are recorded where they belong: the digest computes at send time and keeps no queue (§6.4), and one-click unsubscribe is a stateless HMAC whose whole capability is muting one (user, Team) pair (§6.4). BACKEND_DESIGN gains the table, the two `/auth/me` routes and the unsubscribe endpoint — the only write in the public tier and the only route with no `siteMode`, because the mail went out before the site went into maintenance. Co-Authored-By: Claude --- website/BACKEND_DESIGN.md | 3 ++ website/MODULE_API.md | 51 +++++++++++++++++---- website/TEAMS.md | 94 +++++++++++++++++++++++++++++++++++---- 3 files changed, 132 insertions(+), 16 deletions(-) diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index 33fa4c4..3dfa7c4 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -552,6 +552,7 @@ core's. | `team_forum_posts` | post bodies, sanitised on write through the forum's **own** profile (`utils/forumHtml.js`) and served without re-sanitising. No stored body ever contains an `` | | `team_forum_moderation` | append-only, per Team, recording `actor_role` — WHICH authority was exercised. Deliberately not merged with `mod_actions`/`appeals`, which is Discord-sanction-shaped | | `team_forum_uploads` | attribution for `uploads` mode: who uploaded what, when, how big, and to which post. Also the sweep's worklist | +| `team_notification_prefs` | per-Team notification preference (phase 6). **Opt-out for push, opt-IN for email** — `muted` defaults 0 and `email_mode` defaults `'off'`, so the two sinks default opposite ways and the asymmetry lives here rather than in a condition anyone has to remember. Team scoping lives in this table and in the recipient computation, never in a stream id. `last_digest_at` is the digest's only state and the worker is its only writer | | `content_reports` | member-raised abuse reports (phase 5). **Not a `team_*` table and not named for the forum** — `target_type` is a plain VARCHAR so a wiki page or a news comment becomes a value rather than a table. Team forum content is only the first consumer | **Core had no user-facing report flow of any kind before `content_reports`.** `moderation`, @@ -770,6 +771,7 @@ their own router level, and `/sso/:provider/link` carries `requireAuth` per rout | GET | `/me/devices` · DELETE `…/:id` | cookie / bearer | — | list / unregister own push devices | | GET | `/me/notifications/streams` | cookie / bearer | — | the subscribable catalog (`personal`/`requiresLinkedAccount` flags) | | GET · PUT | `/me/notifications/subscriptions` | cookie / bearer | `{streams:[id]}` on PUT | get / replace own opted-in streams (unknown ids dropped) | +| GET · PUT | `/me/notifications/teams` | cookie / bearer | `{teams:[{teamId,muted,emailMode}]}` on PUT | get / replace own **per-Team** preferences (phase 6, [`TEAMS.md`](TEAMS.md) §6.3). One entry per Team the caller could be notified about — active membership or an active forum grant — plus any Team they already hold a preference for; server-side defaults applied. An entry naming a Team the caller has no access to is **dropped, not refused**: a Team left between loading the screen and saving it is a race, not a client bug. The array is required even when empty (`../android/PLAN.md` §11) | **Role-agnostic self-service (`/auth/me/*`).** The canonical "me" surface for **every** authenticated role. It reuses the exact `account.controller` handlers as `/player/account/*` and `/admin/account/*` @@ -919,6 +921,7 @@ from the per-route **siteMode** middleware (§5), never from an auth gate. | GET | `/teams/:slug` | one Team. An **archived** Team still resolves, read-only, and names its successor when it was renamed — an old bookmark or Discord link lands somewhere that explains itself. A **hidden** Team returns 404, indistinguishable from one that does not exist: "absent from every public surface" includes not confirming it is there. Carries `id`/`externalId`/`moduleId` — this route only, since the index has no use for them | | GET | `/teams/:slug/members` | the roster. In-game display names only — the member key is a game-internal identifier and the user id names a site account, and **neither is published**; `linked` answers whether a character has an account behind it without saying which. **Which rows** appear is the module's audience projection (`projectRoster`), applied per caller: a module that has a rung system and cannot be asked yields an EMPTY roster, not an unprojected one, flagged as `projectionUnavailable`. A session is optional and may widen the result | | GET | `/teams/:slug/activity` | the Team's activity feed, paged, newest first. `public` items to anyone who can see the Team; `members` items additionally to members and forum-granted users, resolved from the session and never from a parameter. `scope` reports which the caller got, so a client can say "some entries are hidden" instead of presenting a filtered feed as the whole one. A hidden Team's feed does not answer the public but does answer its members | +| POST · GET | `/teams/unsubscribe/:token` | one-click unsubscribe from a Team's notification emails (phase 6, [`TEAMS.md`](TEAMS.md) §6.4). **The only write in this tier and the only route with no `siteMode`** — the reader is in their mail client, not signed in, and the mail went out before the site went into maintenance. The token is a stateless HMAC whose whole capability is "set `muted` for one (user, Team) pair". POST acts and **always answers 200**, valid token or forged: distinguishing them would be an oracle for which (user, Team) pairs exist. GET acts on nothing and redirects to the site's own `/unsubscribe/:token` page, because a mail client's link scanner must not be able to mute Teams | | — | `/shard/*` · `/atlas/*` | **Served by `module-uo`, not by core** (25 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md); absent entirely when the module is not installed, which is a 404 and not an error. | Public content GETs pass through the **siteMode** gate (§5). diff --git a/website/MODULE_API.md b/website/MODULE_API.md index 347caaa..a124ccc 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -33,14 +33,21 @@ The client half carries the same number (`client/src/modules/version.js`) and a agree. Duplicated rather than fetched because the value has to be on `window.__rg` before the first module chunk evaluates, which is earlier than any network round trip could answer. -**1.6.0 — Teams, the whole surface.** Eight additions, no removals and no changed signature, so minor; +**1.6.0 — Teams, the whole surface.** Nine additions, no removals and no changed signature, so minor; `module-uo`'s `coreApi: "^1.3.0"` still resolves. `api.registerTeamProvider(...)` and `ctx.teams.publish` / `ctx.teams.reconcile` (§2.3, §2.4a) · `ctx.teams.activity.push` · -the provider's optional `projectRoster` · `api.registerSlashCommands(...)` · +the provider's optional `projectRoster` and `pageUrlTemplate` · `api.registerSlashCommands(...)` · `registry.declareModuleSlot(...)` with `Slot` in the UI kit. > **Amended 2026-08-17 (phase 3), on the org lead's decision.** Two changes. > +> **Amended again 2026-08-18 (phase 6), on the org lead's decision.** A **ninth** member, +> `pageUrlTemplate` on the team provider, joins 1.6.0 in place — same rule as the eighth below, and +> 1.6.0 is still `edge`-only. It is the one thing phase 6 found that the design of record had not +> anticipated: after phase 3 deleted core's Team pages, nothing in this contract could tell core where +> a Team page actually is, so a notification email could name a Team and not link to it. See +> `registerTeamProvider` below. +> > **The eighth member joins 1.6.0 in place rather than getting a 1.7.0.** The rule is the one Protocol > 4 was given in phase 2 — *a contract owes a bump only once it has landed on `main`* — and 1.6.0 has > only ever been on `edge`. `ctx.teams.activity.push` is live now rather than throwing. @@ -395,7 +402,8 @@ Before it existed, core's post controller required `utils/newsGump` directly — naming a UO file, and the last thing binding core to the module. **`registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })`** — added in API 1.6.0. The -module becomes the authoritative source of Teams for this deployment. +module becomes the authoritative source of Teams for this deployment. Two further members, +`projectRoster` and `pageUrlTemplate`, are optional and documented below. **One provider per deployment.** Unlike every other registry, this holds a single value: Teams have one authoritative source by construction, and two modules answering "what Teams exist" would produce @@ -410,6 +418,8 @@ getTeamMembers(externalId) // (string) => Promise<{ ok, complete?, members }> getTeamLeaders(externalId) // (string) => Promise<{ ok, leaders }> // leaders = [memberKey] projectRoster(externalId, members, viewer) // OPTIONAL (1.6.0, phase 3) // => Promise<{ ok, members }> // members = [memberKey] +pageUrlTemplate // OPTIONAL (1.6.0, phase 6) — DATA, not a method + // e.g. '/uo/guilds/{externalId}' // authoritative { ok: true, complete: true, teams: [ { externalId, name, abbr?, meta? } ] } @@ -447,6 +457,27 @@ Core distinguishes two refusals, and a module does not have to do anything to ge serves an empty roster and says so in the response (`projected: false`, `projectionUnavailable: true`). +**`pageUrlTemplate` is the fifth member, it is data rather than a method, and it exists because core +cannot link to a Team page.** Teams are a contract primitive with **no core surface** (TEAMS.md +Part 3): core owns the tables, the sync and the access rules, and the module that owns the vocabulary +owns the page. That is settled and right, and it leaves core unable to write the link a notification +email needs — an email about a forum reply that cannot take you to the thread is most of the way to +useless. So the module that owns the page says where it is. + +```js +api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders, + pageUrlTemplate: '/uo/guilds/{externalId}' }) +``` + +Core substitutes `{externalId}` and `{slug}` and does nothing else with it. **A relative path only** — +a template naming its own host is refused at registration, since there is no reason for a module to +redirect the site's outbound mail, and a protocol-relative `//host/x` is refused with it. Omitting the +member costs the deployment clickable links in Team notification email and nothing else. + +**Data rather than a callback, deliberately.** A function here would put a module hook on the mail +path — one more thing that can hang or throw between a forum reply and the mail about it — to produce +a string that never varies. + Core hands over the roster rows it holds plus a described viewer — `{ userId, role }`, or `null` for an anonymous caller — and never the `users` row, which would make every column of that table part of this contract. **The module answers with member KEYS, not rows.** Core keeps ownership of what a @@ -1201,19 +1232,23 @@ forced it is worth stating because it will recur: ```js // In the module's entry chunk, at registration time: +registry.declareModuleSlot(ID, 'uo.guild.header') registry.declareModuleSlot(ID, 'uo.guild.detail') registry.declareModuleSlot(ID, 'uo.guild.forum') // In the module's page, from the UI kit: + ``` -**A module declares one slot per PLACE, not one per page.** `module-uo` declares two on the same guild -page — core fills the first with the Team activity feed and the second with the Team forum — because a -slot holds one component and the first fill wins. Collapsing them into one would hand core the -decision about where each of its contributions sits, on a page the module owns. Two also keeps them -independent: a deployment with the forum switched off renders the feed unchanged. +**A module declares one slot per PLACE, not one per page.** `module-uo` declares **three** on the same +guild page — core fills them with the Team notification control, the activity feed and the Team forum +— because a slot holds one component and the first fill wins. Collapsing them would hand core the +decision about where each of its contributions sits, on a page the module owns, and the module does +use that freedom: the notification control goes **above** the roster because muting is an action *on* +the page, and the other two go below it because they are content *in* it. Separate slots also keep +them independent: a deployment with the forum switched off renders the other two unchanged. **The name must be namespaced under the declaring module's id**, and that is enforced rather than conventional: it is the only thing keeping two modules from claiming one name, and it makes the owner diff --git a/website/TEAMS.md b/website/TEAMS.md index 58800f3..2084365 100644 --- a/website/TEAMS.md +++ b/website/TEAMS.md @@ -1487,6 +1487,35 @@ CREATE TABLE IF NOT EXISTS team_forum_uploads ( ## Part 6 — Notifications +> **Built 2026-08-18 (phase 6).** As-built, and it deviates from what is written below in four +> places. Each is recorded here rather than by rewriting the section, so the reasoning that produced +> the original design stays legible next to what the build learned: +> +> 1. **There was no web notification settings screen to add the Team list to.** §6.3 says the per-Team +> mute list is surfaced "under the existing notification settings screen". No such screen existed: +> `/auth/me/notifications/*` had been built for the Android app in M7 and had **zero** web +> consumers. Tolerable while push was the only sink — push needs the app anyway. Not tolerable for +> email, whose entire argument (§6.4) is the web-only user, so the sink and the screen to configure +> it shipped together as `/account/notifications`. +> 2. **Email defaults to `off`, not to `digest`.** §6.4 specifies digest-by-default; on the org lead's +> decision it is opt-IN, because digest-by-default means every member of every Team starts +> receiving daily mail the moment an operator connects Gmail — a decision about other people's +> inboxes, made on their behalf. **Push stays opt-out.** The two sinks now default opposite ways; +> the asymmetry lives in the schema's column defaults and nowhere else. +> 3. **Roster events do not email.** All four streams exist and all four tickle. Only the two forum +> streams reach the email sink: §6.4's argument is the reply nobody hears about, and "someone +> joined the guild" arrives from a fifteen-minute sweep, is already on the activity feed, and is +> how a notification feature earns a spam complaint. +> 4. **A ninth member joined `MODULE_API_VERSION` 1.6.0** — `pageUrlTemplate` on the team provider. +> Phase 3 left core with no Team page and therefore no way to *link* to one, so an email could name +> a Team and not take you to it. The module that owns the page now says where it is. See +> [`MODULE_API.md`](MODULE_API.md) `registerTeamProvider`. +> +> Two further build decisions, neither contradicting anything above: the digest **computes at send +> time** and keeps no queue (§6.4 as-built, below), and one-click unsubscribe is a **stateless +> HMAC** rather than a token table. + + ### 6.1 What the existing pipeline gives us, and the one thing it does not Reusable unchanged: ntfy itself (a compose service, declarative config, no per-user accounts), the @@ -1556,9 +1585,23 @@ CREATE TABLE IF NOT EXISTS team_notification_prefs ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` -Applied as a subtraction from the computed recipient set. Surfaced as a mute toggle on the Team page -and as a list under the existing notification settings screen (`GET|PUT -/auth/me/notifications/teams`), which the Android app can adopt without a new screen concept. +Applied as a subtraction from the computed recipient set — **in SQL, not in the caller**: there is no +function in `model/teams/teamNotify.db.js` that returns an unfiltered recipient list, because one +would be a refactor away from being used. + +**As built**, the column is `email_mode ENUM('off','digest','immediate') NOT NULL DEFAULT 'off'` plus +a `last_digest_at DATETIME NULL` (the digest's only state, see §6.4), and it is surfaced in two +places: + +- **`/account/notifications`**, a new core page in the player portal — stream subscriptions, the + per-Team mute list, and the email mode per Team. `GET|PUT /auth/me/notifications/teams`; the `teams` + array is required on PUT even when empty, per the Android gotcha below. +- **A mute toggle on the Team page**, filled into a THIRD module-declared slot, `uo.guild.header`. + Above the roster rather than below it, because muting is an action *on* the page and the feed and + forum are content *in* it — which is exactly the placement decision a module cannot make if core + stacks everything into one fill. It renders nothing for a viewer with no preference row available, + which is a privacy property and not a tidiness one: whether a preference *exists* for a Team answers + "is this person in it", and the guild page is public. ### 6.4 Email — the third sink, already built and unused @@ -1574,13 +1617,43 @@ same event**, not a fourth pipeline. - **Unlike a push tickle, an email carries content** — the same reasoning as the Discord bridge (§7.2): the recipient's mailbox is a destination they chose, not an untrusted relay reached by an unguessable topic. It carries the thread title, an excerpt and a link; never the full post. -- **Digest, not per-event, by default.** A busy Team forum sending one email per reply is how a - notification feature gets marked as spam. Default to a daily digest per Team with an immediate - option, stored in `team_notification_prefs` as a `email_mode ENUM('off','digest','immediate')` - column. +- **Digest, not per-event, when email is on at all.** A busy Team forum sending one email per reply is + how a notification feature gets marked as spam. `email_mode ENUM('off','digest','immediate')` in + `team_notification_prefs`. + > **As built, the default is `off` and not `digest`** (org lead, 2026-08-18): digest-by-default + > would start mailing every member of every Team the moment an operator connects Gmail. Email is + > the one opt-IN sink here. Push stays opt-out, because a mute silences something the user already + > has. +- **The digest computes at send time and keeps no queue** (as built). The only state is + `last_digest_at`; the worker asks what arrived after it and re-runs the access resolver. Three + properties fall out, and the third is why it was chosen over a pending-items table: a deployment + down for two days sends **one** correct digest rather than replaying a backlog; a post a moderator + hid after it was written is simply not in the query; and **a user who lost forum access between the + post and the send is no longer in the recipient set**, so they are not emailed content they can no + longer read. `since` is clamped to at most seven days so a long outage cannot produce one enormous + mail, and `last_digest_at` is stamped **only on a successful send** — stamping first would quietly + eat a day of somebody's notifications every time the mail provider had a bad minute. +- **Roster events do not email** (as built). `team.member.joined` and `team.leadership.changed` + tickle and stop there; only `team.forum.post` and `team.announcement` reach this sink. - **Off unless email is configured.** No `email_config` row means the sink is absent, not broken. - One-click unsubscribe link honouring the same per-Team mute, so an unsubscribe from the mail client writes the preference the site shows. + > **As built: a stateless HMAC over `(version, userId, teamId)`, not a token table.** Every property + > that makes a password-reset token a row is absent here — the link sits in a mailbox for months so + > it has no useful expiry, and clicking it twice must mean what clicking it once meant. The + > capability it carries is deliberately the narrowest that does the job: set `muted` for **one** + > (user, Team) pair. It reads nothing, cannot un-mute, and names no other Team. `version` is the + > only revocation a stateless design can offer — bumping it invalidates every outstanding link at + > once — and it exists before it is needed rather than after. + > + > **Two URLs come out of one token, and they are not interchangeable.** The mail *body* carries the + > site's own `/unsubscribe/:token` page, which POSTs once a human is looking at it. The + > `List-Unsubscribe` *header* carries `POST /api/v1/public/teams/unsubscribe/:token`, because RFC + > 8058 lets a client POST to it without rendering anything. **A GET on the API path redirects and + > does not act** — a mail client's link scanner would otherwise silently mute Teams nobody asked to + > leave. The endpoint answers `200` whatever the token was: a response that distinguished a valid + > token from a forgery would be an oracle for which (user, Team) pairs exist, on a surface with no + > session behind it. Folded into **Phase 6** rather than getting a phase of its own: the recipient set is the work, and it is already being built there. @@ -2390,12 +2463,17 @@ window later. 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`) +### 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 -- 2.49.1 From 9f90a99362c039cd52bea44c673e68199b18c170 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 18 Aug 2026 18:17:26 -0500 Subject: [PATCH 09/17] docs(teams): queue the integration kit as phase 11, last before the cutover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kit is the instruction book for putting a different game on this platform, written for an audience outside this org. Teams expands the contract that book teaches against, so the book is the last thing the bet owes before `edge` becomes `main` (org lead, 2026-08-18). One sentence in it is already wrong rather than merely incomplete. `book/02-website-module.md` tells a reader that core declares a slot and a module may only fill one. Phase 3 inverted exactly that, and by phase 6 module-uo declares three — a new game's module cannot implement Teams at all without the inverted direction. Two shapes are genuinely new and worth teaching: the inverted slot, and `registerTeamProvider` as the first registration where core calls the module and waits — with the asymmetry that every call fails stale except `projectRoster`, which fails closed, because for a visibility question "keep what you have" means serving the roster unprojected. The phase explicitly does NOT enumerate the contract. The kit already teaches four members and has never mentioned notification streams, announce legs or post hooks, all of which predate Teams. MODULE_API.md is normative; the kit teaches one path and links out. Its ordering is awkward and is stated rather than smoothed over: it is written before the cutover and can only merge after it, because CI clones the pinned sha and checks the template against that core's MODULE_API_VERSION — and 1.6.0 does not reach `main` until the cutover lands. Co-Authored-By: Claude --- website/TEAMS.md | 55 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/website/TEAMS.md b/website/TEAMS.md index 2084365..b5f466b 100644 --- a/website/TEAMS.md +++ b/website/TEAMS.md @@ -2165,6 +2165,11 @@ makes §0.1's roster possible: Every phase is independently shippable and leaves the site working. Phases 1 and 2 are the only hard serial dependency in the list. +**Phase 11 is the exception to "independently shippable", and it is last on purpose** (org lead, +2026-08-18). The integration kit teaches an outside audience to build against this contract; Teams +expands the contract, so the book is the last thing owed before `edge` becomes `main`. It is also the +only phase that cannot merge until the cutover exists — see its own note. + ### Phase 0 — a one-guild roster spike (`servuo-plugins` + `link`, throwaway) **Not a deliverable — insurance on the phase that gates everything else.** `servuo-plugins` has no CI @@ -2505,6 +2510,56 @@ rendering the admin UI from the declaration rather than from a hardcoded "Discor purpose: extracting a capability surface from one working implementation is honest; designing it before one exists is speculation. +### Phase 11 — the integration kit (`integration-kit`) — **the last phase before the cutover** + +The kit is the instruction book for putting a *different* game on this platform, written for an +audience outside this org. Teams expands the contract that book teaches against, so the book is the +last thing this bet owes before `edge` becomes `main`. + +**One sentence in it is already wrong.** `book/02-website-module.md` states, of extension slots, +"**core declares a slot; a module may only fill one**". Phase 3 inverted exactly that: with +`declareModuleSlot` a MODULE declares a place on its own page and CORE fills it, and by phase 6 +`module-uo` declares three. A new game's module cannot implement Teams at all without the inverted +direction, so this is not a stale detail — it is the shape the reader needs and does not have. + +**Two genuinely new shapes to teach, and only two:** + +- **The inverted slot** (§3.7a) — a module declaring a place for core, why the name is namespaced under + the module's own id, and why a module wants *separate* slots rather than one (it decides where each + of core's contributions sits on a page it owns). +- **`registerTeamProvider`** — the first registration where **core calls the module and waits**. Every + other one is the module claiming a mount or core notifying it. The envelope, the 10-second budget, + and the asymmetry that matters: every call fails **stale** (core keeps what it has) except + `projectRoster`, which fails **closed**, because for a visibility question "keep what you have" + means serving the roster unprojected. + +`pageUrlTemplate` is a footnote beside those — one optional string, and the reader meets it while +reading the provider. + +**What this phase explicitly does NOT do: enumerate the contract.** The kit already teaches only four +members and has never mentioned `registerNotificationStreams`, `registerAnnounceLeg` or +`registerPostHook`, all of which predate Teams. That is the design, not a gap: +[`MODULE_API.md`](MODULE_API.md) is normative and the kit teaches one path end to end and links out. +The question this phase answers is "did the teaching path change", and the answer is yes in two +places and no everywhere else. + +**Then the two mechanical lines:** `ci/core-ref.json`'s sha moves to the cutover commit and +`template/module.json`'s `coreApi` becomes `^1.6.0`, which puts `scripts/checkCoreApi.js` back to +green. That check is an **equality**, and its going red is the mechanism rather than a bug — a +contract bump is meant to turn that repo red until someone has re-read the chapters. Moving the pin is +that person saying they have. + +> **Ordering, stated because it is genuinely awkward.** This phase is written *before* the cutover and +> can only *merge after* it. CI clones the pinned sha and checks the template against that core's +> `MODULE_API_VERSION` — and 1.6.0 does not exist on `main` until the cutover lands, so there is no sha +> to pin and no core for the template to build against until then. Write the chapters last, open the +> PR once the cutover merge exists, and put the pin move in it. + +**Checks that gate it** (all dependency-free Node scripts, run from the repo root — which is also how a +reader runs them): `checkLinks`, `checkRenameSites`, `checkChapterPaths`, `checkCoreApi --core .core`, +plus the template's own `npm ci` / `check:imports` / `build` / `check:externals` / `npm test` on both +halves. Build the client **before** the client tests; two of them read the built chunk. + ### Cross-cutting, every phase that touches the server `npm run swagger` regenerated and committed · `npm run routes:manifest -- --check` zero-line diff · -- 2.49.1 From c196d03d31c5467c8e0b4531cd392715312f982c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 18:53:56 -0500 Subject: [PATCH 10/17] =?UTF-8?q?docs(teams):=20phase=207=20as=20built=20?= =?UTF-8?q?=E2=80=94=20five=20amendments=20to=20=C2=A77.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The command that proves the seam is the MODULE's `/guild`, not core's `/team`: §7.1 was written before phase 3 settled that Teams is a contract primitive with no core surface, and a core `/team` publishes the same invented noun that got core's Team pages deleted. Its deep link comes from `pageUrlTemplate` for the same reason — `/teams/:slug` does not exist. The re-register nudge is its own bot endpoint rather than a ride on `/internal/config`, whose body carries the decrypted bot token. `actor` carries `role` beside `isStaff`, since a module with its own audience rungs cannot place a caller from a boolean. And "deregistration is free" needed a second half: it holds across the restart an uninstall asks for, not across the runtime toggle, so liveness is asked at both the pull and the dispatch. MODULE_API.md stops saying `registerSlashCommands` throws and documents it — every member of 1.6.0 is live now. Co-Authored-By: Claude --- website/BACKEND_DESIGN.md | 2 +- website/MODULE_API.md | 82 ++++++++++++++++++++++++++++++++++----- website/TEAMS.md | 64 ++++++++++++++++++++++++++---- 3 files changed, 130 insertions(+), 18 deletions(-) diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index 3dfa7c4..c55b761 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -674,7 +674,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 diff --git a/website/MODULE_API.md b/website/MODULE_API.md index a124ccc..ab34b09 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -59,11 +59,9 @@ the provider's optional `projectRoster` and `pageUrlTemplate` · `api.registerSl > `registry.declareModuleSlot(id, name)` 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. -**The number covers the whole surface; the members arrive by phase, and each is marked below.** Seven -are live now. `api.registerSlashCommands` is **present and throws**, with an error naming the phase -that will implement it — chosen over leaving it absent so that a module written against the published -version fails at registration with a sentence explaining itself, rather than at whatever moment -someone first exercises the feature. Do not call it yet; do not treat its throw as a bug. +**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 @@ -301,7 +299,7 @@ api.registerNotificationStreams(streams) api.registerAnnounceLeg({ leg, label, dispatch, classify }) api.registerPostHook({ onSaved, onDeleted }) api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders }) // 1.6.0 -api.registerSlashCommands([...]) // 1.6.0, throws until phase 7 +api.registerSlashCommands([{ name, description, options, access, handler }]) // 1.6.0 api.onBoot(async (ctx) => {}) api.onShutdown(async () => {}) ``` @@ -492,10 +490,74 @@ removals. It defaults to `true` when omitted, so the ordinary authoritative case 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)`** — declared in API 1.6.0 and **not yet implemented**: calling it -throws with an error naming the phase that will. Present rather than absent so a module written -against the published version fails at registration with an explanation, instead of at the moment -someone first types the command. +**`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. diff --git a/website/TEAMS.md b/website/TEAMS.md index b5f466b..86bd0ac 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 @@ -1669,6 +1672,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 @@ -2485,13 +2524,24 @@ them as toggles automatically, but nothing here builds a Team screen or a deep-l 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. + +**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)). + +**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 8 — Discord: notifications bridge (`website` + `bot`) -- 2.49.1 From 953f7fcd20ac6eb7be91cc3e56c661b389fd368a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 19:08:37 -0500 Subject: [PATCH 11/17] docs(teams): what the phase 7 rig walk proved, and the two defects it found Co-Authored-By: Claude --- website/TEAMS.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/website/TEAMS.md b/website/TEAMS.md index 86bd0ac..2ed2e6a 100644 --- a/website/TEAMS.md +++ b/website/TEAMS.md @@ -2537,6 +2537,19 @@ inside `website`, so the bot half lands in the same PR as the server half; `modu 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)). +**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. + +**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. + **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 -- 2.49.1 From 71f0b7ad908646f0dfe177f3205a2d1836960c5d Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 18 Aug 2026 20:25:41 -0500 Subject: [PATCH 12/17] =?UTF-8?q?docs(teams):=20phase=208=20as=20built=20?= =?UTF-8?q?=E2=80=94=20the=20gate=20=C2=A77.2=20could=20not=20check,=20and?= =?UTF-8?q?=20the=20key=20it=20could=20not=20hold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amends §7.2 inline and marks phase 8 done in Part 12; adds the team_integration_config row to BACKEND_DESIGN.md's schema table. Two of the amendments are things the tree disproved rather than choices: - §7.2's DDL cannot hold its own default row. MariaDB coerces PRIMARY KEY columns to NOT NULL, so `team_id NULL` is unrepresentable and the override mechanism has no base case. Confirmed against a real MariaDB (error 1048). - §7.2's visibility gate has no data source on either side and cannot have one: the streams carry no visibility, a forum thread is members-only by construction rather than by a column, and core cannot see a channel's permissions. The gate becomes an attributed operator acknowledgement. Co-Authored-By: Claude --- website/BACKEND_DESIGN.md | 1 + website/TEAMS.md | 154 +++++++++++++++++++++++++++++++++++++- 2 files changed, 152 insertions(+), 3 deletions(-) diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index c55b761..a6f832d 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -553,6 +553,7 @@ core's. | `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 | | `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`, diff --git a/website/TEAMS.md b/website/TEAMS.md index 2ed2e6a..86b4ea9 100644 --- a/website/TEAMS.md +++ b/website/TEAMS.md @@ -1776,10 +1776,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' @@ -1792,10 +1802,33 @@ 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 — phase 10 makes the platform a registry lookup, and what should +> change then is what fills the panel, not where it is. 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 @@ -1803,6 +1836,45 @@ 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 **Shape.** One voice channel per qualifying Team, under a single shared parent category @@ -2556,10 +2628,86 @@ static commands. It is not defensible now that it merges a pulled set into a sin registration and runs the interaction path, and phases 8 and 9 add more. `bot/test/` and a `bot-tests` job replace `bot-install`. -### Phase 8 — Discord: notifications bridge (`website` + `bot`) +### Phase 8 — Discord: notifications bridge (`website` + `docs`) — **DONE 2026-08-18** -`team_integration_config`, the internal fan-out with push and bridge as two consumers, -`POST /internal/team-notify`, the admin per-event configuration. +`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`. Phase 10 replaces "Discord" with whatever the capability registry declares; what +should change then is what fills the panel, not where an operator goes 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` + `bot`) -- 2.49.1 From c87034d7feaa140ebc7dc46211f7e1642311f1f2 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 19 Aug 2026 00:10:18 -0500 Subject: [PATCH 13/17] =?UTF-8?q?docs(teams):=20phase=209=20as=20built=20?= =?UTF-8?q?=E2=80=94=20roles,=20not=20overwrites,=20and=20three=20things?= =?UTF-8?q?=20=C2=A77.3=20named=20that=20do=20not=20exist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amends `TEAMS.md` §7.3 inline, marks phase 9 done in Part 12, and adds the `team_integrations` row to `BACKEND_DESIGN.md`'s schema table. Pairs with **website#159**. Co-Authored-By: Claude --- website/BACKEND_DESIGN.md | 1 + website/TEAMS.md | 187 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 181 insertions(+), 7 deletions(-) diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index a6f832d..a5e7668 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -554,6 +554,7 @@ core's. | `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`, diff --git a/website/TEAMS.md b/website/TEAMS.md index 86b4ea9..650ab2c 100644 --- a/website/TEAMS.md +++ b/website/TEAMS.md @@ -1877,9 +1877,35 @@ Two smaller consequences of the same asymmetry: ### 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 @@ -1889,10 +1915,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 @@ -1908,34 +1982,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 @@ -2709,10 +2832,60 @@ over-long forum post would not have arrived at all rather than arriving truncate **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` + `bot`) +### Phase 9 — Discord: voice channels (`website`) — **DONE 2026-08-19** -`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. +`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`) -- 2.49.1 From 88cc49225a06b351d745b1dd204e05ce70202fc7 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 19 Aug 2026 00:27:22 -0500 Subject: [PATCH 14/17] docs(teams): cancel phase 10, and say what that leaves behind The org lead cancelled the capability layer on 2026-08-19, deferring it until a second integration is wanted or it is asked for by name. Phase 11 is now the last phase of the bet. The same argument that put phase 10 last is the argument for not doing it yet: with one integration built, the refactor would extract a capability surface from a single implementation and have nothing to check the extraction against. It is cheaper and better-informed the day a second platform exists, because that platform is what proves which of the five capabilities the seam needs. Three places pointed forward at it and now say what is true instead: - Sec 7.2 and Sec 7.3 both justify the Admin -> Teams panels by "phase 10 makes the platform a registry lookup". The decision survives its reason: an operator should not have to know which platform is configured to find the panel, and that holds whether or not the registry is ever built. - Sec 8.2 keeps the Matrix comparison and the capability table, with a note that no registry is built either. The research did its job by keeping core's calls phrased as eligibility questions rather than as Discord operations; what is absent is the indirection, so `discord` is named directly in the bridge, the voice provisioner and the command dispatcher. The phase entry keeps its body rather than deleting it, because the argument for the layer is what a future phase would start from. Co-Authored-By: Claude --- website/TEAMS.md | 47 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/website/TEAMS.md b/website/TEAMS.md index 650ab2c..06f5f9b 100644 --- a/website/TEAMS.md +++ b/website/TEAMS.md @@ -1824,8 +1824,9 @@ identical to `announce` and `mod-reverse`. > 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 — phase 10 makes the platform a registry lookup, and what should -> change then is what fills the panel, not where it is. It is **admin-only**, the one such corner of a +> 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. @@ -2113,6 +2114,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 @@ -2399,6 +2408,9 @@ 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 @@ -2807,8 +2819,11 @@ does not move. #### 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`. Phase 10 replaces "Discord" with whatever the capability registry declares; what -should change then is what fills the panel, not where an operator goes to find it. It is the one +`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. @@ -2887,13 +2902,35 @@ and an admin removal working anyway, with a 404 for a Team that has none. 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`) +### 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 -- 2.49.1 From d10f5809b3bc2c7ab60a70930f6e1a3c75fafbdd Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 19 Aug 2026 01:16:07 -0500 Subject: [PATCH 15/17] docs(modules): core offers a contribution, never a slot name Amends MODULE_API 1.6.0 in place - it has only ever been on edge, the same rule the eighth and ninth members were given - and it is a correction rather than an addition. As first written, the inverted slot direction had core fill three literal uo.guild.* names. That worked for module-uo and silently did nothing for anyone 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 string literals the Sec 5.2 checker masks by construction. Sec 3.7a now documents declareModuleSlot(id, name, { core }) and the three contributions core offers - team.activity, team.forum, team.notify - as a table, with the rules that follow from the direction: the member is optional, a slot that asks for nothing stays empty, more than one slot may ask for the same contribution, and asking for one core does not offer THROWS at the declaration rather than rendering empty forever. TEAMS.md's two accounts of the inversion (Part 3's supersession note and the phase 3 amendment) say the same thing. Also corrects the UI kit's count in Sec 3.4 and Sec 3.7a: Slot made it nine in phase 3 and three places still said eight. Found by phase 11 while writing the chapter that teaches this shape to an audience outside this org. Co-Authored-By: Claude --- website/MODULE_API.md | 66 +++++++++++++++++++++++++++++++++---------- website/TEAMS.md | 35 +++++++++++++---------- 2 files changed, 71 insertions(+), 30 deletions(-) diff --git a/website/MODULE_API.md b/website/MODULE_API.md index ab34b09..74c0611 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -37,8 +37,16 @@ module chunk evaluates, which is earlier than any network round trip could answe `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. +`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, @@ -56,8 +64,8 @@ the provider's optional `projectRoster` and `pageUrlTemplate` · `api.registerSl > 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)` 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. +> `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 @@ -1050,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 @@ -1293,10 +1301,11 @@ forced it is worth stating because it will recur: > only core can resolve — are contributed to it. ```js -// In the module's entry chunk, at registration time: -registry.declareModuleSlot(ID, 'uo.guild.header') -registry.declareModuleSlot(ID, 'uo.guild.detail') -registry.declareModuleSlot(ID, 'uo.guild.forum') +// In the module's 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: @@ -1304,6 +1313,33 @@ registry.declareModuleSlot(ID, 'uo.guild.forum') ``` +**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 @@ -1318,25 +1354,25 @@ 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 (`fillModuleSlot`, core-only) and +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 fill for a slot no installed module declares is a no-op, never an error.** The declaring module is -simply not 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 +**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 eighth member of the UI kit** (§3.4) for this. A module could not render one of these +**`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. `fillModuleSlot` and -`applyCoreFills` are not: filling one of these is core's, exactly as declaring a §3.7 slot is. +`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. --- diff --git a/website/TEAMS.md b/website/TEAMS.md index 650ab2c..849698d 100644 --- a/website/TEAMS.md +++ b/website/TEAMS.md @@ -830,18 +830,20 @@ named for a *place* and never for a meaning): > **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)` — a **module** declares a place on its own page, namespaced -> under its own id, and **core** fills it: +> `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 | the Team activity feed (§4.3) | `{ externalId, moduleId }` | -> | `uo.guild.forum` | `module-uo` | the same page, below the feed | the Team forum (Part 5) — added in phase 4 | `{ externalId, moduleId }` | +> | `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 fills are applied at MOUNT, not eagerly: core's bundle evaluates before every module chunk, -> so when core registers a fill the slot does not exist yet. A fill for a slot no installed module -> declares is a no-op, not an error — the mirror of an unfilled slot rendering nothing. `Slot` becomes -> the eighth member of the shared UI kit so a module renders the place with core's own error boundary, +> 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. > @@ -2535,13 +2537,16 @@ guild called "Admin" cannot put an official-looking page on the site. > > **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)`: a **module** declares a place on its own page, namespaced -> under its own id, and **core** fills it. `module-uo` declares `uo.guild.detail`; core fills it with -> 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's fills are applied at mount rather than eagerly — -> core's bundle evaluates before every module chunk, so at the moment core registers a fill the slot -> does not exist yet. `Slot` joins the shared UI kit as its eighth member so the module renders the -> place with core's own error boundary. +> `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 -- 2.49.1 From 2adf397da4c87ed7260449657a73222fcec1eaed Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 19 Aug 2026 01:31:52 -0500 Subject: [PATCH 16/17] docs(teams): phase 11 as built, and what the rig walk found The phase ran before the cutover as Part 12 planned, and found what it was meant to: the inverted slot direction worked for module-uo and nobody else. Recorded beside the phase entry, with the org lead's two decisions of the day - core offers a contribution rather than naming a slot, and the template grows a real provider rather than a snippet. Also records the live-rig walk and the one defect it found that no test could: PageHeader takes `lead`, not `subtitle`, and React drops an unknown prop in silence, so every page built from the kit's template had been rendering its heading with nothing under it. Co-Authored-By: Claude --- website/TEAMS.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/website/TEAMS.md b/website/TEAMS.md index 849698d..89b3e25 100644 --- a/website/TEAMS.md +++ b/website/TEAMS.md @@ -2932,6 +2932,35 @@ members and has never mentioned `registerNotificationStreams`, `registerAnnounce 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 -- 2.49.1 From 7e08a55f7669eafbde6f454af717eba1202aa675 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 19 Aug 2026 03:51:07 -0500 Subject: [PATCH 17/17] docs(modules): the Rust dry run, revisited for Teams The kit's README sends a reader here FIRST - it is the shortest honest picture of the whole job - and it predated Teams, so it taught a second game to build its teams as private module data and never mentioned the provider. The two places the contract changed since it was written are now in it, and nothing else moved: all four findings stand, including the identity gap, which is still the one a real second module hits first. What Rust adds that UO does not, and why it was worth revisiting rather than noting: - externalId must survive a rename and a Rust team HAS no name - it is a numeric team id in the save. The right answer, and the one a designer is least likely to reach for. - `complete` is per SERVER, not per community. Six servers are six team spaces, so a provider that can reach five must leave `complete` off or core archives every team on the sixth. - A wipe empties every team, so { ok: true, complete: true, teams: [] } is TRUE once a month 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. - The team route carries a server id as well as a team id, so the external id is :. Core stores that and never parses it; an external id is opaque to core by design, and this is the case that shows why. Also: rust_teams stays the module's table and core's teams stays core's, which is the boundary worth stating in the one document where both appear; and the kit is nine members now, not seven. Co-Authored-By: Claude --- modules/rust-dryrun.md | 49 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 5 deletions(-) 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 -- 2.49.1