docs(modules): the module-rust plan, its decisions of record and its rig
The dry run designed this module on paper and deliberately did not build it. This is the document that builds it: eleven phases, three new repos, and the three decisions the org lead settled on 2026-09-15. R1 identity is an in-game link code for v1. There is still no registerAuthProvider at MODULE_API_VERSION 1.10.0, so "Sign in with Steam" is not reachable from a module. What changed since the dry run is the stakes, not the options: the dry run rated this survivable because the module only read, and R2/R3 make the site the author of who may do what and the thing that hands out loot. A weak link is now a privilege-escalation path. R2 site-authored permissions mirror into Oxide's own permission store, so every third-party plugin honours them with no adapter and a wipe stops being a data-loss event for permissions. This is a direction the Integration Kit has no chapter for - not the read path, not a ledgered one-shot, but continuously reconciled state where the website is authoritative. Its nearest relative is the Team provider inverted. Whether that deserves a sixth chapter is phase 10's question. R3 the Kits reward action always registers and refuses with a reason in `error`, rather than vanishing from the form or refusing to boot. Phase 8 must declare reversible: 'none' honestly - there is no way to un-grant loot a player has spent - and count cost() per kit actually granted. The rig is D:\rust, which has been booted and carries a matched Oxide 2.0.7585. Two traps recorded: its start.bat updates C:\rustserver and launches D:\rust, so the server that boots has never been updated by its own script; and C:\oxide_files is a 2025-04-23 Oxide whose bundled Assembly-CSharp.dll would downgrade a real install. One question left open: which plugins besides Kits are in the required base set. oxide/plugins/ is empty, so all of it is a fresh install either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
207
modules/rust/PLAN.md
Normal file
207
modules/rust/PLAN.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# `module-rust` — the plan
|
||||
|
||||
**Status:** approved in outline 2026-09-15, not started. Three decisions of record settled; one
|
||||
question open (§3).
|
||||
|
||||
The [dry run](../rust-dryrun.md) designed this module on paper and deliberately did not build it.
|
||||
This is the document that builds it. Where the two disagree, this one is later and wins — but the dry
|
||||
run's four *findings* still stand, and one of them (identity) has moved from a footnote to the
|
||||
critical path. §2 says why.
|
||||
|
||||
Nothing here is normative. [`MODULE_API.md`](../../website/MODULE_API.md) is the contract,
|
||||
[`MODULE_SYSTEM.md`](../../website/MODULE_SYSTEM.md) the system, [`EVENTS.md`](../../website/EVENTS.md)
|
||||
the event design of record, and the [Integration Kit][kit] is the teaching text this plan follows
|
||||
chapter by chapter. This document is a *schedule and a set of decisions*, not a specification.
|
||||
|
||||
---
|
||||
|
||||
## 1. The shape, and what is already settled
|
||||
|
||||
Three new repositories, mirroring the three the platform already has for Ultima Online, plus the
|
||||
optional fourth part that lives inside the module:
|
||||
|
||||
| Part | New repo | Mirrors | What it is |
|
||||
|---|---|---|---|
|
||||
| Website module | `RunicGateway/Module-rust` | `Module-uo` | Routes, schema fragment, prebuilt client chunk, nav |
|
||||
| Sidecar | `RunicGateway/rust-link` | `link` | Owns the game connection and the durable copy |
|
||||
| Oxide bridge plugin | `RunicGateway/rust-plugins` | `servuo-plugins` | C# inside the game, dials out, never blocks |
|
||||
| *(event capability)* | — | — | Declarations inside `Module-rust` ([kit][kit] ch. 5) |
|
||||
|
||||
None exist yet. The module is built from [`integration-kit/template/`][kit], which CI holds against a
|
||||
pinned core — currently `MODULE_API_VERSION` **1.10.0**.
|
||||
|
||||
Settled before this document and unchanged by it (dry run, org lead, 2026-08-19):
|
||||
|
||||
- **One server, one sidecar**, on that server's own host. A community with six servers runs six
|
||||
pairs; the module holds six clients and core never learns there is more than one.
|
||||
- **The plugin dials out.** Rust's server is a binary, so the way in is Oxide's published hook API
|
||||
rather than source — and the shard-dials-out invariant survives that change of footing unchanged.
|
||||
- **No RCON.** It was the original design and it was overruled.
|
||||
- **`wipe_id` on every table that holds gameplay data.** It is the whole shape of the game in one
|
||||
column, and it is the first thing a UO-shaped mental model gets wrong.
|
||||
|
||||
## 2. Decisions of record
|
||||
|
||||
### R1 — identity is an in-game link code for v1
|
||||
|
||||
**Decided 2026-09-15 (org lead).** A player proves account ownership by typing a command in-game; the
|
||||
plugin issues a one-time code; the website confirms it through the sidecar. Exactly the shape
|
||||
`module-uo` uses.
|
||||
|
||||
This is the dry run's finding 1 answered for now rather than closed. There is still **no
|
||||
`registerAuthProvider`** in the contract at 1.10.0 — verified against `MODULE_API.md` §2.4 on
|
||||
2026-09-15 — so "Sign in with Steam", which every Rust community expects, is not reachable from a
|
||||
module today. The link code is one screen worse and needs no core change, so it is what v1 ships.
|
||||
|
||||
**What changed since the dry run is the stakes, not the options.** The dry run rated this survivable
|
||||
because the module only *read*: a site that renders a leaderboard does not need to know which account
|
||||
owns a Steam ID. R2 makes the site the author of who may do what in the game, and R3 makes it the
|
||||
thing that hands out loot. Both are grants against a Steam ID. **A weak identity link is now a
|
||||
privilege-escalation path, not a missing convenience** — so the code must be single-use,
|
||||
short-lived, rate-limited, and issued in-game to the player who will own it.
|
||||
|
||||
Adding `registerAuthProvider` properly stays the first candidate for a future `MODULE_API` bump. It
|
||||
participates in session creation, which is the one part of core a module must never be able to
|
||||
weaken, and it must inherit core's existing policy: **SSO is link-only, identities are never
|
||||
auto-provisioned.** Specified deliberately, not bolted on. It is out of scope here.
|
||||
|
||||
### R2 — site-authored permissions are mirrored into Oxide's own permission store
|
||||
|
||||
**Decided 2026-09-15 (org lead).** The website is the author of record for groups and grants. The
|
||||
bridge plugin applies them through Oxide's own API (`permission.GrantUserPermission` /
|
||||
`RevokeUserPermission`), so **Oxide is an enforcement cache and the site is the thing that
|
||||
remembers.**
|
||||
|
||||
The alternative — the plugin keeping a private table only our own features consult — was rejected
|
||||
because it cannot reach any third-party plugin, and reaching them is the point: a grant authored on
|
||||
the site has to gate Kits.
|
||||
|
||||
Three properties fall out, and they are the reason this shape is worth its cost:
|
||||
|
||||
- **Every third-party plugin honours site-authored grants with no adapter**, because they all already
|
||||
call `permission.UserHasPermission`.
|
||||
- **A wipe stops being a data-loss event for permissions.** The game forgets; the site does not, and
|
||||
re-pushes the whole set on the next connect.
|
||||
- **Hand edits are reported, not overwritten.** Somebody typing `oxide.grant` at the console is
|
||||
drift, and drift is surfaced to an operator — the same posture a lease's `restore()` takes when it
|
||||
finds a value a human has moved ([kit][kit] ch. 5).
|
||||
|
||||
**This is a direction the Integration Kit has no chapter for, and that is a finding.** Chapters 3 and
|
||||
4 are the read path — data leaving the game. Chapter 5 is one-shot commands with a ledger and a
|
||||
teardown. This is neither: it is *continuously reconciled state where the website is authoritative*,
|
||||
and its nearest relative in the contract is the Team provider **inverted** — instead of core asking
|
||||
the module what the game knows, the module tells the game what the site knows. The mechanism it
|
||||
borrows is chapter 4's: **every board's current state has exactly one producer, and it runs on
|
||||
connect**, pointed the other way. Whether this deserves a sixth chapter is a question for phase 10,
|
||||
after it has been built once.
|
||||
|
||||
### R3 — the Kits reward action registers always and refuses with a reason
|
||||
|
||||
**Decided 2026-09-15 (org lead).** Kits is required for event rewards, but "required" means the
|
||||
action always exists and fails honestly where it cannot work — `{ ok: false, retry: false, error:
|
||||
'Kits plugin not installed' }` — rather than vanishing from the authoring form or refusing to boot
|
||||
the module.
|
||||
|
||||
Two details from [kit][kit] ch. 5 that this depends on and are easy to get wrong:
|
||||
|
||||
- **The reason must be in `error`.** Core reads exactly `ok`, `retry` and `error` off a failure
|
||||
envelope; a message under any other name is dropped and the operator sees a bare
|
||||
`"<action id> refused"`.
|
||||
- **`retry: false` has to be reachable.** Core's dispatcher enforces `budgetMs` and classifies a
|
||||
budget timeout as retry *unconditionally* — so if the sidecar client's timeout is longer than
|
||||
`budgetMs`, our own `retry: false` is unreachable code. Derive one constant from the other and
|
||||
assert the inequality in a test. The first module this project shipped had exactly that pairing.
|
||||
|
||||
## 3. Open question
|
||||
|
||||
**Which other uMod plugins are in the required base set?** Kits is named. The rest of "a couple" is
|
||||
not, and phase 0 cannot finish its install list without it. Everything beyond the base set is
|
||||
phase 9's optional tier.
|
||||
|
||||
`D:\rust\oxide\plugins\` is currently **empty**, so whatever the set is, all of it is a fresh install.
|
||||
|
||||
## 4. The test rig
|
||||
|
||||
`D:\rust` on the org lead's workstation. It has been booted, it has a generated world
|
||||
(procedural, seed 1234, size 4000, save v287) and Oxide **2.0.7585** matched to its build, and its
|
||||
Oxide permission store already holds a `default` and an `admin` group with one admin user — which
|
||||
means R2's mechanism can be exercised on day one.
|
||||
|
||||
Two traps recorded here because both cost time before they were understood:
|
||||
|
||||
- **`D:\rust\start.bat` updates the wrong directory.** It runs
|
||||
`steamcmd +force_install_dir c:\rustserver\ +app_update 258550` and then launches
|
||||
`D:\rust\RustDedicated.exe`. The server that boots has never been updated by its own script. That
|
||||
is why a second, never-booted install exists at `C:\rustserver` and why `D:\rust` is a wipe behind.
|
||||
- **`C:\oxide_files` is a 2025-04-23 Oxide and must not be copied anywhere.** Oxide ships a patched
|
||||
`Assembly-CSharp.dll`; that bundle's is 6,842,880 bytes against the live 9,780,224, so copying it
|
||||
over a real install is a hard downgrade. `D:\rust` is already correct and needs nothing from it.
|
||||
|
||||
**Rust force-wipes on the first Thursday of the month and Oxide is rebuilt to match**, so "is the
|
||||
rig current" is a recurring question, not a one-time setup step. Every phase that touches the plugin
|
||||
re-checks it.
|
||||
|
||||
## 5. The phases
|
||||
|
||||
Phases 0–3 produce a working read-only Rust site. 4–5 are the permissions product. 7–8 are events.
|
||||
9 is the optional tier. Each phase ends with its findings written down, as every workstream here does.
|
||||
|
||||
| # | Phase | Repos | Done when |
|
||||
|---|---|---|---|
|
||||
| 0 | **The rig.** Fix `start.bat`, update to the current wipe, confirm the Oxide build still matches, install the base set, prove a grant made at the console is visible to a plugin | docs | A current server boots with the base mods loaded and `oxide.grant` demonstrably gates something |
|
||||
| 1 | **Protocol 1 and three skeletons.** Bounded drop-oldest queue, one writer thread, reconnect with a tagged epoch, dial-out; sidecar listener + SQLite + always-on token auth + version header; module from `template/` | all 3 + docs | One hello line travels game → sidecar → module, and killing the sidecar does not stall the game |
|
||||
| 2 | **The read path.** First hook wave from [`HOOKS.md`](HOOKS.md); events and snapshots kept distinct at the wire; `wipe_id` everywhere; every board re-emitted on connect | all 3 + docs | A restarted sidecar is fully populated within one connection, with no negotiation |
|
||||
| 3 | **The first pages.** Server list, per-server status, killfeed, leaderboard; `capabilities`; nav | Module-rust | The site renders the last thing the game said while the game is off |
|
||||
| 4 | **Identity** (R1) | 3 + docs | A player links an account in-game and the site names their Steam ID |
|
||||
| 5 | **Site-owned permissions** (R2). Groups and grants authored on the site; full set pushed on connect, deltas after; drift reported | all 3 + docs | A grant made on the website gates a third-party plugin in-game, and survives a wipe |
|
||||
| 6 | **Teams provider.** Event-driven with a baseline on connect — Rust delivers membership transitions in real time, so no sweep | Module-rust + 2 | Core's reconciler is answered from live transitions, `complete` claimed only per reachable server |
|
||||
| 7 | **Events: one budget, one lease.** [kit][kit] ch. 5's own ordering — the lease before the action | Module-rust + 2 | The leased value is observed changing in the running game and restored, per key |
|
||||
| 8 | **Events: the Kits reward action** (R3) | all 3 | A retried step grants loot once, and the ledger and the world agree |
|
||||
| 9 | **Optional mod integrations.** One adapter per plugin, each detecting via `[PluginReference]` and degrading to absent | rust-plugins + docs | A server missing every optional mod still runs the module |
|
||||
| 10 | **Docs, kit feedback, cutover** | docs + Integration-kit | `docs/` describes what shipped; R2's missing chapter answered either way |
|
||||
|
||||
### Why the lease comes before the reward action
|
||||
|
||||
The stated priority is Kits rewards, and this plan still schedules a lease first. [kit][kit] ch. 5 is
|
||||
explicit about it and the reasoning survives restating: a lease is *a value that already existed,
|
||||
changed for a while, and put back*, so reading it first gives you the baseline for nothing. An action
|
||||
makes something that did not exist. The lease proves the whole command path — correlation, the
|
||||
idempotency key, `budgetMs` against the client timeout, the deadline the game enforces on its own —
|
||||
before anything hands out loot. It is a cheaper place to find all four of chapter 5's invisible
|
||||
failures.
|
||||
|
||||
Reversible on request.
|
||||
|
||||
### What phase 8 must declare honestly
|
||||
|
||||
**A kit grant cannot be `reversible: 'ledger'`.** That value is a promise that core may come back and
|
||||
have the thing undone, on every terminal path including an abort — and there is no honest way to
|
||||
un-grant loot a player has already spent. The correct declaration is `reversible: 'none'`, and saying
|
||||
so is the point: a capability that claims a reversal it cannot perform is the "capability that lies"
|
||||
chapter 5 names, and neither core nor review can catch it.
|
||||
|
||||
`cost()` must count the kits actually granted, derived from the params, every time. Core prices
|
||||
`cost` before dispatch and never reconciles it against what came back — it cannot, it does not know
|
||||
what a kit is — so an action that reports one while granting twelve turns an operator's cap of 30
|
||||
into a cap of 360 with nothing anywhere going red.
|
||||
|
||||
And the monthly wipe is the case `revert`'s tolerance rule was written for: every ledgered resource
|
||||
is invalidated at once, and **"gone, and that is fine" is a success**, not a failure.
|
||||
|
||||
## 6. Risks worth naming now
|
||||
|
||||
- **Hooks bind by name and arity, by reflection, with no compile-time check.** A misspelled hook is
|
||||
never called, silently, with no warning at load — the single most common way a Rust plugin does
|
||||
nothing. The plugin must log which of its expected hooks have fired at least once, so a hook
|
||||
Facepunch renamed on a wipe is visible rather than mysterious. See [`README.md`](README.md) §2.
|
||||
- **A convar that applies cleanly and does nothing.** Most game config is read once at boot and
|
||||
cached; applying it later succeeds, reads back correctly, and changes nothing. Every lease key gets
|
||||
verified live — apply, observe in the running game, restore — before it is advertised. The UO
|
||||
module surveyed 156 config reads and found roughly eight that were live.
|
||||
- **The wipe cadence is the schedule.** A monthly force wipe moves the hook list, rebuilds Oxide, and
|
||||
invalidates every ledgered resource. Phases that end near one should expect to re-verify rather
|
||||
than assume.
|
||||
- **`start.bat`'s RCON password is `letmein` in plaintext with `rcon.web 1`.** Acceptable on a
|
||||
loopback dev rig, and it must never be the shape anything published copies.
|
||||
|
||||
[kit]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit
|
||||
Reference in New Issue
Block a user