docs(teams): the Teams bet, as built (Teams cutover 6/6) #169

Merged
whitlocktech merged 32 commits from edge into main 2026-08-19 09:02:16 +00:00
7 changed files with 1838 additions and 48 deletions

View File

@@ -283,11 +283,26 @@ Guilds expose only one in-game event (a member joining), so the roster is polled
| kind | fields | notes |
|------|--------|-------|
| `guild.update` | `id`, `name`, `abbr`, `members`, `online`, `alliance` (or null), `leader` (actor object or null) | A guild's roster/leader/alliance changed, or its first sight this connection. A **leave** shows up here as `members` dropping. |
| `guild.update` | `id`, `name`, `abbr`, `members`, `online`, `alliance` (or null), `leader` (actor object or null) | A guild's leader/alliance/name changed, its member count moved, or its first sight this connection. |
| `guild.remove` | `id` | The guild disbanded (leader gone) or was removed. Drop the row. |
| `guild.join` | `id`, `name`, `abbr`, `who` (actor object) | Real-time: a player joined a guild (`EventSink.JoinGuild`). |
| `guild.roster` **(4)** | `id`, `name`, `abbr`, `total`, `seq`, `more`, `members` (array of actor objects **carrying rank**) | The full member list. Emitted whenever the member set changes. **`seq` 0 supersedes whatever roster you hold for that guild; `more: false` ends it.** |
| `guild.leave` **(4)** | `id`, `name`, `who` (serial string) | Real-time: a member left. Advisory — see below. |
The `leader`/`who` **actor object** is `{serial, name, acct?, webId?, player}` — `acct`/`webId` present when the mobile has an account / a linked website user.
The `leader`/`who` **actor object** is `{serial, name, acct?, webId?, player}` — `acct`/`webId` present when the mobile has an account / a linked website user. Note `guild.leave`'s `who` is a bare **serial string**, not an actor object: the mobile has already left, so there is nothing to attribute.
**A roster member carries rank as well.** `rank` is 04 with 4 being Leader, plus `rankCliloc` (the cliloc the game names that rank with) or `rankName` when a shard uses custom rank definitions with literal names. Only the raw rank is sent: ServUO ships no text for those clilocs, so turning 1062960 into "Warlord" is the consumer's job.
Two things to get right, both of which bite:
- **Several members can hold rank 4.** `guild.update`'s single `leader` is the guild's founder-leader; it is not the set of leaders. If you need "who leads this guild", read the roster's ranks and treat `leader` as one more entry rather than the answer.
- **An absent `rank` means "not known" — never 0, and never leadership.** It has one deliberate cause: `PlayerMobile.GuildRank` reports Leader for any account at GameMaster or above whatever their real rank, so the bridge omits the rank for staff rather than publishing a claim it knows is false. Defaulting a missing rank to 0 silently demotes them; reading absence as leadership republishes exactly the lie the bridge avoided.
**On Protocol 4.** Before it, a guild's membership was a *count* and a leave surfaced only as that count dropping. `guild.roster` carries the members themselves, and `guild.leave` names who went.
`guild.leave` is **advisory**: any change to the member set re-emits the whole roster, so a consumer holding a membership table stays correct even if it ignores every leave event. Handle it when you want a "so-and-so left" feed to update without waiting for the sweep.
**Rosters can arrive in several frames.** Members per frame are capped so a large guild cannot produce an unbounded line (~69 bytes per member; the default cap is 500). Every realistic guild arrives as one frame with `seq: 0, more: false` and needs no special handling — but if you consume the raw stream, accumulate from `seq` 0 and apply on `more: false`, discarding a partial roster if a frame arrives out of order or the shard reconnects. `GET /guilds` hands you rosters already reassembled. A guild with no members emits one frame with an empty array, so an emptied roster is distinguishable from an absent one.
```json
{"kind":"guild.update","id":1042,"name":"The Silver Hand","abbr":"TSH","members":14,
@@ -296,8 +311,16 @@ The `leader`/`who` **actor object** is `{serial, name, acct?, webId?, player}`
"t":1752489280000}
{"kind":"guild.join","id":1042,"name":"The Silver Hand","abbr":"TSH",
"who":{"serial":"0x77","name":"Bran","acct":"bran","player":true},"t":1752489281000}
{"kind":"guild.roster","id":1042,"name":"The Silver Hand","abbr":"TSH",
"total":14,"seq":0,"more":false,
"members":[{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true},
{"serial":"0x77","name":"Bran","acct":"bran","player":true}],
"t":1752489282000}
{"kind":"guild.leave","id":1042,"name":"The Silver Hand","who":"0x77","t":1752489283000}
```
`acct` is genuinely optional on a member — a character can have no account at all — so do not assume it is present.
Render the current board from `GET /guilds` (§6) on connect, then keep it live with these events.
#### Town governors (Protocol 2.0)
@@ -853,7 +876,17 @@ GET /guilds
"t":1752489280000}, ... ] }
```
Every guild's latest roster snapshot at once — the live board. Served from the sidecar's projection (no shard round-trip), kept current by the `guild.*` stream (§4). Render on load, then subscribe. Each entry is exactly a `guild.update` payload; ordered by name. Survives a sidecar restart.
Every guild's latest snapshot at once — the live board. Served from the sidecar's projection (no shard round-trip), kept current by the `guild.*` stream (§4). Render on load, then subscribe. Ordered by name. Survives a sidecar restart.
Each entry is a `guild.update` payload **plus, from Protocol 4, a `roster` key** holding the member list — already reassembled, so the frame-splitting described in §4 never reaches this endpoint:
```
→ { "guilds": [ {"kind":"guild.update","id":1042, ..., "roster":[
{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true},
{"serial":"0x77","name":"Bran","acct":"bran","player":true} ]}, ... ] }
```
A guild that has had a `guild.update` but no roster yet has **no `roster` key at all** — deliberately distinct from `"roster": []`, which means the guild is genuinely empty. Do not conflate "not known" with "known to be empty".
### Governor board (Protocol 2.0)

View File

@@ -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`.

269
link/v4.md Normal file
View File

@@ -0,0 +1,269 @@
# Protocol 4 — Guild membership on the wire
**Status:** Feature-complete on `edge`. All work lands on an `edge` branch in each repo; `edge``main` is the v4 cutover.
**Date:** 2026-08-17
**Codebase:** ServUO 57.4, `<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 04, 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 (10629591062963) 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

@@ -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 `<serverId>:<teamId>` 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

View File

@@ -525,6 +525,143 @@ have been found earlier, because until then no caller had ever passed a non-null
Design of record: [`MODULE_SYSTEM.md`](MODULE_SYSTEM.md) §2.4; the loader's obligations are
[`MODULE_API.md`](MODULE_API.md) Part 4.
### The eleven Team tables — core's, populated by a module (Teams phases 25)
*Twelve rows in the table below: `content_reports` is listed here because Team forum content is its
first consumer, and it is deliberately **not** one of the eleven — it carries no `team_*` prefix, its
`target_type` is an open VARCHAR, and a wiki page or a news comment is meant to become a value in it
rather than a table of its own.*
A Team is a **core** entity that a **module** answers for. The module says what Teams exist and who is
in them, through the team provider; core stores that answer, gates it and displays it. Every table
here is core-internal — a module must never read or write one, even though a module is what fills
them — and none carries a `<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 |
| `team_notification_prefs` | per-Team notification preference (phase 6). **Opt-out for push, opt-IN for email**`muted` defaults 0 and `email_mode` defaults `'off'`, so the two sinks default opposite ways and the asymmetry lives here rather than in a condition anyone has to remember. Team scoping lives in this table and in the recipient computation, never in a stream id. `last_digest_at` is the digest's only state and the worker is its only writer |
| `team_integration_config` | where a Team's notifications go on another platform (phase 8). One row per (platform, Team) plus a **deployment-wide default** whose `team_id` is NULL — expressed with a generated `team_key AS IFNULL(team_id, 0)` in the unique key, because a NULL cannot live in a primary key and the default row is the base case of the whole override mechanism. `members_ack` is a **precondition, not a preference**: forum posts and announcements are members-only always, core cannot see a channel's permissions, so enabling one requires an attributed operator acknowledgement that the destination is restricted — and changing the channel clears it |
| `team_integrations` | a Team's provisioned resource on another platform — today its Discord **voice channel and the role that opens it** (§7.3, phase 9). Both refs on one row because they are one lifecycle: a role for a channel that no longer exists is a badge for nowhere. `state` is core's BELIEF about the platform, never the platform's answer — the reconciler writes what it just did and the next pass re-derives the truth. A Team that stops qualifying goes to `pending_removal` with `remove_after` rather than being deleted at once, so a Team hovering around the size threshold does not delete-and-recreate its channel and change its id. `synced_at` is separate from `updated_at`, which moves whenever core writes a belief including an error |
| `content_reports` | member-raised abuse reports (phase 5). **Not a `team_*` table and not named for the forum**`target_type` is a plain VARCHAR so a wiki page or a news comment becomes a value rather than a table. Team forum content is only the first consumer |
**Core had no user-facing report flow of any kind before `content_reports`.** `moderation`,
`mod_notes` and `appeals` are all either staff-initiated or Discord-sanction-shaped; nothing anywhere
let a *member* say "this is a problem". That was survivable while every piece of content on the site
came from staff, and stops being the moment a Team forum lets players write to each other. Four
properties are worth carrying:
- **Reports reach site staff and nobody else.** A Team's leaders moderate their own forum, so a
leader-visible queue would route a complaint *about* a leader back to that leader. There is one
queue, mounted at `/admin/moderation/reports` beside appeals — a staffer working a queue should have
one place to work — and no leader-facing counterpart anywhere
([`TEAMS.md`](TEAMS.md) §5.6, org lead 2026-08-18).
- **A report is not a moderation action.** Filing one changes nothing about the content; it opens a
queue item. That keeps it clear of `team_forum_moderation`, which records things that actually
happened, and stops "report" becoming a way for any participant to hide anything.
- **One OPEN report per (target, reporter)**, enforced by a unique key over a generated `open_marker`
that is `1` while open and `NULL` once closed — the same encoding as
`team_forum_grants.active_marker`, and for the same reason: only the *live* rows may collide. A
closed report frees the slot, so a member whose first report was dismissed may raise the same target
again if the behaviour recurs.
- **Every transition writes `activity_log`, `dismissed` included.** A queue where acting is audited and
declining to act is not is one where the cheapest way to make a report vanish leaves no trace.
**`teams_forum_edit_window_minutes`** (01440, default 15) bounds how long an author may edit their own
post; staff are not bound by it. It is resolved on the server **twice** — the read path stamps each
post with `canEdit`/`editableUntil` so a client knows whether to draw the control, and the write
re-derives it from `created_at` before allowing anything. The read is advice, the write is enforcement,
and the split exists because a time-bounded permission must not take its clock from the party it
bounds. It is deliberately **not** in `settings.getPublic()`: the client that needs the number is the
admin screen, and the client that needs the decision already has it per post.
**The forum's tables are guarded at the ROUTE and never at the data.** `teams_forums_enabled` off
means every forum route answers **404** — not 403, which would advertise a feature the operator
deliberately turned off — while threads, posts, grants and notification preferences are all untouched.
Re-enabling restores the forum exactly as it was. That is the same principle as the module disabled
guard ([`MODULE_API.md`](MODULE_API.md) §4.5).
**The author never writes an `<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
@@ -539,7 +676,7 @@ are authoritative, and they answer different questions:
| Artifact | Source of truth for | Generated by |
|---|---|---|
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs CORE serves.** 166 public routes + 2 on the internal listener, sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack |
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs CORE serves.** Every core URL — the public app plus the internal listener sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack |
| `server/swagger/swagger-output.json` — merged into `/api/docs` | **What each core route means.** Parameters, bodies, response codes, security. | `npm run swagger`, from `#swagger.*` annotations |
Both are **core's**. An installed module's routes are in neither: they are in that module's own
@@ -636,6 +773,7 @@ their own router level, and `/sso/:provider/link` carries `requireAuth` per rout
| GET | `/me/devices` · DELETE `…/:id` | cookie / bearer | — | list / unregister own push devices |
| GET | `/me/notifications/streams` | cookie / bearer | — | the subscribable catalog (`personal`/`requiresLinkedAccount` flags) |
| GET · PUT | `/me/notifications/subscriptions` | cookie / bearer | `{streams:[id]}` on PUT | get / replace own opted-in streams (unknown ids dropped) |
| GET · PUT | `/me/notifications/teams` | cookie / bearer | `{teams:[{teamId,muted,emailMode}]}` on PUT | get / replace own **per-Team** preferences (phase 6, [`TEAMS.md`](TEAMS.md) §6.3). One entry per Team the caller could be notified about — active membership or an active forum grant — plus any Team they already hold a preference for; server-side defaults applied. An entry naming a Team the caller has no access to is **dropped, not refused**: a Team left between loading the screen and saving it is a race, not a client bug. The array is required even when empty (`../android/PLAN.md` §11) |
**Role-agnostic self-service (`/auth/me/*`).** The canonical "me" surface for **every** authenticated
role. It reuses the exact `account.controller` handlers as `/player/account/*` and `/admin/account/*`
@@ -654,6 +792,14 @@ and relies on it: its `/player/shard/*` handlers are the identical self-scoped o
under `/admin/shard/*`, so the two are interchangeable. This is why a staff account with linked game characters gets its "My characters" and
personal notification streams on the mobile client — the group no longer 403s a non-`player` role.
`teams.router.js` joins the group in Teams phase 2, and relies on exactly that rule: a moderator is in
guilds too, and gating this group on the role would 403 them off their own Teams.
| Method | Path | Notes |
|---|---|---|
| GET | `/teams` | the caller's Teams, each carrying the **reason** it is listed: `membership` \| `grant` \| `both`. Membership and forum access are separate authority paths and the reason is what keeps them distinguishable — `both` is a real state, and a Team **hidden** from public surfaces is still listed here, because suppression is a public-surface rule and a member is not a member of the public |
| GET | `/teams/:slug/access` | the caller's own resolved access on one Team: `allowed`, `viaMembership`, `viaGrant` (kept even when membership also holds, so the grant survives as audit history) and `isLeader` with any staff override applied |
**Password reset.** Uses the same audited pattern as `user_invites`: an opaque 32-byte token
whose **sha256 hash only** is stored in `password_resets`, single-use and short-lived (~1h). It
also serves SSO-only accounts (null `password_hash`) as their "set an initial password" path. The
@@ -772,6 +918,12 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
| GET | `/wiki` | list of pages (slug + title) |
| GET | `/wiki/:slug` | single page |
| POST | `/contact` | (rate-limited) send mail via SMTP; if unconfigured, respond `{fallback:"mailto", email}` |
| GET | `/teams/by-external/:moduleId/:externalId` | one Team named the way the OWNING MODULE names it. Exists so a module's page can find core's Team without holding core's identifiers, which are core-internal. The module id is matched rather than trusted: an external id is unique only within a module |
| GET | `/teams` | active, publicly visible Teams, paged. Every payload carries `{ configured, stale, lastSyncAt }` so a page can say how recently the projection was confirmed rather than presenting a stale roster as current, plus `enabled` — whether this deployment has Teams at all |
| GET | `/teams/:slug` | one Team. An **archived** Team still resolves, read-only, and names its successor when it was renamed — an old bookmark or Discord link lands somewhere that explains itself. A **hidden** Team returns 404, indistinguishable from one that does not exist: "absent from every public surface" includes not confirming it is there. Carries `id`/`externalId`/`moduleId` — this route only, since the index has no use for them |
| GET | `/teams/:slug/members` | the roster. In-game display names only — the member key is a game-internal identifier and the user id names a site account, and **neither is published**; `linked` answers whether a character has an account behind it without saying which. **Which rows** appear is the module's audience projection (`projectRoster`), applied per caller: a module that has a rung system and cannot be asked yields an EMPTY roster, not an unprojected one, flagged as `projectionUnavailable`. A session is optional and may widen the result |
| GET | `/teams/:slug/activity` | the Team's activity feed, paged, newest first. `public` items to anyone who can see the Team; `members` items additionally to members and forum-granted users, resolved from the session and never from a parameter. `scope` reports which the caller got, so a client can say "some entries are hidden" instead of presenting a filtered feed as the whole one. A hidden Team's feed does not answer the public but does answer its members |
| POST · GET | `/teams/unsubscribe/:token` | one-click unsubscribe from a Team's notification emails (phase 6, [`TEAMS.md`](TEAMS.md) §6.4). **The only write in this tier and the only route with no `siteMode`** — the reader is in their mail client, not signed in, and the mail went out before the site went into maintenance. The token is a stateless HMAC whose whole capability is "set `muted` for one (user, Team) pair". POST acts and **always answers 200**, valid token or forged: distinguishing them would be an oracle for which (user, Team) pairs exist. GET acts on nothing and redirects to the site's own `/unsubscribe/:token` page, because a mail client's link scanner must not be able to mute Teams |
| — | `/shard/*` · `/atlas/*` | **Served by `module-uo`, not by core** (25 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md); absent entirely when the module is not installed, which is a 404 and not an error. |
Public content GETs pass through the **siteMode** gate (§5).
@@ -842,6 +994,16 @@ file a route sits in — that is the property the route manifest freezes.
| POST | `/modules/:id/disable` | the one module action that takes effect immediately — dispatches that module's `onShutdown`, then its routes, nav and client chunk answer 404. A real kill switch, not a visibility flag |
| POST | `/modules/:id/purge` | run a **disabled** module's `purge.sql`, dropping its tables and data. `409` while it is still running; `400` if it ships no `purge.sql` |
| DELETE | `/modules/:id[?purge=true]` | uninstall: stop, then (with `purge=true`) drop its data, then delete its directory. Non-destructive by default — the row stays `disabled` and the data is left for a reinstall to pick up. The purge option lives here because it cannot live after: `purge.sql` is a file inside the directory being deleted |
| GET | `/teams` | every Team incl. hidden ones, plus the module's **sync state verbatim** — last attempt, last success, consecutive failures, the last error and any held empty answer. Verbatim because an operator debugging a stale projection needs what the provider actually said |
| GET | `/teams/:id` | one Team with its roster (departed members included), its grant ledger and its pending requests. Each roster row carries the **resolved** leadership and `isLeaderSynced` — what the game actually said — so an override reads as a decision rather than as fact |
| POST | `/teams/resync` | run a reconciliation now, **awaited**, so the response carries the outcome including the provider's own refusal reason. The four refusal gates still apply: a manual resync cannot make core act on an answer it does not trust |
| POST | `/teams/:id/archive` · `/teams/:id/hide` | staff archive / hide. **Not gated** — both withdraw a Team from public surfaces rather than publishing anything, and withdrawing has to be possible at once, by whoever is on duty |
| POST | `/teams/:id/unhide` · `/teams/:id/display-name` | the two **gated** actions (§2.9): an admin applies at once, a **moderator** files a pending request and nothing changes publicly. The caller does not choose — the server decides from the role it re-validates on the request |
| GET | `/teams/:id/grants` | the full forum-grant ledger, revoked rows included. Read-only in this phase; the grant flow lands with the forums |
| POST | `/teams/:id/leader-override` · DELETE `…/:memberKey` | set or clear a staff leadership decision, applied **on top of** the synced value at read time. Not gated: it publishes no game-sourced string |
| GET | `/moderation/reports` · POST `…/:id/handle` | the member-raised content-report queue (phase 5, [`TEAMS.md`](TEAMS.md) §5.6) and the staff decision on one. Mounted under **moderation**, not under Teams: a staffer working a queue should have one place to work, and `target_type` is open-ended so the next reportable thing arrives as a row rather than as a screen. Each row carries its target already resolved — a post's excerpt and author, a thread's title, or an upload's uploader, byte size and **sniffed** mimetype — in three batched reads, never one per row. A target hard-deleted since reporting comes back `null` and the row still lists. **There is no leader-facing counterpart to either route**, deliberately |
| GET | `/teams/review` | the reserved-name review queue — Teams auto-hidden because their name matched, each showing which term |
| GET | `/teams/requests` · POST `…/:id/decide` | the approval queue, and the decision. **Admin only** to decide, checked live rather than from a token claim; a request already decided returns `409`, so two admins deciding at once cannot double-apply |
| — | `/shard/*` · `/uo-link/*` | **Served by `module-uo`, not by core** (33 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md) |
Every admin write logs to `activity_log`.

View File

@@ -26,13 +26,57 @@ here extends the contract first, in this file, before the module is written agai
Core exports a single integer-major semver string from `server/src/modules/version.js`:
```js
const MODULE_API_VERSION = '1.5.0'
const MODULE_API_VERSION = '1.6.0'
```
The client half carries the same number (`client/src/modules/version.js`) and a test asserts the two
agree. Duplicated rather than fetched because the value has to be on `window.__rg` before the first
module chunk evaluates, which is earlier than any network round trip could answer.
**1.6.0 — Teams, the whole surface.** Nine additions, no removals and no changed signature, so minor;
`module-uo`'s `coreApi: "^1.3.0"` still resolves. `api.registerTeamProvider(...)` and
`ctx.teams.publish` / `ctx.teams.reconcile` (§2.3, §2.4a) · `ctx.teams.activity.push` ·
the provider's optional `projectRoster` and `pageUrlTemplate` · `api.registerSlashCommands(...)` ·
`registry.declareModuleSlot(...)` with `Slot` in the UI kit — the ninth member of it.
> **Amended 2026-08-19 (phase 11), on the org lead's decision.** `declareModuleSlot` takes an
> optional `{ core }` naming which of core's contributions belongs in the declared place, and core
> offers contributions instead of naming slots (`CORE_CONTRIBUTIONS`, §3.7a). In 1.6.0 in place, by
> the same rule as the two amendments below: 1.6.0 has only ever been on `edge`. It is a **correction
> and not an addition** — as first written, core filled three literal `uo.guild.*` names, so the
> inverted direction worked for exactly one module and silently did nothing for any other, which the
> integration kit found while trying to teach it to an audience outside this org.
>
> **Amended 2026-08-17 (phase 3), on the org lead's decision.** Two changes.
>
> **Amended again 2026-08-18 (phase 6), on the org lead's decision.** A **ninth** member,
> `pageUrlTemplate` on the team provider, joins 1.6.0 in place — same rule as the eighth below, and
> 1.6.0 is still `edge`-only. It is the one thing phase 6 found that the design of record had not
> anticipated: after phase 3 deleted core's Team pages, nothing in this contract could tell core where
> a Team page actually is, so a notification email could name a Team and not link to it. See
> `registerTeamProvider` below.
>
> **The eighth member joins 1.6.0 in place rather than getting a 1.7.0.** The rule is the one Protocol
> 4 was given in phase 2 — *a contract owes a bump only once it has landed on `main`* — and 1.6.0 has
> only ever been on `edge`. `ctx.teams.activity.push` is live now rather than throwing.
>
> **The client slots `team.overview` and `team.member.row` are replaced by the INVERTED direction.**
> Both assumed core rendered a Team page. It does not: **Teams is a contract primitive, not a
> surface** — core owns the tables, the sync, the access rules and the activity feed, and does not own
> the word for one, so the module that owns the vocabulary owns the page. In their place,
> `registry.declareModuleSlot(id, name, { core })` lets a MODULE declare a place on its own page for
> CORE to fill, and `Slot` joins the UI kit so the module can render it. See §3.7a.
**Every member of 1.6.0 is live as of phase 7.** `api.registerSlashCommands` was the last one still
throwing, and it now registers — the staged rollout the paragraphs above describe is finished. A
module may call any member of this version and get the behaviour documented below.
**`registerTeamProvider` is the first registration where core calls the MODULE and waits.** Every
existing one is either the module claiming a mount or core notifying it; the closest precedent is
`registerAnnounceLeg`'s `dispatch`, which is why this is modelled on it. That direction is what makes
the envelope, the 10-second budget and the refusal semantics contract rather than implementation —
they are how a module says "I cannot answer" without core hearing "there is nothing".
**1.5.0 — Phase 5 slice 3, the page shell.** `PublicLayout` takes an optional **`shell`** prop —
`'narrow'`, `'mid'` or `'wide'` — that renders the page-body wrapper core's own pages have always
written by hand (§3.4). Found by the acceptance run in
@@ -181,6 +225,51 @@ module-uo does not need is on the list.
| `ctx.middleware.rateLimit` | `(options) => middleware` | `middleware/rateLimit` | the market search (1.1.0) |
| `ctx.middleware.accountChangeLimiter` | middleware | `middleware/rateLimit` | `player/shard.router` (1.1.0) |
| `ctx.moduleId` | the id from `module.json` | loader | log tags, table checks |
| `ctx.teams.publish` | `(event) => Promise<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:
@@ -217,6 +306,8 @@ api.registerExtension(slot, router)
api.registerNotificationStreams(streams)
api.registerAnnounceLeg({ leg, label, dispatch, classify })
api.registerPostHook({ onSaved, onDeleted })
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders }) // 1.6.0
api.registerSlashCommands([{ name, description, options, access, handler }]) // 1.6.0
api.onBoot(async (ctx) => {})
api.onShutdown(async () => {})
```
@@ -316,6 +407,166 @@ meant a `dispatch` that must not be retried and a `classify` that means nothing.
Before it existed, core's post controller required `utils/newsGump` directly — core's publish path
naming a UO file, and the last thing binding core to the module.
**`registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })`** — added in API 1.6.0. The
module becomes the authoritative source of Teams for this deployment. Two further members,
`projectRoster` and `pageUrlTemplate`, are optional and documented below.
**One provider per deployment.** Unlike every other registry, this holds a single value: Teams have
one authoritative source by construction, and two modules answering "what Teams exist" would produce
two disjoint sets under one table with no rule for merging them. A second registration is a
collision, reported against the module that holds it. Three methods are required — a provider
that could list Teams but not their members would leave core holding Teams it can never populate,
which is not the same as a call that fails. The fourth is optional; see below.
```js
getTeams() // () => Promise<{ ok, complete?, teams }>
getTeamMembers(externalId) // (string) => Promise<{ ok, complete?, members }>
getTeamLeaders(externalId) // (string) => Promise<{ ok, leaders }> // leaders = [memberKey]
projectRoster(externalId, members, viewer) // OPTIONAL (1.6.0, phase 3)
// => Promise<{ ok, members }> // members = [memberKey]
pageUrlTemplate // OPTIONAL (1.6.0, phase 6) — DATA, not a method
// e.g. '/uo/guilds/{externalId}'
// authoritative
{ ok: true, complete: true, teams: [ { externalId, name, abbr?, meta? } ] }
// the module knows it cannot answer — sidecar down, cache cold, boot not finished
{ ok: false, reason: 'sidecar unreachable' }
```
A member is `{ memberKey, displayName?, rankLabel?, leader?, online?, userId? }`. `userId` is
resolved **by the module** — it owns the game↔site link table, and a core that resolved it would be
core reading a module's table by name.
**Every method returns an envelope, never a bare array, and this is the load-bearing part of the
contract.** A rejected promise, a synchronous throw, a timeout (core's budget: **10 seconds**), a
non-object, a missing `ok`, or a structurally malformed row are all read exactly as a deliberate
`{ ok: false }`. There is **no shape a failure can take that core reads as "zero teams"** — which is
the whole argument for the envelope, since a bare array has exactly one such shape, `[]`, and it is
the one a module returns while its sidecar is still connecting.
A refusal costs staleness and nothing else: core keeps the projection it has, records the reason, and
surfaces it. It never empties a roster on an answer it does not trust.
**`projectRoster` is the exception to that last paragraph, and the exception is deliberate.** It
answers *who is allowed to look at a roster*, on the request path, because the audience model and its
configuration are the module's and core does not have one (TEAMS.md §3.3). For a visibility question,
"keep what you have" is a leak: leaving the answer alone means serving the roster unprojected to
whoever asked. So this one call **fails closed**.
Core distinguishes two refusals, and a module does not have to do anything to get the right one:
- **no provider, or a provider without `projectRoster`** — there is no audience model to consult and
nothing is being withheld, so core serves the roster whole at its own public shape. This is what
makes the member genuinely optional: bare core, and a module with no rungs of its own, both render
the page core writes.
- **a provider that HAS `projectRoster` and refused, threw, timed out or answered malformed** — core
serves an empty roster and says so in the response (`projected: false`,
`projectionUnavailable: true`).
**`pageUrlTemplate` is the fifth member, it is data rather than a method, and it exists because core
cannot link to a Team page.** Teams are a contract primitive with **no core surface** (TEAMS.md
Part 3): core owns the tables, the sync and the access rules, and the module that owns the vocabulary
owns the page. That is settled and right, and it leaves core unable to write the link a notification
email needs — an email about a forum reply that cannot take you to the thread is most of the way to
useless. So the module that owns the page says where it is.
```js
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders,
pageUrlTemplate: '/uo/guilds/{externalId}' })
```
Core substitutes `{externalId}` and `{slug}` and does nothing else with it. **A relative path only**
a template naming its own host is refused at registration, since there is no reason for a module to
redirect the site's outbound mail, and a protocol-relative `//host/x` is refused with it. Omitting the
member costs the deployment clickable links in Team notification email and nothing else.
**Data rather than a callback, deliberately.** A function here would put a module hook on the mail
path — one more thing that can hang or throw between a forum reply and the mail about it — to produce
a string that never varies.
Core hands over the roster rows it holds plus a described viewer — `{ userId, role }`, or `null` for
an anonymous caller — and never the `users` row, which would make every column of that table part of
this contract. **The module answers with member KEYS, not rows.** Core keeps ownership of what a
published row looks like and re-normalises whatever comes back through its own public shape, so a
module can narrow which rows appear and cannot widen which fields do: the member key and the site
account id are withheld from every public roster whatever a module returns.
`complete: false` means "valid but partial": core applies additions and updates and performs **no**
removals. It defaults to `true` when omitted, so the ordinary authoritative case needs no ceremony.
**A malformed row fails the whole call rather than being dropped.** One unreadable member quietly
omitted from a roster is indistinguishable, downstream, from that member having left — core would
mark them departed on the strength of a broken payload. Refusing costs one interval of staleness.
**`registerSlashCommands(commands)`** — chat-platform commands whose definition AND handler both
belong to the module, live since phase 7 (TEAMS.md §7.1).
```js
api.registerSlashCommands([{
name: 'guild', // lowercase, 1-32, no dots
description: 'Show a guild on this shard', // 1-100 characters
options: [ // the restricted schema, below
{ name: 'name', type: 'string', description: 'Guild name or abbreviation', required: false },
],
access: 'everyone', // 'everyone' | 'linked' | 'staff'
async handler({ command, options, actor }) {
return { title, text, fields, url, ephemeral, notice } // every field optional
},
}])
```
**The handler runs in the WEBSITE process, never in the bot.** The bot container has no `modules`
volume and cannot load a line of module code, so it pulls the definitions over an internal API and
owns every platform-specific concern — deferral, the acknowledgement deadline, ephemerality,
follow-ups, embeds. A module that wanted to call `interaction.deferReply()` would be a module holding
a Discord handle, and this split is the reason a second platform could implement the same contract.
**`actor` is resolved by core before the handler is entered**, and is the whole of what a handler
learns about the caller:
| field | |
| --- | --- |
| `platform` | `'discord'` today; the only platform-shaped thing a handler ever sees |
| `platformUserId` | the caller's id on that platform |
| `guildId` | the platform community the command was run in, or `null` |
| `userId` | the site account, or `null` when the platform identity is not linked |
| `role` | that account's role — a module with audience rungs needs more than a boolean |
| `isLinked` | whether `userId` resolved |
| `isStaff` | `admin` or `moderator`, the same two roles every other Team surface means |
A **banned or disabled** account resolves as unlinked, so a chat surface is never the one place a ban
does not reach. The Discord provider is found by `auth_providers.kind`, not by its id — the id is an
operator-chosen slug.
**`access` is enforced twice, and only the server half is the gate.** The bot sets a platform-side
permission default from it where the platform can express one; core re-checks it in the dispatcher on
every call. `'linked'` has no Discord equivalent at all — there is no "has a website account"
predicate — so it is simply not advertised, which is exactly why the client half cannot be the
boundary.
**The option schema is deliberately small: `string | integer | boolean | user`,** each with
`required` and optional `choices` (`string` and `integer` only). No subcommand groups, autocomplete,
attachments, modals or component interactions — those are the features whose semantics do not survive
a second platform. A command needing them is a bot-side command, written in the bot.
**A definition the platform would reject fails at `register()`**, not at the next connection: the bot
registers the whole set in one call, so one bad option type would cost every command, the bot's own
included. Names are validated (lowercase, 1-32, no dots), as are description lengths, the option
types, and the ordering rule that a required option may not follow an optional one.
**Commands are NOT namespaced under the module id**, unlike stream ids and announce legs — Discord's
name grammar has no `.` in it. Collisions are first-come with the holder named, and a name that
collides with one of the bot's own built-ins is dropped by the bot, which is the one collision core
cannot see.
**A handler's failure is its own.** A throw, or a handler still running after core's timeout, becomes
a refusal the platform renders; the handler never runs in the bot process, so it cannot cost anything
but its own reply. `ok` is core's verdict and sits outside the envelope, so a handler cannot forge it.
**A disabled module's commands stop answering immediately.** Registration has no removal path — a
claim is made once, at load — so liveness is asked at both the pull and the dispatch: an operator who
switches a module off does not leave a live handler behind it.
**`onBoot(fn)` / `onShutdown(fn)`** — §2.5.
### 2.5 Lifecycle
@@ -799,6 +1050,7 @@ The kit is **curated and closed**, not a re-export of `components/`:
| `Loading`, `ErrorState`, `EmptyState` | `components/PageState.jsx` | the three states every data page has |
| `useAsync` | `lib/useAsync.js` | the fetch/loading/error hook every data page uses |
| `useAuth`, `useSite` | `contexts/*` | read-only access to session and site settings |
| `Slot` *(1.6.0)* | `modules/Slot.jsx` | renders a place this module declared for core to fill (§3.7a) |
Everything else — tables, chips, tabs, the tiptap editor, dnd-kit — a module bundles itself.
Adding to the kit is a **minor** `MODULE_API_VERSION` bump; *changing* a kit component's existing
@@ -806,7 +1058,7 @@ props is a **major** one, because that breaks a call already written. Adding an
minor (§1.1). That is a real constraint on core and it is the price of the boundary being worth
anything.
The kit is those **eight exports**five rows, because `PageState` contributes three. An earlier
The kit is those **nine exports**six rows, because `PageState` contributes three. An earlier
draft of this table listed a ninth, `AdminPage`, and core has no such component — admin views are
plain markup inside `AdminLayout`. It was struck in Phase 2 PR 7 rather than satisfied by inventing a
core component with no consumer until Phase 3; adding it later costs a minor bump, which is the case
@@ -1035,6 +1287,93 @@ intends to fill permanently, and a slot core *does* intend to fill is a slot tha
members of the `registry` object handed to modules, for the same reason `featureProviders()` is not:
declaring is core's, and so is reading back who filled what.
### 3.7a Inverted slots — CORE content inside a MODULE's page *(1.6.0)*
The mirror of §3.7, added for Teams. §3.7 assumes core owns the page and a module contributes to it,
which is right for the footer and the admin user detail. This is the other shape, and the case that
forced it is worth stating because it will recur:
> **A core primitive whose vocabulary core does not own.** Teams are core's — core owns the tables,
> the reconciler, the access resolver and the activity feed — but core has no word for one. A UO shard
> calls them guilds; the next game will call them clans. A core-rendered `/teams` page would publish a
> noun core invented, beside the module's own page for the same thing. So the **page** is the
> module's, and the parts core cannot hand over — here the activity feed, whose public/members split
> only core can resolve — are contributed to it.
```js
// In the module's entry chunk, at registration time. The second argument names
// which of CORE's contributions belongs in that place:
registry.declareModuleSlot(ID, 'uo.guild.header', { core: 'team.notify' })
registry.declareModuleSlot(ID, 'uo.guild.detail', { core: 'team.activity' })
registry.declareModuleSlot(ID, 'uo.guild.forum', { core: 'team.forum' })
// In the module's page, from the UI kit:
<Slot name="uo.guild.header" externalId={guildId} moduleId="uo" />
<Slot name="uo.guild.detail" externalId={guildId} moduleId="uo" />
<Slot name="uo.guild.forum" externalId={guildId} moduleId="uo" />
```
**Core offers a CONTRIBUTION; it never names a slot.** This is the part a second game depends on, and
the first cut of 1.6.0 had it the other way round — core filled the three literal names above, which
worked for `module-uo` and silently did nothing for anybody else: a module declaring `clan.detail`
under its own id got an empty page and no error, because "a fill for a slot nobody declared is not an
error" is exactly the rule that makes an unknown name invisible. It also put a module identifier
inside core, in three string literals `scripts/checkModuleIdentifiers.js` masks by construction and
could never have caught (§5.2). Corrected inside 1.6.0, before it reached `main`.
So the module says WHERE, in its own vocabulary, and WHICH of core's contributions goes there:
| Contribution *(1.6.0)* | What core puts in the slot | Why it is core's |
| --- | --- | --- |
| `team.activity` | the Team activity feed | only core can resolve the public/members split on it |
| `team.forum` | the Team forum panel | membership and manual grants are core's rules |
| `team.notify` | the per-Team notification control | core resolves whether the viewer is in the Team |
`options.core` is **optional** — a module may declare a place it fills itself, or one it is keeping
empty for now. Asking for a contribution core does not offer **throws at the declaration**, and that
asymmetry with an unfilled slot is deliberate: core's catalogue is fixed at build time and the
module's `coreApi` range has already been checked, so an unknown contribution is always a typo or a
version skew, and the alternative failure is a page that renders empty forever with nothing logged.
**Adding a contribution is a minor bump**; removing one is major.
More than one slot may ask for the same contribution and each gets it. Core has no reason to care how
many places a module wants its feed in, and refusing the second would be core making a layout decision
on a page it does not own.
**A module declares one slot per PLACE, not one per page.** `module-uo` declares **three** on the same
guild page — core fills them with the Team notification control, the activity feed and the Team forum
— because a slot holds one component and the first fill wins. Collapsing them would hand core the
decision about where each of its contributions sits, on a page the module owns, and the module does
use that freedom: the notification control goes **above** the roster because muting is an action *on*
the page, and the other two go below it because they are content *in* it. Separate slots also keep
them independent: a deployment with the forum switched off renders the other two unchanged.
**The name must be namespaced under the declaring module's id**, and that is enforced rather than
conventional: it is the only thing keeping two modules from claiming one name, and it makes the owner
readable at the fill site.
**Core fills these at MOUNT, not eagerly, and the ordering is why the call exists at all.** Core's
bundle evaluates before every module chunk (§3.1), so at the moment core would like to fill one of
these the slot does not exist. Core registers its intent (`offerCoreFill`, core-only) and
`applyCoreFills()` runs once, from `main.jsx`, after every chunk has evaluated and before the first
render.
**A contribution nothing asks for is a no-op, never an error.** No game module is installed, which is
the ordinary case on any deployment — the exact mirror of an unfilled slot rendering nothing. Note the asymmetry with §3.7, where an unknown slot throws: there, an unknown
name is always a typo or a version skew, because core declares before any module can name one.
**First fill still wins**, so a module that fills its own declared slot keeps it and core's fill is
skipped. That is deliberate: the module owns the page.
**`Slot` is the ninth member of the UI kit** (§3.4) for this. A module could not render one of these
otherwise, and reimplementing it would mean a second error boundary with different behaviour — which
matters more here than anywhere else in the kit, because the thing being contained is *core's* content
failing inside the *module's* page.
`declareModuleSlot` is on the `registry` object handed to modules. `offerCoreFill`, `applyCoreFills`
and `CORE_CONTRIBUTIONS` are not: offering into one of these is core's, exactly as declaring a §3.7
slot is.
---
## Part 4 — The loader's obligations

File diff suppressed because it is too large Load Diff