# 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/`](../link/PLAN.md), 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 refused `409` with 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. So `overlay.toml` declares 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. ```json { "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 RunicGateway` must 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 at `Init` would 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 `saveCreatedAt` changing; 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 `, `X-Api-Key: `, or `?token=` — 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. 1. **`Emit` is 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. 2. **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. 3. **A reader thread parses inbound lines and marshals each to the main thread** via `Interface.Oxide.NextTick`. The reader touches no Unity object, no `BasePlayer` and no `ConVar`. 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. ```json { "Host": "127.0.0.1", "Port": 7799, "QueueCap": 5000, "ServerId": "main" } ``` ### 6.2 The sidecar — `sidecar.toml` Resolved as `--config `, 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].path` resolves against the directory holding `sidecar.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_id` is a cross-check, not a second source of truth.** The plugin announces its own `serverId` and 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: ```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 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: ```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. ---