feat(sidecar)!: Protocol 3.0 cutover — X-UOLink-Version 2 → 3 #21

Merged
whitlocktech merged 8 commits from edge into main 2026-08-01 06:34:21 +00:00
Member

What & why

Order 6 of the Protocol 3.0 plan (v3.md §4) — the edgemain cutover, sidecar side. One of four PRs that merge together, and the one that makes the break visible: after this, every response carries X-UOLink-Version: 3 and any client still declaring 2 is refused 409.

What edge carries over main:

  • world.ruleset (#17) — persisted as a singleton and served from GET /ruleset.
  • points.board (#18) — one row per point system, served from GET /points.
  • vendor.listing / vendor.listing.remove (#19) — the player-vendor market index, served from GET /market. /market, not /vendors, because axum would otherwise route it into the account-provisioning tree.
  • PROTOCOL_VERSION 2 → 3 (#20 — must merge into edge before this PR).

The store-backed shape is deliberate and unchanged from 2.0's world-state boards (PROTOCOL_2.md §12.2): the shard publishes, the sidecar persists, and the reads answer from the last-known snapshot without the shard — so a shard outage degrades to stale data rather than to nothing. The sidecar stays a dumb forwarder; access control and the admin-toggleable visibility scope live entirely on the website.

Breaking change

X-UOLink-Version goes 23. Any integration pinning 2 gets a 409 on every protected route and a closed WebSocket on the ws.hello mismatch. Nothing that existed in v2 changed shape, so upgrading is changing the constant you send — see INTEGRATION.md §2. The website migrates its own pinned version on boot, so operators need no manual edit.

How it was tested

cargo build --release and cargo clippy --all-targets clean. Against the release binary: /health reports "protocol": 3, responses carry x-uolink-version: 3, an authenticated GET /ruleset declaring 3 returns 200 and the same call declaring 2 returns the 409 with {"sidecar_protocol":3,"client_protocol":"2"}. Each stream was verified end-to-end against the real shard when it landed on edge — most recently 27 vendors / 1,040 listings from ServUO through this sidecar into the site.

Note: no v3 PR ever ran CI, since edge PRs don't trigger it. This PR is the first CI run over the whole stack.

Merge order

  1. link #20edge (the bump), website #117edge, docs #72edge.
  2. Then this PR together with servuo-plugins #6, website #118 and docs #73.

This PR's diff picks up #20 automatically once that merges.

Checklist

  • I have read CONTRIBUTING.md.
  • The change builds and existing tests/checks pass locally.
  • I have added or updated tests/docs where it makes sense.
  • My commits are reasonably scoped with clear messages.

AI-assisted contributions (required)

  • No AI tools were used to produce this contribution.
  • AI tools were used. Tool(s): Claude Code. I have reviewed and understand
    every change, and take responsibility for it. AI-authored commits are
    marked with a Co-Authored-By / Assisted-By trailer.

License

  • I agree that my contribution is licensed under this project's license
    (GNU GPL v3.0 or later), and I have the right to contribute it.
## What & why **Order 6 of the Protocol 3.0 plan ([`v3.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/edge/link/v3.md) §4) — the `edge` → `main` cutover, sidecar side.** One of four PRs that merge together, and the one that makes the break visible: after this, every response carries `X-UOLink-Version: 3` and any client still declaring `2` is refused **409**. What `edge` carries over `main`: - **`world.ruleset`** (#17) — persisted as a singleton and served from `GET /ruleset`. - **`points.board`** (#18) — one row per point system, served from `GET /points`. - **`vendor.listing` / `vendor.listing.remove`** (#19) — the player-vendor market index, served from `GET /market`. `/market`, not `/vendors`, because axum would otherwise route it into the account-provisioning tree. - **`PROTOCOL_VERSION` 2 → 3** (#20 — must merge into `edge` before this PR). The store-backed shape is deliberate and unchanged from 2.0's world-state boards ([`PROTOCOL_2.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/edge/link/PROTOCOL_2.md) §12.2): the shard publishes, the sidecar persists, and the reads answer from the last-known snapshot without the shard — so a shard outage degrades to stale data rather than to nothing. The sidecar stays a dumb forwarder; access control and the admin-toggleable visibility scope live entirely on the website. ## Breaking change `X-UOLink-Version` goes `2` → `3`. Any integration pinning `2` gets a 409 on every protected route and a closed WebSocket on the `ws.hello` mismatch. Nothing that existed in v2 changed shape, so upgrading is changing the constant you send — see [`INTEGRATION.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/edge/link/INTEGRATION.md) §2. The website migrates its own pinned version on boot, so operators need no manual edit. ## How it was tested `cargo build --release` and `cargo clippy --all-targets` clean. Against the release binary: `/health` reports `"protocol": 3`, responses carry `x-uolink-version: 3`, an authenticated `GET /ruleset` declaring `3` returns 200 and the same call declaring `2` returns the 409 with `{"sidecar_protocol":3,"client_protocol":"2"}`. Each stream was verified end-to-end against the real shard when it landed on `edge` — most recently 27 vendors / 1,040 listings from ServUO through this sidecar into the site. **Note:** no v3 PR ever ran CI, since `edge` PRs don't trigger it. This PR is the first CI run over the whole stack. ## Merge order 1. link [#20](https://gitea.whitlocktech.com/RunicGateway/link/pulls/20) → `edge` (the bump), website [#117](https://gitea.whitlocktech.com/RunicGateway/website/pulls/117) → `edge`, docs [#72](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/72) → `edge`. 2. Then this PR together with servuo-plugins [#6](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/6), website [#118](https://gitea.whitlocktech.com/RunicGateway/website/pulls/118) and docs [#73](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/73). This PR's diff picks up #20 automatically once that merges. ## Checklist - [x] I have read [CONTRIBUTING.md](CONTRIBUTING.md). - [x] The change builds and existing tests/checks pass locally. - [x] I have added or updated tests/docs where it makes sense. - [x] My commits are reasonably scoped with clear messages. ## AI-assisted contributions (required) - [ ] No AI tools were used to produce this contribution. - [x] AI tools were used. Tool(s): `Claude Code`. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a `Co-Authored-By` / `Assisted-By` trailer. ## License - [x] I agree that my contribution is licensed under this project's license (**GNU GPL v3.0 or later**), and I have the right to contribute it.
wtclaude added 6 commits 2026-07-29 23:07:16 +00:00
Protocol 3.0 §5 (docs/link/v3.md). The shard publishes one world.ruleset frame
per connect describing how it is configured; the sidecar folds it into a
singleton row and serves it back.

Store-backed rather than an RPC, for the same reason /guilds and /houses are
(PROTOCOL_2.md §12.2): a rules page that goes blank while the shard restarts is
worse than one that is briefly stale. `{"ruleset": null}` distinguishes "the
shard has never published one" — an old plugin, or Bridge.RulesetEnabled=false —
from a published ruleset, which the website renders differently.

`rev` (the shard's FNV-1a of the body) is kept alongside the JSON so a reader can
tell "same ruleset, re-sent on reconnect" from "the operator changed something"
without diffing.

PROTOCOL_VERSION stays 2. The 2→3 bump is a hard operator-visible cutover and
happens exactly once, at the end of v3 (§4), not per phase.

Smoke-tested against a fake shard on loopback: frame ingested, GET /ruleset
returns it with plugin_connected=false (outage path), and the route sits behind
the gate (409 on a version mismatch, 401 unauthenticated). cargo build + clippy
clean.

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #17
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
Protocol 3.0 §7 (docs/link/v3.md). The shard publishes ~25 points/loyalty
leaderboards as one points.board frame per system; the sidecar folds each into a
projection table and serves them back, so the site's leaderboards page renders
during a shard outage.

  - points_boards(system PK, name, json, updated_t), keyed by the shard's own
    PointsType name. `name` is hoisted only for the ORDER BY.
  - main.rs gains a points.board arm keyed on `system`, alongside the existing
    champ/guild/governor/house/ruleset projections. There is deliberately no
    delete counterpart: the shard's set of point systems is fixed at startup, so
    it emits no points.remove — the same shape the governor board already has.
  - GET /points returns every board ordered by display name; GET /points/:system
    returns one, or 404 when the shard has never published that system. 404 and
    "a published board nobody has scored in yet" (200, empty top) are different
    answers, and the website renders them differently.

Store-backed rather than an RPC for the same reason as the other boards, and it
matters more here: these are standings accumulated over months, so blanking them
during a shard restart reads as data loss rather than as staleness.

PROTOCOL_VERSION stays at 2 — the bump to 3 is the one-time edge → main cutover
in v3.md §4, not a per-phase change.

cargo build and cargo clippy --all-targets are clean. Smoke-tested against a
driver on the loopback link: two boards stored and served, a re-emitted system
overwriting rather than accumulating, 404 for an unknown system, 401
unauthenticated. Also verified against the real ServUO shard, which fed five
live boards through this path.

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #18
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
Protocol 3.0 §8. Ingests vendor.listing / vendor.listing.remove into a `vendors`
table and serves GET /market.

The frame is authoritative for one vendor, so the upsert is a whole-row
overwrite. Unlike the other 3.0 boards there IS a remove: a vendor is dismissed,
expires, or its owner switches off the in-game Vendor Search flag — the last of
those is a privacy control, so dropping the row promptly is the point.

Items ride inside the stored blob and are deliberately not normalized into a
vendor_items table. The sidecar's job for the market is outage resilience
(PROTOCOL_2.md §12.2), not search; search lives in MariaDB on the website side,
where the query surface, the indexes and the cliloc-resolved names already are.

/market is the only PAGED read the sidecar serves, because it is the only board
that can be a whole world's inventory. limit clamps to 1..1000 (default 200) and
`total` comes back so a caller knows when to stop rather than paging until it
sees a short page, which would race a concurrent sweep. Ordering is by SERIAL,
not shop name: a serial is stable while a shop name is renameable, so a rename
mid-walk cannot make a vendor skip or repeat a page.

The route is /market and not /vendors: /vendors/:account next door is the
per-account RPC, and two routes a prefix apart meaning "this player's shops" and
"every shop on the shard" is a readability trap.

Frames are served verbatim, owner names and coordinates included — the sidecar
defines no audiences (v3.md §3.2).

Verified against the live shard: 27 vendors / 1,040 listings ingested from the
plugin, plus a synthetic insert-then-remove confirming the delete path.

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #19
whitlocktech added 2 commits 2026-07-30 03:03:37 +00:00
Protocol 3.0 is feature-complete on `edge` -- world.ruleset, points.board and
vendor.listing / vendor.listing.remove all landed there while the sidecar kept
declaring 2, because a bump is an operator-visible hard break (409 on every
protected route via web.rs::gate, and the website closes the WS on the ws.hello
mismatch). Doing it per phase would have broken the site four times; this is the
one time it happens.

Nothing that existed in v2 changed shape, so the version constant and its doc
comment are the whole change here. The README's worked example moves with it --
it still claimed "currently 1", two bumps stale.

Verified against the release binary: /health reports "protocol": 3, every
response carries `X-UOLink-Version: 3`, an authenticated request declaring 2 is
refused 409 {"sidecar_protocol":3,"client_protocol":"2"}, and one declaring 3
gets 200 off /ruleset. cargo build --release + cargo clippy --all-targets clean.

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #20
whitlocktech approved these changes 2026-08-01 06:34:14 +00:00
whitlocktech merged commit 2301c57768 into main 2026-08-01 06:34:21 +00:00
whitlocktech deleted branch edge 2026-08-01 06:34:21 +00:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/link#21
No description provided.