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
29 KiB
rust-link — the wire protocol
Canonical. This document defines the two contracts that make up the Rust bridge. Code in three repositories is held against it, and a change here is a change in all of them.
| Contract | Between | Transport |
|---|---|---|
| The game link | the Oxide bridge plugin ↔ the sidecar | loopback TCP, newline-delimited JSON |
| The website API | the sidecar ↔ module-rust |
HTTP + WebSocket, bearer token |
Mirrors link/, which is the same pair of contracts for Ultima Online. Where
this document is silent, that one is not a fallback: the two protocols are independent and share
only their shape.
1. Why the game does not listen
The plugin is the TCP client; the sidecar owns the listener. A Rust server therefore opens no extra port, and the only component the website can reach is the sidecar. This is inherited unchanged from the ServUO bridge — the footing changed (Oxide hooks instead of game source) and the invariant did not.
Rust server + Oxide (Rust-Plugins, C#)
│ the plugin DIALS OUT · 127.0.0.1:7799 · newline-delimited JSON, bidirectional
▼
rust-link sidecar (Rust-Link) ← the only network-facing bridge component
│ WebSocket (live feed) + REST (point-in-time reads), bearer-token auth
▼
module-rust, inside a website core
One game server, one sidecar, on that server's own host. A community running six servers runs
six pairs; module-rust holds six clients and the website core never learns there is more than one.
Nothing in the sidecar is multiplexed and nothing in it should become multiplexed — the serverId
on every frame exists so the module can tell its clients apart, not so the sidecar can.
1.1 Loopback is the trust boundary on the game link
There is no token on the game link. The plugin and the sidecar share a host, and the sidecar
binds 127.0.0.1 — that is the authentication, exactly as on the ServUO bridge. Binding
[game].bind to a routable address puts an unauthenticated command channel on the network.
The website-facing surface is the opposite: authentication there is always on and cannot be turned off. The sidecar generates and persists a token on first start, so there is no state in which it is listening without one.
2. Versioning
The wire version is a single integer — 2 as of the read path (§8) — declared in four places that must agree:
| Where | Repo |
|---|---|
PROTOCOL_VERSION in sidecar/src/main.rs |
Rust-Link |
ProtocolVersion in overlay/oxide/plugins/RunicGateway.cs |
Rust-Plugins |
protocol in overlay.toml |
Rust-Plugins |
PROTOCOL_VERSION in server/sidecarClient.js |
Module-Rust |
Bump all four in the same change as the emitters, together with this document.
The two halves of the contract enforce it differently, and the asymmetry is the reason
overlay.toml exists at all:
- On the website API the check is live. Every response carries
X-RustLink-Version; a client that declares a different one in its request header is refused409with both numbers in the body, rather than served something it will mis-parse. - On the game link there is no such check, and a mismatched plugin would simply mis-parse. The
plugin announces its protocol in
server.hello, which is readable only after the game server has booted with it loaded — far too late for an installer to refuse a bad pairing. Sooverlay.tomldeclares it statically, and the installer refuses to pair an overlay and a sidecar whose numbers disagree. A bump landing in one repo and not the others fails to compose rather than half-deploying.
3. Protocol 1 — the transport
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
Newline-delimited JSON over TCP, both directions, UTF-8. One complete JSON object per line, no embedded newlines.
- Outbound frames (plugin → sidecar) carry
kind. - Inbound frames (sidecar → plugin) carry
cmd.
Both ends cap an inbound line at 1 MiB. An over-long line is discarded, not buffered, and the connection stays up: a single malformed frame is not a reason to tear down a link that live events are flowing over, and a dropped reply simply times out on the caller's side and is re-requested.
The cap exists from protocol 1 rather than being added after the first large frame arrives. An unbounded read facing a peer that will one day send a map image is a memory-exhaustion shape we would be inventing ourselves.
3.2 server.hello — plugin → sidecar
Sent on every successful connect, not once at game-server start. The sidecar restarts independently of the game, so anything it needs up front has to be re-sent per connection.
{
"kind": "server.hello",
"t": 1789510452152,
"protocol": 1,
"serverId": "main",
"bootId": "boot-20260915T194502Z",
"plugin": "0.1.0",
"hostname": "Test Server",
"description": "No server description has been provided.",
"level": "Procedural Map",
"seed": 1234,
"worldSize": 4000,
"maxPlayers": 10,
"players": 0,
"joining": 0,
"queued": 0,
"uptimeSec": 8947,
"saveCreatedAt": "2026-09-15T19:58:17Z"
}
| Field | Meaning |
|---|---|
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. 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.
3.2.1 bootId identifies the server PROCESS
It is the server process's start instant, formatted boot-yyyyMMddTHHmmssZ, and it must change
when and only when the world started over.
That makes three things it is deliberately not:
- Not a fresh value per plugin load.
oxide.reload RunicGatewaymust not change it. The website watches this value to tell a game restart — where everything an event put in the world is gone — from a bridge reconnect, which loses nothing; a plugin reload is the second kind, and a boot id regenerated atInitwould ask the site to reconcile its whole ledger for no news. - Not the sidecar's identity. The sidecar restarting is invisible to the world.
- Not the wipe. A wipe is
saveCreatedAtchanging; a restart is not a wipe.
The plugin reads it from Process.StartTime, which is exact and identical on every read.
3.3 ping / pong — the heartbeat
The sidecar sends {"cmd":"ping"} every 30 seconds while a plugin is connected; the plugin answers
{"kind":"pong","t":…}.
A pong is never persisted. It only moves the sidecar's last_event, which is the whole point:
a Rust server with nobody on it is very quiet, and without a heartbeat "the game has said nothing
for six hours" would be indistinguishable from "the link died six hours ago".
3.4 server.status — the request/reply verb
The one correlated round trip in protocol 1. It exists so the correlation path is exercised by something before anything depends on it.
sidecar → plugin {"cmd":"server.status","reqId":"r-1"}
plugin → sidecar {"kind":"server.status","reqId":"r-1","t":…, …the §3.2 body…}
Correlation is by reqId, a process-unique counter minted by the sidecar. The plugin echoes it
verbatim and only when one was supplied: a reply that invented one would be routed to nobody,
and a reply that omitted one the caller sent would leave that caller waiting out its whole timeout.
server.hello and server.status share a body by construction, in one function in the plugin. They
differ in what wraps them, not in what they say about the server, and letting them drift is how a
site ends up showing two different player counts.
3.5 link.down — the sidecar's own observation
Not a frame the plugin sends. When a plugin connection ends the sidecar synthesises
{"kind":"link.down"} onto its broadcast channel, so the website sees the drop without polling. It
is never persisted: it is this process's observation, not something the game said.
4. The website API
Served by the sidecar. Everything except /health requires the token, which may arrive as
Authorization: Bearer <t>, X-Api-Key: <t>, or ?token=<t> — the last so browser WebSocket
clients, which cannot set handshake headers, can still authenticate. The compare is constant-time.
Every response carries X-RustLink-Version, including /health and including error responses.
| Route | Backed by | Notes |
|---|---|---|
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=&wipe=&limit= |
the store | Newest first; limit clamped to 1–1000. 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 |
4.1 The split between store-backed and live is deliberate
The store-backed reads answer while the game server is off, which is what lets the website render
a server list during a wipe or a restart. /status is the one route that fails when the game is
down, because "what is it doing right now" has no stale answer worth giving.
4.2 204 is an answer
GET /server answers 204, not 200 with a null, when the game has never connected. "We have
never heard from this server" and "this server reports nothing" are different answers, and a client
that cannot tell them apart renders a server that does not exist. module-rust maps the two onto
distinct stored states (reachable without online, versus neither).
4.3 Status codes carry the diagnosis
A wrong URL, a wrong token and a mismatched protocol all present to an operator as "the site says my server is offline", and each has a different fix. The codes keep them apart:
| Code | Means | Where the fix is |
|---|---|---|
409 |
protocol mismatch, both numbers in the body | upgrade one component |
401 |
wrong or missing token | the admin form |
503 |
no plugin connected | the game server |
504 |
the plugin did not reply in time | the game server, differently |
| (transport error) | nothing is listening | the sidecar, or the URL |
4.4 The RPC timeout is a ceiling on every later command budget
The sidecar waits 10 seconds for a correlated reply (rpc::REPLY_TIMEOUT). module-rust's own
client waits 12 seconds (TIMEOUT_MS).
Core's event dispatcher classifies a budgetMs overrun as retryable unconditionally — it cannot
ask the action, which is still awaiting a socket. So an action whose budgetMs does not exceed the
module's client timeout can never report retry: false, and that code is unreachable. The ordering
is:
sidecar RPC timeout (10s) < module client timeout (12s) < an action's budgetMs
Derive one from another rather than writing all three down independently.
5. What the plugin owes the game
Three rules, and each has a failure behind it. They are the ServUO bridge's, unchanged.
Emitis called from the main thread. It formats nothing, blocks on nothing, and touches no socket. It enqueues and returns. A slow, wedged, or absent sidecar cannot stall the game.- One link thread owns the socket. A single writer keeps event ordering intact. It reconnects with bounded backoff, and the backoff waits on a handle rather than sleeping — an uninterruptible sleep there is a stall of up to the backoff on every plugin reload, on the main thread.
- A reader thread parses inbound lines and marshals each to the main thread via
Interface.Oxide.NextTick. The reader touches no Unity object, noBasePlayerand noConVar.
The outbound queue is bounded, drop-oldest: on overflow the oldest record goes and is counted, because telemetry is worth less than the server's memory.
5.1 Diagnosing the link
rg.link
from the game server's console or over RCON:
protocol=1 serverId=main connected=True depth=0 sent=3 dropped=0 received=2
connects=1 writeErrors=0 bootId=boot-20260915T194502Z
This separates "the plugin is not loaded", "the plugin cannot reach the sidecar" and "the website cannot reach the sidecar", which look identical from the site.
6. Configuration
6.1 The plugin — oxide/config/RunicGateway.json
Written by Oxide on first load; edited like any other plugin's config.
{
"Host": "127.0.0.1",
"Port": 7799,
"QueueCap": 5000,
"ServerId": "main"
}
6.2 The sidecar — sidecar.toml
Resolved as --config <PATH>, else $RUSTLINK_CONFIG, else ./sidecar.toml. Environment variables
override the file.
| Key | Env | Default |
|---|---|---|
[game].bind |
RUSTLINK_GAME_BIND |
127.0.0.1:7799 |
[game].server_id |
RUSTLINK_SERVER_ID |
(empty) |
[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:
- A relative
[store].pathresolves against the directory holdingsidecar.toml, not the working directory. A service manager's working directory must not decide where the database lands — on Windows that can be%SystemRoot%\System32, or a silently redirected VirtualStore copy. [game].server_idis a cross-check, not a second source of truth. The plugin announces its ownserverIdand that is the authority; when both are set and they disagree, the sidecar logs the disagreement loudly and keeps the plugin's. Two game servers pointed at one sidecar by a copied config is the mistake this catches, and it is silent in every other design.
rust-link-sidecar --print-config resolves the configuration exactly as a normal start would —
writing the file and generating the token if they are missing — and prints it as JSON on stdout,
including the token in clear text. That is the supported way for an installer to read it back.
7. What is deliberately not here yet
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:
- 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. §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:
{
"kind": "player.death",
"type": "event",
"t": 1789510452152,
"serverId": "main",
"wipeId": "w-20260915T195817Z"
}
typeis required. A frame without one is dropped and counted, and the sidecar says so once per connection. It is not defaulted toevent: 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.serverIdis 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.wipeIdis 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
wipeIdis absent, never""and neverw-unknown. Absent is a fact; an empty string is a row that will sort beside every other empty string forever. - The id changes on
OnNewSaveand 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
OnNewSaveand 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 3–19 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:
{ "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.