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

@@ -164,6 +164,17 @@ Two failures that look alike and are not:
says what was true when it was written; nothing has written it since. Either the poll is failing
(the website's log) or the sidecar stopped (its own).
### 3.0 `untyped_frames` on `/health` is not zero
**The plugin and the sidecar are on different protocol versions.** The game link has no handshake
to catch that at connect time (`PROTOCOL.md` §2), so it shows up here instead: the sidecar files a
frame by its `type`, a frame from the wrong version does not carry one it recognises, and it is
dropped and counted rather than guessed at.
The symptom without this counter is the confusing one — a game server plainly up, a sidecar plainly
healthy, and a website showing nothing. Check the plugin's `rg.link` (it prints its protocol) against
the sidecar's `/health` (which prints its own) and upgrade whichever is behind.
### 3.1 The failures that are supposed to happen
Three things look like breakage and are the design:
@@ -193,6 +204,29 @@ you get a warning naming both ids.
---
## 4.1 What the bridge sends, and how much of it is kept
From protocol 2 the plugin sends the read path: connects and disconnects, deaths, chat, gathering,
bans and reports, and the wipe. Two things about the volume are worth knowing before you size
anything.
**Gathering and NPC kills are counted, not forwarded.** `OnDispenserGather` fires on every swing at
a tree; sending one frame per swing would make the bridge the most expensive thing on the server. The
plugin keeps a per-player tally and flushes it once a minute as a single `player.tally` frame. So the
leaderboard is exact and the wire is quiet.
**The sidecar's history is bounded; the website's is not.** `[store].retain_days` (default 14) is
how long the sidecar keeps raw events. The permanent record — per-wipe totals that survive a wipe —
lives in the website's own tables, so shortening this loses recent detail and never loses a player's
history. Set it to `0` to keep everything, if the host's disk is yours to spend.
**Every row carries its wipe.** The plugin derives a `wipeId` from the save's creation time and
stamps it on every frame, so a wipe splits the history rather than ending it. That is also why
**the sidecar's database must never be in a wipe script's delete list** — see the Pterodactyl egg's
`REMOVE_FILES`.
---
## 5. Upgrading
The four declaration sites in [`PROTOCOL.md` §2](PROTOCOL.md#2-versioning) must agree. In practice

100
rust-link/PLAYER_WALK.md Normal file
View File

@@ -0,0 +1,100 @@
# The player walk — proving the half of the read path a console cannot reach
Protocol 2's catalogue divides cleanly in two, and the line is not about importance: it is about
whether a hook can fire without somebody holding a mouse.
Everything in the first half was proven from a console and a REST client while phase 3 was built —
the boards, the wipe id, the envelope, bans, the server lifecycle. Everything below needs a **real
player on a real server**, because the hooks carry a `BasePlayer`, a `HitInfo` or a chat line, and
none of those three can be manufactured from a console without becoming a different test.
This document is the walk that closes it. It takes about ten minutes, it is the same on Oxide and on
Carbon, and it is written so that the answer is readable afterwards rather than watched live.
---
## Before you start
1. A rig running, with `RunicGateway.cs` loaded — `oxide.plugins` (or `c.plugins`) lists *Runic
Gateway*, and `rg.link` answers `connected=True`.
2. A sidecar the rig can reach, with its store **empty** — that is what makes the event list at the
end readable as a transcript of the walk and nothing else.
3. The sidecar's token to hand, for the reads at the bottom.
Run this once, before you join:
```
rg.hooks
```
Every player hook should read **silent**. That is the baseline: the point of the walk is to move
them, and starting from a run where some already fired proves less.
---
## The walk
Do these in order. The order matters only in two places, noted where it does.
| # | Do this | Fires | The frame should carry |
|---|---|---|---|
| 1 | **Join the server** | `CanUserLogin`, `OnUserApproved`, `OnPlayerConnected` | Three frames, in that order. The first two carry your **IP address** — check it is a real address and not the string `0`. `player.connected` carries your steam id and name |
| 2 | **Wake up / spawn in** (click Respawn if you are dead) | `OnPlayerRespawned` | `player.respawned`, steam id only. It does **not** fire if you simply wake from sleeping — that is the hook's own documented behaviour, so no frame here is a pass, not a failure |
| 3 | **Say something in chat**, then **say something in team chat** if you have a team | `OnPlayerChat` | Two `player.chat` frames, with `channel` reading `Global` and `Team`. The message must arrive whole — if it is truncated or the frame is missing, the flattener ate it |
| 4 | **Chop a tree for about twenty seconds**, then **mine a node** | `OnDispenserGather` | **Nothing immediately.** This is the aggregate: one `player.tally` frame within 60 seconds, carrying `gathered` with `wood` and `stones`, summed. Seeing a frame per swing would be the bug |
| 5 | **Kill an animal or a scientist** | `OnEntityDeath` | Again nothing immediately — `npcKills` on the next `player.tally`. No `player.death`: a chicken is not a killfeed entry |
| 6 | **Die to the environment** — fall damage is easiest | `OnPlayerDeath` | `player.death` with `attackerType: "environment"`, a `grid` like `H7`, and **no** `attackerId`. Check the grid against the map: a wrong sign in the row arithmetic mirrors the whole map, and only a human with the map open can see that |
| 7 | **Kill yourself**`kill` in the F1 console | `OnPlayerDeath` | `attackerType: "self"`, no `attackerId` |
| 8 | **If a second player is available**: kill each other once | `OnPlayerDeath` | `attackerType: "player"`, with `attackerId`, `attackerName`, a `weapon` shortname and a `distance` in metres. This is the killfeed's whole shape, and it is the one row phase 4's page is built from |
| 9 | **Build a foundation, then destroy it yourself** | `OnEntityDeath` | `entity.destroyed` with `ownerId` (yours), `prefab`, `grid` and `attackerId`. Decay must **not** produce one of these — only a player breaking it |
| 10 | **Disconnect** | `OnPlayerDisconnected` | `player.disconnected` with a `reason` and a **`sessionSec`** roughly equal to how long you were on. It also flushes your tally first, so any gathering since the last minute arrives immediately before it |
Two ordering notes: step 4 must come before step 10 by at least a minute if you want to see the
cadence flush rather than the disconnect flush, and step 1's three frames are the only place the
order between hooks is itself part of the answer.
---
## Reading the result
From the machine running the sidecar:
```bash
TOKEN=# [web].auth_token from sidecar.toml, or `--print-config`
BASE=http://127.0.0.1:8090
# The whole walk, oldest first, as a transcript.
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/feed?since=0&limit=500" \
| python -m json.tool
# Or one kind at a time.
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/events?kind=player.death&limit=20"
```
And from the game console:
```
rg.hooks
```
Every hook in the walk should now read **fired**, with a count. A hook still `silent` after the step
that should have fired it is the finding — and on Carbon it is the specific question
[`CARBON.md`](../modules/rust/CARBON.md) §6 asks, since Carbon's catalogue omits thirteen uMod names
and nobody has yet checked whether they are renames or holes.
---
## What counts as a pass
Not "frames arrived". Three things, and the third is the one worth slowing down for:
1. **Every hook in the table fired**, on both frameworks, from the same plugin file.
2. **Every frame carries the envelope**`type`, `serverId` and `wipeId` on all of them
([`PROTOCOL.md`](PROTOCOL.md) §8.1). A player frame without a `wipeId` cannot be attributed to a
wipe and its rollup is lost.
3. **The aggregates are aggregates.** `player.tally` is a delta since the last flush, so two minutes
of chopping is two frames that sum to the total, not two frames each carrying the total. Getting
this backwards makes every leaderboard roughly double, and it looks correct until somebody counts.
Anything that disagrees with the table is a finding about the game or the framework rather than a
mistake in the table — record it, the same way phases 0, 1 and 2 recorded theirs.

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.
---