Files
docs/link/v4.md
Claude 36f8e1071d fix(link): a protocol bump owes four declaration sites, not three
v4's cross-repo obligation table named module-uo's ingest work and stopped
there, so both of that module's PIN sites — DEFAULT_PROTOCOL and the
uo_link_config.protocol column default — stayed at 3 when protocol 4 shipped.

The consequence is not partial degradation. A sidecar answers a stale client
`409 protocol version mismatch` rather than mis-parsing it, so a fresh install
read nothing at all 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: their stored row had
already been moved by the protocol-3 one-shot, and the stored row wins.

Found while standing up a demo deployment for the marketing site's screenshots;
fixed in Module-uo.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 19:23:24 -05:00

17 KiB
Raw Blame History

Protocol 4 — Guild membership on the wire

Status: Shipped. The edgemain 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, <servuo>, net48 / x64, Expansion EJ. Companion to PLAN.md (1.0 read/event plane), PROTOCOL_2.md (2.0 provisioning + world-state streams), v3.md (3.0 shard content + the visibility framework), 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 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 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:

{"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:

{"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:

{"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). 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 PlayerMobiles 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.