Files
docs/modules/rust-dryrun.md
wtclaude 3116e7bbf6 docs(modules): close Phase 5 — the acceptance run, and the page shell it found
Slice 3 of Phase 5 (MODULE_SYSTEM.md 2.11.1), and the phase's last slice.

docs/modules/kit-acceptance.md is decision 5's deliverable: a cold agent given the
Integration Kit and the documents it links to — never core's source, never
module-uo — built a working module for a second game, which was then installed
into a real core and taken through MODULE_API.md 7.7's browser smoke. Verdict
recorded whichever way it went, and it went **yes, with caveats**: one pass, no
core source, and three of the four normative documents never opened.

The finding that justifies the two-stage shape is the one the agent structurally
could not reach, because it had no core to render against. A module page built
exactly as the kit teaches renders OUTSIDE the site: PublicLayout supplies the
chrome and not the body, and the `shell-... page-body` wrapper every core public
page writes for itself is two class names that appear in no contract. That is
3.4's own stated failure — "a module page that does not look like the site it is
installed in" — reached by following 3.4.

Fixed in core rather than documented at the reader, so the class names stay
core's private business and the theming workstream keeps its freedom to rename
them: PublicLayout takes an opt-in `shell` width, MODULE_API_VERSION 1.5.0
(website#148, merges first).

- MODULE_API.md 1.1: 1.5.0's entry, and a new bump-table row — adding an
  OPTIONAL prop or argument is minor. "A member's signature changes" is major
  because a call already written changes meaning, and an optional prop changes
  none; the table now says what it means rather than leaving it to be argued.
- MODULE_API.md 3.4: the shell prop, why a module names a width and never a
  class, and the eight-vs-seven miscount the run also turned up — the kit had
  faithfully carried it out of the contract into the template, which is the
  never-re-specify rule working exactly as designed on a wrong input.
- rust-dryrun.md: coreApi ^1.3.0 -> ^1.5.0, as a dated correction per decision 33.
  It is the only complete module.json in the kit's reading path and nothing
  checks a JSON block inside a Markdown file, which is the reusable half.
- MODULE_SYSTEM.md 2.11.1: slice 3 recorded, plus the third finding worth
  generalising — a check whose failure message asserts a diagnosis has to be
  right about it. `check:swagger` failed on a pristine template on Windows
  (CRLF) while blaming the routes, green on the Linux runner forever.
- Decision 34: core owns the page body as well as the chrome.

The banner does not come off. Decision 32 makes that a person's to remove, this
run exercised the website-module half only (the module has no sidecar, so
chapters 3 and 4 were never tested), and an agent does not skim or give up.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 14:28:26 -05:00

288 lines
15 KiB
Markdown

# `module-rust` — a dry run
**Phase 3's fourth acceptance criterion** ([`../website/MODULE_SYSTEM.md`](../website/MODULE_SYSTEM.md)
§2.7). A written design for a module serving a **Rust** (Facepunch) community, taken far enough to
find out whether the contract generalises past the game it was extracted from — **and deliberately
not implemented.** A contract validated only against the module it was carved out of has not been
validated.
The exercise is honest if it finds something. It found four things, one of which is a gap in the
contract that a real second module would hit on its first day. They are in *Findings* at the end; the
design comes first, because a finding is only worth anything with the design that produced it.
Rust was chosen because it is unlike Ultima Online in the ways most likely to break assumptions:
it **wipes** every month, a community runs **several servers** rather than one shard, its identity
is **Steam**, and its server speaks a **protocol nobody has to write** — RCON over WebSocket, built
in. If the contract survives that, "game-agnostic" means something.
> Nothing here re-specifies the contract. [`../website/MODULE_API.md`](../website/MODULE_API.md) is
> normative; this document only *uses* it.
---
## 1. The manifest
```json
{
"id": "rust",
"name": "Rust",
"version": "0.1.0",
"coreApi": "^1.5.0",
"server": "server/index.js",
"client": { "entry": "client/dist/entry.js" },
"schema": "server/db/schema.sql",
"purge": "server/db/purge.sql",
"mounts": {
"public": ["/rust"],
"admin": ["/rust"],
"player": ["/rust"]
},
"extensions": ["admin.users.detail"],
"capabilities": ["servers", "wipes", "leaderboards", "map", "killfeed", "teams"]
}
```
> **Correction, 2026-08-12.** `coreApi` read `^1.3.0` here until the Integration Kit's acceptance run
> ([`kit-acceptance.md`](kit-acceptance.md)) found it. The contract was at 1.3.0 when this design was
> written and has moved twice since; the number is now `^1.5.0`. Left as a correction rather than a
> silent edit because *why* it went stale is the reusable part: this is the only complete `module.json`
> in the kit's reading path, so it is what a newcomer copies — and unlike the template, which CI holds
> against core's `MODULE_API_VERSION` on every pull request
> ([`../website/MODULE_SYSTEM.md`](../website/MODULE_SYSTEM.md) §2.11.1 d2), **a JSON block inside a
> Markdown document has nothing checking it.** A range is also the shape least likely to be noticed
> when it rots: `^1.3.0` is *satisfied* by a 1.5.0 core, so a module copied from here would have loaded
> fine and simply been wrong about what it was written against.
One prefix per tier, named for the module rather than for a feature — the opposite of module-uo's
`/shard` + `/atlas` + `/uo-link`, and the better choice for anything new. Module-uo's prefixes are
what they are because §1.2 froze the URLs core already served; a module written today has no such
debt and should claim one obvious segment. It also sidesteps the collision surface entirely:
`/shard` is a word a second game might want, `/rust` is not.
## 2. The server half
```js
module.exports = function register(ctx, api) {
core.init(ctx)
const publicRouter = require('./router/public/rust.router')
const adminRouter = require('./router/admin/rust.router')
const playerRouter = require('./router/player/rust.router')
const userExt = require('./router/admin/usersRust.router')
api.registerRoutes({
public: { '/rust': publicRouter },
admin: { '/rust': adminRouter },
player: { '/rust': playerRouter },
})
api.registerExtension('admin.users.detail', userExt)
api.registerNotificationStreams([
{ id: 'rust.wipe', label: 'Server wipes',
description: 'A server wiped — new map, new seed, everything reset.',
personal: false, requiresLinkedAccount: false },
{ id: 'rust.raid', label: 'Base raided',
description: 'Your base took damage while you were offline.',
personal: true, requiresLinkedAccount: true },
])
api.registerAnnounceLeg({
leg: 'rust.ingame',
label: 'In-game chat',
dispatch: (post) => rcon.say(`[NEWS] ${post.title}${ctx.site.baseUrl}/news/${post.slug}`),
classify: (result) => (result.ok ? { outcome: 'done' } : { outcome: 'retry', error: result.error }),
})
api.onBoot(async () => { await rcon.connectAll() })
api.onShutdown(async () => { await rcon.closeAll() })
}
```
Everything above is a call the contract already has, used the way module-uo uses it. Two details are
worth pointing at:
- **`rust.wipe` and `rust.raid` are namespaced**, with no grandfathering request. Module-uo's seven
bare stream ids are allowlisted because they were in `notification_subs` before the rule existed
(§6.5); a new module gets the rule, and the rule is exactly right.
- **The announce leg goes to in-game chat over RCON**, which is a one-shot delivery with retry —
`registerAnnounceLeg`, not `registerPostHook`. The distinction §2.4 draws holds up on a game that
has nothing in common with the one it was drawn for.
### Tables
`rust_servers`, `rust_wipes`, `rust_players`, `rust_player_stats`, `rust_teams`, `rust_events`,
`rust_bans`, `rust_maps`. All `rust_`-prefixed, all in one idempotent `schema.sql` fragment.
**Every table that holds gameplay data carries a `wipe_id`.** That is the whole shape of the game in
one column: a leaderboard means "since the last wipe", a base means "on this map", and a player's
stats are per-wipe with an all-time rollup kept separately. It has no bearing on the contract —
core replays the fragment and never looks inside — but it is the first thing a UO-shaped mental
model gets wrong, and it is worth writing down for whoever builds this.
### Talking to the game
**A thin sidecar.** Rust ships RCON over WebSocket, so `rust-link` is small: it holds the RCON
connection to each server with the token an admin saved, and presents the website the same shape
`uo-link` does — a bearer-authed HTTP + WebSocket API in front of a SQLite store. The module talks
only to it, never to a game server.
The store is the reason it exists even though the game is already remote-controllable. RCON is a
live channel with no memory: what it tells you while nobody is listening is gone. So the sidecar
appends every kill, wipe and chat line, keeps the latest snapshot of each server's state, and
answers the website's reads from disk — a website that is down, restarting or mid-deploy loses
nothing, and a leaderboard renders the last thing the server said rather than an error. It also
keeps the RCON token, the reconnect loop and the per-server fan-out out of an Express process, where
a stalled socket is a stalled request handler.
**`wipe_id` makes the durable copy load-bearing rather than a nicety.** A wipe is the moment the
game forgets; the sidecar is the only thing that remembers the shape of the map that just ended.
> **Correction, 2026-08-12.** This section originally concluded **"No sidecar"** — the module dialling
> RCON directly — and offered it as evidence that core has no opinion about how a module reaches its
> game. That was overruled by the org lead when Phase 5 (§2.11.1 d3/d4) settled the kit's stance, and
> the prohibition is now contract: [`../website/MODULE_API.md`](../website/MODULE_API.md) §2.7, as of
> `MODULE_API_VERSION` 1.4.0, a module does not open a connection to a game server from the website
> process. The original reasoning was not wrong about *ServUO* — the shard-dials-out invariant in
> [`../link/PLAN.md`](../link/PLAN.md) really is a property of an engine with no remote-control
> surface — but it mistook that for the whole reason a sidecar exists. The other reason is durability:
> the website is not the right place to hold a game connection, because it is the process most likely
> to be restarted and the one facing the internet. The finding is left in view rather than edited out;
> what a dry run concluded is worth more than a tidy document.
## 3. The client half
```js
registry.registerRoutes('rust', {
public: [
{ path: 'servers', element: <Servers /> },
{ path: 'servers/:id', element: <ServerDetail /> },
{ path: 'servers/:id/map', element: <MapView /> },
{ path: 'leaderboards', element: <Leaderboards /> },
{ path: 'wipes', element: <WipeSchedule /> },
],
player: [
{ path: 'account', element: <LinkedAccount /> },
{ path: 'stats', element: <MyStats /> },
],
admin: [
{ path: 'servers', element: <AdminServers />, gate: { roles: ['admin'] } },
{ path: 'ops', element: <AdminOps />, gate: { roles: ['admin', 'moderator'] } },
],
})
registry.registerNav('rust', {
area: 'public',
items: [
{ label: 'Servers', to: '/rust/servers', group: 'Play', order: 10, icon: ServerIcon },
{ label: 'Leaderboards', to: '/rust/leaderboards', group: 'Play', order: 20, icon: TrophyIcon,
feature: 'leaderboards' },
{ label: 'Wipe schedule', to: '/rust/wipes', group: 'Play', order: 30, icon: CalendarIcon },
],
})
registry.registerFeatureProvider('rust', 'rust', useRustFeatures)
registry.registerExtension('rust', 'admin.users.detail', LinkedSteamAccounts)
```
`Play` is a group core does not have; §3.3 appends an unknown group rather than dropping the items,
so this works and lands at the end of the nav — where an operator can move it, because a module row
is an ordinary row once it is interleaved.
The pages need `PublicLayout`, `PageHeader`, the three `PageState` components, `useAsync` and
`useAuth`: **six of the kit's seven members**, and the seventh (`useSite`) on the wipe-schedule page
for the site's timezone. A second game, unrelated to the first, wanting exactly what the kit
contains is the strongest evidence available that §3.4 was curated at the right altitude.
The map view is the one page that wants something the kit does not have — a pan/zoom canvas. It
bundles one, which is the answer §3.4 already gives ("everything else a module bundles itself"), and
it costs the chunk about 40 KB.
---
## Findings
### 1. A module cannot register an identity provider — and Rust's identity is Steam
The gap. Module-uo proves account ownership with an in-game `[link` command that issues a one-time
code; the website confirms it with the sidecar. Nothing about that needs core's auth layer, so
nothing in `api` ever needed to touch it.
**Rust's answer is Steam OpenID**, and every Rust community expects "Sign in with Steam". Core has an
SSO layer — `authProviders`, `userIdentities`, OAuth2/OIDC providers behind a registry — and
[`MODULE_API.md`](../website/MODULE_API.md) §2.4 offers a module no way in. There is no
`registerAuthProvider`, and §2.7 forbids reaching for one.
A Rust module can still ship: it can copy the UO shape, issuing a code in-game and matching it on
the website. That works, it is one screen worse, and it leaves the community's obvious expectation
unmet.
**This is not a defect in what was built** — it is the boundary of what was specified, found exactly
where a dry run is supposed to find it. It is worth stating precisely, because whoever adds it has to
answer a question the current policy already has an opinion about: **SSO is link-only by design**, an
external identity must already be linked to an existing account, and identities are never
auto-provisioned. A Steam provider a module registers must inherit that, not route around it. It is
also *not* a small addition: an identity provider participates in session creation, which is the one
part of core a module must never be able to weaken.
Recommendation: leave it out of v1 of the contract and record it here as the first candidate for
`MODULE_API_VERSION` 1.4 or 2.0, specified deliberately rather than bolted on when someone needs it.
### 2. "One module, one game" is not the same as "one module, one server"
A UO community runs one shard. A Rust community runs four or five, wipes them on different
schedules, and every page is a per-server view.
The contract is silent on this, and silence turns out to be right: multiplicity lives entirely in the
module's own tables and route parameters (`/rust/servers/:id`). Core's mount prefixes, capabilities
and state machine are per-**module**, and none of them wanted to be per-server.
Worth recording only because it looks like a problem until you try it — and because it is the shape
that would have broken a contract designed around "the shard" as a singular noun. The phase-2
inversions that removed core's opinions about game content (the push catalog, the announce legs,
`mapEvent` dropped) are why it does not.
### 3. The event catalog generalises; the *retention* assumption does not
`rust_events` is the twin of `shard_events`, and the ingest shape carries over unchanged.
What does not carry over is that a UO event log grows forever while a Rust one is **truncated every
wipe**. That is module-internal — but it lands on something core does own: the schema fragment
**must not** be where that truncation happens. §2.6's leading-verb allowlist (`CREATE`, `ALTER`,
`INSERT`, `UPDATE`) already forbids `DELETE` and `TRUNCATE` in a fragment, precisely because the
fragment is replayed on **every boot** and would empty the table each restart.
So a wipe is a runtime operation on a module route, not a schema one. The allowlist was written for a
different reason — the phase-2 note says "the file replays every boot, so TRUNCATE/DELETE would empty
a table each restart" — and it correctly forbids the first mistake this module's author would make.
A rule that catches a case it was not written for is a rule at the right altitude.
### 4. `capabilities` earns its keep the moment there are two modules
With one module, `capabilities` reads like decoration — core never interprets one, and the SPA knows
what it registered. With two, it is the only thing a *client* can ask.
The Android app is the case: it feature-detects against `GET /api/v1/public/modules` and must render
a site whose module it has never heard of. `["servers", "wipes", "leaderboards"]` tells it there is
nothing shard-shaped here without it having to know what `rust` means, and §2.9's rule — treat an
unknown capability as absent, never infer a route from one — is what keeps that from becoming a
second, worse route table.
No change needed. Recorded because the design decision looked over-engineered with one module and is
load-bearing with two.
---
## Verdict
**The contract generalises.** A second game, chosen for how little it shares with the first, is
served by the same `module.json`, the same seven registration calls, the same schema-fragment rules,
the same client registry and the same UI kit — with one genuine gap (identity providers), one
non-issue that looks like a gap (multiple servers), and two places where a rule written for one
reason turns out to cover another.
The gap is worth having found before something was built on top of it, which is what a dry run is
for. What it does **not** establish is that someone outside this org could build this module from the
documentation alone — that is the Integration Kit's acceptance test (§2.11), and it stays untested
until a person who did not write any of this does it.