# 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 — **8** as of the leases (§14) — 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 | | `GET /lease`, `POST /lease`, `POST /lease/release` | the plugin (RPC) | Protocol 8, the leases (§14) | ### 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. **Lease calls are the exception, and a deliberate one** (§14.7): `core.lease` spends one default budget on two calls, so the module's lease timeout is *shorter* than the sidecar's. --- ## 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 4 is the transport, the read path, identity and the permission mirror. 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)~~ — **protocol 3, §9** - ~~the permission mirror (phase 7)~~ — **protocol 4, §10** - ~~plugin configuration edited from the site (phase 7b)~~ — **protocol 5, §11** - ~~clans, for core's Team provider (phase 9)~~ — **protocol 6, §12** - ~~who lives in a raided base, for the raid alert (phase 10)~~ — **protocol 7, §13** - 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` | **presence** | steamId, name | | `player.disconnected` | `OnPlayerDisconnected` | **presence** | steamId, name, reason, sessionSec | | `player.respawned` | `OnPlayerRespawned` | **presence** | steamId | | `player.death` | `OnPlayerDeath` | **presence** | victim, attacker, attackerType, weapon, distance, grid | | `player.chat` | `OnPlayerChat` | **presence** | steamId, name, channel, message | | `player.tally` | *aggregate* — see §8.6 | **presence** | steamId, gathered{}, npcKills, structures | | `entity.destroyed` | `OnEntityDeath` on owned building blocks — **and doors, external walls and the cupboard from protocol 7 (§13)** | **staff** | ownerId, prefab, grid, attacker; from protocol 7 also `structure`, `buildingId`, `authorized` | | `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 | — | | `account.link.requested` | `/link` chat command *(protocol 3)* | **staff** | steamId, name, ttlSec — **never the code** | | `account.unlinked` | `/unlink` chat command *(protocol 3)* | **staff** | steamId, name, origin | `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. **`presence` is `public` with an audience an operator chooses** (added 2026-09-22, [`PLAN.md`](../modules/rust/PLAN.md) §23). The six kinds marked so each say that a *named* player was on the server at a given moment, and the org lead's rule is that nothing names who is online by default: `module-rust` serves them only to viewers inside an operator-chosen audience — staff unless widened, fleet-wide with a per-server override. Below it the public feed carries only what names nobody (a wipe, a start, a shutdown). Nothing on the wire changed: the class is still the module's to enforce, which is why the rule could be added without a protocol bump. `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. --- ## 9. Protocol 3 — identity R1's identity link, and the first message in this bridge that the **website** originates. Everything in protocol 2 was the game talking, or the sidecar asking the game to repeat something it already knew. The shape is the one the UO bridge proved: the player asks in game, the plugin mints a one-time code and hands it to them privately, and the website redeems it through the sidecar. ``` player plugin sidecar website │ /link │ │ │ ├────────────────────►│ mint code, hold it │ │ │◄────── code ────────┤ in memory, 5 min │ │ │ ├─ account.link.requested ►│ ───── feed ───────►│ │ │ │ ………… the player types the code into the website ……………………………►│ │ │ │◄ POST /link/confirm ┤ │ │◄──── link.confirm ───────┤ │ │ ├───── link.ok ───────────►│ ── steamId, name ──►│ │ │ (code spent) │ │ ``` **Nothing about the link is stored in the game.** The site is the author of record, which is not a preference: there is no per-account store in Rust that survives a wipe, and phase 7 makes the site authoritative anyway — it pushes permissions *into* the game keyed by Steam id. A copy on the game host would be a second thing to reconcile every wipe, answering no question better. ### 9.1 `/link` and `/unlink` are CHAT commands, and the reply is private `[ChatCommand("link")]`. Both frameworks consume a `/` command rather than broadcasting it, and `SendReply` addresses one player — so neither the request nor the code reaches anybody else's chat. That is load-bearing rather than polish: **a code read off a stream is a code somebody else can spend.** `/unlink` emits rather than deletes, because the plugin holds no link to delete. It exists because the website **refuses** to move a Steam id another account already holds (D23): without a way out, a player who linked the wrong account while signed in as it would need staff. The authority on that path is the Steam account itself — whoever is connected to the game as it is who it is. ### 9.2 The code is **not** on the wire `account.link.requested` carries the Steam id, the name and the TTL, and **never the code**. The event exists so an operator can see linking being used and so the site can see a player fishing; it is not how the code travels. The code travels **through the player**, which is what makes typing it into a signed-in browser proof that they are the one who asked. Both account frames are **staff** class (§8.5). Neither carries a secret, but both name a Steam id beside a website account's activity, and that join — *this player is that person* — is a fact about somebody's identity rather than about what happened on the server. ### 9.3 `link.confirm` — website → plugin The first inbound command that is not a request to repeat something. ```json { "cmd": "link.confirm", "reqId": "r-42", "code": "K7M2PQ" } ``` Answered with `link.ok` carrying `steamId` and `name`, or `link.error` carrying a `reason` of `unknown`, `expired` or `malformed`. Both are replies, correlated by `reqId` like `server.status`. **A code is consumed on the FIRST lookup, whether or not it turns out to be expired.** The removal happens before the expiry check rather than after it, so a code cannot be probed twice. **`unknown` and `expired` are separate here and identical to the player.** An operator reading a log wants to know whether codes are being guessed or merely going stale; a stranger typing codes must not learn which of the two they hit, because that is the difference between "keep guessing" and "guess faster". ### 9.4 The code itself Six characters from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` — **no O, 0, I or 1**, because a player reads this off their screen and types it into a browser, often on a phone. A five-minute TTL, a thirty-second cooldown per player, **one outstanding code each** (a new `/link` drops the old one), and a purge timer, because an unconfirmed code is never looked up and nothing else would ever remove it. They live in plugin memory and nowhere else. A plugin reload drops every pending code — and phase 7b's config editor will reload plugins routinely — but the cost of that is a player typing `/link` again, which is cheaper than an unconfirmed credential living in a second process. ### 9.5 `POST /link/confirm` — the first route on this sidecar that is not a GET ``` POST /link/confirm { "code": "K7M2PQ" } → 200 { "kind": "link.ok", "steamId": "765…" } → 200 { "kind": "link.error", "reason": "unknown" } → 503 the game is not connected → 504 the game is up and did not answer ``` **A refused code is a `200`.** `link.ok` and `link.error` are both answers; the sidecar reserves its own status codes for the transport, because the website has to tell *"that code is wrong"* from *"the game never replied"* to say the right thing to a player (§4.3). The sidecar validates nothing but the shape — it trims the code, bounds its length, and forwards it. Only the game holds the pending codes, and putting the table here instead would give the sidecar a credential and an opinion, which D2 and the bridge principles say it has neither of. ### 9.6 The website asks EVERY server (D24) A code is minted by one server, and the player types six characters into a browser. Nothing in the code says which server it came from, so the module asks each configured server in turn and the first `link.ok` wins; the others answer `unknown` and nothing happens there, because a code is only spent at the server that holds it. Asking the player to pick was rejected: a wrong pick comes back indistinguishable from a wrong code. The consequence for this protocol is worth stating, because it is the shape of every later fleet-wide command: **"every reachable server refused" is not the same answer as "a server could not be reached"**, and a module that collapses them tells the player whose server is down that their code is wrong — so they fetch another code from the same server and hear it again. --- ## 10. Protocol 4 — the permission mirror R2, and the first command on this bridge that **changes the game**. Protocol 3's `link.confirm` was the website originating a message, but it spent a code the game itself had minted; this writes to a store the game enforces. ``` website sidecar plugin │ │ │ ├── POST /permissions/sync ►│ ──── perm.sync ─────────►│ diff against the │ the whole desired set │ (the same object) │ live store, apply │ │ │ the difference in │◄──── the report ──────────│◄──── perm.report ────────┤ bounded steps │ │ │◄──── perm.drift (event) ──────────────────────────────┤ somebody else wrote ``` **The website is the author of record and the framework's store is an enforcement cache.** Every third-party plugin honours a site grant with no adapter, because they all already call `permission.UserHasPermission` — reaching them is the point, and it is why the site does not keep a private table of its own. ### 10.1 One verb, and the PLUGIN does the diffing `perm.sync` carries the whole set the site authors **for that server**. The plugin compares it against the live store and writes only what differs. The alternative — the plugin reporting its store and the website computing the difference — was rejected for two reasons. The store is the bigger of the two sets and would cross the wire constantly, and a website holding a copy of it has a second source of truth that is stale the moment it lands. ```json { "cmd": "perm.sync", "reqId": "r-42", "setId": "69dfc769…", "groups": [ { "name": "vip", "title": "VIP", "rank": 10, "permissions": ["kits.vip"], "members": ["76561198000000001", "76561198000000002"] } ], "grants": [ { "steamId": "76561198000000001", "permissions": ["kits.gold"] } ], "managed": ["kits.vip", "kits.gold"], "retire": [ { "kind": "grant", "subject": "76561198000000003", "object": "kits.silver" } ] } ``` | Field | Means | |---|---| | `setId` | the site's digest of the set, echoed in the report. It is how the site knows a report describes the set it sent rather than an earlier one | | `groups` | group definitions, what each carries, and who is in it. **Three separate facts**, because the game can fail at each independently | | `grants` | permissions held by one account without a group | | `managed` | the permission namespace the site claims. Foreign holders are only looked for within it — which also bounds the scan by the site's own set rather than by the size of the store | | `retire` | what the site put there and has since withdrawn (§10.3) | ### 10.2 `perm.report` — what actually happened ```json { "kind": "perm.report", "type": "reply", "reqId": "r-42", "setId": "69dfc769…", "applied": { "grants": 1, "revokes": 0, "groupsCreated": 1, "groupPermissions": 1, "members": 2, "membersRemoved": 0, "groupsRemoved": 0, "groupPermissionsRemoved": 0 }, "alreadyCorrect": 14, "absent": 0, "unresolved": ["kits.gold"], "pending": ["76561198000000003:vip"], "foreign": [{ "kind": "grant", "subject": "76561198000000009", "object": "kits.admin" }], "operations": 4 } ``` **`unresolved` and `pending` are the two ways a push looks like it worked and did not**, and both are load-bearing: - **`unresolved`** — no loaded plugin on that server has registered the name. `permission.GrantUserPermission` returns void, throws nothing and logs nothing for an unregistered name ([PLAN.md §12.2](../modules/rust/PLAN.md) rule 1), so without the `PermissionExists` pre-check the grant vanishes without a trace. The plugin does **not** register the name itself: that fabricates a permission the operator never installed. - **`pending`** — the store has never seen that player, so there is no user record to put in a group (§12.2 rule 4). A **direct grant** to the same account works immediately, and the asymmetry is exactly why groups are not the only shape the site can express. The membership lands on their first connection. Neither is recorded by the website as pushed. A site that recorded them would believe it had given a privilege it had not — and would later "retire" it from a server that never had it, which is a no-op that reads as a success in every log. **A refusal of the whole sync is `perm.error`**, with a reason of `busy` (an earlier sync is still draining) or `too-large`. Like `link.error` it is a `200` from the sidecar: the transport worked and the game answered. ### 10.3 Retirement is the one thing the game cannot work out A name in the store that is not in the desired set is **either** something the site authored and has since withdrawn **or** something a human granted at a console — and those two have opposite correct answers. The store records who granted a permission nowhere, so only the website can tell them apart, from its own memory of what it pushed. So the site sends `retire` explicitly, and everything else it did not ask for comes back as `foreign`. **Nothing in `foreign` is ever removed by a sync** (D31): a console `oxide.grant` during an incident is drift, not an error, and an operator is offered two answers to it on the website — adopt it, or revoke it. ### 10.4 `perm.drift` — a reason to reconcile, not the reconciliation Both frameworks raise a hook for every permission write. The plugin subscribes to six of them and emits `perm.drift` for writes **it did not make itself**, staff class (§8.5): it names a Steam id beside a privilege, which is a fact about a person's standing rather than about what happened on the server. ```json { "kind": "perm.drift", "type": "event", "action": "granted", "steamId": "76561198000000009", "permission": "kits.admin" } ``` `action` is one of `granted`, `revoked`, `group-added`, `group-removed`, `group-permission-granted`, `group-permission-revoked`. **It cannot say whether the change is foreign** — only the desired set can, and that comparison happens in a sync. So the website treats the frame as a reason to reconcile *soon*: a hand edit shows up in seconds instead of at the next audit, and the authoritative answer still arrives as a report. That division is what makes the hooks safe to trust at this weight: one that stops firing on a framework upgrade costs latency, not correctness. The plugin suppresses them while it is applying a sync, because they fire for its own writes too — and the site cannot tell its own grant from a human's by looking at one. ### 10.5 Nothing the far side sends may cost the main thread unbounded work This is the first command whose work is **not** bounded by its own shape. A community with two thousand linked players sends thousands of store operations in one frame, and applying them in the tick the frame arrives is a freeze an operator will blame on the game. So a sync is compiled into a list of single-store operations and drained a few hundred at a time on a timer; the report goes back when the last one lands. Compiling touches nothing, so an oversized or malformed sync is refused before any state exists to unwind. That is §5's rule — the one that keeps a wedged sidecar from stalling the game — pointed at the inbound half. Three bounds, each on the side that can say something useful when it is hit: | Bound | Where | Why there | |---|---|---| | ~15,000 rows | the website | it can name the server and reach an operator | | 1 MiB | the sidecar | it is the game link's own line cap (§3.1); forwarded, the line is discarded silently and presents as a `504` | | 20,000 operations | the plugin | past it, a half-applied permission set is the state nobody can reason about | ### 10.6 `GET /permissions/catalogue` A live round trip to the plugin: every permission the loaded plugins have registered, and the groups the store holds. It is the option source behind the website's authoring form — a grant can only be written against a name that will actually resolve — and, like `/status`, it fails when the game is down, because "what exists right now" has no stale answer worth giving. ### 10.7 What the sidecar does NOT do It defines no schema for either body. Protocol 4 adds the largest command on this bridge and touches neither the store nor the feed, which is §8.1's dumb-forwarder property paying for itself a second time. What it does own is the envelope: `cmd` and `reqId` are written over whatever the caller sent, so no request can arrive claiming to be a different command or aimed at a correlation id somebody else is waiting on. --- ## 11. Protocol 5 — configuration from the site R18, and the first command on this bridge that writes to the game host's **filesystem**. Protocol 4 wrote to a store the game owns through an API the game owns; this replaces bytes in a file and then asks the framework to read them. ``` website sidecar plugin │ │ │ ├── GET /config/files ─────►│ ──── config.list ───────►│ walk ConfigDirectory │◄──── the tree ────────────│◄──── config.catalogue ───┤ (never DataDirectory) │ │ │ ├── GET /config/file ──────►│ ──── config.read ───────►│ one file + a version │ │ │ ├── POST /config/write ────►│ ──── config.write ──────►│ back up, write, │ whole file TEXT │ │ reload, WATCH │◄──── the report ──────────│◄──── config.report ──────┤ …or restore it all ``` **The website composes the bytes and the plugin writes them.** That split is the one design decision everything else here follows from, and §11.5 is why. ### 11.1 The roots come from the framework, and one of them is forbidden The walk is rooted at `Interface.Oxide.ConfigDirectory` — `oxide/config` on Oxide, `carbon/configs` on Carbon, and neither on a server whose operator moved it with `-carbon.configdir` ([`CARBON.md`](../modules/rust/CARBON.md) §3). It is never composed from a literal, and that amendment was proven the best way it could have been: this bridge's own config landed in **both** places, written by the same source file. `DataDirectory` is **never walked**. It holds live state — kit cooldowns, zone definitions — and both frameworks' own permission stores (`oxide.users.data`, `oxide.groups.data`), which is protocol 4's mirror one directory over. A settings editor that strayed there would be editing §10 underneath itself. ### 11.2 `config.list` — a description of the tree, never its contents ```json { "kind": "config.catalogue", "type": "reply", "reqId": "r-7", "root": "/home/container/oxide/config", "self": "RunicGateway", "files": [ { "path": "ZoneManager.json", "bytes": 4210, "modified": 1758500000000, "plugin": "ZoneManager", "editable": true }, { "path": "Kits/kits.json", "bytes": 980, "modified": 1758400000000, "plugin": "Kits", "editable": true }, { "path": "Huge.json", "bytes": 9400000, "editable": false, "reason": "larger than this bridge will carry" } ], "plugins": [ { "name": "ZoneManager", "title": "Zone Manager", "version": "3.1.14" } ], "truncated": false, "limits": { "depth": 6, "files": 500, "fileBytes": 262144, "writeFiles": 10 } } ``` Four things about that shape are load-bearing. **No file is hashed here.** A version is produced by `config.read`, on the one file somebody actually opened. Hashing 500 files would be up to 128 MB of reads in a single frame, which is the unbounded main-thread work §10.5 forbids — so this walk reads directory entries and nothing else. **`plugin` is a GUESS and is labelled one all the way to the form.** It is the folder for a nested file and the filename otherwise, and a folder name is convention rather than contract. Infer it silently and the failure is the nastiest available here: the wrong plugin is reloaded, `OnPluginLoaded` fires for *it*, and the write is reported as a success while the plugin that was actually edited never re-read anything. **A file past a limit is listed and marked, never hidden.** An operator who cannot find a file they know exists goes looking for a bug in the bridge; one who can see why it was refused does not. **`self` is the plugin naming itself**, so the website can lock the three keys in *our* config that would cut this link (§11.6) without matching on a filename somebody may rename. ### 11.3 `config.read` — one file, and the version a write must present back ```json { "kind": "config.file", "type": "reply", "reqId": "r-8", "path": "ZoneManager.json", "text": "{\n \"Auto Show\": true\n}", "version": "1a4-3f2c8a91b0de4471", "bytes": 420, "modified": 1758500000000 } ``` `version` is the file's length and an FNV-1a hash of its text. It is deliberately **not** a cryptographic digest: nothing here is a security claim — the website never computes one, it only echoes back the one it was given — and `System.Security.Cryptography` is one more thing that would have to be available under two plugin compilers. ### 11.4 `config.write` — the set, the reload, and the undo ```json { "cmd": "config.write", "reqId": "r-9", "files": [ { "path": "ZoneManager.json", "version": "1a4-3f2c…", "text": "{…}" } ], "reload": "ZoneManager" } ``` The plugin, in order: 1. resolves and guards every path (§11.6), checks every version, and checks that every document parses — **before the first byte is written**. Same posture as `perm.sync`: a refusal that has touched nothing has nothing to unwind; 2. backs each file up under `DataDirectory/RunicGateway/config-backups/`, keeping the last ten per file, and holds the original in memory for the rollback; 3. writes the set; 4. reloads the named plugin **through the framework**, not by composing a console string — Carbon's commands are `c.`-prefixed, an alias for the Oxide names is opt-in, and a wrong prefix on Carbon prints *nothing*, so it looks exactly like a command that worked; 5. waits up to **four seconds** for `OnPluginLoaded` naming that plugin; 6. if it arrives, re-reads each file and reports the new versions. If it does not, **restores every file, reloads again, and reports the failure with the tail of the server's newest log file.** ```json { "kind": "config.report", "type": "reply", "reqId": "r-9", "ok": false, "reloaded": false, "rolledBack": true, "reason": "'ZoneManager' did not reload within 4s", "log": "…Error while compiling ZoneManager…", "files": [ { "path": "ZoneManager.json", "version": "1a4-…", "rewritten": false } ] } ``` **That rollback is the feature.** Without it this is a web form that takes a required plugin off a production server one typo at a time — and four plugins are required (R6/R17), so a broken `ZoneManager` config is also event participation gone. Three consequences worth naming: - **The window is arithmetic, not taste.** The worst path is two windows — wait, give up, restore, wait again — and the caller holds a socket throughout. It must fit inside the sidecar's `REPLY_TIMEOUT` (§4.4, 10s), or the rollback report arrives after the only thing waiting for it has gone. The sidecar mirrors the number as `web::CONFIG_RELOAD_WINDOW` and a test asserts the inequality rather than trusting it. - **`rewritten` is normal.** Both frameworks merge missing defaults into a config on load and save it back, so the file after a successful reload is regularly not the file that was sent. The report says so; a website that assumed otherwise would conflict with itself on the next save. - **The bridge will not reload itself.** The reload would unload this plugin and close the link carrying the answer, leaving a rollback with nothing watching it — the one failure the mechanism exists to report would be the one it could not. `reload-self` is refused, and our own settings apply on the next deliberate reload instead. ### 11.5 JavaScript cannot tell `1` from `1.0`, so it never writes the number `JSON.parse('{"Rate":1.0}')` yields `1` and `JSON.stringify` writes `1`. Both frameworks deserialize a config into typed C# classes, so a naive read-modify-write **silently rewrites every whole-numbered float as an integer, on fields nobody touched** — and Newtonsoft may coerce that or may throw. A throw at load is a plugin that does not come back. So the website never parses, mutates and re-serialises. Its editor records the **source span** of every value and splices new literals into them, which is why `config.write` carries whole file text: the bytes on the wire are the bytes that will be on disk, and the fields nobody edited are byte-identical. A number's new value travels as the literal an admin typed, and never becomes a JavaScript number anywhere in the path. The plugin's contribution to that is deliberately nothing beyond checking that the document parses. Giving this end an opinion about content would put the decision in two places, and only one of them can be tested against a real Newtonsoft. ### 11.6 Addressing by path is a new bug class, and it is guarded here Protocol 4 addressed things by name. This addresses them by path, which is exactly the change that introduces traversal — so the plugin refuses a path that is absolute, carries a drive letter, contains `..`, does not end in `.json`, or does not resolve **under the canonicalised config root**. Links are not followed: any file or directory carrying a reparse point is skipped by the walk and refused by the resolver, because resolving one is how a tree that looks bounded turns out not to be. The sidecar forwards the path verbatim and judges nothing, as it forwards a link code and a permission set. That is not laziness: only the process holding the directory can decide whether a path resolves inside it, and a guard in the middle would be a weaker second opinion in a place with no way to check it. The website checks the *shape* before spending a round trip, and the bridge's own three keys — `Host`, `Port`, `ServerId` — are refused there rather than here, because "which file is ours" is a question about the website's configuration, not about the game's. ### 11.7 What the sidecar does NOT do It stores nothing. Nothing from protocol 5 reaches the store or the feed: a config this sidecar cached would be an edit an operator made over SSH that the website then silently overwrote. All three routes fail when the game is down, like `/status`, because "what is on that host's disk" has no stale answer worth giving. The one thing it adds is a better `504`. A timeout on `/config/write` is the only timeout on this bridge with a knowable answer, because the plugin writes a whole set or restores a whole set and never half of either — so the body says to re-read rather than to guess, and names the reload window that is probably still running. --- --- ## 12. Protocol 6 — first-party clans Phase 9, R5. Rust's **own** clan system becomes core's Teams: the plugin reports it, the module answers core's Team provider from it, and the website owns the clan page. Design of record: [`PLAN.md`](../modules/rust/PLAN.md) §24 (D47–D58). It is not the uMod **Clans** plugin. That is a separate system that never touches the game's `ClanManager` (D47), so a server running it has two unrelated clan systems, and only the game's becomes Teams. The plugin reads nothing of it except whether it is loaded. **The sidecar changed nothing but its version.** One board and five events, filed by `type` (§8.1). ### 12.1 The `clans` board A snapshot, re-sent on connect, on the 60-second cadence, and about three seconds after any clan hook fires, so a roster follows the change that caused it. ```json { "kind": "clans", "type": "snapshot", "t": 1790158748054, "serverId": "rust-oxide", "enabled": true, "backend": "LocalClanBackend", "supported": true, "truncated": false, "umodClans": false, "count": 1, "clans": [{ "clanId": 1, "createdMs": 1790158729260, "name": "Northwatch", "color": "#3fa9f5", "score": 0, "maxMembers": 100, "members": [{ "steamId": "76561190000000001", "rank": 1, "role": "Leader", "joinedMs": 1790158729264, "name": "…" }] }] } ``` | Field | Meaning | |---|---| | `enabled` | The game's `clan.enabled` convar. `false` is an authoritative answer — no clans — and is `supported` | | `supported` / `reason` | Could the plugin read the clans at all. `false` when the backend has not started, or is not the local one (a **Nexus** server keeps its clans elsewhere) — refused with a reason rather than guessed at | | `truncated` | There may be clans the board does not list. See §12.3 | | `umodClans` | The uMod Clans plugin is loaded. The website warns that its clans are not Teams | | `rank` | The member's role rank. **Rank 1 is leader**, and several members may hold it. Absent when the member's role id matched no role — never defaulted | | `name` | Present when the framework knows the player. Absent, not empty, otherwise | A member's `LastSeen` is **not sent**. It is presence, and nothing names who is online by default (PLAN.md §23). ### 12.2 The five events | `kind` | Hook | Carries | |---|---|---| | `clan.created` | `OnClanCreated(LocalClan, ulong)` | clan, founder (`steamId`, `name`) | | `clan.disbanded` | `OnClanDisbanded(LocalClan, ulong)` | clan, who disbanded it | | `clan.member.added` | `OnClanMemberAdded(long, ulong)` | clan, the new member | | `clan.member.left` | `OnClanMemberLeft(LocalClan, ulong)` | clan, the member | | `clan.member.kicked` | `OnClanMemberKicked(LocalClan, ulong, ulong)` | clan, the member, and who kicked them (`bySteamId`, `byName`) | "Clan" is always `clanId`, `createdMs` and `clanName`. Every one is **`staff` class** in §8.5's terms: clan membership is members-only (D49), so the public feed never carries it. It reaches a clan's members through core's Team feed, where core decides who is a member. `OnClanColorChanged` is hooked too, but produces no event: a colour is a property of the clan, so it travels on the board, and the hook only brings the next board forward. Three facts the hook sites impose, all read from the game's assemblies: - **The founder's membership fires no `OnClanMemberAdded`.** The game adds them inside the creation, so `clan.created` implies it. - **`OnClanMemberAdded` hands over a bare id**, fired from inside the database layer before the game's cached clan is refreshed. The plugin reads the clan back for `createdMs` and `clanName`; if even that fails the frame carries `clanId` alone and the website matches on it. - **The game raises no promote or demote hook.** Leadership travels on the board only, and the website diffs one board against the next (D54). ### 12.3 Identity, and the ceiling **A clan's identity is `clanId` AND `createdMs`.** The game keeps clans in `clans..db` with the version hard-coded, so a game update that bumps it starts a fresh file whose ids restart at 1. The website keys a Team on `::` (D52), and every clan frame carries both halves for that reason. **The game has no "list every clan" call.** Its backend offers get-by-id and get-by-member; the only listing is the clan leaderboard, which runs `SELECT … ORDER BY score DESC LIMIT ?` with the limit **clamped to 100**. So the board lists at most the top 100 clans by score. A board at that ceiling cannot be told apart from one with exactly 100 clans, and says `truncated: true` either way. It also says so if its rows would pass **768 KiB**, well inside the sidecar's 1 MiB line cap, which drops a longer line outright — a board that never arrived would read as a server with no clans. The org lead accepted the ceiling (D55) over reading the game's private SQLite schema directly. A truncated board is answered to core as partial, so core adds and updates Teams there and never removes one on its word. ### 12.4 A hook name another plugin also raises The uMod Clans plugin raises `OnClanDisbanded(string tag, List members)`, and a Universal form with `List`. Both have **the same name and arity** as the game's `OnClanDisbanded(LocalClan, ulong)`. The bridge declares the game's types exactly, and the framework matches a call to a method by its argument types, so neither call reaches it. **Walked on the Oxide rig (2026-09-23):** both uMod-shaped calls were raised from a rig plugin, and the bridge's own `rg.hooks` count for `OnClanDisbanded` stayed at the one real disband, with nothing logged. A loosely typed signature (`object, object`) would have filed the plugin's clans as the game's. ## 13. Protocol 7 — the raid frame names who lives there Added in phase 10 ([`PLAN.md`](../modules/rust/PLAN.md) §25). The raid alert goes to the people whose base it was (D59), and protocol 2's `entity.destroyed` could not say who that is: it named the block's **placer** (`ownerId`), which is not the base's owner in any sense a Rust player recognises, and it fired only for `BuildingBlock` — which a door is not. **No new kind and no new route;** one frame widens, and `clan.disbanded` gains its roster. ### 13.1 `entity.destroyed`, widened It now fires, still only when a real (non-NPC) player did it and `OwnerID` is non-zero, for four kinds of entity. The kind travels as `structure`: | `structure` | Game type | Notes | |---|---|---| | `block` | `BuildingBlock` | as before; the only kind the `structures` tally counts, so that column keeps its meaning | | `door` | `Door` (an `AnimatedBuildingBlock`, a *sibling* of `BuildingBlock`) | external gates are doors too | | `wall` | `SimpleBuildingBlock` | external walls | | `cupboard` | `BuildingPrivlidge` | the tool cupboard itself; `OnEntityDeath` runs before the kill, so it still reports its own list | Two fields are added when the entity resolves to a cupboard (`DecayEntity.GetBuildingPrivilege()`, which goes through the building, or the cupboard itself): | Field | Meaning | |---|---| | `buildingId` | the cupboard's network id, as a string — the base's identity, and the raid alert's cooldown subject | | `authorized` | `[{ steamId, online }]` from the cupboard's `authorizedPlayers`, **bounded at 64**; `authorizedTruncated: true` when cut | **Both are ABSENT when there is no cupboard**, which is a different answer from an empty list, and the website alerts nobody in that case (D67). `recentGroupMembers` — which also sits on the cupboard — is **not** authorisation: it counts code-lock users toward group upkeep, and it is not sent. `attackerId` is now derived from the player's `userID` rather than `UserIDString`, which the game fills in only for a connected player, a loaded sleeper or an engine bot. The website skips the alert when the attacker is on the cupboard (a self-demolish, a teammate), and a null would defeat that. The class is unchanged: **staff**. A structure's grid is where somebody lives, and the frame now also names who. It reaches a player only through the raid alert, which is ceilinged `owner` and sent one person at a time. ### 13.2 `clan.disbanded` carries `members` The Steam ids of the clan it ended. The website tells a disbanded clan's members, and by the time it reads the frame the next `clans` board may already have removed the roster from its store — the board is re-sent seconds after the event, and after an outage it is applied before the backlog. The game deletes the clan and walks `Members` to drop each membership, but never empties the list, so it is whole when the hook fires. Bounded by the clan's own member limit. ### 13.3 The sidecar `PROTOCOL_VERSION` becomes 7 and nothing else changes: both frames are `event`s, stored and served as they arrive (§8.1). The bump exists because a website that alerts on `authorized` must not pair with a protocol-6 plugin that never sends it — against one it would read every raid as a base with no cupboard and alert nobody while looking healthy. ## 14. Protocol 8 — the leases Added in phase 12 ([`PLAN.md`](../modules/rust/PLAN.md) §27). An event borrows a value on a server and gives it back. **Three commands, one event and one plugin config key.** The plugin holds the allowlist, the bounds, the seven-day ceiling and the deadline. The website holds the ledger (core's `core.lease`). This process forwards three routes and learns nothing about either side. The shape is UO's lease plane (`link/v6.md` §8), and its three rules carry over unchanged: - **`holdMs` is authoritative and `untilMs` is display.** An absolute deadline computed on the website and honoured on the game host is measured against two clocks. - **Values cross as text and compare parsed.** - **A hold over the ceiling is refused, never clamped.** What differs from UO is what Rust's convars and permission store are like (§14.4). ### 14.1 The commands ```json {"cmd":"lease.apply","reqId":"r-7","key":"decay.scale","family":"decay","value":"0", "holdMs":3600000,"untilMs":1790000000000} ``` | Command | Answers | | |---|---|---| | `lease.list` | `lease.list` | Every allowlisted key with `family`, `min`/`max`, `current` (or `unreadable` with a reason), `held`, and while held `baseline`/`applied`/`untilMs`/`runId`. Plus `holds` (every hold in force), `eventsEnabled` and `maxHoldMs`. **Narrowed by `key`, and by `target` for a group permission**, which has one value per pair rather than one per key | | `lease.apply` | `lease.ok` or `lease.error` | `lease.ok` carries `baseline`, `applied` and `untilMs` | | `lease.release` | `lease.ok`, `lease.drifted` or `lease.error` | Compare-and-set. `lease.ok` carries `restored`, or `targetGone: true` for a group deleted mid-hold | **The allowlist is the plugin's.** It holds `decay.scale` (family `decay`, 0–10), eighteen animal and vehicle `*.population` convars (family `population`, 0–50, **all per square kilometre**, vehicles included, whatever the game's help text says), and `spawn.min_rate` and `spawn.min_density` (family `spawn`, 0–10). Every key was walked live: set, seen changing the game's own computation, and given back. The two `spawn.max_*` scalars only matter with players online, and no walk has had any, so they are not lent (PLAN.md §27.5). A `family` sent with an apply must match, so a website that confused two leases is refused rather than obeyed. The one key that is not a convar is `group.permission`, whose `target` is `group/permission` (split at the **last** slash, because a group name is free text and a permission name never contains one) and whose value is `true` or `false`. **The plugin grants with a `null` owner.** Given an owner, Oxide's `GrantGroupPermission` first checks that *that* plugin registered the name, and returns silently when it did not. Every permission a lease borrows belongs to another plugin, so the call has to name none. **`lease.error` reasons**, each with a `message` meant for an operator: | `reason` | Means | Worth retrying | |---|---|---| | `events-disabled` | `EventsEnabled` is off on this server (§14.3) | no | | `unknown-key` | not a value this server lends, or not in the family named | no | | `out-of-range` | outside the plugin's own bounds for the key | no | | `too-long` | `holdMs` over seven days | no | | `unresolved` | a group permission naming a permission no loaded plugin registered | no | | `target-gone` | the group does not exist | no | | `malformed` | a field is missing or unparseable | no | | `unreadable` | the current value could not be read this moment | yes | | `refused` | the game did not take the value: it read back as something else, and the old value was put back | yes | ### 14.2 The two mechanisms **The deadline lives on the game.** A hold is checked every second. When its deadline passes, the plugin restores the baseline (compare-and-set, as a release would) and emits `lease.expired`, whether or not the website is ever heard from again: ```json {"kind":"lease.expired","type":"event","key":"decay.scale","runId":"77","drifted":false} ``` The website maps it to nothing. Core learns what happened through `restore` and `inForce`, just as UO's website does. **Release is compare-and-set.** The comparison is against what the lease applied, taken from the plugin's own record of the hold when it has one, else from the website's `expected`. A current value that is neither what was applied nor what would be restored was moved by somebody on purpose. The answer is `lease.drifted` with that value, the world is left alone, and the hold is over. A current value that already equals the baseline is a success and nothing is written, which is what a release finds after a deadline or a restart has already given the value back. **A drifted release is a `200`**: the plugin did what it was asked. **An apply of a key already held keeps the original baseline.** Core reserves the target before it applies, so a second holder is refused on the website's side. A second apply arriving here therefore means the first one's answer was lost and core is trying again. The value to give back is still what was there before anybody borrowed it. ### 14.3 `EventsEnabled` A new key in the plugin's config, **`false` by default** (D76), written into an existing config the first time protocol 8 loads so that the site's config editor can show it. It gates **`lease.apply` only**. Listing and releasing always work, so switching events off never strands a value somebody already borrowed. It is its own switch for UO's reason: a scheduled change to the world at four in the morning is a different consent from a permission sync or a moderation action. ### 14.4 What a restart gives back, and what it does not **No allowlisted convar is `Saved`.** The game writes the `Saved` set to `serverauto.cfg`, and none of these is in it. So a convar hold is memory-only, and **a restart is a free restore**. The plugin checks rather than trusts: at load it refuses any allowlisted convar whose `Command.Saved` is true, with a reason in `unreadable`. **A group permission is persisted by both frameworks**, so a hold on one survives a crash, a restart and a plugin reload. The plugin therefore keeps its own record, `leases.json` under its data directory (which R18's editor never walks), with the `bootId` each hold was taken under: | On load | A convar hold | A group-permission hold | |---|---|---| | **Same boot** (a plugin reload) | re-armed: the value is still in the game's memory, and a config save must not end an event | re-armed | | **New boot** | dropped: the restart restored it | re-armed, and restored at once if its deadline passed while the server was down | Holds are **not** given back on unload. The file keeps them. ### 14.5 The permission mirror defers to a lease A `(group, permission)` pair held by a lease belongs to the lease until the hold ends. `perm.sync` neither grants nor revokes it, and the scan never reports it `foreign`. It is listed in the report's new **`leased`** array. What the site asked for in the meantime is recorded on the hold, and at release the pair is set to **that** rather than to the baseline: the lease borrowed the pair, and the site owns what it becomes afterwards. The plugin's own lease writes raise no `perm.drift`. ### 14.6 The sidecar `PROTOCOL_VERSION` becomes 8. Three routes, each a correlated round trip that fails when the game is down: | Route | Command | | |---|---|---| | `GET /lease?key=&target=` | `lease.list` | Both query fields optional and forwarded as they are | | `POST /lease` | `lease.apply` | Opaque object; `cmd` and `reqId` written over the caller's | | `POST /lease/release` | `lease.release` | The same | `lease.expired` is an `event`, filed and served like every other (§8.1). ### 14.7 The website's timeout, again `core.lease` declares no `budgetMs`, so it runs under the dispatcher's default of **10 s**, and it makes **two** calls into the module inside that (`read`, then `apply`). `module-rust` therefore gives lease calls their own client timeout of **4.5 s** (`LEASE_TIMEOUT_MS`), so that two fit inside the budget. A test asserts the sum. That is below the sidecar's 10 s reply timeout, so the module can give up on an apply the game is still going to take. It follows a timed-out apply with a release of the same value down the same link. The plugin handles the two in order: if the apply landed, the hold's own baseline goes back, and if it never did, the compare finds nothing to do.