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());