docs(rust-link): protocol 2 — the read path, and phase 3 as built

The specification the other three repositories are held against, plus the phase
record.

`PROTOCOL.md` §8 is the new contract. Its centre is one field: every frame now
carries `type` — `event`, `snapshot`, `reply`, `control` — and the sidecar files
on that and nothing else. That is the dumb-forwarder property made structural
rather than intended: ten new event kinds are zero change in Rust-Link, and only
a version adding an indexed column touches it at all.

Also in §8: the fifteen-kind catalogue and what each frame carries; `wipeId`
derived by the plugin, which REVERSES §3.2's "deriving one is the website's job"
and says why; boards re-sent on connect and on a cadence; the aggregate rule (a
hook that can fire more than once a second per player is a counter, not an
event); the void rule that stops a read-path hook vetoing a death or a login; and
`GET /feed`, a cursor route separate from `/events` because one route with two
orderings serves the wrong one to every caller that forgets the parameter.

§8.5 is the part to read twice. The classification of a kind as public or staff
is NOT on the wire, deliberately: a boundary declared by the sender is one a
compromised or out-of-date game host can widen, so the module holds a
default-deny allowlist and this table is what its test holds it against.

§8.8 corrects a catalogue rather than a defect: PLAN.md §10 sources
`rust.login.denied` from `CanUserLogin`, and that hook fires on every attempt —
the only way to learn of a denial from it is to be the denier. A denial is the
absence of an approval, and protocol 2 emits both facts so phase 10 can pair them.

`PLAYER_WALK.md` is new, and it exists because half this catalogue cannot fire
without somebody holding a mouse. Ten steps, what each one should produce, and
what counts as a pass — written so the walk can be run without watching the
output live, and so the answer afterwards is readable as a transcript.

PLAN.md §16 is phase 3 as built: the four decisions, the two defects only a
server that BOOTED with the plugin could find (a wipe id that was null for every
real session, and a two-second main-thread stall on unload), what was proven and
how, and — stated plainly rather than implied — the three measurements still
queued on the org lead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-16 08:39:27 -05:00
parent ddf777fd8c
commit 30e72adfcf
6 changed files with 597 additions and 15 deletions

View File

@@ -50,7 +50,8 @@ it is listening without one.
## 2. Versioning
The wire version is a single integer, declared in **four** places that must agree:
The wire version is a single integer**2** as of the read path (§8) — declared in **four** places
that must agree:
| Where | Repo |
|---|---|
@@ -77,7 +78,11 @@ Bump all four in the same change as the emitters, together with this document.
## 3. Protocol 1 — the transport
Everything phase 1 defines, and deliberately nothing more.
Everything phase 1 defines, and deliberately nothing more. It is still the floor every later version
stands on — the framing, the greeting, the heartbeat and the one correlated round trip are unchanged
— but **two things below were amended by protocol 2**: every frame now carries `type`, `serverId`
and `wipeId` (§8.1), and `server.hello` is a *board* rather than a one-off greeting (§8.3). Read §8
beside this section rather than after it.
### 3.1 Framing
@@ -128,7 +133,7 @@ independently of the game, so anything it needs up front has to be re-sent per c
| `t` | epoch milliseconds, stamped when the world was read |
| `serverId` | this server's stable identity across wipes and restarts, from the plugin's config. **Not derived from the hostname** — an operator renames a server for a season and the site must not lose its history for it |
| `bootId` | see §3.2.1 |
| `saveCreatedAt` | when the current save was created. **Raw material for a wipe id, not a wipe id** deriving one is the website's job and is not yet specified, and emitting a guess now would bake a wrong one into stored rows |
| `saveCreatedAt` | when the current save was created. Protocol 1 called this *raw material for a wipe id* and left deriving one to the website; **§8.2 reversed that** — the plugin derives `wipeId` from this value and stamps it on every frame |
Everything from `hostname` down is read from `ConVar.Server` and `BasePlayer.activePlayerList` on
the game's main thread. A field the game cannot answer is **absent**, never zero.
@@ -196,7 +201,8 @@ Every response carries `X-RustLink-Version`, including `/health` and including e
|---|---|---|
| `GET /health` | — | **Unauthenticated**, so monitoring can reach it |
| `GET /server` | the store | The last `server.hello`. **`204` when the game has never connected** |
| `GET /events?kind=&limit=` | the store | Newest first; `limit` clamped to 11000 |
| `GET /events?kind=&wipe=&limit=` | the store | Newest first; `limit` clamped to 11000. For a human |
| `GET /feed?since=&limit=` | the store | **Oldest first**, from a cursor. For a consumer that must not miss a row (§8.9) |
| `GET /status` | the plugin (RPC) | A live round trip. `503` with no plugin, `504` on no reply |
| `GET /ws` | broadcast | The live feed; sends `{"kind":"ws.hello","protocol":1}` on connect |
@@ -304,6 +310,7 @@ override the file.
| `[web].bind` | `RUSTLINK_WEB_BIND` | `127.0.0.1:8090` |
| `[web].auth_token` | `RUSTLINK_WEB_TOKEN` | *(generated on first start)* |
| `[store].path` | `RUSTLINK_DB_PATH` | `rust-link.db` |
| `[store].retain_days` | `RUSTLINK_RETAIN_DAYS` | `14` |
Two things about those are load-bearing:
@@ -323,15 +330,258 @@ writing the file and generating the token if they are missing — and prints it
## 7. What is deliberately not here yet
Protocol 1 is the transport. Every one of these arrives with the phase that needs it, and each is a
version bump:
Protocol 2 is the transport plus the read path. Every one of these arrives with the phase that needs
it, and each is a version bump:
- the read path — player events, kills, clans, presence
- identity and the in-game link code
- the permission mirror
- leases, budgets and the event actions
- the map image over the asset-bridge shape
- identity and the in-game link code (phase 6)
- the permission mirror (phase 7), and plugin configuration edited from the site (phase 7b)
- clans, for core's Team provider (phase 9)
- leases, budgets and the event actions (phases 12-13)
- the map image over the asset-bridge shape (phase 14)
The rule that governs all of them: **the sidecar is a dumb forwarder.** It defines no schema for a
frame's contents, so a version that adds fields to an event needs no change there — only one that
adds a new *indexed* column does.
adds a new *indexed* column does. §8.1 is what turns that from an intention into a property of the
code.
---
## 8. Protocol 2 — the read path
Protocol 1 proved a line could travel. Protocol 2 is what travels: presence, deaths, chat, gathering,
moderation and the wipe, on both mod frameworks from one plugin file.
It is the first version with a *catalogue*, and a catalogue is the thing that grows fastest. So the
shape below is chosen to make growth free everywhere except in the one place that must stay
deliberate — what the public is allowed to see.
### 8.1 Every frame says what it **is**, not only what it is about
Protocol 1 routed on `kind`, in a `match` the sidecar had to learn a new arm for on every addition.
Protocol 2 adds **`type`**, and the sidecar files by `type` alone:
| `type` | Persisted | Broadcast on `/ws` | Routed by `reqId` | Example |
|---|---|---|---|---|
| `event` | appended to the history | yes | no | `player.death` |
| `snapshot` | **replaces** the board of that `kind` | yes | no | `players.online` |
| `reply` | no | no | **yes** | `server.status` |
| `control` | no | no | no | `pong` |
**This is the dumb-forwarder property made structural.** A protocol version that adds ten event
kinds needs no change in the sidecar at all, because the sidecar never learns a kind — it learns
four verbs, and they are the complete set of things that can be done with a frame. Only a version
that adds a new *indexed column* touches it.
Every outbound frame therefore carries five fields before anything specific to it:
```json
{
"kind": "player.death",
"type": "event",
"t": 1789510452152,
"serverId": "main",
"wipeId": "w-20260915T195817Z"
}
```
- **`type` is required.** A frame without one is **dropped and counted**, and the sidecar says so
once per connection. It is not defaulted to `event`: guessing files a board as history, which is
invisible until somebody wonders why the presence board has four thousand rows. The game link has
no version handshake (§2), so this is the place a mismatched pair fails loudly instead of quietly.
- **`serverId` is on every frame**, not only in the server body (R8). A frame is stored beside
frames from five other servers and has to be able to say which one it came from on its own.
- **`wipeId` is on every frame** — see §8.2.
### 8.2 `wipeId` is derived by the **plugin**, and this amends §3.2
§3.2 called `saveCreatedAt` *"raw material for a wipe id, not a wipe id — deriving one is the
website's job"*. That is reversed here, deliberately, and the reason is that by protocol 2 there are
**three** components storing rows that need it:
```
w-yyyyMMddTHHmmssZ e.g. w-20260915T195817Z
```
It is `SaveRestore.SaveCreatedTime` in UTC, to the second — the same instant `saveCreatedAt` already
reports, in the id-shaped spelling `bootId` uses. The plugin stamps it because the plugin is the only
component that can *read* it; every other component would be re-deriving a value it was already told,
and two derivations of one fact eventually disagree about a boundary.
Three consequences worth stating rather than discovering:
- **A server that has never saved has no wipe**, so `wipeId` is **absent**, never `""` and never
`w-unknown`. Absent is a fact; an empty string is a row that will sort beside every other empty
string forever.
- **The id changes on `OnNewSave` and at no other time.** It is not the boot id: a restart re-reads
the same save and reports the same wipe, which is exactly what R12 needs to keep a player's
history across a restart while splitting it across a wipe.
- **A wipe boundary is a fact about the world, not about the bridge.** The plugin re-reads the value
on `OnNewSave` and caches it otherwise; nothing about a reconnect can change it.
### 8.3 Boards — current state, one producer, re-sent on connect
A board is chapter 4's word: *current state with exactly one producer, re-sent on every connect*.
Protocol 2 defines two.
| Board (`kind`) | Holds |
|---|---|
| `server.hello` | the server's own description — §3.2's body, now `type: "snapshot"` |
| `players.online` | who is connected right now: `steamId`, `name`, `connectedAt`, `sleeping` |
**Boards are re-emitted on connect and on a 60-second cadence thereafter.** The events carry the
story — `player.connected`, `player.disconnected` — and the board is the **reconciliation point**. A
missed event is corrected within a minute rather than persisting until the next restart, and the
acceptance criterion *"a restarted sidecar is fully populated within one connection"* is met by
construction rather than by hoping no event was in flight.
The cadence is cheap on purpose: a full board for a 100-slot server is a few kilobytes, and a server
with nobody on it emits an empty array, which is a different answer from having said nothing.
### 8.4 The catalogue
Every kind protocol 2 defines, and the hook behind it. **`class` is not a field on the wire** — see
§8.5 — it is what this table binds the module's allowlist to.
| `kind` | Hook | `class` | Carries |
|---|---|---|---|
| `player.connected` | `OnPlayerConnected` | public | steamId, name |
| `player.disconnected` | `OnPlayerDisconnected` | public | steamId, name, reason, sessionSec |
| `player.respawned` | `OnPlayerRespawned` | public | steamId |
| `player.death` | `OnPlayerDeath` | public | victim, attacker, attackerType, weapon, distance, grid |
| `player.chat` | `OnPlayerChat` | public | steamId, name, channel, message |
| `player.tally` | *aggregate* — see §8.6 | public | steamId, gathered{}, npcKills, structures |
| `entity.destroyed` | `OnEntityDeath` on owned building blocks | **staff** | ownerId, prefab, grid, attacker |
| `player.reported` | `OnPlayerReported` | **staff** | reporter, target, subject, message, type |
| `player.banned` / `player.unbanned` | `OnUserBanned` / `OnUserUnbanned` | **staff** | id, name, **ip**, reason |
| `player.login.attempt` | `CanUserLogin` *(observed, never answered)* | **staff** | id, name, **ip** |
| `player.approved` | `OnUserApproved` | **staff** | id, name, **ip** |
| `server.wipe` | `OnNewSave` | public | the new `wipeId`, the one it replaced |
| `server.initialized` | `OnServerInitialized` | public | — |
| `server.shutdown` | `OnServerShutdown` | public | — |
`grid` is the Rust map reference (`H7`), not a coordinate. A death's grid is where a fight happened
and every community site shows it; a **structure's** grid is where somebody lives, which is why
`entity.destroyed` is staff-class here and why R9 makes the same distinction for map layers.
**Three hooks are deliberately not in this wave, and none of them is an oversight:** `OnEntityTakeDamage`
and `OnFrame`/`OnTick` fire at a rate that makes a bridge a performance regression, and nothing in
phases 319 needs per-hit or per-frame fidelity. R17's warning about chatty zone transitions is the
same rule: **subscribe selectively; the cost of a hook is paid on the game's main thread.**
### 8.5 The class is enforced by the **module**, not declared on the wire
The wire carries no visibility field, and this is a security decision rather than an economy.
**A boundary must be enforced by the side that serves, never declared by the side that sends.** The
website's own shard fan-out works this way — a public SSE stream with an allowlist of event kinds,
and an admin stream that adds the rest — and the property that makes it trustworthy is that a
compromised or merely out-of-date sender cannot widen it. A `"class":"public"` field on the frame
would move the decision to the game host.
So: the table in §8.4 is the specification, `module-rust` holds the allowlist, and it is
**default-deny** — a kind the allowlist has never heard of is not public. The module's own test holds
its allowlist against this document, so adding a kind here without classifying it there fails a
build rather than shipping an IP address to a public page.
`player.login.attempt`, `player.approved` and `player.banned` carry **IP addresses**, and
`player.reported` carries the text of one player's complaint about another. They are stored because
an operator chasing ban evasion needs them and because the sidecar persists what it is told; they
reach no tier below admin, and the raw window that holds them is bounded (§8.9).
### 8.6 Two things are aggregated in the plugin, and that is the interesting part of this phase
`OnDispenserGather` fires on **every swing at a tree**. A single player chopping for a minute is
hundreds of hooks; ten players gathering is a frame rate problem in the bridge rather than in the
game. The same is true of animal and scientist kills, at a lower rate.
Neither is interesting per occurrence — nobody wants a killfeed of chickens — and both are wanted
*in total*, for the leaderboard. So the plugin keeps a per-player tally on the main thread and flushes
it as one `player.tally` frame:
- on a **60-second cadence**, for players with a non-zero tally;
- on **disconnect**, so a session's last minute is not lost;
- at `OnServerShutdown`, which is the flush that covers a restart.
A plugin *reload* is the one case that loses a tally, by choice: `Unload` runs on the game's main
thread, and draining the outbound queue there means waiting on a socket from the main thread — the
stall phase 1 removed. Under a minute of one player's gathering is the price, and a wedged peer
would make the cure worse than the disease.
A tally frame is a **delta, not a running total** — it reports what happened since the last flush,
so the consumer sums rather than diffs and a missed frame costs that interval instead of corrupting
the series.
This is the general rule for every later wave: **if a hook can fire more than once a second per
player, it is a counter, not an event.**
### 8.7 The read path never vetoes, and it is structural rather than disciplined
Four hooks in §8.4 are documented by uMod as *"returning a non-null value overrides default
behavior"* — `OnPlayerDeath`, `OnDispenserGather` and `CanUserLogin` among them. A read-path bridge
that returned something by accident would cancel a death, swallow a player's wood, or refuse a
login, and it would do it on a production server at 3am.
**So every vetoable hook in the read path is declared `void`.** Both frameworks bind hooks by name
and arity and take the method's return value; a `void` method returns nothing and therefore cannot
override anything. The rule is enforced by the signature rather than by remembering to write
`return null`, which is the only version of this rule that survives a year of edits.
`CanUserLogin` is in the wave for what it *observes*, never for what it answers.
### 8.8 A login denial is not a hook — and §10 of `PLAN.md` says it is
`PLAN.md` §10 sources the `rust.login.denied` trigger from `CanUserLogin`. Reading the hook says that
cannot work: `CanUserLogin` is called on **every** connection attempt, and the only way to learn of a
denial from it is to *be* the denier, which §8.7 forbids. uMod publishes no `OnUserRejected`.
What the game can actually tell us is two facts — an attempt, and an approval — so protocol 2 emits
both and **a denial is the absence of an approval** for an attempt, decided by a deferred read rather
than by a hook. Phase 10 owns that pairing; protocol 2 owes it the two frames and the `t` on each.
Recorded here because it is a correction to a catalogue, not a defect: the trigger survives, its
source changes.
### 8.9 History, cursors and retention
Three changes on the sidecar's own side follow from a catalogue that actually produces volume.
**`events` gains `server_id` and `wipe_id` as indexed columns.** This is the one migration shape the
store's own header predicted: *"only a version that adds a new indexed column ever needs a
migration"*. It is applied as an `ALTER` guarded by a column check, never as an edit to the `CREATE`
— the same rule the website's schema fragments live under, for the same reason.
**A new route, `GET /feed?since=&limit=`, is the ingest cursor**, and it is deliberately *not*
`/events` with a flag:
| Route | Order | For |
|---|---|---|
| `GET /events?kind=&wipe=&limit=` | newest first | a human, an admin screen, a point-in-time look |
| `GET /feed?since=&limit=` | **oldest first**, from a cursor | a consumer that must not miss a row |
One route with two orderings depending on a query parameter is a trap: every caller that forgets the
parameter gets the other one silently, and for the ingesting caller that means it advances its cursor
past rows it never read. Two routes, one ordering each.
`/feed` items are wrapped rather than bare, because a cursor needs the row's identity:
```json
{ "items": [ { "id": 1041, "t": 1789, "kind": "player.death", "frame": { } } ],
"lastId": 1041, "more": false }
```
`more` is `true` when the page filled, so a consumer that has fallen an hour behind drains at its own
pace instead of guessing from a count.
**Omitting `since` asks where the end is** — no rows, and the current `lastId`. `since=0` is the
other question entirely: replay everything retained. That is deliberate, because the two intentions
must not be separated by whether somebody typed a parameter: a module installed today against a
month-old sidecar wants what happens next, not a fortnight of deaths it has no rollups for.
**The store prunes.** `[store].retain_days` (default 14) bounds the event history, swept hourly.
Three things make that safe rather than lossy: the website holds the permanent per-wipe rollups
(R12), boards are never pruned because they hold exactly one row per kind, and the sidecar's database
lives inside a game container whose disk is the operator's (R20). A store that grows without bound on
a game host is a wipe-day outage waiting for a busy month.
---