Files
docs/modules/rust/PLAN.md
wtclaude a5881d5a55 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
2026-09-15 11:12:20 -05:00

208 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# `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 03 produce a working read-only Rust site. 45 are the permissions product. 78 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