Compare commits

..

1 Commits

Author SHA1 Message Date
ceab67f285 docs(teams): use the correct presentation of the project name, and match both
"Runic Gateway" (two words) is the correct presentation; "RunicGateway" is
accepted only where the name has to condense to a single token — the Gitea org,
a package name, a URL segment. Three prose uses in TEAMS.md had the condensed
form for no reason, including the upload warning text, and are corrected. The
`RunicGateway/<repo>` slugs throughout android/PLAN.md are condensed by
necessity and are left alone.

This turned out to matter beyond presentation. The reserved-name matcher (2.8)
specified whole-word matching over a normalisation that case-folds, strips
punctuation and collapses whitespace — under which "RunicGateway" is a SINGLE
word and would never have matched the two-word term "Runic Gateway". The
condensed form is the one an impersonator reaches for, precisely because it is
what the org and every URL already use, so the check would have missed its most
likely input.

Multi-word terms are now additionally compared with whitespace removed on both
sides, so `Runic Gateway` matches `RunicGateway`, `runic-gateway`, `Runic_Gateway`
and `RUNIC GATEWAY` alike. The widening applies only to terms CONTAINING
whitespace, which keeps it clear of the single-word terms where whole-word
matching is doing the false-positive work: `admin` is still compared as a word
and still does not fire on "Badminton". Listing the condensed form as a separate
reserved term was the alternative and was rejected — it is a second thing to keep
in sync and would still miss the hyphenated and underscored variants.

The same reasoning applies to a deployment's own BRAND_NAME, which is free text
and may well be spaced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnDSWzpUjw8t8C2hghysNz
2026-08-17 05:04:22 -05:00
6 changed files with 11 additions and 849 deletions

View File

@@ -283,26 +283,11 @@ 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 leader/alliance/name changed, its member count moved, or its first sight this connection. |
| `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.remove` | `id` | The guild disbanded (leader gone) or was removed. Drop the row. |
| `guild.join` | `id`, `name`, `abbr`, `who` (actor object) | Real-time: a player joined a guild (`EventSink.JoinGuild`). |
| `guild.roster` **(4)** | `id`, `name`, `abbr`, `total`, `seq`, `more`, `members` (array of actor objects **carrying rank**) | The full member list. Emitted whenever the member set changes. **`seq` 0 supersedes whatever roster you hold for that guild; `more: false` ends it.** |
| `guild.leave` **(4)** | `id`, `name`, `who` (serial string) | Real-time: a member left. Advisory — see below. |
The `leader`/`who` **actor object** is `{serial, name, acct?, webId?, player}` — `acct`/`webId` present when the mobile has an account / a linked website user. Note `guild.leave`'s `who` is a bare **serial string**, not an actor object: the mobile has already left, so there is nothing to attribute.
**A roster member carries rank as well.** `rank` is 0–4 with 4 being Leader, plus `rankCliloc` (the cliloc the game names that rank with) or `rankName` when a shard uses custom rank definitions with literal names. Only the raw rank is sent: ServUO ships no text for those clilocs, so turning 1062960 into "Warlord" is the consumer's job.
Two things to get right, both of which bite:
- **Several members can hold rank 4.** `guild.update`'s single `leader` is the guild's founder-leader; it is not the set of leaders. If you need "who leads this guild", read the roster's ranks and treat `leader` as one more entry rather than the answer.
- **An absent `rank` means "not known" — never 0, and never leadership.** It has one deliberate cause: `PlayerMobile.GuildRank` reports Leader for any account at GameMaster or above whatever their real rank, so the bridge omits the rank for staff rather than publishing a claim it knows is false. Defaulting a missing rank to 0 silently demotes them; reading absence as leadership republishes exactly the lie the bridge avoided.
**On Protocol 4.** Before it, a guild's membership was a *count* and a leave surfaced only as that count dropping. `guild.roster` carries the members themselves, and `guild.leave` names who went.
`guild.leave` is **advisory**: any change to the member set re-emits the whole roster, so a consumer holding a membership table stays correct even if it ignores every leave event. Handle it when you want a "so-and-so left" feed to update without waiting for the sweep.
**Rosters can arrive in several frames.** Members per frame are capped so a large guild cannot produce an unbounded line (~69 bytes per member; the default cap is 500). Every realistic guild arrives as one frame with `seq: 0, more: false` and needs no special handling — but if you consume the raw stream, accumulate from `seq` 0 and apply on `more: false`, discarding a partial roster if a frame arrives out of order or the shard reconnects. `GET /guilds` hands you rosters already reassembled. A guild with no members emits one frame with an empty array, so an emptied roster is distinguishable from an absent one.
The `leader`/`who` **actor object** is `{serial, name, acct?, webId?, player}` — `acct`/`webId` present when the mobile has an account / a linked website user.
```json
{"kind":"guild.update","id":1042,"name":"The Silver Hand","abbr":"TSH","members":14,
@@ -311,16 +296,8 @@ Two things to get right, both of which bite:
"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)
@@ -876,17 +853,7 @@ GET /guilds
"t":1752489280000}, ... ] }
```
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".
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.
### Governor board (Protocol 2.0)

View File

@@ -236,14 +236,6 @@ 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`.

View File

@@ -1,269 +0,0 @@
# 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, `<servuo>`, net48 / x64, Expansion **EJ**.
**Companion to** [`PLAN.md`](PLAN.md) (1.0 read/event plane), [`PROTOCOL_2.md`](PROTOCOL_2.md) (2.0 provisioning + world-state streams), [`v3.md`](v3.md) (3.0 shard content + the visibility framework), [`INTEGRATION.md`](INTEGRATION.md) (website API).
---
## 1. Why 4
Protocol 2 gave the website a guild board: name, abbreviation, leader, alliance, and a member
**count**. It could say a guild had 155 members. It could not say who they were, and there was no
event for anyone leaving one.
That gap was known when 2.0 shipped. [`PROTOCOL_2.md` §10.1](PROTOCOL_2.md) sketched exactly this
design — hold a member-serial set, diff it each sweep, emit `guild.join`/`guild.leave` — and then
2.0 shipped only the half that needed no new state, folding membership into the board signature as a
serial *sum*. Protocol 4 builds the rest of what §10.1 described.
The immediate consumer is a richer public Guilds page, which is worth the bump on its own merit. The
reason it is being built **now** is that [`../website/TEAMS.md`](../website/TEAMS.md) needs it:
platform Teams are sourced from game guilds, and nothing in Team core can be built against a count.
That is a dependency, not a coupling — nothing in this protocol version knows what a Team is.
**Two kinds, both additive:**
| Kind | Shape | Purpose |
|---|---|---|
| `guild.roster` | full member list, possibly split across frames | the membership itself; supersedes whatever was held |
| `guild.leave` | one departing member | the real-time counterpart to the existing `guild.join` |
Nothing existing changed shape. `GET /guilds` grows a `roster` key; a v3 consumer that ignores it
keeps working.
---
## 2. The shard side
`BridgeSocial.cs` holds each guild's member serial **set** instead of folding it into the signature
as a sum. Two consequences, both the point of the change:
- **A set comparison cannot collide.** The old sum could: one member joining and another leaving
between two sweeps offset each other, and the guild read as unchanged.
- **A set can be differenced.** Departures are the prior set minus the current one — which is what
makes a per-member `guild.leave` possible without a core tap, since ServUO raises no event for
leaving, disbanding, or a leader change.
A changed set re-emits `guild.roster`, the whole member list. That is what lets `guild.leave` stay
**advisory**: a consumer building a "so-and-so left" feed wants the individual events, but a consumer
holding a membership table only needs the roster, so nothing downstream has to replay deltas to
remain correct. A dropped `guild.leave` costs latency, never accuracy.
On a guild's **first** sweep there is no prior set, so nothing is reported as leaving. An unknown
roster becoming known is not 155 people leaving at once.
### 2.1 Frames are capped, and split when they must be
A roster is the only fat frame this bridge emits. Measured against a real 155-member guild: **10,812
bytes, about 69 bytes per member.** The sidecar's `read_line` has no length bound, so an uncapped
roster is an unbounded line.
Members per frame are therefore capped (`Bridge.GuildRosterMembersPerLine`, default 500 ≈ 35 KB), and
a guild over the cap is split into frames carrying `seq`, `more` and `total`:
```jsonc
{"t":1786988708513,"kind":"guild.roster","id":1,"name":"The Silver Hand","abbr":"TSH",
"total":155,"seq":0,"more":true,"members":[ /* 50 actor objects */ ]}
{"t":1786988708514,"kind":"guild.roster","id":1,"name":"The Silver Hand","abbr":"TSH",
"total":155,"seq":1,"more":true,"members":[ /* 50 */ ]}
{"t":1786988708514,"kind":"guild.roster","id":1,"name":"The Silver Hand","abbr":"TSH",
"total":155,"seq":2,"more":true,"members":[ /* 50 */ ]}
{"t":1786988708514,"kind":"guild.roster","id":1,"name":"The Silver Hand","abbr":"TSH",
"total":155,"seq":3,"more":false,"members":[ /* 5 */ ]}
```
Reading the flags: **`seq` 0 begins a roster and supersedes whatever was held for that guild**;
`more: false` ends it. Every realistic guild is inside the cap and emits exactly one frame with
`seq: 0, more: false` — the same shape as if chunking did not exist. A guild with no members still
emits one frame with an empty array, or a consumer could never learn that a roster it holds has
emptied.
`guild.leave` is a small frame naming the departed serial:
```jsonc
{"t":1786988770099,"kind":"guild.leave","id":1,"name":"The Silver Hand","who":"0x1F5"}
```
### 2.2 The reconnect baseline is spread
`BridgeLink.OnConnected` clears the diff caches, so after a reconnect **every** guild reads as
changed at once. The outbound queue is not the risk — the cap is 10,000 *lines* and a few hundred
guilds is a few hundred lines — but building hundreds of fat JSON frames in a single Core-thread tick
is exactly the stall this bridge exists to avoid.
So at most `Bridge.GuildRosterGuildsPerTick` guilds (default 25) emit a roster per sweep. A guild over
budget keeps its previously held member set, so it still reads as changed on the next pass; its
`guild.update` has already gone, so the board's counts are current either way. While a baseline is
draining the sweep re-arms itself after 2 s rather than waiting a full `GuildSweepSeconds`, so
catch-up takes seconds instead of one sweep interval per batch.
### 2.3 What a roster member carries
Each entry is the standard actor object — `serial`, `name`, `player`, plus `acct` when the mobile has
an account and `webId` when that account is linked — **and the member's rank in this guild**:
```jsonc
{"serial":"0x1F5","name":"Seed000A","acct":"seed_000","webId":"42","player":true,
"rank":4,"rankCliloc":1062959}
```
`acct` is **genuinely optional**: a `PlayerMobile` can have no `Account` at all, and the local test
world contains such mobiles. Consumers must not assume it is present.
These identity fields are emitted unconditionally, by design — the sidecar is a forwarder, and
deciding who may see them is the website's job. See §4.
#### Rank
> **Added 2026-08-17, amending Protocol 4 in place.** The version is **not** bumped: Protocol 4 has
> not reached `main`, and a protocol owes a bump only once it has been released. The roster shipped
> without rank, and Teams phase 2 then found the consequence — the website could learn leadership
> only from the board's single `leader` field, so it could name exactly one leader while a UO guild
> routinely has several.
| Field | Shape | When |
| --- | --- | --- |
| `rank` | integer 0–4, 4 being Leader (`RankDefinition.Ranks`) | whenever the rank is known |
| `rankCliloc` | integer — the cliloc the game names the rank with | when the rank's name is a cliloc, i.e. the five stock ranks |
| `rankName` | string | when a shard's custom rank definition carries a literal name instead |
**Rank is on roster members only.** It is a property of a mobile's membership of *this* guild, not of
the mobile, and every other actor the bridge writes is a bystander, a killer or a governor, where
guild rank is meaningless.
**Only the raw rank is emitted, never a resolved label.** ServUO names the five stock ranks with
clilocs (1062959–1062963) and ships no text for them, so the shard cannot produce "Warlord" without a
client-file table it does not have. The website module does have one, and resolving a game term is
its job in any case.
**An absent rank means "not known", and a consumer must not read it as 0 or as leadership.** It has
one deliberate cause, which is the trap this amendment found: `PlayerMobile.GuildRank` returns
`RankDefinition.Leader` for anyone at **GameMaster or above**, whatever their real rank. That is a
gameplay convenience so staff can operate a guild stone, not a claim about who leads the guild — and
the true value is in a private field with no accessor. So the bridge writes **no rank at all** for a
staff account rather than publishing a leadership claim it knows to be false. A staff member who
genuinely leads their guild therefore appears unranked, which is a visible gap rather than a lie on a
public roster.
**The sidecar is unaffected.** It treats roster members as opaque values and never reads a field
inside one, so a new member field needs no sidecar change and no store migration — which is the
forwarder design (§3) doing its job.
---
## 3. The sidecar side
`guild.roster` writes a **`members` column on the `guilds` board**, not a field inside the existing
`json` column. That column holds the verbatim `guild.update` line, and a roster written into it would
clobber the snapshot — name, abbreviation, leader, online count — that `guild.update` owns. Two
writers across two columns of one row keeps both as plain upserts: neither reads the other's value
first, so there is no read-modify-write and no ordering requirement between the two kinds. Either can
arrive first.
`GET /guilds` folds the roster back in as `roster` at read time. A guild that has had a
`guild.update` but no `guild.roster` yet simply has **no `roster` key** — the honest representation
of "not known", and deliberately distinct from a guild whose roster is genuinely empty.
`guild.leave` gets **no board projection at all**. The sidecar persists and broadcasts it like any
event and leaves the board alone; the roster self-corrects on the next `guild.roster`, which the
shard re-emits whenever the member set changes. Keeping the delta out of the projection is what keeps
the sidecar a forwarder rather than a thing that maintains state.
### 3.1 Split rosters are reassembled in memory
A roster split across frames is reassembled **before** it is stored, and written once on the frame
that closes it. Appending to the column per frame was rejected twice over: it would make the write a
read-modify-write — the exact thing the two-column split exists to avoid — and it would publish a
torn roster, since a reader hitting `GET /guilds` between frames would see a partial member list
presented as the whole truth.
This is transport-level reassembly, the same category of work as turning bytes into a line, and it
holds nothing once a roster is complete. The ordinary single-frame case never enters the buffer at
all. Around it: a fresh `seq: 0` supersedes an abandoned partial, an out-of-order frame discards the
partial rather than storing one with an undetectable hole, a continuation with no start is ignored, a
`server.hello` drops every partial (a reconnected shard restarts each roster at 0), and accumulation
is bounded so a shard that never sends a closing frame cannot grow the buffer without limit.
### 3.2 This is the first bump that needed a store migration
`store.rs`'s `SCHEMA` is `CREATE TABLE IF NOT EXISTS`, which can add a table but **cannot add a
column to a table that already exists**. Every schema change up to and including Protocol 3.0 added
whole tables, so this never mattered and `ALTER TABLE` appeared nowhere in the repo's history.
`guilds.members` is the first column added to an existing table: without a mechanism it would simply
never reach an installed sidecar, and every roster write would fail.
The counter is SQLite's own **`PRAGMA user_version`** — an integer in the database header, so it
costs no table and cannot drift from the file it describes. Each step runs in a transaction together
with the bump recording it, so a step lands completely or not at all and an interrupted run resumes
in the right place.
- **A failure aborts startup**, which was already the behaviour. A half-migrated store answers the
website with confusing partial data, which is worse than being plainly absent — and the shard dials
*out*, so a sidecar that refuses to start never stalls the game.
- **A database written by a newer sidecar warns and continues.** Every step is additive, so a newer
schema has only columns an older reader ignores; refusing to start would turn rolling the binary
back — a recovery path — into a dead end.
`sqlx::migrate!` was considered and rejected: it checksums each migration file, so editing an
already-released migration hard-fails startup, which is a poor trade for a schema of eleven
JSON-blob tables.
---
## 4. Visibility
Both kinds map to the existing **`guilds`** feature in the website's visibility framework
([`v3.md` §3](v3.md)). That mapping is required, not cosmetic: rule 2 fails an *unmapped* kind closed
to admin-only, which would have quietly kept rosters off the public Guilds page forever.
Mapping them is safe because of how the framework strips fields. A roster is the first frame carrying
locked fields inside an **array** of actors rather than a single nested actor — but the projection
walker already recurses into arrays and matches `acct`/`webId` **by suffix, on meaning rather than
spelling**. So a member's account name is stripped below `admin` by exactly the rule that already
strips `guild.leader.acct`.
The website stores `acct`/`web_id` per member, because that is what lets a linked member be matched to
a site user at all. It never projects them below `admin`. The difference this rule makes is a public
page listing character names versus one publishing 150 account names, so it carries a test of its own.
---
## 5. Cross-repo obligations
| Repo | Change |
|---|---|
| `servuo-plugins` | member-set diff, `guild.roster` + `guild.leave`, `BridgeJson.Actors`, two `Bridge.cfg` keys, **`overlay.toml` protocol → 4** |
| `link` | `PROTOCOL_VERSION` → 4, `members` column + `user_version` migration, roster reassembly, `GET /guilds` projection |
| `module-uo` | `shard_guild_members`, `guild.roster`/`guild.leave` ingest, the kind→feature map entries |
| `installer` | `backup.rs`'s stated reason for skipping the sidecar DB (§3.2 falsifies it) — docs only |
| `docs` | this file, `PROTOCOL_2.md` §10.1, `INTEGRATION.md` |
**`overlay.toml` must move in the same PR as the emitters.** CI folds it into the release manifest and
the installer refuses to pair an overlay and a sidecar whose protocol numbers disagree, so a bump
landing separately would silently fail to compose into a bundle.
---
## 6. Verification
`servuo-plugins` has no CI build — the plugin compiles only inside ServUO — so "it compiled" is not
evidence and neither is a clean boot. What was actually run:
1. **A throwaway one-guild spike first** (TEAMS.md Phase 0), against the real Rust sidecar rather than
a stub, to retire the unknowns before committing to a four-repo bump. It proved the two-column
board shape, measured the line, and surfaced §3.2 — the missing migration mechanism — which no
amount of reading would have.
2. **A live end-to-end run**: 155 members seeded from real `PlayerMobile`s on the local ServUO tree,
the cap forced down to 50 so the split path actually fired, producing 50/50/50/5 across four
frames; two members then removed on a timer, producing exactly two `guild.leave` frames with the
correct serials and a re-emitted roster at `total: 153`.
3. **A restart test with the shard stopped first**, so its reconnect could not re-emit and fake
persistence. The board's row came back byte-identical.
Step 2 is what caught the reassembly bug in §3.1: every unit test passed through it, because they all
exercised a single-frame roster. The case does not arise until a guild exceeds the cap.
Still outstanding for the cutover: the five-rung shard visibility walk against a live shard, confirming
`acct`/`webId` never reach a caller below their rung.

View File

@@ -525,104 +525,6 @@ 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)
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 `<moduleId>_` prefix, correctly: that rule binds modules, and these are
core's.
| Table | What it holds |
|---|---|
| `teams` | the Team itself. `external_id` is the module's own stable id, opaque to core; `name` is **immutable** for the life of the row; `slug` is derived once at create and frozen with it |
| `team_members` | the membership **projection**. Module-authoritative, and the sync is its only writer. Rows are soft-departed rather than deleted so history and rejoins survive |
| `team_sync_state` | one row per module: last attempt, last success, consecutive failures, last error, and the empty-answer quarantine |
| `team_leader_overrides` | a staff decision about leadership, applied **on top of** the synced value at read time and never written into the projection |
| `team_forum_grants` | the append-only forum grant/revoke ledger, which is also the current state. Created in phase 2 so the access resolver is written once; the grant flow is phase 4's |
| `team_moderation_requests` | the approval queue for the three actions that publish untrusted game-sourced strings |
| `team_activity` | the per-Team feed (phase 3). **Two writers, one table:** core writes its own membership and rename items with `source='core'`, and a module pushes game items through `ctx.teams.activity.push`. `summary` is already-rendered text and core never composes one; `kind` and `payload` are opaque to core |
| `team_forum_threads` | forum threads (phase 4). The FULL schema lands with announcements, including the `type`, `pinned` and `locked` columns only discussion uses — phase 5 opens paths rather than migrating data |
| `team_forum_posts` | post bodies, sanitised on write through the forum's **own** profile (`utils/forumHtml.js`) and served without re-sanitising. No stored body ever contains an `<img>` |
| `team_forum_moderation` | append-only, per Team, recording `actor_role` — WHICH authority was exercised. Deliberately not merged with `mod_actions`/`appeals`, which is Discord-sanction-shaped |
| `team_forum_uploads` | attribution for `uploads` mode: who uploaded what, when, how big, and to which post. Also the sweep's worklist |
**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 `<img>` tag, and that is what makes the image policy enforceable.** The
shared sanitiser (`utils/sanitizeHtml.js`) allows `<img>` 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 `<img>` 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: <version>`; the admin checkbox is how
the gate is presented, never the gate. The accepted TEXT VERSION is stored in
`teams_forum_uploads_ack`, whose `updated_by`/`updated_at` answer who and when, plus an `activity_log`
row. If the wording is ever revised the stored version goes stale — uploads **keep working**, a
persistent banner requires re-acknowledgement, and no other forum setting may be saved until it is
given. `teams_forums_enabled` and `teams_forum_images` are published in `settings.getPublic()`; the
acknowledgement is not.
**`team_activity` is bounded on purpose.** A feed fed by a game loop is the obvious unbounded-growth
failure, so retention ships with the feed rather than after someone notices: a nightly worker applies
an age horizon (`team_activity_retain_days`, default 90) **and** a per-Team row cap
(`team_activity_row_cap`, default 2000). Both, because either alone has a hole — age lets one busy
guild write a million rows inside the window, and a cap keeps a dead Team's feed forever.
`dedupe_key` is optional and unique per Team, written with `INSERT IGNORE` — the same idempotence
trick `shard_events` uses, and what makes a sidecar reconnect backfill safe to replay. Core
deliberately emits **no join items for a Team's first roster** (`roster_synced_at IS NULL`): importing
a 155-member guild is one Team arriving, not 155 people joining.
**A rename is an archive plus a create**, never an edit. Core's identity is (`module_id`,
`external_id`, `name`) taken together: a known id under a new name archives the old row
(`archived_reason='renamed'`, `succeeded_by` pointing at the successor) and creates a new one, so the
old Team keeps its activity, its grants and its forum as a read-only record and its old slug still
resolves. Whether two names are "really" the same guild is the module's judgement, expressed in
whether it reuses the external id.
**Uniqueness among ACTIVE rows only** is expressed with STORED generated columns, because MariaDB has
no partial index and NULL never collides in a UNIQUE key: `active_key` and `active_slug` on `teams`
are NULL for archived rows, so any number of them may share an `external_id`.
**`team_forum_grants` departs from the obvious encoding, and the reason matters.** Its marker is
`active_marker AS (IF(revoked_at IS NULL, 1, NULL))` with `user_id` in the KEY rather than the
generated column, because MariaDB refuses `ON DELETE SET NULL` on a foreign key whose column is a base
column of a stored generated column (error 1901) — and `SET NULL` is required here: `CASCADE` would
delete the audit trail of who granted whom, which is exactly what an audit exists to survive. The
semantics are identical: at most one active grant per (team, user), unlimited revoked rows.
**Account deletion is settled per column, not inherited from the defaults.** Content and audit
survive; preferences and links do not. `team_members.user_id` and every actor column on the grant
ledger and the approval queue go `SET NULL` with a **username snapshot** alongside, so the record
stays readable after the account is gone. Only `team_id` cascades.
**Two columns exist that the design of record did not contemplate**, both on `teams` and both
serving the refusal gates below: `roster_synced_at`, because `team_sync_state` holds one row per
*module* and a single Team's roster can be left untouched while the others sync — without a per-Team
stamp that Team's page would report the module's last success as its own; and `members_empty_since`,
the per-Team twin of `pending_empty_since`.
Design of record: [`TEAMS.md`](TEAMS.md) Parts 2 and 5. The contract surface a module sees is
[`MODULE_API.md`](MODULE_API.md); everything in these tables is explicitly *not* it.
---
## 4. API contract
@@ -752,14 +654,6 @@ 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
@@ -878,11 +772,6 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
| GET | `/wiki` | list of pages (slug + title) |
| GET | `/wiki/:slug` | single page |
| POST | `/contact` | (rate-limited) send mail via SMTP; if unconfigured, respond `{fallback:"mailto", email}` |
| GET | `/teams/by-external/:moduleId/:externalId` | one Team named the way the OWNING MODULE names it. Exists so a module's page can find core's Team without holding core's identifiers, which are core-internal. The module id is matched rather than trusted: an external id is unique only within a module |
| GET | `/teams` | active, publicly visible Teams, paged. Every payload carries `{ configured, stale, lastSyncAt }` so a page can say how recently the projection was confirmed rather than presenting a stale roster as current, plus `enabled` — whether this deployment has Teams at all |
| GET | `/teams/:slug` | one Team. An **archived** Team still resolves, read-only, and names its successor when it was renamed — an old bookmark or Discord link lands somewhere that explains itself. A **hidden** Team returns 404, indistinguishable from one that does not exist: "absent from every public surface" includes not confirming it is there. Carries `id`/`externalId`/`moduleId` — this route only, since the index has no use for them |
| GET | `/teams/:slug/members` | the roster. In-game display names only — the member key is a game-internal identifier and the user id names a site account, and **neither is published**; `linked` answers whether a character has an account behind it without saying which. **Which rows** appear is the module's audience projection (`projectRoster`), applied per caller: a module that has a rung system and cannot be asked yields an EMPTY roster, not an unprojected one, flagged as `projectionUnavailable`. A session is optional and may widen the result |
| GET | `/teams/:slug/activity` | the Team's activity feed, paged, newest first. `public` items to anyone who can see the Team; `members` items additionally to members and forum-granted users, resolved from the session and never from a parameter. `scope` reports which the caller got, so a client can say "some entries are hidden" instead of presenting a filtered feed as the whole one. A hidden Team's feed does not answer the public but does answer its members |
| — | `/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).
@@ -953,15 +842,6 @@ 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`.

View File

@@ -26,44 +26,13 @@ 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.6.0'
const MODULE_API_VERSION = '1.5.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.** 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(...)` ·
`registry.declareModuleSlot(...)` with `Slot` in the UI kit.
> **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
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
`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
@@ -212,51 +181,6 @@ 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<void>` | `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<void>`, fire-and-forget | `model/teams/teamActivity` | the Team provider's module (1.6.0) |
**`ctx.teams` is push only, and that is the contract.** There is no reader: a module *answers*
questions about Teams, it does not ask them. Every Team table is core-internal (§1.2), and a
`getTeamRoster` on `ctx` would be core offering to read back the module's own answer — which the
module already holds, in its own store.
**`ctx.teams.activity.push(items)`** writes the per-Team feed (TEAMS.md §4.1). Each item is:
```js
{ externalId, kind, summary, occurredAt?, visibility?, actorMemberKey?, actorUserId?, payload?, dedupeKey? }
```
Four things about it are contract rather than implementation:
- **`summary` is already rendered and core stores it verbatim.** Core cannot phrase "gained 15,000
gold" for a game whose vocabulary it does not know, and a core that templated it would have
re-acquired exactly the semantics the module system exists to remove. `kind` and `payload` are
likewise opaque — core filters on them and only the module's `team.overview` slot renders anything
richer than the text.
- **A Team is named by the module's own `externalId`**, which core maps, and only that module's
ACTIVE Teams resolve. There is no id a module can send that reaches another module's Team, and an
archived Team is not writable — its feed is a closed record of what happened before the rename.
- **`visibility` defaults to `'members'` — fail closed.** The module chooses it per item; core
enforces it on the read path.
- **It never throws at the call site and never rejects.** This is called from inside a game-event
handler, and a storage problem of core's must not become the module's control flow. A malformed
item is dropped and logged; a `dedupeKey` collision is a successful no-op, which is what makes a
sidecar reconnect backfill safe to replay.
Both live members are **fire-and-forget**. `publish` is an optimisation that makes a membership
change visible at once; `reconcile` is a debounced *request* that returns immediately and never
rejects. Correctness comes from reconciliation either way, so neither can make a module's own call
site slow or turn a background failure into the module's error.
The six event kinds `publish` accepts are `team.created`, `team.disbanded`, `team.member.added`,
`team.member.removed`, `team.leader.added` and `team.leader.removed`. Six rather than four because
leadership is its own authority path: a leadership change has to be expressible without pretending
someone joined or left. Every event carries `externalId`; the four member and leader kinds also carry
`memberKey`. **`team.created` and `team.disbanded` only ask for a reconciliation** — core will not
invent a Team from a delta (it would have no name, no roster and no leaders) and will not archive one
from a delta either, because an archive driven by a message that may simply have been repeated is
destruction on no evidence.
Three narrowings from `MODULE_SYSTEM.md` §2.1, all deliberate:
@@ -293,8 +217,6 @@ 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 () => {})
```
@@ -394,78 +316,6 @@ 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. Three methods are required — a provider
that could list Teams but not their members would leave core holding Teams it can never populate,
which is not the same as a call that fails. The fourth is optional; see below.
```js
getTeams() // () => Promise<{ ok, complete?, teams }>
getTeamMembers(externalId) // (string) => Promise<{ ok, complete?, members }>
getTeamLeaders(externalId) // (string) => Promise<{ ok, leaders }> // leaders = [memberKey]
projectRoster(externalId, members, viewer) // OPTIONAL (1.6.0, phase 3)
// => Promise<{ ok, members }> // members = [memberKey]
// authoritative
{ ok: true, complete: true, teams: [ { externalId, name, abbr?, meta? } ] }
// the module knows it cannot answer — sidecar down, cache cold, boot not finished
{ ok: false, reason: 'sidecar unreachable' }
```
A member is `{ memberKey, displayName?, rankLabel?, leader?, online?, userId? }`. `userId` is
resolved **by the module** — it owns the game↔site link table, and a core that resolved it would be
core reading a module's table by name.
**Every method returns an envelope, never a bare array, and this is the load-bearing part of the
contract.** A rejected promise, a synchronous throw, a timeout (core's budget: **10 seconds**), a
non-object, a missing `ok`, or a structurally malformed row are all read exactly as a deliberate
`{ ok: false }`. There is **no shape a failure can take that core reads as "zero teams"** — which is
the whole argument for the envelope, since a bare array has exactly one such shape, `[]`, and it is
the one a module returns while its sidecar is still connecting.
A refusal costs staleness and nothing else: core keeps the projection it has, records the reason, and
surfaces it. It never empties a roster on an answer it does not trust.
**`projectRoster` is the exception to that last paragraph, and the exception is deliberate.** It
answers *who is allowed to look at a roster*, on the request path, because the audience model and its
configuration are the module's and core does not have one (TEAMS.md §3.3). For a visibility question,
"keep what you have" is a leak: leaving the answer alone means serving the roster unprojected to
whoever asked. So this one call **fails closed**.
Core distinguishes two refusals, and a module does not have to do anything to get the right one:
- **no provider, or a provider without `projectRoster`** — there is no audience model to consult and
nothing is being withheld, so core serves the roster whole at its own public shape. This is what
makes the member genuinely optional: bare core, and a module with no rungs of its own, both render
the page core writes.
- **a provider that HAS `projectRoster` and refused, threw, timed out or answered malformed** — core
serves an empty roster and says so in the response (`projected: false`,
`projectionUnavailable: true`).
Core hands over the roster rows it holds plus a described viewer — `{ userId, role }`, or `null` for
an anonymous caller — and never the `users` row, which would make every column of that table part of
this contract. **The module answers with member KEYS, not rows.** Core keeps ownership of what a
published row looks like and re-normalises whatever comes back through its own public shape, so a
module can narrow which rows appear and cannot widen which fields do: the member key and the site
account id are withheld from every public roster whatever a module returns.
`complete: false` means "valid but partial": core applies additions and updates and performs **no**
removals. It defaults to `true` when omitted, so the ordinary authoritative case needs no ceremony.
**A malformed row fails the whole call rather than being dropped.** One unreadable member quietly
omitted from a roster is indistinguishable, downstream, from that member having left — core would
mark them departed on the strength of a broken payload. Refusing costs one interval of staleness.
**`registerSlashCommands(commands)`** — 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
@@ -949,7 +799,6 @@ 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
@@ -1186,61 +1035,6 @@ 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')
registry.declareModuleSlot(ID, 'uo.guild.forum')
// In the module's page, from the UI kit:
<Slot name="uo.guild.detail" externalId={guildId} moduleId="uo" />
<Slot name="uo.guild.forum" externalId={guildId} moduleId="uo" />
```
**A module declares one slot per PLACE, not one per page.** `module-uo` declares two on the same guild
page — core fills the first with the Team activity feed and the second with the Team forum — because a
slot holds one component and the first fill wins. Collapsing them into one would hand core the
decision about where each of its contributions sits, on a page the module owns. Two also keeps them
independent: a deployment with the forum switched off renders the feed unchanged.
**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

View File

@@ -717,10 +717,7 @@ 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, 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/public/teams/:slug/members roster, field-projected per audience rung (§3.3)
GET /api/v1/player/teams the caller's Teams (membership + grants), with the
reason for each: 'membership' | 'grant' | both
@@ -759,11 +756,6 @@ 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 |
@@ -823,37 +815,13 @@ 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 | `{ displayName, isLeader, linked }` |
| `team.member.row` | each roster row, trailing cell | `{ memberKey, userId, displayName }` |
> **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 }` |
> | `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
> 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.
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".
### 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
@@ -1058,19 +1026,6 @@ 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.
@@ -1262,9 +1217,6 @@ 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:
@@ -1936,14 +1888,6 @@ 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.
@@ -2002,28 +1946,7 @@ 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` + `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).
### Phase 1 — the roster on the wire (`servuo-plugins` + `link` + `module-uo` + `docs`)
The prerequisite for everything. Nothing in Team core can be built against counts.
@@ -2031,8 +1954,7 @@ 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.
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`.
`docs/link/PROTOCOL_2.md` + `v3.md` + `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`
@@ -2047,40 +1969,6 @@ 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;
@@ -2107,103 +1995,13 @@ guild called "Admin" cannot put an official-looking page on the site.
### Phase 3 — Team pages, roster, nav, activity feed (`website` + `module-uo`)
> **Amended 2026-08-17, while building this.** Six corrections. The first is the org lead's, and it
> changes what this phase ships; the rest were found by building the thing described below.
>
> **THERE IS NO CORE TEAM SURFACE. Teams is a contract primitive, not a page.** §3.1 puts `/teams`,
> `/teams/:slug`, `/teams/:slug/roster` and `/player/teams` in core and §3.5 registers three core nav
> entries for them. **Settled (org lead): all seven are dropped.** Core does not own the word for a
> Team — a UO shard calls them guilds, and the Rust module that comes next will call them clans — so a
> core page under a noun core invented would sit beside `module-uo`'s existing `/uo/guilds` saying the
> same thing twice, in the wrong vocabulary. Core keeps the tables, the sync, the access resolver, the
> activity feed and the whole API; the **module** builds the pages on that contract. `/admin/teams`
> stays: an operator inspecting the primitive is looking at the primitive.
>
> **So the extension slots invert, and that is a new `MODULE_API` §3.7 direction.** `team.overview`
> and `team.member.row` assumed core rendered the page. They are replaced by
> `registry.declareModuleSlot(id, name)`: 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
> phase 2 — a contract owes a bump only once it has landed on `main`, and 1.6.0 is on `edge` only.
>
> **"The module declines" needed splitting in two before it could be implemented.** §3.3 says a module
> that declines yields the public projection, fail closed. But *no module at all* and *a module whose
> rung system could not be consulted* are opposite situations: the first is withholding nothing and
> must serve the roster whole, the second must serve none of it. The refusal therefore carries
> `projects` — `false` for "there is no audience model here", `true` for "there is one and I could not
> ask it" — and only the second fails closed. Also, the module answers with member **keys**, not rows:
> returning rows would let a module widen what is published by handing back a `userId` core had
> withheld, leaving core's field guarantee resting on every module's good behaviour.
>
> **Core's five activity kinds are four here.** `core.forum.thread` has nothing to emit it until the
> forum lands in phase 4. Separately, and not in the doc at all: **the first roster for a Team emits
> no join items.** Importing a 155-member guild is one Team arriving, not 155 people joining, and
> emitting a join per member would bury every real event under the import and reach the row cap on day
> one. `roster_synced_at IS NULL` is the condition, which covers both a new Team and a newly installed
> module adopting an existing one.
>
> **§2.11's route table has no activity endpoint** though §4.3 describes a feed filtered by the
> viewer's access. Added on the org lead's decision: `GET /api/v1/public/teams/:slug/activity`, paged,
> with the visibility resolved from the session and never from a parameter. It is the first public
> route whose *content* depends on identity, which needed a new `optionalAuth` middleware —
> `attachSession` only decodes a token, so a banned or logged-out account would have kept reading the
> members-only half until its JWT expired.
`/teams`, `/teams/:slug`, `/teams/:slug/roster`, `/player/teams`; the linked/unlinked/guest surface;
`team.overview` + `team.member.row` slots; nav registration; `team_activity` +
`ctx.teams.activity.push` + core's own five activity kinds + the retention prune.
**Ships:** the whole public Team experience. Independently valuable with no forum and no Discord.
### Phase 4 — Forum 5a: access model + announcements + admin controls (`website` + `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.
### Phase 4 — Forum 5a: access model + announcements + admin controls (`website`)
`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.