docs(link): the Protocol 3.0 cutover (v3.md order 6)

INTEGRATION.md was written for the window that just closed -- it told integrators
the version had NOT been bumped yet and that a sidecar on `edge` reports 2 while
already carrying v3 kinds. That guidance is now wrong in the direction that
matters, so the version section states 3 (header, /health, ws.hello, the 409
example and the §8 worked example) and replaces the "until then" paragraph with
what a v2 integration actually has to do to upgrade: change the constant it
sends, and nothing else, because nothing that existed in v2 changed shape.

v3.md gains §4.1 for what the bump touches and, more importantly, WHY the
website's boot migration is gated on a marker row: schema.sql is re-run on every
boot and uo_link_config.protocol is admin-editable, so an ungated UPDATE would
silently un-pin an operator running an older sidecar. That is the one piece of
the cutover a reader could not infer from the code being one constant.

Progress tables: 5b done, 6 in review.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-29 18:04:00 -05:00
parent cdea1aa7cd
commit 71207cef16
3 changed files with 63 additions and 24 deletions

View File

@@ -31,31 +31,31 @@ Missing or wrong token → **401** `{"error":"missing or invalid auth token"}`.
The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly.
- Every response carries an **`X-UOLink-Version: 2`** header.
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 2`.
- **Optionally**, send `X-UOLink-Version: 2` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
- Every response carries an **`X-UOLink-Version: 3`** header.
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 3`.
- **Optionally**, send `X-UOLink-Version: 3` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
```json
{ "error": "protocol version mismatch", "sidecar_protocol": 2, "client_protocol": "1" }
{ "error": "protocol version mismatch", "sidecar_protocol": 3, "client_protocol": "2" }
```
Pin the version you built against and compare it to the header (or `/health.protocol`) at startup.
**v2 (Protocol 2.0)** added the account-provisioning surface (§6.x: `POST /accounts/create`, `DELETE /link/{account}`) and the `account.*` events. Outbound event kinds are **additive** — a v1 client that ignores unknown kinds keeps working against the live feed — but the new *endpoints* require a v2 sidecar. If you send `X-UOLink-Version: 1`, calls to the new endpoints are refused with the 409 above.
**v3 (Protocol 3.0) is being built and the version has not been bumped yet.** It is defined as *adds
`world.ruleset`, `points.board`, `vendor.listing` / `vendor.listing.remove`*, and the bump to
`X-UOLink-Version: 3` happens **exactly once**, at the end, when [`v3.md`](v3.md) §4's `edge` → `main`
cutover lands — because a bump is an operator-visible hard break (409 on every protected route, and
the website's WS closes on the `ws.hello` mismatch), so doing it per phase would break the site
repeatedly.
**v3 (Protocol 3.0)** adds `world.ruleset`, `points.board` and `vendor.listing` /
`vendor.listing.remove`, with the `GET /ruleset`, `/points` and `/market` reads that serve them from
the sidecar's store. Same shape as the v2 bump: the event kinds are additive, so a v2 client that
ignores unknown kinds keeps working against the live feed, but the three new endpoints require a v3
sidecar. There is deliberately **no feature-negotiation array** — v3 implies all three kinds, so the
version number alone tells you what is available.
Until then, sidecars on `edge` still report `2` while already carrying some v3 kinds and endpoints.
That is safe in the direction that matters: event kinds are additive, and a client that ignores
unknown kinds and tolerates a `404` on a not-yet-present endpoint keeps working. What you must **not**
do is infer feature availability from the version number during this window — probe the endpoint, or
treat a missing `world.ruleset` as "this shard hasn't published one". There is deliberately **no
feature-negotiation array**: v3 implies all three kinds.
**Upgrading a v2 integration.** The bump is an operator-visible hard break in one direction only: a
client still declaring `2` gets a 409 on every protected route and, on the WebSocket, a closed
connection on the `ws.hello` mismatch. So update the pinned version at the same time you deploy the
v3 sidecar. Nothing that existed in v2 changed shape, so that is the whole migration — the website
does it with a one-shot boot migration of its `uo_link_config.protocol` row ([`v3.md`](v3.md) §4.1);
a third-party client changes the constant it sends.
---
@@ -68,7 +68,7 @@ GET /health (no auth)
```json
{
"status": "ok", // "ok" when plugin connected AND db reachable, else "degraded"
"protocol": 1,
"protocol": 3,
"plugin_connected": true, // is the shard link up right now?
"database": "ok", // "ok" | "error"
"uptime": "3d 12h",
@@ -91,7 +91,7 @@ A push-only stream of game events as they happen. You do **not** send commands o
**On connect**, the first frame is:
```json
{ "kind": "ws.hello", "protocol": 1 }
{ "kind": "ws.hello", "protocol": 3 }
```
**Then** a continuous stream of event frames, each with at least `t` (epoch ms) and `kind`. Route on `kind`.
@@ -955,7 +955,7 @@ sidecar defines no audiences. Deciding who may see what is the consuming site's
A typical character page:
```js
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "2" };
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "3" };
// 1. render the roster
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());

View File

@@ -347,6 +347,12 @@ of 25 vendors x 40 listings and **0.3 ms** in steady state (the per-vendor diff)
items / 43k mobiles. It is also the first stream to honour a per-player privacy toggle: ServUO's own
`PlayerVendor.VendorSearch` flag, so a shop hidden in game is hidden on the site.
That completes 3.0's feature work, so the last step is the version itself: `PROTOCOL_VERSION` **2 →
3** and the coordinated `edge``main` merge across all four repos ([`v3.md`](v3.md) §4 and §4.1).
The bump is deliberately the *only* thing that happens at that moment — v3 adds kinds and endpoints
but changes nothing that already existed in v2 — so the operator-visible break is limited to
re-pinning the version, which the website does for itself in a one-shot boot migration.
### Config keys (`Config/Bridge.cfg`)
```ini

View File

@@ -1,6 +1,6 @@
# Protocol 3.0 — Shard content, standings & the visibility framework
**Status:** In progress. All work lands on an `edge` branch in each repo; `edge``main` is the v3 cutover.
**Status:** Feature-complete on `edge`; the cutover (order 6) is in review. All work lands on an `edge` branch in each repo; `edge``main` is the v3 cutover.
**Date:** 2026-07-28
**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), [`ADMIN_CONTROLS.md`](ADMIN_CONTROLS.md) (staff write plane), [`INTEGRATION.md`](INTEGRATION.md) (website API).
@@ -16,8 +16,8 @@ Each part is marked off here as it lands on `edge`. §9 carries the same state p
| 3 | **C** — spawn atlas (§6) | ✅ **Done** | website [#112](https://gitea.whitlocktech.com/RunicGateway/website/pulls/112) (parsers + CLI + tables) + [#113](https://gitea.whitlocktech.com/RunicGateway/website/pulls/113) (API + pages + admin panel), docs [#67](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/67) + [#68](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/68) |
| 4 | **B/2**`points.board` (§7) | ✅ **Done** | servuo-plugins [#4](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/4), link [#18](https://gitea.whitlocktech.com/RunicGateway/link/pulls/18), website [#114](https://gitea.whitlocktech.com/RunicGateway/website/pulls/114), docs [#69](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/69) |
| 5a | **B/3 dependency** — cliloc table (§8.6) | ✅ **Done** | website [#115](https://gitea.whitlocktech.com/RunicGateway/website/pulls/115), docs [#70](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/70) |
| 5b | **B/3**`vendor.listing` (§8) | 🟨 In review | servuo-plugins [#5](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/5), link [#19](https://gitea.whitlocktech.com/RunicGateway/link/pulls/19), website [#116](https://gitea.whitlocktech.com/RunicGateway/website/pulls/116), docs [#71](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/71) |
| 6 | **Cutover**`PROTOCOL_VERSION` 2→3 (§4) | ⬜ Not started | — |
| 5b | **B/3**`vendor.listing` (§8) | **Done** | servuo-plugins [#5](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/5), link [#19](https://gitea.whitlocktech.com/RunicGateway/link/pulls/19), website [#116](https://gitea.whitlocktech.com/RunicGateway/website/pulls/116), docs [#71](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/71) |
| 6 | **Cutover**`PROTOCOL_VERSION` 2→3 (§4) | 🟨 In review | the bump: link [#PR_LINK_BUMP](https://gitea.whitlocktech.com/RunicGateway/link/pulls/PR_LINK_BUMP), website [#PR_SITE_BUMP](https://gitea.whitlocktech.com/RunicGateway/website/pulls/PR_SITE_BUMP), docs [#PR_DOCS_BUMP](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/PR_DOCS_BUMP) — then `edge``main`: servuo-plugins [#PR_PLUG_CUT](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/PR_PLUG_CUT), link [#PR_LINK_CUT](https://gitea.whitlocktech.com/RunicGateway/link/pulls/PR_LINK_CUT), website [#PR_SITE_CUT](https://gitea.whitlocktech.com/RunicGateway/website/pulls/PR_SITE_CUT), docs [#PR_DOCS_CUT](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/PR_DOCS_CUT) |
Order 5 split in two once §8.6's cliloc dependency turned out to be a client-format problem rather
than a parser (see §8.6). 5a is website-only and lands first so the marketplace ships with real item
@@ -242,6 +242,39 @@ admin-set `uo_link_config.protocol` column — so it happens **exactly once**, a
from 2 to 3, so the cutover doesn't require a manual admin edit. `UOLINK_PROTOCOL` still overrides.
- No feature-negotiation array anywhere — v3 implies all three kinds.
### 4.1 What the bump actually touches
The version lives in five places, and all five move together:
| Where | Change |
|---|---|
| `link/sidecar/src/main.rs` | `PROTOCOL_VERSION` 2 → 3 (with the v3 note beside the v2 one), plus the sidecar README's worked example |
| `website/server/db/schema.sql` | `uo_link_config.protocol` column default 1 → 3, plus the boot migration below |
| `website/server/src/model/uoLinkConfig/uoLinkConfig.model.js` | `DEFAULT_PROTOCOL` — what a site with nothing saved yet declares |
| `website/server/src/utils/uoLinkClient.js` + `uoLinkSocket.js` | the `config.protocol || …` fallbacks, so an unset value can never quietly send `1` and 409 with a confusing message |
| `website/client/.../ShardAdmin.jsx`, `website/.env.example` | the admin form's initial value and the documented env default |
**The migration has to be one-shot, and that is the only subtle part.** `schema.sql` is re-run on
*every* boot (`utils/db.js::ensureSchema`), and every other statement in its migration block is an
idempotent `ADD COLUMN IF NOT EXISTS` / `MODIFY`. A bare `UPDATE uo_link_config SET protocol = 3`
would not be idempotent in the sense that matters: `protocol` is **admin-editable**, so an operator
who deliberately pins an older sidecar in Admin → Shard would silently be un-pinned on the next
restart. It is therefore gated on a marker row in `settings`:
```sql
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 3;
UPDATE uo_link_config SET protocol = 3
WHERE id = 1 AND protocol < 3
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_3_migrated');
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated', '1');
```
The marker is written *after* the `UPDATE`, so the first boot on the new build migrates and every
later boot is a no-op. A fresh install has no `uo_link_config` row to update and simply gets the
marker plus the new column default. `protocol < 3` rather than `= 2` so an install that never left
the old default of `1` is carried across too — it could not have been talking to a v2 sidecar
anyway.
---
## 5. Part B/1 — `world.ruleset` ✅ Done
@@ -887,8 +920,8 @@ not a blocker here.)
| 3 | **C** — spawn atlas (§6) | website, docs | none | ✅ Done |
| 4 | **B/2**`points.board` (§7) | all four | new kind + `char.profile` field | ✅ Done |
| 5a | **B/3 dependency** — cliloc table (§8.6) | website, docs | none | ✅ Done |
| 5b | **B/3**`vendor.listing` (§8) | all four | new kinds | 🟨 In review |
| 6 | **Cutover**`PROTOCOL_VERSION` 2→3, `edge``main` | all four | the bump | |
| 5b | **B/3**`vendor.listing` (§8) | all four | new kinds | ✅ Done |
| 6 | **Cutover**`PROTOCOL_VERSION` 2→3, `edge``main` | all four | the bump | 🟨 In review |
---