docs(modules): module-rust phase 1 as built — the transport, and three org-lead decisions
Adds docs/rust-link/, the canonical spec for the Rust bridge: PROTOCOL.md (the game link and the website API) and INTEGRATION.md (standing it up by hand, and which of the three components is wrong when it does not work). A new top-level directory mirroring link/, which is decision D1 below — it keeps the uo/link symmetry and keeps module docs separate from bridge docs. Records phase 1 in PLAN.md as section 13. Both criteria met: a server.hello produced by the live Rust rig travelled game → sidecar → module → the public website API, killing the sidecar left the game untouched, and all five guards are green on the module skeleton. Three org-lead decisions this phase needed, none settled by section 2: * D1 — the bridge docs live at docs/rust-link/. * D2 — loopback is the ONLY trust boundary on the game link, no token, exactly as on the ServUO bridge. Argued the other way on the grounds that Rust servers are far more often on GSPs; overruled, and the consequence is now written down as the mistake rather than defended against. * D3 — the plugin reads Oxide's own config file, so it lands inside the phase-7b config editor for free. The argument against — that editing Host/Port from the website could cut the link carrying the edit — becomes that phase's guard rather than a reason for a second config mechanism. Section 11.3 is corrected in place: it read module.json's "extensions" array as held against reality by the loader in the way "mounts" is. Only half true. The loader checks that a named slot EXISTS and never that the module filled it — checkDeclared covers "mounts" alone — and only ONE of R13's two slots can be declared there at all, because site.footer.status is a CLIENT slot and naming it fails the load outright. Section 13.3 records five defects a running server found that no test could, including a bootId that regenerated on every plugin load rather than every server start — which would have asked core to reconcile its whole ledger on every oxide.reload, for a world that never moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
@@ -749,7 +749,7 @@ Each phase ends with its findings written down, as every workstream here does.
|
||||
| # | Phase | Repos | Done when |
|
||||
|---|---|---|---|
|
||||
| 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.** Plugin: bounded drop-oldest queue, one writer thread, tagged reconnect epoch, dial-out. Sidecar: listener, SQLite, always-on token auth, version header, rpc correlation. Module: `id: rust`, `/rust` on all three tiers (R14), `schema.sql` **and `purge.sql`**, the **`extensions`** declaration (§11.3), per-server sidecar tokens through **`ctx.secretBox`** (§11.4), the vite aliases and shims, `checkExternals`, `checkImports`, the swagger fragment and its staleness check, explicit `onBoot`/`onShutdown`, `capabilities` | 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 |
|
||||
| 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.** `release.yml`, the install manifest, the `sha256`, the host allowlist — and a real install into a running core from a manifest URL | Module-Rust + docs | An operator installs the empty module from Admin -> Modules and it reaches `started` |
|
||||
| 3 | **The read path.** 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 | all 3 + docs | A restarted sidecar is fully populated within one connection, and a wipe does not erase a player's history |
|
||||
| 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 |
|
||||
@@ -1118,6 +1118,8 @@ Like `mounts`, it is a statement of surface that the loader holds against realit
|
||||
`admin.users.detail` and `site.footer.status` are declared there as well as registered. Phase 1 adds
|
||||
it to the list of `module.json` fields that must be got right.
|
||||
|
||||
> **Corrected in phase 1 (§13.2), and it is half wrong.** The loader checks only that a named slot EXISTS (`loader.js:681` → `registries.hasSlot`); `checkDeclared` covers `mounts` **alone**, so a declaration with nothing behind it loads cleanly and means nothing. And only ONE of R13's two slots can be declared here at all — `admin.users.detail` is the only server slot core declares, while `site.footer.status` is a CLIENT slot registered from the chunk, and naming it in `extensions` fails the load outright.
|
||||
|
||||
### 11.4 The `ctx` members the plan had never named
|
||||
|
||||
`ctx` has **29 members** (§2.3). The plan named a handful. The ones that change work:
|
||||
@@ -1348,4 +1350,165 @@ And a reason to hold that boundary harder than R18 states: **`oxide/data/` is wh
|
||||
own permission store** (`oxide.users.data`, `oxide.groups.data`). A config editor that strayed one
|
||||
directory over would be editing R2's mirror underneath itself.
|
||||
|
||||
## 13. Phase 1 as built — the transport, 2026-09-15
|
||||
|
||||
**Both criteria met.** A `server.hello` produced by the live Rust rig travelled game → sidecar →
|
||||
module → the public website API; killing the sidecar left the game untouched; all five guards are
|
||||
green on the module skeleton. Three repositories have their first commits:
|
||||
[`Rust-Link`][rl], [`Rust-Plugins`][rp], [`Module-Rust`][mr], plus
|
||||
[`rust-link/PROTOCOL.md`](../../rust-link/PROTOCOL.md) and
|
||||
[`INTEGRATION.md`](../../rust-link/INTEGRATION.md) here.
|
||||
|
||||
### 13.0 The three org-lead decisions this phase needed
|
||||
|
||||
None of them were settled by §2, and each would have been expensive to reverse later.
|
||||
|
||||
- **D1 — the Rust bridge's docs live at `docs/rust-link/`**, a new top-level directory mirroring
|
||||
`docs/link/`, rather than under `modules/rust/`. It keeps the `uo`/`link` symmetry and keeps
|
||||
*module* docs separate from *bridge* docs, which are different contracts with different audiences.
|
||||
- **D2 — loopback is the only trust boundary on the game link**, exactly as on the ServUO bridge:
|
||||
no token between plugin and sidecar. The alternative was argued on the grounds that Rust servers
|
||||
are far more often on GSPs than ServUO shards are, so binding to something other than `127.0.0.1`
|
||||
is a realistic operator need. **Overruled**, and the consequence is written into the plugin's
|
||||
class docs and `PROTOCOL.md` §1.1: moving that bind puts an unauthenticated command channel on the
|
||||
network, and it is documented as the mistake rather than defended against.
|
||||
- **D3 — the plugin reads its settings from Oxide's own config file**
|
||||
(`oxide/config/RunicGateway.json`) rather than a standalone `Bridge.cfg`-shaped file. It is
|
||||
idiomatic for Oxide, and it lands inside R18's phase-7b config editor for free. The argument
|
||||
against — that editing `Host`/`Port` from the website could cut the link carrying the edit — is
|
||||
real and is now phase 7b's problem to guard rather than a reason for a second config mechanism.
|
||||
|
||||
### 13.1 What phase 1 deliberately did NOT register
|
||||
|
||||
The module registers **routes on three tiers and the two lifecycle hooks, and nothing else**. No
|
||||
Team provider, no triggers, no audiences, no engagement seeds, no notification streams, no event
|
||||
budgets/leases/actions/option sources, no extension slots.
|
||||
|
||||
That is asserted by a test (`nothing is registered that has nothing behind it yet`) so that removing
|
||||
it is deliberate. The reasoning is worth keeping: **a declared trigger nothing emits and a declared
|
||||
slot nothing fills are both surfaces an operator can configure and then wait on**, which is worse
|
||||
than an absent one, because the absence is visible.
|
||||
|
||||
### 13.2 §11.3 was half wrong about `extensions`
|
||||
|
||||
§11.3 reads `module.json`'s `extensions` as *"declared, not just registered… like `mounts`, held
|
||||
against reality by the loader"*. Reading the loader says otherwise, in two ways:
|
||||
|
||||
1. **The loader never checks that a declared slot was filled.** `loader.js:681` asks only
|
||||
`registries.hasSlot(slot)` — does this slot *exist*. `checkDeclared` covers `mounts` **alone**,
|
||||
in both directions. So a declaration with nothing behind it loads cleanly and means nothing.
|
||||
2. **Only one of R13's two slots can be declared there at all.** `admin.users.detail` is the only
|
||||
server slot core declares (`router/v1/admin/users.router.js:202` is the sole `declareSlot` call
|
||||
outside the registry). `site.footer.status` is a **client** slot, registered from the chunk —
|
||||
naming it in `extensions` fails the load with `unknown extension slot "site.footer.status"`.
|
||||
|
||||
So the field is written when phase 6 registers the server half, and never before. Phase 4's
|
||||
`site.footer.status` work does not touch it.
|
||||
|
||||
### 13.3 Five defects the rig found that no test could
|
||||
|
||||
Each of these was written from source reading, shipped, and then corrected by a live Rust server.
|
||||
The hit rate is the phase-0 lesson repeating.
|
||||
|
||||
1. **A disconnect was silent in the game console.** The link teardown log sat in `LinkLoop`'s
|
||||
`catch`, and a connection that ends because the *reader* saw EOF leaves the writer to exit
|
||||
cleanly — nothing throws, so nothing is logged. The fix captures `_connected` at the top of the
|
||||
`finally`. Worth generalising: **a log in a catch block only covers the failures that throw**, and
|
||||
an orderly shutdown of a peer is not one of them.
|
||||
2. **`Unload` blocked the main thread for 1.9 seconds**, which Oxide reports as
|
||||
`Calling 'Unload' on 'RunicGateway v0.1.0' took 1918ms`. The reconnect backoff was
|
||||
`Thread.Sleep`, and `Unload` joins the link thread — so every plugin reload froze the server for
|
||||
up to the backoff. Waiting on the same `AutoResetEvent` that `Unload` already signals makes it
|
||||
immediate. **The ServUO plugin has the same `Thread.Sleep`**, and it is survivable there only
|
||||
because ServUO does not hot-reload the way Oxide does.
|
||||
3. **Mono's `SocketException.Message` is NUL-padded.** A connect refusal came back with ~200 `\0`
|
||||
bytes in the middle of the sentence, from a fixed-size OS buffer. `\0` is not whitespace, so
|
||||
`Trim()` does not touch it and neither does a whitespace-only collapse — the log line looks like
|
||||
it contains a huge run of spaces and no amount of trimming removes it. The flattener has to treat
|
||||
`char.IsControl` as a separator too. It took `od -c` on the log to see this at all.
|
||||
4. **`bootId` regenerated on every PLUGIN load, not every SERVER start.** A fresh `Guid` at `Init`
|
||||
meant `oxide.reload RunicGateway` announced a brand-new boot — and §11.1's whole reconcile design
|
||||
hangs off that value, so every reload would have asked core to sweep its entire resource ledger
|
||||
for a world that never moved. It is now `Process.StartTime`, which is exact, identical on every
|
||||
read, and changes when and only when the thing it names changes. **Verified by reloading the
|
||||
plugin twice and watching the id hold** (`boot-20260915T194502Z`, matching the process).
|
||||
5. **A four-connection SQLite pool over `:memory:` hands out four empty databases.** An in-memory
|
||||
database is per *connection*, so the schema created on the first pooled connection is invisible
|
||||
to the second. It presents as `no such table` from a random subset of queries. The sidecar now
|
||||
caps the pool at one connection for an in-memory path — which is the only coherent reading of
|
||||
`:memory:` and is what makes it usable at all. The ServUO sidecar never hit this because it only
|
||||
ever opens a file.
|
||||
|
||||
### 13.4 Two things the kit's own template got wrong for this module
|
||||
|
||||
Both are feedback for phase 19, and both are the kit being right about the general case and specific
|
||||
about the wrong detail.
|
||||
|
||||
- **`registration.test.js` reads one page by NAME** (`src/routes/public/Clan.jsx`) to check that
|
||||
every declared slot is rendered somewhere. A module that declares no slots — as phase 1 does — dies
|
||||
on `ENOENT` before reaching the loop that would have been empty. Generalised here to scan every
|
||||
file under `src/routes`.
|
||||
- **`test/_fakes.js` supplies `validator: {}`.** The template's routers never use express-validator,
|
||||
so `{}` is enough for them; an admin router that builds validation chains at file scope cannot be
|
||||
*required* with it. The fake now holds the real library, for the same reason it holds a real
|
||||
express Router: a fake of either would only ever test the fake.
|
||||
|
||||
The kit was also **right in a way worth recording**: `noGameConnection.test.js`'s header predicts, in
|
||||
so many words, that a module adding a sidecar client will see this check go red and tells the reader
|
||||
to narrow it rather than delete it — naming `sidecarClient.js` as the file to allow. That is exactly
|
||||
what happened, on the first run, and the fix was the one line the header names.
|
||||
|
||||
### 13.5 The player tier is thin on purpose
|
||||
|
||||
R14 puts the module on all three tiers from the start, and the loader holds `mounts` against what is
|
||||
registered in both directions — so the declaration and the registration land together or not at all.
|
||||
|
||||
What the player tier will carry is the signed-in view of a server: the viewer's linked Steam
|
||||
identity, their presence, their entitlements. None of that exists before phase 6. So the one route
|
||||
there answers the server list on the authenticated tier, delegating to the **same model** the public
|
||||
tier uses, so the two cannot drift while they are meant to be the same.
|
||||
|
||||
That is a real route rather than a placeholder: it is the address the app and the SPA will call, and
|
||||
it starts answering correctly now rather than moving later.
|
||||
|
||||
### 13.6 Three timeouts in a row, and the ordering is load-bearing
|
||||
|
||||
```
|
||||
sidecar RPC reply timeout (10s) < module client timeout (12s) < an action's budgetMs
|
||||
```
|
||||
|
||||
Core classifies a `budgetMs` overrun as retryable **unconditionally** — it cannot ask the action,
|
||||
which is still awaiting a socket. So an action whose budget does not exceed the module's client
|
||||
timeout can never report `retry: false`, and that branch is unreachable code. Phase 13's actions
|
||||
must derive their budgets from `sidecarClient.TIMEOUT_MS` rather than writing a number beside it.
|
||||
|
||||
The first module this project shipped got this the wrong way round and retried a verb it had
|
||||
explicitly refused, which is why all three values are now written down in one place
|
||||
(`PROTOCOL.md` §4.4) instead of three.
|
||||
|
||||
### 13.7 The rig, as it stands after phase 1
|
||||
|
||||
The phase-0 rig plus the bridge. `RGProbe.cs` is still installed beside `RunicGateway.cs` and is
|
||||
still useful — its `rg.checkperm` / `rg.zoneonme` commands are phase 7 and phase 12 instruments.
|
||||
|
||||
```
|
||||
D:\rust\oxide\plugins\ Clans.cs Kits.cs PopupNotifications.cs ZoneManager.cs
|
||||
RGProbe.cs RunicGateway.cs
|
||||
rust-link-sidecar game 127.0.0.1:7799 · web 127.0.0.1:8090 · its own scratch config
|
||||
website (edge) modules/rust/ installed as a directory; server row "main"
|
||||
```
|
||||
|
||||
A dependency-free WebSocket RCON driver was rebuilt this phase (the phase-0 one lived in a scratchpad
|
||||
that did not survive). `rcon.web 1`, port 28016 — the password is in `D:\rust\start.bat`. It is the
|
||||
only way to reach `rg.link` without a console session, and phase 7 will need it again.
|
||||
|
||||
**The phase-0 ceiling still stands and still blocks phase 7:** no console session can observe a
|
||||
permission gate, because every plugin's check short-circuits without a `BasePlayer`, and an admin
|
||||
account bypasses most of them non-uniformly. A second, non-admin Steam account has to be arranged
|
||||
before phase 7 — it is the one prerequisite this rig cannot satisfy on its own.
|
||||
|
||||
[rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link
|
||||
[rp]: https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins
|
||||
[mr]: https://gitea.whitlocktech.com/RunicGateway/Module-Rust
|
||||
|
||||
[kit]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit
|
||||
|
||||
Reference in New Issue
Block a user