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

@@ -980,7 +980,7 @@ Each phase ends with its findings written down, as every workstream here does.
| 0 | **The rig.****Done 2026-09-15 — as built and findings in §12.** Updated to the current wipe (the script was fixed *again*, properly), Oxide re-laid, base set installed, the grant path proven end to end and both zone transitions observed live with a player connected. **Both criteria met** | docs | A current server boots with all four loaded, `oxide.grant` demonstrably gates something, and a test zone reports who is standing in it |
| 1 | **Protocol 1, three skeletons, and every bundle seam at once.****Done 2026-09-15 — as built and findings in §13.** Plugin, sidecar and module all exist and all three were exercised against the live rig; three org-lead decisions (§13.0), five defects only a running server found (§13.3), and a correction to §11.3 (§13.2). **Both criteria met** | all 3 + docs | One hello line travels game -> sidecar -> module; killing the sidecar does not stall the game; all five guards green on an untouched skeleton |
| 2 | **Packaging and release.****Done 2026-09-16 — as built and findings in §15.** `release.yml` *and* the gate that was missing entirely (`pr-checks.yml`, including the frozen-manifest job); the include list with two readers; `v0.1.0` published and installed into a running core from its manifest URL. Three org-lead decisions (§15.0), and the first proof by a core that `/rust` collides with nothing (§15.2). **Criterion met** | Module-Rust + docs | An operator installs the empty module from Admin -> Modules and it reaches `started` |
| 3 | **The read path, on both frameworks.** First hook wave from [`HOOKS.md`](HOOKS.md); events and snapshots distinct at the wire; `wipe_id` **and server id** on every row (R8); all-time rollups (R12); every board re-emitted on connect. **First phase to run against the Carbon rig (R19/R21)** — it turns [`CARBON.md`](CARBON.md) from a source-read hypothesis into tested fact, including whether the 13 unlisted hook names are renames or holes | all 3 + docs | A restarted sidecar is fully populated within one connection, a wipe does not erase a player's history, and **the same plugin file does all of that on Oxide and on Carbon** |
| 3 | **The read path, on both frameworks.****Built and largely proven 2026-09-16 — as built and findings in §16.** Protocol 2: fifteen hooks, an envelope every frame carries, boards, a cursor feed and bounded history; four org-lead decisions (§16.0), two defects only a booted server could find (§16.2), and CI for the two bridge repositories that had none. **The player half of the catalogue is written down as a walk to run rather than measured** — see §16.7. First hook wave from [`HOOKS.md`](HOOKS.md); events and snapshots distinct at the wire; `wipe_id` **and server id** on every row (R8); all-time rollups (R12); every board re-emitted on connect. **First phase to run against the Carbon rig (R19/R21)** — it turns [`CARBON.md`](CARBON.md) from a source-read hypothesis into tested fact, including whether the 13 unlisted hook names are renames or holes | all 3 + docs | A restarted sidecar is fully populated within one connection, a wipe does not erase a player's history, and **the same plugin file does all of that on Oxide and on Carbon** |
| 4 | **The first pages.** Server list as the landing page, `/rust/servers/:id` beneath it, killfeed, leaderboard; nav rows; the UI kit (`PublicLayout` `shell`, `PageHeader` props); `capabilities`; the `site.footer.status` slot (R13) | Module-Rust | The site renders the last thing each server said while every server is off |
| 5 | **Android leg A** (R10). Capability-driven shell from `GET /api/v1/public/modules`, plus the phase-4 screens | Android-app | The app renders a Rust site it has never seen, and a UO site unchanged |
| 6 | **Identity** (R1), and the `admin.users.detail` slot (R13) | 3 + docs | A player links an account in-game; an operator sees the Steam id inside core's own user page |
@@ -2192,6 +2192,203 @@ Linux engine was found dead — its WSL distribution stopped, the `uomm-db` cont
Restarting Docker Desktop and the container fixed it. Worth writing down because the failure presents
as the *website* being broken (`ECONNREFUSED` to a database that is simply not there), and because
`CLAUDE.md` points every smoketest at that one container.
## 16. Phase 3 as built — the read path, 2026-09-16
The first phase that had to be true on two mod frameworks, and the first with a
catalogue rather than a message. Four repositories moved: the spec here, the plugin, the sidecar,
and the module.
**Status: the bridge half is done and proven; two proofs are queued on the org lead.** What the
plugin sends and what the sidecar does with it are built, tested and exercised against live Oxide
and Carbon servers. The player-facing half of the catalogue — deaths, chat, gathering, sessions —
cannot fire without somebody holding a mouse, and is written down as a walk to run rather than
guessed at: [`PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md). §16.7 lists everything still open.
### 16.0 The four decisions this phase needed
- **D7 — the widest first hook wave.** The options ran from "presence, deaths and the wipe" to
"everything read-only worth having", and the widest was chosen: fifteen hooks, including the
moderation set that carries IP addresses and player reports. The consequence is real and is
designed around rather than deferred — those frames arrive **nine phases before** the visibility
framework phase 14 builds, so the classification and its default-deny allowlist ship now (§16.4).
- **D8 — the plugin derives `wipeId`.** `PROTOCOL.md` §3.2 had reserved that for the website. By
protocol 2 three components store rows that need it and only one of them can read the value, so
the reversal is written into §8.2 rather than left as a contradiction.
- **D9 — the live feed is a cursor, and D5 stands.** Core runs Node 20, where a global `WebSocket`
is still behind a flag, so a socket means taking `ws` — against a release that asserts it ships no
runtime dependencies. The deciding argument was the other one: **a socket needs a cursor anyway**
for what it missed while the module was restarting, and the catch-up path is the one that must be
right. One mechanism exercised every five seconds beats two where the second only runs after an
outage nobody planned.
- **D10 — rollups permanent, raw bounded.** The website keeps per-player-per-wipe totals for ever
and a 30-day window of raw events; the sidecar keeps 14 days and prunes hourly. R12's "a wipe does
not erase a player's history" is met by the totals, which is the row an operator actually reads.
- **D11 — CI for both bridge repositories**, which had none at all. Phase 2 found that hole in
Module-Rust; it was still open in the two repositories that ship the half running inside somebody
else's game server.
### 16.1 `type` is the whole of protocol 2 in the sidecar
Protocol 1 routed on `kind`, in a `match` that needed a new arm per addition. Protocol 2 adds
**`type`** — `event`, `snapshot`, `reply`, `control` — and the sidecar files on that and nothing
else. Ten new event kinds are now zero change in Rust-Link, which is the property that matters when
the thing growing fastest is the catalogue.
A frame whose `type` this build does not know is **dropped and counted**, never guessed at.
Defaulting an absent one to `event` would file a *board* as history — the presence board appended a
few thousand times, which nothing reports and nobody notices until they wonder why the database is
large.
**It caught a real mismatch three seconds after it first ran**, which was not planned: a protocol 1
plugin was still live on the retired workstation rig, dialled the new sidecar, and its `server.hello`
went straight into the counter. The game link has no version handshake by design (§2), so
`untyped_frames` on `/health` is the only place that failure is visible — and the symptom without it
is a website showing nothing while the game is plainly up.
### 16.2 Two defects a live server found, and neither could have been found anywhere else
**The wipe id was null for every real session.** `Init` runs *before* the save is loaded, so
`SaveRestore.SaveCreatedTime` is not yet meaningful there, and the id resolved at load time stayed
null for the life of the process — every frame shipping without the field R12 splits history on.
It was invisible for the reason such things usually are: a **hot-reloaded** plugin reads an
already-loaded world and gets the right answer every time. Every development iteration on the
workstation rig was a hot reload. It took a server that *booted* with the plugin installed — which
is every real one — to show `wipeId=none` beside a save sitting on disk. Now resolved again at
`OnServerInitialized`, and lazily while still unknown.
**`Unload` blocked the game's main thread for two seconds.** Carbon reported it exactly:
`hook 'Unload' took longer than 100ms [2002ms]`, next to `link thread did not stop cleanly`. That is
phase 1's stall arriving by a different road — the link thread sits in a blocking
`TcpClient.Connect`, which has no timeout of its own and cannot be woken, and `Unload` joins it from
the main thread.
The reason two phases missed it is worth keeping: **a host that refuses answers instantly, and a
host that drops does not answer at all.** Every loopback test is the first kind. A firewalled
address, a typo, a machine that is off are all the second, and the deployment this phase was being
tested through happened to be one. The connect is now bounded and waits on a stop handle of its own
— it cannot share `Wake`, which also means "the queue has something in it" and is signalled by every
hook that fires. After the fix the same reload logs no slow-hook warning and no stranded thread.
### 16.3 The aggregate, and the rule it generalises
`OnDispenserGather` fires on **every swing at a tree**. A frame per swing would make the bridge the
most expensive thing on a busy server, and nobody wants a killfeed of chickens either, so gathering
and NPC kills are counted in the plugin and flushed once a minute as one `player.tally` frame.
The rule: **if a hook can fire more than once a second per player, it is a counter, not an event.**
R17's warning about chatty zone transitions is the same rule arriving early.
A tally is a **delta, not a running total** — what happened since the last flush — so the consumer
sums rather than diffs, and a dropped frame costs one interval instead of corrupting the series. The
outbound queue is drop-oldest by design, so frames are genuinely allowed to go missing; a running
total over a lossy link is a number that is quietly wrong for ever.
One honest limitation, corrected in the code rather than in the comment that first claimed
otherwise: **a plugin reload loses up to a minute of one player's tally.** `Unload` enqueues the
flush, but the writer stops on the same flag and the queue is cleared after the join. Draining it
first would mean waiting on a socket from the main thread — the stall §16.2 just removed — so the
loss is taken deliberately. A real shutdown flushes at `OnServerShutdown`, and a player leaving
flushes at their disconnect.
### 16.4 The boundary is enforced by the side that serves
The widest hook wave brings IP addresses (`CanUserLogin`, `OnUserApproved`, `OnUserBanned`), one
player's report about another, and the grid reference of somebody's base — nine phases before the
visibility framework §11.2 costed. So the classification ships with the catalogue.
**It is not a field on the wire.** The plugin could have stamped a class on every frame; it
deliberately does not. A boundary declared by the *sender* is one a compromised — or merely
out-of-date — game host can widen. The website's own shard fan-out works the same way: a public
stream with an allowlist of kinds and an admin stream that adds the rest, and what makes it
trustworthy is that the decision lives on the serving side.
So `module-rust/server/catalogue.js` holds it, **default-deny**: a kind this build has never heard
of is not public. That is the shape of the mistake it prevents — the next protocol version adds a
kind, the module stores it happily, and a deny-list filter would publish it the day it first
arrived, before anybody decided whether it should be. A test holds the list against §8.4's table, so
adding a kind to the protocol without classifying it fails a build.
### 16.5 A login denial is not a hook, and §10 says it is
`PLAN.md` §10 sources the `rust.login.denied` trigger from `CanUserLogin`. Reading the hook says that
cannot work: it fires on **every** connection attempt, and the only way to learn of a denial from it
is to *be* the denier — which §8.7 forbids, structurally, by declaring every read-path hook `void` so
it cannot answer. uMod publishes no `OnUserRejected`.
What the game can tell us is two facts: an attempt, and an approval. Protocol 2 emits both, and a
denial is **the absence of an approval** — a deferred read, phase 10's to make. The trigger survives;
its source changes. (The same shape the engagement workstream hit at its own phase 10, which is
either a coincidence or a property of login paths.)
### 16.6 What was proven, and how
| Claim | How | Result |
|---|---|---|
| The read path compiles and loads on **Oxide** | live server, protocol 2, 15 hooks bound | ✅ |
| …and on **Carbon** 2.0.259.0, from the **byte-identical file** | the Pterodactyl rig, config read from `carbon/configs/` | ✅ |
| Every frame carries `type`, `serverId`, `wipeId` | `server.hello` and `players.online` read back off the sidecar | ✅ |
| A wipe id derived from the save, changing only with the save | `rg.link` reports `w-20260915T195817Z` against `saveCreatedAt 2026-09-15T19:58:17Z` | ✅ |
| **A restarted sidecar is fully populated within one connection** | store deleted, process restarted: both boards present **0.3 s** after the listener bound, and **zero** events in history | ✅ |
| Boards are not replayed as history | the same walk: `/events` returned 0 rows while `/boards` returned 2 | ✅ |
| Moderation frames reach the sidecar whole | `banid` / `unban` over RCON | ✅ |
| A protocol mismatch is counted, not mis-filed | a live protocol 1 plugin against the protocol 2 sidecar | ✅ |
| `rg.hooks` answers on both frameworks | Oxide and Carbon, identical output shape | ✅ |
| 44 sidecar tests, 95 module server tests, 20 client tests, every guard | locally, and now in CI on both repos | ✅ |
| The module's route manifest against a **real core** at the pinned ref | 10 routes, all documented, none of core's moved | ✅ |
**One finding about the rigs rather than the code:** the panel rigs cannot reach a sidecar running on
the workstation, because Windows Firewall holds two program-scoped **Block** rules for
`rust-link-sidecar.exe` — created by a dismissed prompt at some point — and a program-level block
beats any port-level allow. Removing them needs elevation. It is a rig problem only: the shipped
design puts the sidecar on the game host's own loopback (D2, R20), where it is the deployment that
never needs a rule at all.
The local workstation rig, which does reach its sidecar on loopback, is what proved everything in the
table above that needs a live socket. **`D:\rust` is therefore not as retired as R21 assumed** — it
survives as the fast loop (a saved file is a reloaded plugin in about ten seconds, against nine
minutes of world generation on the panel), and the panel rigs are what answer "on both frameworks".
### 16.7 What is still open, and who it is waiting on
Three things, all of them measurements rather than decisions, and all of them the org lead's to run:
1. **The player walk** — [`PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md). Ten minutes on a rig
with a mouse, and it closes `OnPlayerDeath`, `OnPlayerChat`, `OnDispenserGather`,
`OnPlayerRespawned`, `OnEntityDeath`-by-a-player, and `sessionSec`. The document says what each
step should produce, so it can be run without anybody watching the output live.
2. **The wipe walk.** `OnNewSave` fires when a server starts with no save — the panel rigs wipe
through the egg's own `REMOVE_FILES`, so this is the rig's own mechanism rather than a special
test. What it proves is the second half of the acceptance criterion: that the old wipe's rows are
still queryable by `?wipe=` afterwards.
3. **The Carbon socket leg.** Everything up to the socket is proven on Carbon; what is not is frames
actually arriving over a live link, which is one elevated firewall command away
(`Remove-NetFirewallRule -DisplayName "rust-link-sidecar.exe"`, then an allow for the rig).
Until 1 and 2 are run, the honest statement of this phase is: **the transport, the envelope, the
boards and the classification are proven on both frameworks; the player half of the catalogue is
built, reviewed against the hook documentation, and unmeasured.** That is written here rather than
in a commit message because it is the kind of thing a later phase will want to know it inherited.
### 16.8 Smaller things worth keeping
- **`rg.hooks` collides with `RGProbe`**, the phase-0 rig plugin, which registered the same console
command first. Oxide warns and the last loaded wins, which happens to be the bridge. Left alone:
`RGProbe` is rig scaffolding that never ships, and renaming the shipping command to avoid a
test tool would be the wrong way round.
- **The IP a console ban reports is the literal string `"0"`**, not an address and not a null, when
the banned id is offline. Observed, not guessed. The plugin omits the field instead of forwarding
it — a column full of `"0"` survives every is-it-missing test a reader writes and then fails
whatever parses it.
- **`--print-config` reports the *effective* configuration and writes the *file* one.** Environment
variables override the file (R22 depends on that), and the written file never contains them. Not a
bug, but the two are not the same document and an installer reading one should not assume the
other.
- **A `cargo clippy` run does not produce a binary.** Two rig readings disagreed with the source
because the sidecar under test was an older `cargo build`; `clippy` and `test` compile without
writing one. Rebuild before believing a rig.
---
[rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link

View File

@@ -18,7 +18,7 @@ differ.
| [`DEFINITIONS.md`](DEFINITIONS.md) | **What things are called.** 678 items (short name, id, display name) and 2,590 workshop skin ids across 104 items. |
| [`OPERATING.md`](OPERATING.md) | **How it gets run.** The 6 operator pages — installing Oxide on a server, then installing, configuring and permissioning plugins. |
| [`agent/`](agent/README.md) | The same facts in **machine shape** — TSV and JSONL, ~46% of the tokens. Generated in the same pass, so it cannot drift. |
| [`CARBON.md`](CARBON.md) | **The other framework.** Where Carbon diverges from Oxide and nowhere else — file layout, the permission store, the `c.` commands, 30 Carbon-only hooks and 13 uMod names its catalogue omits. Sourced from Carbon's own metadata and source, **not yet proven on a live Carbon server.** |
| [`CARBON.md`](CARBON.md) | **The other framework.** Where Carbon diverges from Oxide and nowhere else — file layout, the permission store, the `c.` commands, 30 Carbon-only hooks and 13 uMod names its catalogue omits. Sourced from Carbon's own metadata and source, and **proven on a live Carbon 2.0.259.0 server** — R19 at phase 0, and the whole read path at phase 3. |
**The one file here that is ours:** [`PLAN.md`](PLAN.md) — the schedule and the decisions of record
for actually building `module-rust`. Everything else in this directory is copied from uMod; that one