Compare commits
1 Commits
e68affbcf5
...
docs/teams
| Author | SHA1 | Date | |
|---|---|---|---|
| ceab67f285 |
@@ -283,19 +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) | 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.
|
||||
|
||||
**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,
|
||||
@@ -304,16 +296,8 @@ 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)
|
||||
@@ -869,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)
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
232
link/v4.md
232
link/v4.md
@@ -1,232 +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:
|
||||
|
||||
```jsonc
|
||||
{"serial":"0x1F5","name":"Seed000A","acct":"seed_000","webId":"42","player":true}
|
||||
```
|
||||
|
||||
`acct` is **genuinely optional**: a `PlayerMobile` can have no `Account` at all, and the local test
|
||||
world contains such mobiles. Consumers must not assume it is present.
|
||||
|
||||
These identity fields are emitted unconditionally, by design — the sidecar is a forwarder, and
|
||||
deciding who may see them is the website's job. See §4.
|
||||
|
||||
---
|
||||
|
||||
## 3. The sidecar side
|
||||
|
||||
`guild.roster` writes a **`members` column on the `guilds` board**, not a field inside the existing
|
||||
`json` column. That column holds the verbatim `guild.update` line, and a roster written into it would
|
||||
clobber the snapshot — name, abbreviation, leader, online count — that `guild.update` owns. Two
|
||||
writers across two columns of one row keeps both as plain upserts: neither reads the other's value
|
||||
first, so there is no read-modify-write and no ordering requirement between the two kinds. Either can
|
||||
arrive first.
|
||||
|
||||
`GET /guilds` folds the roster back in as `roster` at read time. A guild that has had a
|
||||
`guild.update` but no `guild.roster` yet simply has **no `roster` key** — the honest representation
|
||||
of "not known", and deliberately distinct from a guild whose roster is genuinely empty.
|
||||
|
||||
`guild.leave` gets **no board projection at all**. The sidecar persists and broadcasts it like any
|
||||
event and leaves the board alone; the roster self-corrects on the next `guild.roster`, which the
|
||||
shard re-emits whenever the member set changes. Keeping the delta out of the projection is what keeps
|
||||
the sidecar a forwarder rather than a thing that maintains state.
|
||||
|
||||
### 3.1 Split rosters are reassembled in memory
|
||||
|
||||
A roster split across frames is reassembled **before** it is stored, and written once on the frame
|
||||
that closes it. Appending to the column per frame was rejected twice over: it would make the write a
|
||||
read-modify-write — the exact thing the two-column split exists to avoid — and it would publish a
|
||||
torn roster, since a reader hitting `GET /guilds` between frames would see a partial member list
|
||||
presented as the whole truth.
|
||||
|
||||
This is transport-level reassembly, the same category of work as turning bytes into a line, and it
|
||||
holds nothing once a roster is complete. The ordinary single-frame case never enters the buffer at
|
||||
all. Around it: a fresh `seq: 0` supersedes an abandoned partial, an out-of-order frame discards the
|
||||
partial rather than storing one with an undetectable hole, a continuation with no start is ignored, a
|
||||
`server.hello` drops every partial (a reconnected shard restarts each roster at 0), and accumulation
|
||||
is bounded so a shard that never sends a closing frame cannot grow the buffer without limit.
|
||||
|
||||
### 3.2 This is the first bump that needed a store migration
|
||||
|
||||
`store.rs`'s `SCHEMA` is `CREATE TABLE IF NOT EXISTS`, which can add a table but **cannot add a
|
||||
column to a table that already exists**. Every schema change up to and including Protocol 3.0 added
|
||||
whole tables, so this never mattered and `ALTER TABLE` appeared nowhere in the repo's history.
|
||||
`guilds.members` is the first column added to an existing table: without a mechanism it would simply
|
||||
never reach an installed sidecar, and every roster write would fail.
|
||||
|
||||
The counter is SQLite's own **`PRAGMA user_version`** — an integer in the database header, so it
|
||||
costs no table and cannot drift from the file it describes. Each step runs in a transaction together
|
||||
with the bump recording it, so a step lands completely or not at all and an interrupted run resumes
|
||||
in the right place.
|
||||
|
||||
- **A failure aborts startup**, which was already the behaviour. A half-migrated store answers the
|
||||
website with confusing partial data, which is worse than being plainly absent — and the shard dials
|
||||
*out*, so a sidecar that refuses to start never stalls the game.
|
||||
- **A database written by a newer sidecar warns and continues.** Every step is additive, so a newer
|
||||
schema has only columns an older reader ignores; refusing to start would turn rolling the binary
|
||||
back — a recovery path — into a dead end.
|
||||
|
||||
`sqlx::migrate!` was considered and rejected: it checksums each migration file, so editing an
|
||||
already-released migration hard-fails startup, which is a poor trade for a schema of eleven
|
||||
JSON-blob tables.
|
||||
|
||||
---
|
||||
|
||||
## 4. Visibility
|
||||
|
||||
Both kinds map to the existing **`guilds`** feature in the website's visibility framework
|
||||
([`v3.md` §3](v3.md)). That mapping is required, not cosmetic: rule 2 fails an *unmapped* kind closed
|
||||
to admin-only, which would have quietly kept rosters off the public Guilds page forever.
|
||||
|
||||
Mapping them is safe because of how the framework strips fields. A roster is the first frame carrying
|
||||
locked fields inside an **array** of actors rather than a single nested actor — but the projection
|
||||
walker already recurses into arrays and matches `acct`/`webId` **by suffix, on meaning rather than
|
||||
spelling**. So a member's account name is stripped below `admin` by exactly the rule that already
|
||||
strips `guild.leader.acct`.
|
||||
|
||||
The website stores `acct`/`web_id` per member, because that is what lets a linked member be matched to
|
||||
a site user at all. It never projects them below `admin`. The difference this rule makes is a public
|
||||
page listing character names versus one publishing 150 account names, so it carries a test of its own.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-repo obligations
|
||||
|
||||
| Repo | Change |
|
||||
|---|---|
|
||||
| `servuo-plugins` | member-set diff, `guild.roster` + `guild.leave`, `BridgeJson.Actors`, two `Bridge.cfg` keys, **`overlay.toml` protocol → 4** |
|
||||
| `link` | `PROTOCOL_VERSION` → 4, `members` column + `user_version` migration, roster reassembly, `GET /guilds` projection |
|
||||
| `module-uo` | `shard_guild_members`, `guild.roster`/`guild.leave` ingest, the kind→feature map entries |
|
||||
| `installer` | `backup.rs`'s stated reason for skipping the sidecar DB (§3.2 falsifies it) — docs only |
|
||||
| `docs` | this file, `PROTOCOL_2.md` §10.1, `INTEGRATION.md` |
|
||||
|
||||
**`overlay.toml` must move in the same PR as the emitters.** CI folds it into the release manifest and
|
||||
the installer refuses to pair an overlay and a sidecar whose protocol numbers disagree, so a bump
|
||||
landing separately would silently fail to compose into a bundle.
|
||||
|
||||
---
|
||||
|
||||
## 6. Verification
|
||||
|
||||
`servuo-plugins` has no CI build — the plugin compiles only inside ServUO — so "it compiled" is not
|
||||
evidence and neither is a clean boot. What was actually run:
|
||||
|
||||
1. **A throwaway one-guild spike first** (TEAMS.md Phase 0), against the real Rust sidecar rather than
|
||||
a stub, to retire the unknowns before committing to a four-repo bump. It proved the two-column
|
||||
board shape, measured the line, and surfaced §3.2 — the missing migration mechanism — which no
|
||||
amount of reading would have.
|
||||
2. **A live end-to-end run**: 155 members seeded from real `PlayerMobile`s on the local ServUO tree,
|
||||
the cap forced down to 50 so the split path actually fired, producing 50/50/50/5 across four
|
||||
frames; two members then removed on a timer, producing exactly two `guild.leave` frames with the
|
||||
correct serials and a re-emitted roster at `total: 153`.
|
||||
3. **A restart test with the shard stopped first**, so its reconnect could not re-emit and fake
|
||||
persistence. The board's row came back byte-identical.
|
||||
|
||||
Step 2 is what caught the reassembly bug in §3.1: every unit test passed through it, because they all
|
||||
exercised a single-frame roster. The case does not arise until a guild exceeds the cap.
|
||||
|
||||
Still outstanding for the cutover: the five-rung shard visibility walk against a live shard, confirming
|
||||
`acct`/`webId` never reach a caller below their rung.
|
||||
@@ -525,55 +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 six Team tables — core's, populated by a module (Teams phase 2)
|
||||
|
||||
A Team is a **core** entity that a **module** answers for. The module says what Teams exist and who is
|
||||
in them, through the team provider; core stores that answer, gates it and displays it. Every table
|
||||
here is core-internal — a module must never read or write one, even though a module is what fills
|
||||
them — and none carries a `<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 this phase so the access resolver is written once; the grant flow lands with the forums |
|
||||
| `team_moderation_requests` | the approval queue for the three actions that publish untrusted game-sourced strings |
|
||||
|
||||
**A rename is an archive plus a create**, never an edit. Core's identity is (`module_id`,
|
||||
`external_id`, `name`) taken together: a known id under a new name archives the old row
|
||||
(`archived_reason='renamed'`, `succeeded_by` pointing at the successor) and creates a new one, so the
|
||||
old Team keeps its activity, its grants and its forum as a read-only record and its old slug still
|
||||
resolves. Whether two names are "really" the same guild is the module's judgement, expressed in
|
||||
whether it reuses the external id.
|
||||
|
||||
**Uniqueness among ACTIVE rows only** is expressed with STORED generated columns, because MariaDB has
|
||||
no partial index and NULL never collides in a UNIQUE key: `active_key` and `active_slug` on `teams`
|
||||
are NULL for archived rows, so any number of them may share an `external_id`.
|
||||
|
||||
**`team_forum_grants` departs from the obvious encoding, and the reason matters.** Its marker is
|
||||
`active_marker AS (IF(revoked_at IS NULL, 1, NULL))` with `user_id` in the KEY rather than the
|
||||
generated column, because MariaDB refuses `ON DELETE SET NULL` on a foreign key whose column is a base
|
||||
column of a stored generated column (error 1901) — and `SET NULL` is required here: `CASCADE` would
|
||||
delete the audit trail of who granted whom, which is exactly what an audit exists to survive. The
|
||||
semantics are identical: at most one active grant per (team, user), unlimited revoked rows.
|
||||
|
||||
**Account deletion is settled per column, not inherited from the defaults.** Content and audit
|
||||
survive; preferences and links do not. `team_members.user_id` and every actor column on the grant
|
||||
ledger and the approval queue go `SET NULL` with a **username snapshot** alongside, so the record
|
||||
stays readable after the account is gone. Only `team_id` cascades.
|
||||
|
||||
**Two columns exist that the design of record did not contemplate**, both on `teams` and both
|
||||
serving the refusal gates below: `roster_synced_at`, because `team_sync_state` holds one row per
|
||||
*module* and a single Team's roster can be left untouched while the others sync — without a per-Team
|
||||
stamp that Team's page would report the module's last success as its own; and `members_empty_since`,
|
||||
the per-Team twin of `pending_empty_since`.
|
||||
|
||||
Design of record: [`TEAMS.md`](TEAMS.md) Part 2. The contract surface a module sees is
|
||||
[`MODULE_API.md`](MODULE_API.md); everything in these tables is explicitly *not* it.
|
||||
|
||||
---
|
||||
|
||||
## 4. API contract
|
||||
@@ -703,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
|
||||
@@ -829,9 +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` | active, publicly visible Teams, paged. Every payload carries `{ configured, stale, lastSyncAt }` so a page can say how recently the projection was confirmed rather than presenting a stale roster as current |
|
||||
| GET | `/teams/:slug` | one Team. An **archived** Team still resolves, read-only, and names its successor when it was renamed — an old bookmark or Discord link lands somewhere that explains itself. A **hidden** Team returns 404, indistinguishable from one that does not exist: "absent from every public surface" includes not confirming it is there |
|
||||
| GET | `/teams/:slug/members` | the roster. In-game display names only — the member key is a game-internal identifier and the user id names a site account, and **neither is published**; `linked` answers whether a character has an account behind it without saying which. The module's per-audience field projection lands with the Team pages |
|
||||
| — | `/shard/*` · `/atlas/*` | **Served by `module-uo`, not by core** (25 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md); absent entirely when the module is not installed, which is a 404 and not an error. |
|
||||
|
||||
Public content GETs pass through the **siteMode** gate (§5).
|
||||
@@ -902,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`.
|
||||
|
||||
@@ -26,31 +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.** Seven additions, no removals and no changed signature, so minor;
|
||||
`module-uo`'s `coreApi: "^1.3.0"` still resolves. `api.registerTeamProvider(...)` and
|
||||
`ctx.teams.publish` / `ctx.teams.reconcile` (§2.3, §2.4a) · `ctx.teams.activity.push` ·
|
||||
`api.registerSlashCommands(...)` · the client slots `team.overview` and `team.member.row`.
|
||||
|
||||
**The number covers the whole surface; the members arrive by phase, and each is marked below.** Three
|
||||
are live now. `ctx.teams.activity.push` and `api.registerSlashCommands` are **present and throw**, with
|
||||
an error naming the phase that will implement them — chosen over leaving them absent so that a module
|
||||
written against the published version fails at registration with a sentence explaining itself, rather
|
||||
than at whatever moment someone first exercises the feature. Do not call them yet; do not treat a
|
||||
throw as a bug.
|
||||
|
||||
**`registerTeamProvider` is the first registration where core calls the MODULE and waits.** Every
|
||||
existing one is either the module claiming a mount or core notifying it; the closest precedent is
|
||||
`registerAnnounceLeg`'s `dispatch`, which is why this is modelled on it. That direction is what makes
|
||||
the envelope, the 10-second budget and the refusal semantics contract rather than implementation —
|
||||
they are how a module says "I cannot answer" without core hearing "there is nothing".
|
||||
|
||||
**1.5.0 — Phase 5 slice 3, the page shell.** `PublicLayout` takes an optional **`shell`** prop —
|
||||
`'narrow'`, `'mid'` or `'wide'` — that renders the page-body wrapper core's own pages have always
|
||||
written by hand (§3.4). Found by the acceptance run in
|
||||
@@ -199,28 +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>` — **throws until the Team activity feed lands** | — | (1.6.0, declared) |
|
||||
|
||||
**`ctx.teams` is push only, and that is the contract.** There is no reader: a module *answers*
|
||||
questions about Teams, it does not ask them. Every Team table is core-internal (§1.2), and a
|
||||
`getTeamRoster` on `ctx` would be core offering to read back the module's own answer — which the
|
||||
module already holds, in its own store.
|
||||
|
||||
Both live members are **fire-and-forget**. `publish` is an optimisation that makes a membership
|
||||
change visible at once; `reconcile` is a debounced *request* that returns immediately and never
|
||||
rejects. Correctness comes from reconciliation either way, so neither can make a module's own call
|
||||
site slow or turn a background failure into the module's error.
|
||||
|
||||
The six event kinds `publish` accepts are `team.created`, `team.disbanded`, `team.member.added`,
|
||||
`team.member.removed`, `team.leader.added` and `team.leader.removed`. Six rather than four because
|
||||
leadership is its own authority path: a leadership change has to be expressible without pretending
|
||||
someone joined or left. Every event carries `externalId`; the four member and leader kinds also carry
|
||||
`memberKey`. **`team.created` and `team.disbanded` only ask for a reconciliation** — core will not
|
||||
invent a Team from a delta (it would have no name, no roster and no leaders) and will not archive one
|
||||
from a delta either, because an archive driven by a message that may simply have been repeated is
|
||||
destruction on no evidence.
|
||||
|
||||
Three narrowings from `MODULE_SYSTEM.md` §2.1, all deliberate:
|
||||
|
||||
@@ -257,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 () => {})
|
||||
```
|
||||
@@ -358,53 +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. All three methods are required — a provider
|
||||
that could list Teams but not their members would leave core holding Teams it can never populate,
|
||||
which is not the same as a call that fails.
|
||||
|
||||
```js
|
||||
getTeams() // () => Promise<{ ok, complete?, teams }>
|
||||
getTeamMembers(externalId) // (string) => Promise<{ ok, complete?, members }>
|
||||
getTeamLeaders(externalId) // (string) => Promise<{ ok, leaders }> // leaders = [memberKey]
|
||||
|
||||
// authoritative
|
||||
{ ok: true, complete: true, teams: [ { externalId, name, abbr?, meta? } ] }
|
||||
// the module knows it cannot answer — sidecar down, cache cold, boot not finished
|
||||
{ ok: false, reason: 'sidecar unreachable' }
|
||||
```
|
||||
|
||||
A member is `{ memberKey, displayName?, rankLabel?, leader?, online?, userId? }`. `userId` is
|
||||
resolved **by the module** — it owns the game↔site link table, and a core that resolved it would be
|
||||
core reading a module's table by name.
|
||||
|
||||
**Every method returns an envelope, never a bare array, and this is the load-bearing part of the
|
||||
contract.** A rejected promise, a synchronous throw, a timeout (core's budget: **10 seconds**), a
|
||||
non-object, a missing `ok`, or a structurally malformed row are all read exactly as a deliberate
|
||||
`{ ok: false }`. There is **no shape a failure can take that core reads as "zero teams"** — which is
|
||||
the whole argument for the envelope, since a bare array has exactly one such shape, `[]`, and it is
|
||||
the one a module returns while its sidecar is still connecting.
|
||||
|
||||
A refusal costs staleness and nothing else: core keeps the projection it has, records the reason, and
|
||||
surfaces it. It never empties a roster on an answer it does not trust.
|
||||
|
||||
`complete: false` means "valid but partial": core applies additions and updates and performs **no**
|
||||
removals. It defaults to `true` when omitted, so the ordinary authoritative case needs no ceremony.
|
||||
|
||||
**A malformed row fails the whole call rather than being dropped.** One unreadable member quietly
|
||||
omitted from a roster is indistinguishable, downstream, from that member having left — core would
|
||||
mark them departed on the strength of a broken payload. Refusing costs one interval of staleness.
|
||||
|
||||
**`registerSlashCommands(commands)`** — declared in API 1.6.0 and **not yet implemented**: calling it
|
||||
throws with an error naming the phase that will. Present rather than absent so a module written
|
||||
against the published version fails at registration with an explanation, instead of at the moment
|
||||
someone first types the command.
|
||||
|
||||
**`onBoot(fn)` / `onShutdown(fn)`** — §2.5.
|
||||
|
||||
### 2.5 Lifecycle
|
||||
|
||||
@@ -1888,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.
|
||||
|
||||
@@ -1954,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.
|
||||
|
||||
@@ -1983,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`
|
||||
@@ -1999,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;
|
||||
|
||||
Reference in New Issue
Block a user