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